故障背景:AWS CDN 返回不符合预期的数据
这次问题最初暴露在一个很具体的业务故障里:海外站点的全景图页面在展示 720 tour 时,部分图片面的数据返回异常,前端本来需要拿到图片信息 JSON,却收到了图片二进制内容,最终表现为全景图展示错乱。这个故障看起来像是“某个 CDN 配置没配全”,但真正解决问题却从公司由阿里云切换到腾讯云,再依托 AWS 将业务出海的整体资源链路说起……
全景图资源有两个特点:
- 它本质上仍然是图片资源。
- 它的文件名后缀并不总是标准图片后缀。
例如普通图片可能长这样:
/path/to/image.jpg
而全景图为了表达六面体不同方向,会在图片文件名后继续追加方向标记:
/path/to/pano_3000x500.jpg_f
/path/to/pano_3000x500.jpg_r
这类 *.jpg_f、*.jpg_r 文件对业务来说是图片,但对只看路径后缀的 CDN 规则来说,它们很容易被识别成“非图片文件”。
与此同时,图片访问链路里还存在另一类语义:通过 query string 对原始对象做处理。类似 x-oss-process 的查询参数:同一个原始图片,可以通过 query string 请求缩放、格式转换、图片信息等处理结果。例如:
/path/to/image.jpg
/path/to/image.jpg?x-oss-process=image/resize,w_256
/path/to/image.jpg?x-oss-process=image/info
这里最容易被忽略的是:这三个 URL 虽然路径相同,但语义完全不同。第一个请求原图,第二个请求处理后的缩略图,第三个请求图片元信息 JSON。
这个语义在国内链路里长期是成立的。业务早期从阿里云迁移到腾讯云时,腾讯云为了兼容阿里云 OSS 的使用方式,对照 x-oss-process 做过定制化支持。所以前端和业务代码可以继续把对象处理能力当成一种“对象存储/CDN 自带能力”来使用。
但在国际化业务出海到 AWS 后,这个前提不再成立。S3 只负责对象存储,它不知道 x-oss-process 是什么;CloudFront 也只会按照缓存策略、回源策略和源组规则去转发请求。这个能力不能通过 S3 或 CloudFront 的简单配置直接得到。
为了补齐这层能力,中间件团队参考开源方案自研了一套 serverless 对象处理服务:当请求携带 x-oss-process 时,由处理服务从 S3 读取原始对象,再根据 query string 返回裁剪、转换或图片信息结果。图片、视频等资源都可以走这套处理链路。
海外静态资源还有一个额外约束:文件量很大,不能把国内对象存储里的所有资源一次性全量搬到海外 S3。实际采用的是按需同步策略:用户访问海外 CDN 时,如果 S3 已经有对象就直接返回;如果 S3 没有对象,就通过 CloudFront Origin Group 回源到国内 CDN 获取,同时异步任务根据 S3/CloudFront 访问日志把热点资源同步到海外 S3。(按需动态出海这个工作就是另一个我被临时拉去救火救急的事情了……
所以这次故障发生时,系统里同时存在四层机制:
- 国内云厂商对
x-oss-process的兼容语义。 - AWS S3 + CloudFront 不原生支持这类对象处理语义。
- 自研 serverless 服务负责在 AWS 上补齐图片/视频处理能力。
- 海外 CDN 通过 S3 优先、国内 CDN 兜底、日志异步同步来做按需出海。
全景图的非标准后缀正好卡在这些机制的交界处:它本来应该走图片处理链路,但因为路径规则按后缀分流,被当成普通文件走了 S3/回源链路。第一次请求时回源国内 CDN 还能拿到正确 JSON;等资源被异步同步到 S3 后,后续请求就可能直接从 S3 返回原图,绕过对象处理服务。
所以真正的问题是:如何在 AWS 上重建“同一个对象,不同处理语义”的访问能力,同时保留按需同步、回源兜底和海外访问性能。
原来的链路
原来的 CDN 链路大致长这样:
CloudFront 根据文件路径/后缀把流量分成两类:
- 图片后缀走图片处理服务,比如裁切、格式转换、获取图片信息。
- 其他文件走 S3 优先、国内 CDN 兜底的源组。
这套链路里,403 / 404 不总是“源站坏了”,很多时候是一个回源信号:如果海外 S3 还没有这个对象,就让 CloudFront 通过 Origin Group failover 回到国内 CDN,再由异步同步任务把访问过的资源补到 S3。
这个设计解决了两个目标:
- 不需要全量搬迁海量文件,只同步热点资源。
- 用户第一次访问时仍然有国内 CDN 兜底,不至于直接 404。
但代价也很明显:源组层层嵌套,文件类型靠后缀判断,404 既可能代表资源不存在,也可能代表“去备用源拿一下”。系统能跑,但很难推理。
故障是怎么发生的
全景图的每一面图片路径后缀不是标准的 .jpg、.png、.webp,于是被 CloudFront 的路径规则归到了“其他文件”。问题就在这里开始连锁反应:
- 第一次请求
*.jpg_f?x-oss-process=image/info。 - CloudFront 认为它不是图片,走默认源组。
- S3 没有对象,返回
403/404。 - Origin Group failover 到国内 CDN,国内图片处理服务识别
x-oss-process=image/info,返回 JSON。 - 异步同步任务根据访问日志把这个原图同步到海外 S3。
- 之后缓存过期,再次请求同一个 URL。
- S3 已经有原图了,CloudFront 直接从 S3 返回图片本身。
- 前端期待的是 JSON,却拿到了图片,展示逻辑异常。
相关业务开发在长时间排查后没有能定位到根因,显然因为 CDN 缓存本身的特性、上文所说功能的内在复杂性,以及组织结构间信息的不同步,这个定位是非常困难的。在之前的架构中存在多个假设:
- 文件后缀可以可靠判断资源类型。
403/404可以同时承担“异常”和“回源信号”。- S3 中一旦有对象,就等价于国内对象存储上的完整处理语义。
- 过去由云厂商定制兜住的对象处理语义,可以在 AWS 上自然继承。
后两个假设尤其危险。S3 里有原图,不代表 S3 能返回 image/info;CloudFront 能回源,不代表它理解 x-oss-process。
第一轮方案:继续补路径规则?
最直观的修复是继续补 CloudFront behavior:既然 .jpg_f、.jpg_r 也是图片,那就把这些规则补上。
这个方案短期有效,但我不喜欢它。因为业务语义不是写在后缀里的,而是写在 query string 里的。真正决定请求是否需要对象处理的,是有没有 x-oss-process,不是文件名最后几个字符长什么样。
如果继续补路径规则,系统会变成这样:
- 新的非标准后缀出现一次,就补一次规则。
- 图片处理能力散落在多个 CloudFront behavior 中。
- 每次修改都依赖手工控制台配置,很难自动化。
- 同一个 bug 未来还会以另一个后缀回来。
- 存量 CDN 的复杂度继续上升,未来排障更依赖“某个人还记得历史配置”。
这在我们的实际环境里尤其不可取。国际化业务在 AWS 上有几十个 CloudFront distribution,它们服务不同业务、经历过多次责任人变更,也有不少由运维长期维护出来的历史配置。继续把复杂度加到路径规则和控制台配置里,短期看是在修一个后缀,长期看是在给每一个存量 CDN 增加理解成本。
这个方案最后只适合作为止血,不适合作为架构收敛。
第二轮方案:Lambda@Edge 还是 CloudFront Functions?
更合理的方向是:在边缘层识别 x-oss-process,然后把请求动态路由到对象处理服务。
这里有两个选择:
- CloudFront Functions:运行在 viewer request 阶段,每次请求都会执行,适合轻量、低延迟的请求改写。
- Lambda@Edge:能力更完整,可以在 origin request 阶段执行。对于高缓存命中内容,它只在 cache miss 时触发,成本模型更友好。
这里需要纠正一个容易混淆的点:Lambda@Edge 并不是完全不能做动态回源。AWS 文档明确提到,可以用 CloudFront Functions 或 Lambda@Edge 更新 origin;Lambda@Edge 的 dynamic origin selection 也支持在 origin request 阶段根据 header 等请求特征改写 origin。
但它和 CloudFront Functions 的工作位置不同。CloudFront Functions 在 viewer request 阶段执行;Lambda@Edge 的 origin update 发生在 origin request 阶段,也就是 cache miss 后。AWS 文档还提到,Lambda@Edge 与 origin group 一起使用时,一个 viewer request 在 primary failover 到 secondary 的过程中可能触发两次函数。
这次逻辑很简单:只看 query string,改 primary origin,补一个 header,不需要访问复杂外部服务。更重要的是,我们希望把这套逻辑变成一个轻量、可复用、可以逐步挂到存量 CDN 上的路由层,而不是继续给每个 distribution 扩写 behavior。因此优先选择 CloudFront Functions。
CloudFront Functions 的关键点是 AWS 已经提供了 origin modification helper,可以在 viewer request 中调用 cf.updateRequestOrigin(...) 动态修改请求的源站。官方文档里还有一个非常重要的约束:如果当前行为使用的是 Origin Group,updateRequestOrigin() 只会修改这个 Origin Group 的 primary origin,secondary origin 和 failover 条件保持不变。
这恰好符合我们的需求。
最终方案:只把“需要处理”的请求改到处理服务
新的链路变成这样:
核心思路:
- CloudFront behavior 仍然使用一个 Origin Group。
- 默认情况下,primary 是 S3,secondary 是国内 CDN。
- Viewer request Function 检查 query string。
- 如果没有
x-oss-process,不改 origin,请求正常走 S3/国内 CDN 源组。 - 如果存在
x-oss-process,Function 把 primary origin 改成对象处理服务。 - Function 将 CDN 映射到 bucket 写入 header,使得对象处理服务可以从正确的 S3 bucket 取原图并进行处理。
- secondary origin 和 failover 规则保持不变,处理服务失败或返回指定状态码时仍然可以回源国内 CDN。
示意代码大概是这样:
import cf from "cloudfront";
async function handler(event) {
const request = event.request;
const querystring = request.querystring || {};
if (!querystring["x-oss-process"]) {
return request;
}
const endpoint = "image-handler.example.internal";
const bucket = "oversea-assets-bucket";
cf.updateRequestOrigin({
domainName: endpoint,
timeouts: {
readTimeout: 5,
connectionTimeout: 5,
},
customHeaders: {
cf_use_bucket: bucket,
},
customOriginConfig: {
protocol: "http",
port: 80,
sslProtocols: ["TLSv1.1"],
},
});
return request;
}
这里的 cf_use_bucket 是我们给对象处理服务的约定:处理服务本身不需要知道当前请求来自哪个 CloudFront distribution,只要从 header 里拿 bucket 映射,就能到对应 S3 中取原图并处理。
为了避免每个 distribution 都写死配置,Function 还可以配合 CloudFront KeyValueStore,把 distribution、bucket、handler endpoint 这类映射收口起来。
排障过程中踩到的几个坑
1. 504:默认协议继承导致处理服务超时
第一类问题是 504。
Function 日志看起来没问题,origin 已经被动态改到对象处理服务;但真实请求会 timeout。
原因是对象处理服务只监听 HTTP 80 端口,而 CloudFront origin update 如果不显式指定 customOriginConfig,会继承原有 origin 或 viewer request 的部分配置。结果就是 CloudFront 用了不符合处理服务预期的协议/端口去回源。
修复方式是显式指定:
protocol: "http"port: 80readTimeoutconnectionTimeout
sslProtocols 在这个场景里并没有真正被 HTTP 请求使用,但 origin update 的参数校验要求它存在,所以也需要带上。
2. 资源访问 502:代码没错,行为目标和 S3 访问策略错了
第二类问题是 502。

这个问题更迷惑:Function 日志显示 origin update 正常,直接访问对象处理服务也正常,但 CloudFront 仍然返回 502。
最后定位到不是函数代码问题,而是 CloudFront distribution 的行为配置没有写道预期的 Origin Group 上,同时 S3 侧的只读访问策略也没有配完整。

排障经验:
- cache behavior 最终指向的是单 origin 还是 origin group?
- origin group 的 primary / secondary 是否符合预期?
- failover status code 是否配置正确?
- S3 bucket policy / OAC / 只读策略是否允许 CloudFront 访问?
- origin request policy 是否转发了必要 query string 和 header?
3. 查询参数必须进入缓存键
既然 x-oss-process 决定的是响应内容,那它必须进入 CloudFront cache key。
否则这些请求会互相污染:
/a.jpg
/a.jpg?x-oss-process=image/resize,w_256
/a.jpg?x-oss-process=image/info
它们看起来是同一个对象,实际上是三种不同响应:原图、缩略图、JSON 信息。缓存策略如果不区分 query string,就会把错误结果缓存给后续请求。
方案取舍
为什么保留 Origin Group?
因为 Origin Group 仍然提供了很有价值的兜底能力。
AWS 的 origin failover 文档 里有两个细节很重要:
- CloudFront 每次都会先请求 primary,只有 primary 满足 failover 条件时才请求 secondary。
- 只有
GET、HEAD、OPTIONS这类方法会触发 failover。
我们的静态资源访问基本是 GET,适合这个模型。保留 Origin Group 可以让改造集中在 primary origin,secondary 兜底链路不需要大拆。
这个方案的边界是什么?
最大的边界也来自 updateRequestOrigin():它只改 Origin Group 的 primary origin。
如果对象处理服务整体不可用,CloudFront 会 failover 到 secondary。这个行为对“国内已有资源出海”的场景是好的,因为国内 CDN 可以兜底;但如果某些资源只存在海外 S3,而国内 CDN 没有,那么处理服务异常时这些 S3 独有资源就可能拿不到。
因此这个方案更适合:
- 国内资源按需同步到海外 S3。
- 海外 S3 是国内资源的子集或近似子集。
x-oss-process处理服务只负责加工,不负责最终兜底全量资源。
如果海外 S3 会产生大量独有资源,就需要额外设计灾备路径,不能简单依赖 secondary 国内 CDN。
最后的思考
这次排障给我最大的感受是:CDN 回源不是一个“源站地址配置项”,而是一套比较复杂的状态处理工具
一个请求从浏览器到 CloudFront,再到 S3、对象处理服务、国内 CDN,中间每一步都在根据协议、缓存键、状态码、header 和路径规则做决策。只要其中一个信号被复用,就会出现很隐蔽的行为差异。
比如 404:
- 对浏览器来说,它是资源不存在。
- 对 CloudFront Origin Group 来说,它可能是 failover 条件。
- 对按需同步系统来说,它可能是“海外还没同步,去国内拿”。
- 对排障的人来说,它又可能被误判成源站异常。
所以架构里可以复用状态码,但必须写清楚语义边界。否则系统越跑越复杂,最后没人敢改。这次最终的收益不只是修复了故障根因,并且把规则从“路径后缀匹配”收敛到“请求参数表达意图”,再用 CloudFront Function 把这个意图转换成明确的 origin routing。系统少了一些手工配置,多了一条可以解释、可以验证、可以自动化维护的路径。
在一个技术成熟、分工明确的公司里,我们很容易以为岗位和职责之间的边界是清晰的:前端负责展示,后端负责接口,中间件负责基础服务,运维负责 CDN 和云资源。可真实问题往往不是这样发生的。一次事故可能同时穿过前端资源命名、云厂商能力差异、对象存储同步、边缘计算、历史运维配置和业务出海链路。
如果只站在自己的职责边界里看,很容易得到一个局部正确但整体无效的答案:前端补后缀,中间件补服务,运维补规则,大家都修了一点,但复杂度继续留在系统里。这次让我觉得很有价值的是,保持好奇心,真的去读文档、画链路、搭 demo、验证每一个假设,是可以把一个横跨多个团队的历史问题往前推一步的。不是因为某个人越界做了别人的事,而是因为复杂系统里的边界本来就没有想象中那么坚硬和困难。