用 Lambda@Edge 动态生成图片缩略图,顺便记录几个踩过的坑

AWS Lambda@Edge 图片缩略图实践与排障

某个项目的图片资源一直放在 AWS S3,再通过 CloudFront 对外分发。用户上传的图片、业务封面图虽然在 App 端做了裁剪和压缩,但还是顶不住原图本身体积大。需要查看原图时没有问题,列表页、详情页和图片墙也一直加载这些大图,就有点浪费了。

访问量上来以后,CloudFront 的出站流量也跟着涨。这个问题和图片能不能正常访问没关系,主要还是日常页面根本用不到原始尺寸,却一直在传原图。

针对这种情况,我用 Lambda@Edge 做了一套按需生成缩略图的方案。原图继续保留,前端通过查询参数指定目标尺寸;没有指定参数的高频接口,则默认给图片地址带上缩略图参数。缩略图第一次访问时生成,后面交给 CloudFront 和单独的 S3 桶缓存。

经观察,上线后看了一周,CDN 出站流量大约少了三分之二。

采用的方案

建议不要直接覆盖或压缩原图,而是把原图和缩略图分开管理:

  • 原图桶保留用户上传的原始文件;
  • 缩略图桶保存 Lambda 动态生成的结果;
  • CloudFront 负责缓存和分发;
  • Lambda@Edge 只在需要时读取原图并生成缩略图;
  • S3 生命周期规则定期清理旧缩略图。

Lambda@Edge 动态缩略图链路

图 1:Lambda@Edge 动态缩略图链路

访问方式类似下面这样:

1
2
/path/to/image.png?size=300x0
/path/to/image.gif?size=300x0&get_frame=10

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,再绑定一个能够读取原图桶、读取和写入缩略图桶的执行角色。

创建 Lambda 函数时确认区域、运行时和架构

图 2:创建函数时先确认区域、运行时和架构

Lambda@Edge 和普通 Lambda 还有些区别:它不支持 ARM64、容器镜像和 Lambda Layer。Pillow 这类带二进制文件的依赖,得按照目标 Python 版本、x86_64 架构和 Linux ABI 一起打进 ZIP 包,部署以后没法再靠 Layer 补。

本地构建完成后,在函数的“代码”页面选择“从 .zip 文件更新”。

从 ZIP 文件更新 Lambda 代码

图 3:从 ZIP 文件更新 Lambda 代码

测试时权限可以先放宽,跑通后还是要收回来。最后只留下读取原图和缩略图所需的 s3:GetObject,以及写缩略图用的 s3:PutObject。信任关系需要同时允许:

1
2
lambda.amazonaws.com
edgelambda.amazonaws.com

发布版本并关联 CloudFront

上传代码之后还不能直接把 $LATEST 关联到 CloudFront。Lambda@Edge 只能使用带编号的已发布版本,也不能用别名代替。

第一次可以从“操作”菜单选择“部署到 Lambda@Edge”,后续修改则先发布新版本,再把新的版本 ARN 更新到 CloudFront 行为中。

发布 Lambda 新版本

图 4:发布 Lambda 新版本

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

在 CloudFront 的源响应阶段关联 Lambda@Edge

图 5:在 CloudFront 的源响应阶段关联 Lambda@Edge

每次更新代码后,实际上都要走完下面这条链路:

1
2
3
4
5
6
7
8
9
构建并上传新 ZIP
  ↓
发布新的 Lambda 版本
  ↓
更新 CloudFront 关联的版本 ARN
  ↓
等待 Distribution 变成 Deployed
  ↓
按需执行缓存失效

如果只是上传了新代码,却忘记发布版本或更新 CloudFront 关联,看起来就会像“Lambda 怎么一直没有变化”。

URL 有参数,Lambda 却收不到

第一版部署完成后,我访问了带 size 参数的 URL,返回的却还是原图。开始时一直在检查 Lambda 代码,后来才发现问题在 CloudFront 的缓存策略。

当时 size 和 get_frame 没有被加入缓存键,Lambda 事件中的 request["querystring"] 也是空字符串。同一张图片的不同尺寸本来应该是不同的缓存对象,缓存键里没有尺寸参数,除了 Lambda 拿不到参数,还可能让不同尺寸的请求共用一份缓存。

解决方式是在对应行为上创建自定义 Cache Policy,把真正参与图片处理的查询参数加入允许列表:

把 size 和 get_frame 加入 CloudFront 缓存键

图 6:把 size 和 get_frame 加入 CloudFront 缓存键

修改后还要等 Distribution 部署完成。已有路径如果命中过旧缓存,需要再创建一次针对相关图片路径的 invalidation,否则会误以为配置仍然没有生效。

Lambda 页面看不到调用记录

普通 Lambda 的日志通常比较好找,但 Lambda@Edge 会在靠近请求的区域执行,日志也会写入对应区域的 CloudWatch Logs。函数虽然是在 us-east-1 创建的,调用日志却不一定出现在这个区域。

排查时,先查看响应头:

1
curl -I "https://<cdn-domain>/path/to/image.png?size=300x0"

重点关注:

1
2
3
x-cache
x-amz-cf-pop
x-amz-cf-id

如果看到:

1
x-cache: LambdaExecutionError from cloudfront

说明 Lambda@Edge 已经触发了,只是执行失败。接下来可以从 CloudFront 的监控页面看函数在哪些区域运行,再切换到相应区域查 CloudWatch Logs。默认日志组名称通常类似:

1
/aws/lambda/us-east-1.<function-name>

这里的 us-east-1 是函数创建区域前缀,不代表日志一定存放在 us-east-1。

3 秒超时带来的 503

日志找到以后,503 的原因也出来了。请求返回:

1
2
HTTP 503
x-cache: LambdaExecutionError from cloudfront

CloudWatch 中对应的记录是:

1
2
3
Status: timeout
Duration: 3000.00 ms
Memory Size: 128 MB

函数已经触发,但配置还是默认的 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 为例,最终包内应该能看到与目标运行时匹配的文件,例如:

1
PIL/_imaging.cpython-314-x86_64-linux-gnu.so

构建依赖时,需要通过 pip 的平台、Python 版本和仅使用二进制包等参数,把目标环境写清楚。

中文路径和特殊字符导致 S3 找不到对象

CloudFront 事件中的 uri 可能是 URL 编码后的路径。如果直接把它作为 S3 key 使用,中文名称、空格或其他特殊字符可能触发 NoSuchKey。

处理方式也比较直接:读 S3 之前先对 request["uri"] 做 unquote;生成重定向地址时,再对 Location 做 quote。一个负责解码,一个负责编码,方向不能写反。

curl -I 并不适合验证图片处理结果

curl -I 发出的是 HEAD 请求,适合快速看响应头,却不适合测试需要返回图片 body 的完整处理逻辑。我的代码后来对非 GET 请求直接跳过,避免 HEAD 请求触发没有必要的图片处理。

真正验证缩略图时,我会用 GET 把文件下载下来:

1
2
3
curl -s -D /tmp/headers.txt \
  -o /tmp/resized-image.png \
  "https://<cdn-domain>/path/to/image.png?size=300x0"

下载完成后再看响应头、文件类型、像素尺寸和文件体积。

动图缩小以后仍然很大

GIF 和动态 WebP 的问题不只在分辨率,还在帧数。只把宽度从原始尺寸缩到 300 像素,最终文件仍可能很大。

我后来增加了 get_frame 参数。它大于 1 时,会从完整动画里均匀选出指定数量的帧,而不是只留下开头几帧。被跳过帧的持续时间会合并到保留帧里,尽量不改变整段动画的时长。例如 60 帧保留 10 帧,这 10 帧会分布在整段动画里。

缩略图桶与清理策略

CloudFront 中除了默认图片行为,还需要增加一个指向缩略图桶的行为:

1
2
Path pattern: _thumbs/*
Origin: 缩略图桶

为缩略图桶增加独立的 CloudFront 行为

图 7:为缩略图桶增加独立的 CloudFront 行为

这样,超过 Lambda@Edge 响应限制的结果可以先落到缩略图桶,再通过 /_thumbs/* 地址返回。

缩略图本质上是可重新生成的缓存,没有必要永久保存。我在缩略图桶上按 _thumbs/ 前缀设置 S3 生命周期规则,例如对象创建 30 天或 90 天后删除。文件被清理后,下一次请求会重新生成。

S3 生命周期有个容易误解的地方:它按对象年龄处理,不是“最后一次访问后多久删除”。真要按最后访问时间回收,就得另外记录访问时间,或者分析 CloudFront 日志,复杂度会高不少。我这里把缩略图当成随时可以重新生成的缓存,按创建时间清理就够了。

跑了一周之后

上线后的第一周,我主要看了 CloudFront 的出站流量。和之前相比,大约少了三分之二,列表页这些高频场景也不再默认拉完整原图,基本达到了这次改造的目的。

为什么能降这么多,最确定的还是单张图片体积变小了。手机端缓存可能也帮了一部分忙:文件变小以后,同样的缓存空间能留下更多图片,重复请求自然会少一些。不过当时没有把这两个因素拆开统计,所以后者只是我的判断。

Lambda@Edge 也带来了一些额外工作。第一次请求某个尺寸时要现场处理图片,部署新代码要重新发布版本,日志还分散在不同区域。尺寸参数也不能完全放开,否则一张图可以生成很多份缓存。对这个项目来说,我愿意接受这点复杂度,毕竟流量的变化已经摆在那里。

如果你们的图片同样放在 S3 和 CloudFront,原图又必须保留,可以先看看列表页和详情页是不是也在无差别加载原图。很多时候不需要改动现有的上传流程,只要把日常访问切到合适尺寸,流量就能少掉一大块。

参考资料

0%