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


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

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

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

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

<!--more-->

## 采用的方案

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

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

![Lambda@Edge 动态缩略图链路](/images/20260822/lambda-edge-thumbnail-flow.webp)

*图 1：Lambda@Edge 动态缩略图链路*

访问方式类似下面这样：

```text
/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 函数时确认区域、运行时和架构](/images/20260822/lambda-create-function.webp)

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

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

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

![从 ZIP 文件更新 Lambda 代码](/images/20260822/lambda-upload-zip.webp)

*图 3：从 ZIP 文件更新 Lambda 代码*

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

```text
lambda.amazonaws.com
edgelambda.amazonaws.com
```

## 发布版本并关联 CloudFront

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

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

![发布 Lambda 新版本](/images/20260822/lambda-publish-version.webp)

*图 4：发布 Lambda 新版本*

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

![在 CloudFront 的源响应阶段关联 Lambda@Edge](/images/20260822/cloudfront-origin-response.webp)

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

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

```text
构建并上传新 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 缓存键](/images/20260822/cloudfront-cache-query-strings.webp)

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

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

## Lambda 页面看不到调用记录

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

排查时，先查看响应头：

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

重点关注：

```text
x-cache
x-amz-cf-pop
x-amz-cf-id
```

如果看到：

```text
x-cache: LambdaExecutionError from cloudfront
```

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

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

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

## 3 秒超时带来的 503

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

```text
HTTP 503
x-cache: LambdaExecutionError from cloudfront
```

CloudWatch 中对应的记录是：

```text
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 为例，最终包内应该能看到与目标运行时匹配的文件，例如：

```text
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 把文件下载下来：

```bash
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 中除了默认图片行为，还需要增加一个指向缩略图桶的行为：

```text
Path pattern: _thumbs/*
Origin: 缩略图桶
```

![为缩略图桶增加独立的 CloudFront 行为](/images/20260822/cloudfront-thumbnail-behavior.webp)

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

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

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

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

## 跑了一周之后

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

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

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

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

## 参考资料

- [AWS：Lambda@Edge 限制](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-edge-function-restrictions.html)
- [AWS：CloudFront 与 Lambda@Edge 配额](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html)
- [AWS：Edge function 日志](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/edge-functions-logs.html)
- [AWS：Lambda 支持的运行时与弃用计划](https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html)


---

> 作者: kayoon  
> URL: https://iamky.cn/posts/aws/lambda-edge-dynamic-image-thumbnails/  

