# 用 CloudFront + Lambda@Edge 记录失败请求的 5 个坑

> 我们用双 Lambda@Edge 方案为 CloudFront 实现了完整的请求日志记录。以下是我们踩过的 5 个坑。

- 作者: zhuermu
- 发布: 2026-03-18
- 网页版: https://zhuermu.com/blog/cloudfront-lambda-edge-failed-requests/

---
## 问题背景

我们有一个看起来很直白的需求：**记录每一个失败的请求**——即那些经过 CloudFront 分发的失败请求。"失败"包含两种情况：被 AWS WAF 拦截的请求（HTTP 403），以及到达源站但返回了 4xx 或 5xx 状态码的请求。对于每一次失败，我们都需要拿到完整的请求头和请求体，以便运维团队排查问题，并在需要时重放请求。

听起来很简单，对吧？CloudFront 挡在所有流量前面，Lambda@Edge 允许你介入请求的生命周期——只要在出问题时抓取数据并发送到 CloudWatch 就行。我们本以为一个下午就能搞定。

结果远不止一个下午。在这个过程中，我们发现了五个坑，逼着我们一次又一次重新思考方案，最终才落地了一个真正可用的方案。如果你也在做类似的东西，这篇文章或许能帮你省去同样的头疼。

## 快速科普：CloudFront 的四个事件阶段

在讲这些坑之前，先了解一下 Lambda@Edge 可以拦截请求的四个阶段会很有帮助：

1. **Viewer Request（查看器请求）** —— 当 CloudFront 从客户端收到请求时触发
2. **Origin Request（源站请求）** —— 在 CloudFront 将请求转发到源站之前触发（仅在缓存未命中时）
3. **Origin Response（源站响应）** —— 当 CloudFront 从源站收到响应时触发
4. **Viewer Response（查看器响应）** —— 在 CloudFront 将响应返回给客户端之前触发

每个阶段的能力和限制各不相同。理解这些差异，是接下来一切内容的关键。

## 坑 1：Origin-Response 无法访问请求体

我们的第一直觉是最显而易见的做法：把一个 Lambda@Edge 函数挂到 **origin-response** 事件上。当响应状态码是 4xx 或 5xx 时，就记录请求详情。干净又简单。

我们写好了函数、部署上线，随即撞上了一堵墙：**在 origin-response 事件中拿不到请求体**。

在 CloudFront 的 Lambda@Edge 模型中，请求体只能在 **viewer-request** 和 **origin-request** 两个阶段访问——而且前提是你在 CloudFront 触发器配置中显式开启了 "Include Body"（包含请求体）选项。当执行到达 origin-response 阶段时，请求体已经从事件对象中被剥离了。

从性能角度看，这在某种程度上是说得通的（为什么要把请求体一路带到不需要它的阶段呢？），但它彻底击碎了我们最初的方案。我们需要响应状态码来判断是否失败，但同时也需要请求体来做日志记录。而这两块信息分处不同阶段，永远不会同时出现。

**教训：** 在设计方案之前，务必先确认每个 Lambda@Edge 事件阶段能拿到哪些数据。[AWS 关于 Lambda@Edge 事件结构的文档](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-event-structure.html)详细列出了每个阶段各自包含哪些字段。

## 坑 2：两个不同的请求头大小限制

一旦意识到需要把请求体从较早的阶段（origin-request）传递到较晚的阶段（origin-response），最自然的机制就是自定义请求头。在 origin-request 阶段把请求体存进一个自定义头，然后在 origin-response 阶段读回来。

但这个头能有多大？CloudFront 文档里提到自定义头的限制是 **1,783 个字符**。这看起来太小了——我们大部分的 POST 请求体都远超 2KB。

深入挖掘后，我们发现 CloudFront 实际上有 **两个不同的请求头限制**，分别适用于不同的场景：

| 场景 | 限制 | 适用范围 |
|---------|-------|------------|
| 静态源站自定义头（在 CloudFront 控制台中配置） | 每个头值 1,783 个字符 | 你在分发设置中定义的头 |
| Lambda@Edge 动态头 | 请求总大小 20 KB（所有头合计） | 由 Lambda@Edge 函数添加或修改的头 |

1,783 字符的限制适用于你在 CloudFront 分发设置中配置的静态头。而当 Lambda@Edge 在运行时添加或修改头时，相关的限制是所有头合计的**请求总大小 20 KB**。这给了我们大得多的操作空间。

对于大多数 API 请求体来说，20 KB 绰绰有余。对于请求体超过这个限制的边缘情况，我们会截断并加上一个标志，表明请求体已被裁剪。实践中我们发现，超过 98% 的失败请求，其请求体都远低于这个阈值。

**教训：** CloudFront 的文档在描述各种限制时可能含糊不清，因为不同的限制适用于不同的场景。当你撞上某个限制时，先确认它是否适用于你的具体用例，还是针对另一条配置路径。

## 坑 3：自定义错误页看似完美，却丢失了上下文

在调研备选方案时，我们发现了 CloudFront 的 **自定义错误页（Custom Error Pages）** 功能。它允许你配置 CloudFront 把特定的错误状态码（比如 403 或 500）路由到指定的源站路径——例如一个由 Lambda 函数支撑的 API Gateway。

从纸面上看，这简直完美：CloudFront 检测到错误，路由到我们的日志 Lambda，我们就能抓取一切。完全不需要 Lambda@Edge。

我们做了一个概念验证，很快就发现了致命缺陷：**当 CloudFront 调用自定义错误页时，它会向错误页 URL 发起一个全新的 GET 请求。** 原始请求的头和体统统消失了。你能拿到的只有 CloudFront 附加的少数几个查询字符串参数，比如原始 URL 和状态码。

这是刻意如此设计的——自定义错误页是为了提供对用户友好的错误页面，而不是为了以编程方式访问原始请求。但这意味着我们恰恰丢掉了最需要的数据：请求头（其中包含认证令牌、会话 ID 和链路追踪信息）和请求体（其中包含我们想要重放的载荷）。

**教训：** 自定义错误页是为用户体验服务的，而不是为运维日志服务的。如果你在处理错误时需要原始请求的上下文，就得换一个思路。

## 坑 4：Lambda@Edge 的费用会累积起来

在我们决定采用 Lambda@Edge 方案后，一算成本账，还是略微吃了一惊。Lambda@Edge 的定价与标准 Lambda 有明显不同：

| | 标准 Lambda | Lambda@Edge |
|---|---|---|
| 请求单价 | $0.20 / 100 万次请求 | $0.60 / 100 万次请求 |
| 计算单价（128 MB） | $0.0000000021 / 毫秒 | $0.00000625 / 128 MB-秒 |
| 免费额度 | 每月 100 万次请求 + 40 万 GB-秒 | 无 |
| 内存上限 | 最高 10,240 MB | 128 MB（源站侧）/ 40 KB（查看器侧） |
| 超时时间 | 最长 15 分钟 | 30 秒（源站侧）/ 5 秒（查看器侧） |

按每次请求算，Lambda@Edge 大约比普通 Lambda **贵 3 倍**，而且没有免费额度。内存和超时限制也要紧得多。

话虽如此，当我们针对实际工作负载算账时——大约每天 100 万次请求，origin-request 函数在每次请求上都运行，而 origin-response 日志函数只在约 2% 的失败请求上真正干活——总费用大约是**每月 40 美元**。

明细如下：

- **Origin-request 函数**：3000 万次请求/月 x $0.60/100 万 = 请求费用 $18，加上简单的复制头操作的计算费用（每次约 5ms，128 MB）= 约 $12
- **Origin-response 函数**：3000 万次请求/月 x $0.60/100 万 = 请求费用 $18，但每月只有约 60 万次真正写入 CloudWatch，因此计算费用微乎其微 = 约 $10
- **总计**：约 $40/月

对于带完整请求捕获的生产环境错误可观测性来说，每月 40 美元完全合理。但提前把这笔账算清楚是值得的——如果你处理的是数亿次请求，成本会线性上升，可能变得相当可观。

**教训：** 对大多数工作负载而言，Lambda@Edge 的绝对费用并不高，但 3 倍的乘数和没有免费额度意味着你应该在决定之前先做成本建模。同时也要考虑到，你是在为每一次请求上都执行该函数付费，即便大部分请求都成功、函数几乎没干什么活。

## 坑 5：日志写到了边缘区域，而不是 us-east-1

Lambda@Edge 函数必须部署在 **us-east-1**——这是硬性要求。所以我们很自然地以为 CloudWatch 日志也会出现在 us-east-1。

其实不然。Lambda@Edge 函数在离用户最近的 CloudFront 边缘节点上执行，它们的 **CloudWatch 日志会写入该边缘节点所在的区域**。如果一个东京的用户触发了你的函数，日志就会进 `ap-northeast-1` 的 CloudWatch。法兰克福的用户？`eu-central-1`。弗吉尼亚的用户？只有这时日志才会落到 `us-east-1`。

这意味着你的日志会散落在 CloudFront 有边缘节点的每一个 AWS 区域——那可是相当多的区域。如果你想搜索某个特定失败请求的日志，可能得翻查十几个不同的 CloudWatch 日志组。

我们从两个方面解决了这个问题：

1. **对于实时告警**：origin-response Lambda 把结构化 JSON 写入 CloudWatch。我们配置了 CloudWatch 跨区域日志聚合，把所有日志汇总到一个中心账户。
2. **对于日志 Lambda 本身**：我们不再依赖 CloudWatch 作为最终归宿，而是让函数把失败记录写入一个集中式数据存储（在我们的场景里，是一个 SQS 队列，再喂给位于 us-east-1 的 DynamoDB 表）。

**教训：** 排查 Lambda@Edge 问题时，务必去处理该请求的边缘节点所在区域查看 CloudWatch 日志。更好的做法是，从一开始就把日志设计成写入一个集中式目的地。

## 方案对比：我们评估过的六种方案

在敲定最终架构之前，我们评估了六种不同的方案。下面是它们的对比：

| 方案 | 完整请求头 | 请求体 | 错误状态码 | 成本 | 复杂度 |
|----------|:---:|:---:|:---:|---|---|
| **A. 应用层日志** | 是 | 是 | 是 | 免费（仅改应用） | 低，但需要改动应用 |
| **B. CloudFront 实时日志 + Kinesis** | 部分（选定的头） | 否 | 是 | Kinesis 约 $30/月 | 中 |
| **C. ALB 访问日志** | 部分 | 否 | 是 | 免费（仅 S3 存储费） | 低 |
| **D. 自定义错误页 + Lambda** | 否 | 否 | 是 | 低 | 中 |
| **E. Origin-Request + 实时日志关联** | 是 | 是 | 需要异步关联 | 约 $50/月 | 高 |
| **F. 双 Lambda@Edge（我们的方案）** | 是 | 是 | 是 | 约 $40/月 | 中 |

**方案 A**（应用层日志）在你能掌控源站并对其进行修改时是最简单的。但如果你有多个源站、遗留服务或第三方后端，它就未必可行了。而且它也无法捕获那些从未到达源站、被 WAF 拦截的请求。

**方案 B**（CloudFront 实时日志）将选定的请求字段发送到一个 Kinesis Data Stream。它非常适合做分析，但只支持一组预定义的字段——你可以选择要包含哪些特定的头，却无法捕获请求体。

**方案 C**（ALB 访问日志）只有当你的源站位于 ALB 之后时才可用，而且日志中包含的头信息有限，也没有请求体。

**方案 D**（自定义错误页）因坑 3 所述的原因失败——你会丢失原始请求的上下文。

**方案 E** 是在 origin-request 阶段记录完整请求，再把它与来自实时日志的响应状态码关联起来。理论上可行，但需要一条异步流水线来连接这两路数据流，会增加延迟和复杂度。

**方案 F**（双 Lambda@Edge）是我们最终的选择。它以适中的复杂度和可预测的成本满足了每一项需求。

## 最终架构：双 Lambda@Edge

该方案使用两个协同工作的 Lambda@Edge 函数：

1. **Origin-Request 函数**：把请求体复制到一个自定义头（`x-original-body`）中。每次请求都运行，但只做极少的工作。
2. **Origin-Response 函数**：检查响应状态码。如果是 400 及以上，就提取原始的请求头和请求体（从那个自定义头里取），并记录完整的失败记录。

下面是流程图：

```
Client → CloudFront → [Viewer Request]
                     → [Origin Request] ← Lambda copies body to x-original-body header
                     → Origin Server
                     → [Origin Response] ← Lambda checks status, logs failures
                     → [Viewer Response]
         → Client
```

对于被 WAF 拦截的请求（403），origin-request 函数根本不会触发，因为 WAF 是在请求到达源站之前进行评估的。为了捕获这些请求，我们使用一个独立的 WAF 日志配置，通过 Kinesis Data Firehose 把被拦截的请求数据发送到 S3 存储桶。这是 WAF 的一个标准功能，运行可靠。

### Origin-Request 函数

```javascript
// origin-request.js
exports.handler = async (event) => {
  const request = event.Records[0].cf.request;

  // If the request has a body (POST, PUT, PATCH), store it in a custom header
  if (request.body && request.body.data) {
    request.headers['x-original-body'] = [{
      key: 'X-Original-Body',
      value: request.body.data
    }];
  }

  return request;
};
```

这个函数被刻意写得极简。它在每次缓存未命中时都会运行，所以把执行时间压到最低至关重要。它从 `request.body.data` 读取请求体（当请求体选项设为 "read-only" 或 "replace" 时，该数据是 Base64 编码的），并把它存进一个可在 origin-response 阶段访问的自定义头里。

**重要配置**：在为这个函数配置 CloudFront 触发器时，你必须开启 **"Include Body"** 选项。否则 `request.body` 将会是 `undefined`。

### Origin-Response 函数

```javascript
// origin-response.js
const { CloudWatchLogsClient, PutLogEventsCommand } = require('@aws-sdk/client-cloudwatch-logs');

exports.handler = async (event) => {
  const response = event.Records[0].cf.response;
  const request = event.Records[0].cf.request;
  const status = parseInt(response.status, 10);

  // Only log failed requests
  if (status < 400) {
    return response;
  }

  // Extract the original body from our custom header
  const originalBody = request.headers['x-original-body']
    ? request.headers['x-original-body'][0].value
    : null;

  // Build the failure record
  const failureRecord = {
    timestamp: new Date().toISOString(),
    status: response.status,
    statusDescription: response.statusDescription,
    method: request.method,
    uri: request.uri,
    querystring: request.querystring,
    headers: sanitizeHeaders(request.headers),
    body: originalBody ? decodeBody(originalBody) : null,
    clientIp: request.clientIp,
    responseHeaders: response.headers
  };

  // Log to CloudWatch (or send to SQS/Kinesis for centralized collection)
  console.log(JSON.stringify({
    type: 'FAILED_REQUEST',
    ...failureRecord
  }));

  return response;
};

function sanitizeHeaders(headers) {
  const sanitized = {};
  for (const [key, values] of Object.entries(headers)) {
    // Skip our internal transport header
    if (key === 'x-original-body') continue;
    sanitized[key] = values.map(v => v.value);
  }
  return sanitized;
}

function decodeBody(data) {
  try {
    // request.body.data is Base64-encoded
    return Buffer.from(data, 'base64').toString('utf-8');
  } catch (e) {
    return data;
  }
}
```

origin-response 函数承担了繁重的工作，但只在响应状态码表明失败时才做。对于约 98% 成功的请求，它在做完一次整数比较后就立即返回。对于失败的请求，它会构造一条结构化的 JSON 日志，其中包含运维团队排查问题、以及在需要时重放请求所需的一切信息。

### 部署要点

以下是部署时的一些实用建议：

1. **IAM 角色**：两个函数共用一个 IAM 角色即可，需要 `logs:CreateLogGroup`、`logs:CreateLogStream` 和 `logs:PutLogEvents` 权限。如果你要写入 SQS 或 DynamoDB，把相应的权限也加上。

2. **内存**：把两个函数都设为 **128 MB**（这是源站侧 Lambda@Edge 的上限）。对于它们要做的工作，这通常绰绰有余。

3. **超时**：我们把 origin-request 函数设为 1 秒，origin-response 函数设为 5 秒。origin-response 函数需要更多时间，因为它在失败时要写入 CloudWatch。

4. **版本管理**：Lambda@Edge 要求你部署一个带编号的版本（而不是 `$LATEST`）。在你的 CI/CD 流水线中把这一步自动化，以免手动发布版本。

5. **请求体大小处理**：如果你的 API 会接收大载荷，就在 origin-request 函数中加一个大小检查，把可能使头总大小超过 20 KB 限制的请求体截断：

```javascript
const MAX_BODY_SIZE = 15000; // Leave room for other headers
if (request.body && request.body.data) {
  const bodyData = request.body.data;
  request.headers['x-original-body'] = [{
    key: 'X-Original-Body',
    value: bodyData.length > MAX_BODY_SIZE
      ? bodyData.substring(0, MAX_BODY_SIZE)
      : bodyData
  }];
  if (bodyData.length > MAX_BODY_SIZE) {
    request.headers['x-body-truncated'] = [{
      key: 'X-Body-Truncated',
      value: 'true'
    }];
  }
}
```

## 小结

双 Lambda@Edge 方案并不是解决这个问题的唯一途径，也未必适合每一种场景。如果你能掌控源站并且可以修改应用代码，那么应用层日志（方案 A）更简单也更省钱。如果你只需要请求头而不需要请求体，那么 CloudFront 实时日志（方案 B）可能就够用了。

但如果你需要跨多个源站、捕获失败请求的**完整请求头和请求体**，并且要在 **CDN 边缘实时捕获**，那么双 Lambda@Edge 模式是一个可靠的选择。两个函数都很小，成本可预测，而且该方案对任何源站都适用，无需改动后端。

我们一路踩过的这五个坑，只要你知道去哪儿翻，都能在 AWS 文档中找到。难点在于，相关信息散落在多个文档页面里，分别涵盖 Lambda@Edge 事件结构、CloudFront 限制、自定义错误页、定价以及 CloudWatch 日志路由。希望把它们汇集在这一处，能帮你省下一些时间。

---

## 常见问题

### 如何在 CloudFront 中记录失败请求的完整请求体？

使用两个协同的 Lambda@Edge 函数：origin-request 函数把请求体复制到自定义头（x-original-body）中；origin-response 函数检查响应状态码，若为 400 及以上，就记录包含请求头和从该自定义头中还原的请求体的完整失败记录。之所以要这样做，是因为请求体只能在 viewer-request 和 origin-request 阶段访问，到 origin-response 阶段已被剥离。

### 为什么 Lambda@Edge 的 CloudWatch 日志不在 us-east-1？

虽然 Lambda@Edge 函数必须部署在 us-east-1，但它们在离用户最近的 CloudFront 边缘节点上执行，CloudWatch 日志会写入该边缘节点所在的区域——东京用户的日志进 ap-northeast-1，法兰克福用户的进 eu-central-1。排查时要去处理该请求的边缘节点所在区域查看日志；更好的做法是从一开始就把失败记录写入集中式目的地，例如经 SQS 队列汇入 us-east-1 的 DynamoDB 表。

### Lambda@Edge 比普通 Lambda 贵多少？

Lambda@Edge 的请求单价是每 100 万次 $0.60，而标准 Lambda 是 $0.20——按请求算约贵 3 倍，且没有免费额度。限制也更紧：源站侧函数内存上限 128 MB、超时上限 30 秒。以每天约 100 万次请求的负载为例，双函数方案（约 2% 的失败请求才真正写日志）的总费用大约是每月 40 美元。


---

## 参考资料

- [Lambda@Edge](https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/lambda-at-the-edge.html) — AWS Documentation
- [Lambda@Edge in the Lambda Developer Guide](https://docs.aws.amazon.com/lambda/latest/dg/lambda-edge.html) — AWS Documentation
- [Amazon Kinesis Data Streams](https://docs.aws.amazon.com/streams/latest/dev/introduction.html) — AWS Documentation
