用 Lambda@Edge 动态生成图片缩略图,顺便记录几个踩过的坑
AWS Lambda@Edge 图片缩略图实践与排障
某个项目的图片资源一直放在 AWS S3,再通过 CloudFront 对外分发。用户上传的图片、业务封面图虽然在 App 端做了裁剪和压缩,但还是顶不住原图本身体积大。需要查看原图时没有问题,列表页、详情页和图片墙也一直加载这些大图,就有点浪费了。
访问量上来以后,CloudFront 的出站流量也跟着涨。这个问题和图片能不能正常访问没关系,主要还是日常页面根本用不到原始尺寸,却一直在传原图。
针对这种情况,我用 Lambda@Edge 做了一套按需生成缩略图的方案。原图继续保留,前端通过查询参数指定目标尺寸;没有指定参数的高频接口,则默认给图片地址带上缩略图参数。缩略图第一次访问时生成,后面交给 CloudFront 和单独的 S3 桶缓存。
经观察,上线后看了一周,CDN 出站流量大约少了三分之二。
采用的方案
建议不要直接覆盖或压缩原图,而是把原图和缩略图分开管理:
- 原图桶保留用户上传的原始文件;
- 缩略图桶保存 Lambda 动态生成的结果;
- CloudFront 负责缓存和分发;
- Lambda@Edge 只在需要时读取原图并生成缩略图;
- S3 生命周期规则定期清理旧缩略图。
![]()
图 1:Lambda@Edge 动态缩略图链路
访问方式类似下面这样:
|
|
size=300x0 表示固定宽度为 300 像素并等比缩放;0x300 表示固定高度;两个值都不为 0 时,则按指定宽高处理。get_frame 用来限制 GIF、WebP 等动图保留的帧数。
CloudFront 在缓存未命中时触发 origin-response 阶段的 Lambda@Edge。函数读取请求参数和原图,生成目标尺寸,并把结果写入缩略图桶。较小的结果可以直接返回;如果生成结果超过 Lambda@Edge 在该事件类型下的 1 MB 响应限制,函数会重定向到 /_thumbs/*,再由指向缩略图桶的 CloudFront 行为返回文件。
这里还卡过一次 1 MB 的响应限制。普通静态图缩小后一般碰不到,但 GIF 或 WebP 动图即使尺寸变小,帧数多的时候照样可能超过 1 MB。所以动图不能只缩分辨率,还要能减少帧数,否则 Lambda 生成完也不一定发得出去。
部署 Lambda@Edge
网上很多方案会用 SAM 或 CloudFormation 管理整套配置。我这次改动不大,就直接在 AWS 控制台里操作了,下面也按控制台的流程来写。
首先要把区域切换到美国东部(弗吉尼亚北部)us-east-1。Lambda@Edge 函数必须在这个区域创建。运行时使用 Python 3.14(建议用新一点的,AWS的Lambda会陆续弃用旧版本的),架构选择 x86_64,再绑定一个能够读取原图桶、读取和写入缩略图桶的执行角色。

图 2:创建函数时先确认区域、运行时和架构
Lambda@Edge 和普通 Lambda 还有些区别:它不支持 ARM64、容器镜像和 Lambda Layer。Pillow 这类带二进制文件的依赖,得按照目标 Python 版本、x86_64 架构和 Linux ABI 一起打进 ZIP 包,部署以后没法再靠 Layer 补。
本地构建完成后,在函数的“代码”页面选择“从 .zip 文件更新”。

图 3:从 ZIP 文件更新 Lambda 代码
测试时权限可以先放宽,跑通后还是要收回来。最后只留下读取原图和缩略图所需的 s3:GetObject,以及写缩略图用的 s3:PutObject。信任关系需要同时允许:
|
|
发布版本并关联 CloudFront
上传代码之后还不能直接把 $LATEST 关联到 CloudFront。Lambda@Edge 只能使用带编号的已发布版本,也不能用别名代替。
第一次可以从“操作”菜单选择“部署到 Lambda@Edge”,后续修改则先发布新版本,再把新的版本 ARN 更新到 CloudFront 行为中。

图 4:发布 Lambda 新版本
在事件类型 origin-response添加Lambda函数(填那个Lambda页面右上角的ARN,注意更新版本后这个会变),不用勾选 Include body。函数会读取 CloudFront 事件中的请求和响应信息,再自行从 S3 获取需要处理的原图。

图 5:在 CloudFront 的源响应阶段关联 Lambda@Edge
每次更新代码后,实际上都要走完下面这条链路:
|
|
如果只是上传了新代码,却忘记发布版本或更新 CloudFront 关联,看起来就会像“Lambda 怎么一直没有变化”。
URL 有参数,Lambda 却收不到
第一版部署完成后,我访问了带 size 参数的 URL,返回的却还是原图。开始时一直在检查 Lambda 代码,后来才发现问题在 CloudFront 的缓存策略。
当时 size 和 get_frame 没有被加入缓存键,Lambda 事件中的 request["querystring"] 也是空字符串。同一张图片的不同尺寸本来应该是不同的缓存对象,缓存键里没有尺寸参数,除了 Lambda 拿不到参数,还可能让不同尺寸的请求共用一份缓存。
解决方式是在对应行为上创建自定义 Cache Policy,把真正参与图片处理的查询参数加入允许列表:

图 6:把 size 和 get_frame 加入 CloudFront 缓存键
修改后还要等 Distribution 部署完成。已有路径如果命中过旧缓存,需要再创建一次针对相关图片路径的 invalidation,否则会误以为配置仍然没有生效。
Lambda 页面看不到调用记录
普通 Lambda 的日志通常比较好找,但 Lambda@Edge 会在靠近请求的区域执行,日志也会写入对应区域的 CloudWatch Logs。函数虽然是在 us-east-1 创建的,调用日志却不一定出现在这个区域。
排查时,先查看响应头:
|
|
重点关注:
|
|
如果看到:
|
|
说明 Lambda@Edge 已经触发了,只是执行失败。接下来可以从 CloudFront 的监控页面看函数在哪些区域运行,再切换到相应区域查 CloudWatch Logs。默认日志组名称通常类似:
|
|
这里的 us-east-1 是函数创建区域前缀,不代表日志一定存放在 us-east-1。
3 秒超时带来的 503
日志找到以后,503 的原因也出来了。请求返回:
|
|
CloudWatch 中对应的记录是:
|
|
函数已经触发,但配置还是默认的 128 MB 内存和 3 秒超时。一次请求里既要从 S3 读对象,又要用 Pillow 解码、缩放、重新编码,3 秒基本不够用。
我把配置调整为 512 MB 和 30 秒,然后重新上传 ZIP、发布版本并更新 CloudFront 关联,503 才消失。
30 秒已经是 Lambda@Edge 的函数超时上限。代码里还是得限制最大尺寸、最大帧数和输出体积,不能指望把超时调大以后什么图都硬扛。
可能会遇到的几个坑
控制台找不到上传 ZIP 的入口
有一次准备手动更新代码时,控制台里怎么都找不到上传入口。这种情况先别急着折腾代码,可以挨个确认:函数是不是 ZIP 类型、当前是不是在“代码”页面、账号有没有更新函数代码的权限,以及控制台区域对不对。
如果函数是 Container image 类型,就不会出现同样的 ZIP 更新流程;Lambda@Edge 本身也不支持容器镜像。
Pillow 可以安装,但运行时无法导入
Pillow 里有 .so 二进制文件,直接把本机安装好的目录压进 ZIP 并不可靠。包里的文件必须和 Lambda 使用的 Python 版本、x86_64 架构、Linux ABI 对得上。
以 Python 3.14 为例,最终包内应该能看到与目标运行时匹配的文件,例如:
|
|
构建依赖时,需要通过 pip 的平台、Python 版本和仅使用二进制包等参数,把目标环境写清楚。
中文路径和特殊字符导致 S3 找不到对象
CloudFront 事件中的 uri 可能是 URL 编码后的路径。如果直接把它作为 S3 key 使用,中文名称、空格或其他特殊字符可能触发 NoSuchKey。
处理方式也比较直接:读 S3 之前先对 request["uri"] 做 unquote;生成重定向地址时,再对 Location 做 quote。一个负责解码,一个负责编码,方向不能写反。
curl -I 并不适合验证图片处理结果
curl -I 发出的是 HEAD 请求,适合快速看响应头,却不适合测试需要返回图片 body 的完整处理逻辑。我的代码后来对非 GET 请求直接跳过,避免 HEAD 请求触发没有必要的图片处理。
真正验证缩略图时,我会用 GET 把文件下载下来:
|
|
下载完成后再看响应头、文件类型、像素尺寸和文件体积。
动图缩小以后仍然很大
GIF 和动态 WebP 的问题不只在分辨率,还在帧数。只把宽度从原始尺寸缩到 300 像素,最终文件仍可能很大。
我后来增加了 get_frame 参数。它大于 1 时,会从完整动画里均匀选出指定数量的帧,而不是只留下开头几帧。被跳过帧的持续时间会合并到保留帧里,尽量不改变整段动画的时长。例如 60 帧保留 10 帧,这 10 帧会分布在整段动画里。
缩略图桶与清理策略
CloudFront 中除了默认图片行为,还需要增加一个指向缩略图桶的行为:
|
|
![]()
图 7:为缩略图桶增加独立的 CloudFront 行为
这样,超过 Lambda@Edge 响应限制的结果可以先落到缩略图桶,再通过 /_thumbs/* 地址返回。
缩略图本质上是可重新生成的缓存,没有必要永久保存。我在缩略图桶上按 _thumbs/ 前缀设置 S3 生命周期规则,例如对象创建 30 天或 90 天后删除。文件被清理后,下一次请求会重新生成。
S3 生命周期有个容易误解的地方:它按对象年龄处理,不是“最后一次访问后多久删除”。真要按最后访问时间回收,就得另外记录访问时间,或者分析 CloudFront 日志,复杂度会高不少。我这里把缩略图当成随时可以重新生成的缓存,按创建时间清理就够了。
跑了一周之后
上线后的第一周,我主要看了 CloudFront 的出站流量。和之前相比,大约少了三分之二,列表页这些高频场景也不再默认拉完整原图,基本达到了这次改造的目的。
为什么能降这么多,最确定的还是单张图片体积变小了。手机端缓存可能也帮了一部分忙:文件变小以后,同样的缓存空间能留下更多图片,重复请求自然会少一些。不过当时没有把这两个因素拆开统计,所以后者只是我的判断。
Lambda@Edge 也带来了一些额外工作。第一次请求某个尺寸时要现场处理图片,部署新代码要重新发布版本,日志还分散在不同区域。尺寸参数也不能完全放开,否则一张图可以生成很多份缓存。对这个项目来说,我愿意接受这点复杂度,毕竟流量的变化已经摆在那里。
如果你们的图片同样放在 S3 和 CloudFront,原图又必须保留,可以先看看列表页和详情页是不是也在无差别加载原图。很多时候不需要改动现有的上传流程,只要把日常访问切到合适尺寸,流量就能少掉一大块。