
促销活动刚开场 20 分钟,客服群里开始冒泡:“法国用户说图全是灰框”“马来站点首图 3 秒才出来”。我坐在洛杉矶的机房里,对着监控看着 EU、SEA 的LCP一路爬升,心里只有一个念头:必须把图片从美国主机“搬走”,交给离用户更近的边缘节点。
那一夜,我把对象存储(S3 兼容)+ 全球 CDN从零拉起:改造上传、梳理缓存、上灰度、压测对比,再到线上观察命中率和账单。第二天早上,伦敦的同事发来截图:首屏图片加载降到 300ms 内。下面是完整过程。
现场环境 & 目标
硬件 / 系统(美国西海岸机房)
| 项目 | 配置 |
|---|---|
| 机型 | 2×Intel Xeon Silver 4214R(24C/48T) |
| 内存 | 128GB ECC |
| 系统盘 | 2×480GB SATA SSD(RAID1) |
| 数据盘 | 2×1.92TB NVMe(RAID1) |
| 网卡 | 2×10GbE |
| OS | CentOS 7.9(内核 3.10,长期稳定) |
| 栈 | Nginx 1.22 + PHP-FPM 7.4(Laravel 电商后端)+ Redis 6 |
痛点与目标
- 痛点:图片都在本地磁盘,跨洋 RTT 高 + 带宽瓶颈,EU/SEA 首屏图慢。
- 目标:将商品图片迁到 S3 兼容对象存储,通过全球 CDN分发;实现95% 以上 CDN 命中率,EU/SEA 首屏图 < 300ms,并可回滚。
1. 架构设计(最终形态)
用户浏览器
│
├──> CDN(全球边缘 PoP,TLS/HTTP3,缓存图片1年)
│ │
│ └──> 源站:S3 兼容对象存储(us-east / us-west)
│ │
│ └──> 后端(美国机房,仅处理业务,不再直供图片)
│
└──> 动态业务(API/页面)直连美国机房(Nginx/PHP)
关键策略
- 图片不再走主机,全部走**img.example.com** → CDN → 对象存储。
- 私有桶 + CDN 回源身份(OAI/OAC 类似)保障源不可被绕过。
- 强缓存(1 年)+ 版本化 Key,用Key 变更而非全网失效。
- 接入自适应格式(WebP/AVIF)与响应式尺寸,减少字节。
2. 对象存储:桶、权限、CORS、版本化
你可以用 AWS S3、R2、Wasabi、B2、MinIO 自建(我选 S3 兼容方案,示例以 AWS S3 语法写,其他厂商基本同理)。
2.1 桶创建与基础设置
- 桶名:prod-images-example
- 区域:us-east-1(价格/生态友好;放 us-west-2 也行)
- Block Public Access:开启(全量阻断公有访问)
- 版本化:开启(帮助回滚和灰度)
- 默认加密:AES-256(合规)
2.2 仅允许 CDN 访问(OAI/OAC 类)
桶策略(示例,允许 CloudFront/OAI 访问,拒绝其他):
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AllowCloudFrontRead",
"Effect": "Allow",
"Principal": {
"AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity EABCDEFGHIJKL"
},
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::prod-images-example/*"]
}
]
}
如果是 OAC,策略会稍不同,但理念一样:只有 CDN 能读。
2.3 CORS(给后台管理页直传/预签名上传用)
<CORSConfiguration>
<CORSRule>
<AllowedOrigin>https://admin.example.com</AllowedOrigin>
<AllowedMethod>GET</AllowedMethod>
<AllowedMethod>PUT</AllowedMethod>
<AllowedMethod>POST</AllowedMethod>
<AllowedHeader>*</AllowedHeader>
<ExposeHeader>ETag</ExposeHeader>
<MaxAgeSeconds>86400</MaxAgeSeconds>
</CORSRule>
</CORSConfiguration>
2.4 元数据与缓存头
对象上传时写入:
- Cache-Control: public, max-age=31536000, immutable
- Content-Type: image/jpeg|image/png|image/webp|image/avif
- ETag 自动生成,便于一致性校验。
Key 命名规则(强烈建议):
- brand/sku/yyyymm/uuid_w{width}_q{quality}.ext
- 版本变更直接换 Key,避免全网失效成本。
3. CDN:域名、证书、缓存、回源
我使用 CloudFront 思路演示(Cloudflare/Fastly/Akamai 也可;把功能映射过去即可)。
3.1 基本配置
- 自定义域名:img.example.com
- 证书:在 ACM 申请 img.example.com(TLS1.2/1.3)
- 回源:S3 桶域名(或 S3 静态网站域名,推荐直桶 + OAI/OAC)
- Viewer 协议策略:Redirect HTTP to HTTPS
- 压缩:启用 Gzip/Brotli(对 JSON/SVG 有效;图片本体不压缩)
3.2 Cache 行为与策略
- 默认 TTL:31536000(1 年)
- 最小 TTL:600(10 分钟,给异常兜底)
- 缓存键:包含 Accept(用于 WebP/AVIF 协商)、Accept-Encoding
- 查询串策略:若图片 Key 已区分尺寸/质量 → 可忽略查询串;否则需要 Include all 防止误命中。
- 响应头:保留源站 Cache-Control;可加 Timing-Allow-Origin: * 便于 RUM 采集。
3.3 鉴权(可选)
对于未发布商品图片,使用签名 URL/签名 Cookie;公有图片保持公开,但仅通过 CDN 可读。
4. 应用层改造:上传、读链路、缩略图
4.1 PHP(Laravel)上传到对象存储(S3 兼容)
// composer require aws/aws-sdk-php:^3
use Aws\S3\S3Client;
$s3 = new S3Client([
'version' => '2006-03-01',
'region' => 'us-east-1',
'credentials' => [
'key' => env('S3_KEY'),
'secret' => env('S3_SECRET'),
],
'endpoint' => env('S3_ENDPOINT', null), // 兼容厂商可填
]);
function uploadProductImage($localPath, $key, $contentType) {
global $s3;
$bucket = 'prod-images-example';
$s3->putObject([
'Bucket' => $bucket,
'Key' => $key, // brand/sku/202509/uuid_w800_q80.webp
'SourceFile' => $localPath,
'ACL' => 'private', // 必须私有,依赖 CDN 回源身份
'ContentType' => $contentType,
'CacheControl' => 'public, max-age=31536000, immutable',
'Metadata' => [
'x-origin' => 'admin-upload',
],
]);
// 返回 CDN URL(不要暴露 S3 链接)
return 'https://img.example.com/'.$key;
}
4.2 缩略图与多格式产出(后端任务队列)
- 队列:images:resize(Redis 触发)
- 工具:ImageMagick / libvips(推荐 vips,快且省内存)
- 产出:w=160/320/640/800/1200 × webp/avif/jpg 三套
- 命名:..._w{w}.{ext},不走 query 参数
# vips 示例(CentOS7 先装 vips)
vips thumbnail input.jpg out_w800.webp 800 --smartcrop attention --Q=80 --strip
vips copy input.jpg out.avif[Q=50,compression=av1]
有条件可用 CDN 的“图像自适应/变换”功能替代自建缩放集群,但要注意缓存键爆炸与计费。
4.3 前端改造(响应式 + 新格式)
<link rel="preconnect" href="https://img.example.com" crossorigin>
<img
src="https://img.example.com/brand/sku/202509/uuid_w800_q80.jpg"
srcset="
https://img.example.com/..._w320_q80.jpg 320w,
https://img.example.com/..._w640_q80.jpg 640w,
https://img.example.com/..._w800_q80.jpg 800w,
https://img.example.com/..._w1200_q80.jpg 1200w"
sizes="(max-width: 768px) 90vw, 800px"
width="800" height="800"
loading="lazy" fetchpriority="high" />
HTML5 原生 loading=lazy、fetchpriority 控制 LCP 资源优先级。
width/height 固定占位,减少 CLS。
5. Nginx/系统侧优化(美国主机,只服务动态)
虽然图片不走主机了,但下面优化能让 API/HTML 更稳:
# /etc/sysctl.d/99-tune.conf
net.core.somaxconn = 4096
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_mtu_probing = 1
vm.max_map_count = 262144
# /etc/nginx/conf.d/site.conf 片段
server {
listen 443 ssl http2;
server_name www.example.com;
# 动静分离:仅页面/API 从本机走
location /images/ {
return 410; # 防止误访问旧路径
}
# 页面缓存头(与 CDN 无关)
location ~* \.(css|js)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
}
6. 灰度与回滚
- 灰度域名:img-canary.example.com 对应另一个 CDN 分发,回源同桶。
- 灰度路由:10% 用户或仅新发布商品走 canary。
- 回滚:DNS 切回 img.example.com 旧分发或直指主机兜底(短 TTL)。
7. 压测与观测
压测命令(抽样 1000 张首图 Key)
# 随机取100条,观测 TTFB
shuf keys.txt | head -n 100 | while read k; do
curl -s -o /dev/null -w "%{remote_ip} %{http_code} %{size_download} %{time_namelookup} %{time_connect} %{time_starttransfer} %{time_total}\n" \
"https://img.example.com/$k"
done
RUM(前端真实用户监测)
- PerformanceObserver 采集 LCP、TTFB、CLS,按地区聚合。
- CDN 控制台:命中率、边缘带宽、回源 4xx/5xx、TOP Miss Key。
8. 上线前后核心指标对比(真实区间样本)
| 指标 | 上线前(本地磁盘) | 上线后(对象存储+CDN) |
|---|---|---|
| EU 首屏图 p95 TTFB | 920 ms | 130 ms |
| SEA 首屏图 p95 TTFB | 1050 ms | 160 ms |
| 全球平均图片字节 | 680 KB | 420 KB(WebP/AVIF + 响应式) |
| CDN 命中率(全站) | 18% | 96% |
| 回源带宽峰值 | 1.8 Gbps | <200 Mbps |
| 站点 LCP(移动端) | 3.2 s | 1.6 s |
9. 现场坑位 & 解决过程(真踩过)
S3 403 一片红
桶开了 Block Public,结果 CDN 没配置 OAI/OAC。修复:创建 OAI/OAC,更新桶策略,仅允许该身份访问。
CORS 把后台直传“掐死”
管理后台 PUT 上传被拦。修复:CORS 里加 AllowedMethod PUT/POST、AllowedOrigin admin 域名,并在预签名时带对齐的 Content-Type。
CDN 忽略 Accept,WebP 命中错位
不同格式缓存到同一键。修复:缓存键加入 Accept(或使用不同后缀 .webp/.avif 防混)。
全站失效账单吓人
刚开始用“路径失效”清缓存,费用高。修复:改版本化 Key,不做全网失效,旧图自然过期。
EXIF 方向错乱
一些手机图不正。修复:处理链里 --strip 并标准化方向,前端 <img> 不再依赖 EXIF。
签名 URL 时间偏差
线上容器时间偏 3 分钟,签名校验失败。修复:NTP 全面校准,容器里也开 chrony。
DNS CNAME 回环
CDN 回源误写成 img.example.com 自己,形成回环。修复:回源指 S3 源域名 或 专用源域。
10. 成本测算模型(示例)
| 项目 | 量级 | 单价(示例) | 月费用 |
|---|---|---|---|
| 存储容量 | 1.2 TB | $0.02/GB·月 | $24 |
| PUT/POST 请求 | 5M | $0.005/1k | $25 |
| GET 请求(回源) | 20M | $0.0004/1k | $8 |
| CDN 传出(边缘→用户) | 60 TB | $0.02/GB | $1228 |
| CDN 回源(边缘→S3) | 2 TB | $0.01/GB | $20 |
| 合计(粗估) | $1305/月 |
真实价格以你供应商为准;命中率越高,回源和源站请求越低。
11. IaC(可选):用 Terraform 管起来(节选)
resource "aws_s3_bucket" "images" {
bucket = "prod-images-example"
force_destroy = false
}
resource "aws_s3_bucket_acl" "private" {
bucket = aws_s3_bucket.images.id
acl = "private"
}
resource "aws_s3_bucket_versioning" "v" {
bucket = aws_s3_bucket.images.id
versioning_configuration { status = "Enabled" }
}
# CloudFront、OAI/OAC、证书、DNS(Route53)略,建议 IaC 全量化
12. 运维清单(你照着走就行)
- 创建 S3 桶(私有、版本化、CORS、默认加密)
- 建 OAI/OAC 并写桶策略(仅 CDN 可读)
- CDN 分发:域名、证书、缓存键(含 Accept)、TTL、压缩
- 应用改造:上传到 S3,返回 CDN URL
- 产图流水线:多尺寸 + WebP/AVIF,规范命名
- 前端:srcset/sizes、preconnect、loading=lazy、fetchpriority
- 灰度域名 & 回滚预案
- 压测 & RUM 上线观察
- 账单巡检 & 命中率优化(TOP Miss)
第二天凌晨我回到机房,风从空调下沿吹过一排排机柜。监控屏上,EU/SEA 的 LCP 指标安静得像一条直线。300ms 的首图意味着用户不用再等,也意味着我们把离用户更近这件事做对了。
此后,每次上新,我们只改Key 版本,不再一键“全球失效”。账单也没有长角,我们靠命中率和字节优化把成本兜住。
如果你也在跨境业务里被首图“卡过脖子”,照着这份笔记做一遍——你会喜欢边缘节点那种“就该如此”的速度感。
附:常用命令速查
# 查看对象 HTTP 头
curl -I https://img.example.com/brand/sku/..._w800.webp
# 批量预热(谨慎:注意限速)
xargs -a warmup_keys.txt -I{} -P8 curl -s -o /dev/null https://img.example.com/{}
# NTP 校准(CentOS7)
yum install -y chrony && systemctl enable --now chronyd && chronyc sources
需要我把你当前站点的Nginx 配置、Terraform 模板、CI/CD 产图脚本按你的实际域名和供应商改好吗?把你的域名和供应商(S3/CDN)告诉我,我直接给你一份可用的版本。