用 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 的缓存策略。

当时 sizeget_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;生成重定向地址时,再对 Locationquote。一个负责解码,一个负责编码,方向不能写反。

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%