图片 CDN 实战

一个 URL 的多种形态

接入 CDN 后不久,我遇到了一个问题:同一个图片在前端需要展示不同尺寸。

每种尺寸都需要预先生成、单独存储、各自缓存。100 万张原图 × 5 种尺寸 × 2 种格式 = 1000 万个文件。

有没有更优雅的方式?

有的——URL 参数化实时裁剪。让 CDN 在用户请求时动态生成需要的尺寸:

CDN 收到这个请求后,从源站获取原图,按参数裁剪,缓存结果,返回给用户。

缓存 Key 设计

URL 参数化后,缓存 key 的设计变得至关重要——不同的参数组合会生成不同的缓存 key。

URL 参数化实时裁剪

CDN 本身不提供图片处理能力,需要配置”回源重写 + 图片处理服务”:

方案一:OSS 图片处理

阿里云 OSS 自带图片处理能力,可以通过 URL 参数直接处理:

GET https://guangying-img.oss-cn-beijing.aliyuncs.com/i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/quality,q_80/format,webp

x-oss-process 的语法是”动作/参数,参数_值”用斜杠串起来,每个动作以 image/ 开头。常用动作和参数:

动作参数含义
image/resizew_400指定输出宽度
image/resizeh_300指定输出高度
image/resizem_lfit / m_mfit / m_fill / m_pad / m_fixed缩放模式:等比缩放到给定范围内 / 等比缩放到范围内取小 / 裁剪填满 / 补边 / 强制指定尺寸
image/formatwebp / avif / jpg / png输出格式
image/qualityq_80有损质量因子,取值 1 到 100
image/cropx_100,y_200,w_300,h_300按坐标裁剪
image/rotate90旋转角度
image/blurr_3模糊半径
image/watermarkt_sBpc,a_90加文字水印,s 是字号、a 是透明度
image/auto-orient无按 EXIF 信息自动摆正

参数可以组合,也可以用「图片样式」把一串参数固化下来。样式在控制台创建,或调用 PutBucketStyle 接口写入,之后 URL 只需要带一个样式名:

GET /i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/quality,q_80/thumb_400_webp

样式适合那些”每次都要用同一串参数”的场景——缩略图、头像、封面。好处是参数集中在一处,改一次全站生效;代价是排查问题时要先查样式定义才知道实际做了什么,所以样式名要有语义(thumb_400_webp),不要叫 style1。

直接验证一条处理指令是否生效:

curl -sI "https://guangying-img.oss-cn-beijing.aliyuncs.com/i/a3f8c2e1.jpg?x-oss-process=image/resize,w_400/format,webp" | grep -iE "content-type|content-length|x-oss-request-id"

content-type 应该是 image/webp,content-length 是处理后的大小(不是原图大小)。如果返回的是原图,通常是参数拼错了——最常见的是把 w_400 写成了 w=400,或者漏掉了动作前缀 image/。

在代码里调用,OSS SDK 的 process 参数就是拼这一串:

import oss2

endpoint = "https://oss-cn-beijing.aliyuncs.com"
bucket = oss2.Bucket(oss2.Auth("<AccessKeyId>", "<AccessKeySecret>"), endpoint, "guangying-img")

# 取一张 400 宽的 WebP 缩略图,直接落盘
process = "image/resize,w_400/quality,q_80/format,webp"
bucket.get_object_to_file(
    "i/a3f8c2e1.jpg",
    "a3f8c2e1_400.webp",
    process=process,
)

四个必须提前知道的限制:

  1. 图片处理按调用次数计费,而且是”每个处理请求”计一次,不看处理复杂度。所以绝不能把 OSS 的处理接口直接暴露给终端用户——恶意请求打多少次,就计费多少次。正确做法是让 CDN 边缘先缓存,用户请求只打在边缘上。
  2. 原图必须放在 OSS 上。这个能力是 OSS 自己的功能,处理不了任意 URL 上的图片。
  3. 处理结果不会自动写回 OSS。OSS 处理完把结果直接返回给你,不会在桶里生成一个新对象。要长期保存,得自己再 put_object 回去。
  4. 参数拼错不报错。写错参数时 OSS 通常返回原图(HTTP 200),而不是 4xx。所以验收不能只看状态码,要比对 content-type 和 content-length。

方案二:自建图片处理服务

OSS 图片处理虽然方便,但功能有限,且每张图片每次处理都要收费。如果需要更灵活的处理能力,可以自建:

能力OSS 图片处理自建图片服务
处理算法固定的一组内置动作任意:智能压缩、感知裁剪、人脸检测、风格化
计费方式按调用次数按机器时长,是固定成本
自定义逻辑不支持支持:按用户等级、场景、业务规则选择参数
结果缓存依赖 CDN 边缘缓存服务内部可以再缓存一层
运维成本无要自己管机器、扩缩容、监控
首次处理延迟20 到 200 ms30 到 300 ms(多一跳网络)

什么情况下值得自建?三个信号:

  1. 需要的处理超出 OSS 的动作集合。比如按图片内容自适应质量、按人脸位置做智能裁剪、加智能水印,这些 OSS 都做不到。
  2. 调用量足够大,能摊薄固定成本。自建的核心优势是固定成本:处理机的月租是固定的,处理 10 万次和 100 万次,月租不变;而 OSS 是按次计费,量越大越贵。粗略地说,自建的成本在月处理量达到某个量级之后才开始占优——具体拐点要按自己的机器单价和 OSS 的处理单价实测。
  3. 需要把处理逻辑和业务规则耦合。比如”头像用无损、封面用有损、付费作品一律不降质”。

自建服务的形态很简单:CDN 边缘 → 图片服务 → OSS。边缘负责缓存和参数白名单,图片服务负责真正的变换。

用 Node.js 加 sharp 实现一个最小可用的处理服务。sharp 基于 libvips,处理速度和内存占用都比纯 JS 的方案好得多:

import sharp from "sharp";

const ALLOWED_WIDTHS = new Set([200, 400, 800, 1200, 2000]);
const ALLOWED_FORMATS = new Set(["webp", "avif", "jpeg"]);

export interface TransformQuery {
  width: number;
  format: "webp" | "avif" | "jpeg";
  quality: number;
}

export function parseQuery(params: URLSearchParams): TransformQuery | null {
  const width = Number(params.get("w"));
  const format = params.get("format") ?? "webp";
  const quality = Number(params.get("q") ?? "80");

  if (!ALLOWED_WIDTHS.has(width)) return null;
  if (!ALLOWED_FORMATS.has(format)) return null;
  if (!Number.isInteger(quality) || quality < 1 || quality > 100) return null;

  return { width, format: format as TransformQuery["format"], quality };
}

export async function transform(source: Buffer, query: TransformQuery): Promise<Buffer> {
  const pipeline = sharp(source, { failOn: "none" }).resize({
    width: query.width,
    fit: "inside",
    withoutEnlargement: true,
  });

  if (query.format === "webp") return pipeline.webp({ quality: query.quality }).toBuffer();
  if (query.format === "avif") return pipeline.avif({ quality: query.quality }).toBuffer();
  return pipeline.jpeg({ quality: query.quality, mozjpeg: true }).toBuffer();
}

sharp 的默认并发会按 CPU 核数自动伸缩,服务密集部署时通常还要手动收敛,否则一台 4 核机器上跑十几个进程会互相抢内存:

import os from "node:os";
import sharp from "sharp";

// 进程启动时执行一次:并发数按核数的一半取,给系统留出余量
sharp.concurrency(Math.max(1, Math.floor(os.cpus().length / 2)));
// 限制单张图片的输入像素数,防止超大图把内存打爆
sharp.cache({ memory: 50, files: 0, items: 100 });

自建方案有三个必须提前算清楚的账:

  1. 机器规格要按峰值选,成本按均值付。图片处理是典型的波峰波谷负载,压缩任务集中在用户上传后的几分钟里。所以要用弹性伸缩而不是固定机器:平时 1 台,队列积压超过阈值就扩,处理完再缩回去。算容量的时候按”峰值并发需要的机器数”算,算钱的时候按”月均用量”算。
  2. 原图下载是隐性的大头。处理机要把原图从 OSS 拉下来,这段流量和耗时经常被低估。跨地域访问尤其要注意——处理机和 OSS 同地域部署,回源走内网 endpoint,延迟和费用都低得多。
  3. 结果要落回 OSS 还是留在服务本地。自建服务把结果存在本地磁盘或 R2 上,短期省钱,但源站重启就丢,而且 CDN 边缘刷新后还得重新回源。生产环境通常还是写回 OSS:多一次写入,换来”结果可寻址、可分享、可长期缓存”。

常见的组合是:OSS 图片处理负责常规的缩放和格式转换(零运维),自建服务只处理 OSS 做不到的那部分(智能压缩、感知裁剪),两者由同一个参数白名单约束。

CDN 缓存策略配置

Vary 头的作用

Vary 头告诉缓存:“这个响应取决于哪些请求头”。它是内容协商的必需品,也是缓存命中率的隐形杀手。

典型场景是格式协商。同一个 URL,浏览器声明支持 AVIF,边缘就该返回 AVIF;老浏览器只声明 JPEG,边缘返回 JPEG:

Vary: Accept

有了这行头,边缘才知道”同一个 URL,对不同 Accept 的请求要分别缓存”。少了它,边缘会把发给 Chrome 的 AVIF 直接返回给 Safari,Safari 显示不出来。

代价是缓存被分裂。Accept 头的取值组合不止一种,不同浏览器、不同版本、不同系统发出的 Accept 字符串都不一样,边缘需要为每一种组合各存一份。常见情况下,一个 URL 会分裂成三四份缓存,命中率随之下降。

几个实践上的判断:

做法缓存分裂程度适用情况
Vary: Accept按 Accept 组合分裂格式种类少、且确实需要按浏览器能力返回不同格式
格式写进 URL(/i/xxx_400.webp)按 URL 分裂,语义清晰更推荐,缓存对象与请求一一对应
Vary: User-Agent近乎无限分裂不要用,UA 组合几乎无穷,命中率会归零
Vary: Cookie按登录态分裂不要用,会让未登录用户共享不到缓存

所以我的做法是:把格式写进 URL 路径,用 <picture> 让前端选 URL,而不是靠 CDN 嗅探 Accept 头。这样”哪个对象被缓存”和”哪个对象被请求”是同一件事,排查问题时也直观得多——看到 URL 就知道拿到的是什么格式。

Vary: Accept-Encoding 一般由 CDN 自动处理(gzip / br),业务侧不需要手动加。

验证方法也很简单:用不同的 Accept 头各请求一次同一个 URL,比较边缘返回的缓存命中标记和 Age 头。如果第二次请求的 Age 是 0,说明它没有命中同一份缓存——那就要检查 Vary 和缓存键的设置。

回源优化

回源只在未命中时发生,但它决定了”最坏情况有多坏”。图片系统的回源优化通常包括这些项:

优化项配置解决什么问题注意事项
回源 Host与源站期望的域名一致源站的防盗链、签名校验按 Host 匹配不一致会直接 403
回源协议优先 HTTPS避免明文传输,也避免源站强制跳转源站证书必须有效
回源跟随 301开启源站返回 301 时由节点自己跟随目标地址要稳定,避免跳转链
回源鉴权携带自定义头或带签名的回源 URL源站要求鉴权时仍然可用鉴权参数不进缓存键
回源 Range开启按需取回大文件、Range 请求不必整文件回源需要源站支持 Range
回源超时与重试超时时间、重试次数、是否切备用源站源站抖动时不把错误直接抛给用户重试要设上限,避免放大故障
回源并发限制设置回源 QPS 上限缓存失效或恶意刷量时保护源站限得太低会拖慢正常填充
降级响应回源失败时返回占位图或 404不把 5xx 直接暴露给用户占位图也要短 TTL,避免错误被长期缓存

几条经验:

  1. 回源参数一律不进缓存键。签名、时间戳、追踪参数在边缘校验就好,进了缓存键就会把一份缓存撕成无数份。
  2. 回源失败要区别对待。源站 5xx 是”稍后重试”,源站 404 是”这张图不存在”,前者应该重试和降级,后者应该直接失败。混在一起处理会让真正的问题被掩盖。
  3. 回源链路要和源站一起压测。只压 CDN 不压源站,会得到一个”CDN 很快、源站一被回源就挂”的假象。

前端响应式图片

配合 CDN 的 URL 参数化,前端可以实现真正的响应式图片:

浏览器会自动选择最合适的尺寸——手机上加载 400px 版本,桌面端加载 1200px 版本。

发现内容有问题?

告诉我们哪里需要改进。