先把问题界定清楚
站点的图片一多,服务器就会开始吃紧。这种吃紧不是慢慢来的,而是在某个时间点突然显现:磁盘从 40% 跳到 80%,每天凌晨的备份从二十分钟变成一个半小时,打开后台的媒体库要转好几秒,然后你去看访问日志,发现 wp-content/uploads 目录下的请求占了全站请求量的六成以上,而它们全是静态图片。
这时候大多数人的第一反应是「上 CDN」。CDN 确实能解决用户侧的下载速度,但解决不了源站的问题:图片文件还在一台 Web 服务器的本地磁盘上,CDN 回源的时候还是从这台机器读、这台机器传。源站的磁盘 IO、出口带宽、PHP 进程数(如果图片经过 PHP 输出的话)一样被消耗。
把图片和附件迁到对象存储,是另一种思路:让静态文件彻底离开 Web 服务器,源站只处理动态请求。这篇文章讲的是具体怎么做,包括选型、迁移、回源策略、私有桶的签名访问,以及几个在迁移过程中最容易出问题的地方。
值不值得做:先算三个数
在动手之前,先用三个数字判断这件事对你的站点划不划算。三个数都能从现有日志里直接算出来,不需要额外部署什么。
第一个数:静态文件占总流量的比例。用一天的 access_log 统计 .jpg .png .webp .gif .pdf .zip 这些后缀的请求数和字节数占比。如果数量占比超过 50%、字节占比超过 70%,迁移收益就很明显。
awk '{print $7}' /var/log/nginx/access.log \
| grep -Ei '\.(jpe?g|png|webp|gif|pdf|zip|mp4)$' | wc -l
awk '{sum+=$10} END {printf "%.1f MB\n", sum/1048576}' /var/log/nginx/access.log第二个数:目录大小和文件数。du -sh 和 find | wc -l。这一个数决定了迁移的工程量和耗时。几千个文件、几个 G,用脚本慢慢同步一晚上就完;几十万个文件、几百个 G,就得分批、要考虑断点续传和一致性校验。
第三个数:是不是已经被 CDN 缓存。如果图片已经挂在 CDN 后面、回源率很低(CDN 日志里回源请求只占 5%),那迁移到对象存储的额外收益主要是「源站磁盘和备份时间」,而不是「用户下载速度」。这时候迁移的价值仍然存在,但优先级要往后放。
反过来,如果出现下面任何一种情况,就不建议折腾:图片总量不到 1GB、站点只有一个域名且流量很小、或者你没有任何自动化脚本能力只能手动 FTP 上传。这几种情况下,把图片改成 WebP、加上长缓存头、配好 CDN,收益比迁对象存储大得多,成本也低得多。
选对象存储:看四个点,不看价格表
国内的阿里云 OSS、腾讯云 COS、七牛,海外的 Backblaze B2、Cloudflare R2、Wasabi,都能存文件。选哪个不是看存储单价的绝对值,而是看下面四件事:
- 请求费用怎么算。存储单价通常很便宜,贵的是请求次数和外网流出流量。图片站的特点是「小文件、高请求」,一篇带二十张图的文章,一天一千次访问就是两万个 GET 请求。要算清楚每万次请求多少钱,以及 CDN 回源请求算不算在内。
- 是否支持自定义域名加 HTTPS。有些便宜方案要求用它们提供的域名访问,那你的图片链接就变成了别人的域名,SEO 上的「图片外链到第三方站」和「以后换服务商要改几万条链接」都是麻烦。必须支持绑定自己的子域名,比如
img.example.com。 - 是否支持 origin 私有 + CDN 回源鉴权。这一步决定了你的存储桶能不能设成私有,防止别人直接列出、盗链、或者扫你的桶。支持「回源时带一个签名 URL 或者自定义 header」的方案,才能既私有又让 CDN 正常取图。
- 跨域与图片处理能力。如果站点有前端 canvas 处理图片、或者用
<img crossorigin>,桶上必须配 CORS。图片处理(按需缩放、转 WebP)是加分项,能省掉自己搭一套缩略图服务的工作。
我个人的经验是:如果站点已经有 CDN,优先选和 CDN 同厂的对象存储,回源链路最短、鉴权配置最简单;如果追求成本和透明度,海外的 R2 这类「出网流量免费」的方案对图片站特别友好,但要注意国内访问速度和备案合规问题。选型这一步别纠结太久,因为后面所有代码都通过 S3 兼容接口操作,换供应商的成本比想象中低得多。
迁移:先只读同步,再统一切换
迁移最忌讳的是「一边传一边改链接」。正确的做法是分两个阶段:先把文件全量同步上去并且验证,再一次性切换 URL 生成规则。这样任何一步出问题都能回退,不会出现「一半图片在本地一半在云上」的混乱状态。
用什么工具传?如果文件量不大,rclone 是最好用的;如果文件几十万个,用云厂商自己的命令行工具(支持并发和断点续传)更稳。用 rclone 的关键是加 --checksum,让它在同步后按哈希校验,而不是只看文件大小和修改时间。
rclone copy /srv/www/wp-content/uploads remote:site-uploads \
--transfers 16 --checkers 32 \
--checksum --progress \
--include "*.{jpg,jpeg,png,webp,gif,pdf,zip,mp4}"几个参数的意义:--transfers 是并发上传数,太高会被对象存储限流返回 503;--checkers 是并发对比数;--checksum 让 sync/copy 走哈希比对(会先算本地哈希,大文件会慢一些,但可靠)。如果中途断了,重新跑同样的命令会跳过已完成的文件,这是 copy 和 sync 的默认行为。
特别注意不要用 rclone sync 到桶上,除非你非常确定本地是唯一真源。sync 会删除目标端多出来的文件——如果桶里有其他程序上传的文件,或者你从两个地方往同一个桶同步,一次 sync 就可能把别人的文件删掉。用 copy 只增不删,安全得多。
同步完成后要做三件事验证:文件数量一致、总字节数一致、随机抽十个文件做内容比对。第三步最重要,因为前两步可以通过,内容却可能是截断的(网络中断、并发冲突都会造成)。
# 数量
rclone size remote:site-uploads
find /srv/www/wp-content/uploads -type f | wc -l
# 抽查内容
for f in 2026/01/a.jpg 2026/02/b.png 2026/03/c.webp; do
a=$(md5sum "/srv/www/wp-content/uploads/$f" | cut -d' ' -f1)
b=$(rclone cat "remote:site-uploads/$f" | md5sum | cut -d' ' -f1)
[ "$a" = "$b" ] && echo "OK $f" || echo "DIFF $f"
doneURL 怎么切:别在数据库里做全量替换
切换链接的方式有两种。第一种是把数据库里所有的 URL 都改成对象存储域名,第二种是在输出层把本地路径改写成云地址。我强烈建议第二种。
原因很实际:全量替换数据库是不可逆的,一旦对象存储那边出问题、你想回滚,就得再反向替换一次,而反向替换会漏掉「迁移期间新发的文章」。另外 SQL 的 REPLACE 会锁定大表,几百兆的 posts 表替换一次可能锁表几分钟。
输出层改写有几种做法,取决于你的站点系统:
- Nginx 层重定向:把
/wp-content/uploads/这个前缀 301 到对象存储域名。优点是零改动、可以随时关掉;缺点是每个图片请求都要多一次 301 往返,虽然浏览器会缓存 301,但首次访问有额外延迟。 - 应用层过滤器:WordPress 用
wp_get_attachment_url过滤器,Typecho 用内容输出钩子,把本地域名替换成图片域名。优点是无额外请求,缺点是依赖框架钩子,写错会把正文里的其他链接也改掉。 - CDN 回源改写:让 CDN 的「回源 host」指向对象存储,前端 URL 完全不变。优点是用户感知不到、SEO 无影响;缺点是强依赖 CDN,直连源站时图片会 404。
我选的是第二种加第三种组合:应用层把新上传的文件直接写成云地址,老文件通过 CDN 回源改写兜住。这样新文件不经过源站,老文件 URL 不变,两边都稳。
add_filter('wp_get_attachment_url', function ($url) {
$local = home_url('/wp-content/uploads/');
if (strpos($url, $local) === 0) {
return 'https://img.example.com/' . substr($url, strlen($local));
}
return $url;
}, 10, 1);注意这里必须用 strpos(...) === 0 判断前缀,不能用 strpos() !== false。后者会把任何包含上传路径的 URL 都改写,包括文章正文里外链到别人站点的图片。=== 0 保证只在开头匹配。
私有桶与签名访问:什么时候需要
大部分图片站的图片是要公开访问的,桶设成公共读最简单。但如果你的站点有付费内容、或者有用户上传的私密附件,就必须用私有桶 + 签名 URL。
签名 URL 的机制是:你在服务端用密钥和过期时间算一个签名,拼进 URL,对象存储验证签名后才返回文件。这个 URL 有时效,过期就失效。关键在于签名要用短时效,比如 5 到 15 分钟,而不是一天。长时效的签名 URL 一旦被转发出去,等于永久公开。
还有一个常见误区:把签名过程放在前端做。这是致命的——前端能算签名,就意味着密钥在前端,任何人打开控制台都能拿到密钥,然后下载你所有的私密文件。签名必须在服务端完成,前端只拿到最终 URL。
如果图片是公开的,但你想防止别人盗链,更好的方案不是签名 URL,而是在 CDN 层配 Referer 白名单,或者在回源时加一个自定义 header(对象存储的回源鉴权)。这两种做法对用户完全透明,URL 可以正常被搜索引擎抓取和缓存,也不会因为 URL 带签名参数导致 CDN 缓存命中率掉到零。
迁移后最容易出问题的五个地方
第一,CORS 没配,前端 canvas 报错。迁移后图片域名变了,从 www 到 img 就是跨域。如果前端有 canvas.drawImage 或者用了 crossorigin 属性,桶上没有 CORS 规则就会报「图片被污染,无法读取」。修法是在桶上配 Access-Control-Allow-Origin,别图省事写 * 加 Allow-Credentials: true,浏览器会直接拒绝这个组合。
第二,缓存头没设,CDN 命中率上不去。对象存储返回的默认缓存头可能很保守。记住一句话:文件名带内容指纹的可以永久缓存,文件名固定的不能永久缓存。如果文件名是 logo.png 这种,你改了图但缓存不失效,用户就一直看旧图。所以上传时最好给文件名加上内容哈希。
第三,忘记处理缩略图。WordPress 一篇图上传会生成三到五个尺寸(原图、缩略图、中等、大图)。迁移时如果只传了原图,页面上所有 -300x200.jpg 这类文件都会 404。同步脚本里必须包含所有尺寸,或者干脆在迁移前用工具把缩略图重新生成一遍。
第四,本地目录还在长。迁移完没改上传配置,新图片还是存在本地。这个要靠配置解决:把上传路径指向临时目录,上传成功后由钩子传到对象存储再删本地文件。不做这一步,三个月后本地磁盘又满了。
第五,备份脚本还在备份整个上传目录。这是最容易被忽略的一条。图片迁走了但备份策略没改,每天还在备份本地那个已经空了的目录——看起来没问题,实际上备份里没有图片。如果哪天对象存储被误删(这不是危言耸听,桶的生命周期规则配错就会自动删),你以为有备份,结果没有。所以迁移之后必须把对象存储纳入备份范围,用 rclone sync 反向拉一份到另一家存储,或者至少开桶的版本控制。
回切预案:给自己留一条退路
任何一次架构调整都该想好怎么退。图片迁对象存储的退路很简单:本地目录保留只读副本,DNS 记录控制切换。
具体做法是迁移完成后先把本地目录改名成 uploads_archive,同时用一个 Nginx 的 location 把它挂在原来的路径下作为兜底。这样即使对象存储不可用,你只要把 CDN 回源改回源站、或者把图片域名直接指回源站 IP,站点立刻恢复正常。等观察一两个月确认没问题,再考虑清理本地副本释放磁盘。
另外要留意图片域名不要用 CNAME 指向对象存储的默认域名,而是绑自己的子域名。这一步在迁移之前就要做,并且提前把 SSL 证书签好。用别人的默认域名访问,未来想换服务商时,几万条已经发出去的图片链接就动不了了。
小结
图片附件迁对象存储,真正的难点不在「传文件」,而在 URL 切换方式和回切预案。全量替换数据库是最诱人但最不可逆的做法;输出层改写加 CDN 回源,才是既有收益又能全身而退的路径。迁移前算清三个数、迁移中先同步后切换、迁移后记住缩略图与备份这两件容易忘的事,这套流程就能稳稳落地。