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

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

zhuermu··12 分钟
CloudFrontLambda@EdgeAWSWAFServerless
用 CloudFront + Lambda@Edge 记录失败请求的 5 个坑

问题背景

我们有一个看起来很直白的需求:记录每一个失败的请求——即那些经过 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-requestorigin-request 两个阶段访问——而且前提是你在 CloudFront 触发器配置中显式开启了 “Include Body”(包含请求体)选项。当执行到达 origin-response 阶段时,请求体已经从事件对象中被剥离了。

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

教训: 在设计方案之前,务必先确认每个 Lambda@Edge 事件阶段能拿到哪些数据。AWS 关于 Lambda@Edge 事件结构的文档详细列出了每个阶段各自包含哪些字段。

坑 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 有明显不同:

标准 LambdaLambda@Edge
请求单价$0.20 / 100 万次请求$0.60 / 100 万次请求
计算单价(128 MB)$0.0000000021 / 毫秒$0.00000625 / 128 MB-秒
免费额度每月 100 万次请求 + 40 万 GB-秒
内存上限最高 10,240 MB128 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 函数

// 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 函数

// 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:CreateLogGrouplogs:CreateLogStreamlogs: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 限制的请求体截断:

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 日志路由。希望把它们汇集在这一处,能帮你省下一些时间。

参考资料

  1. Lambda@Edge — AWS Documentation
  2. Lambda@Edge in the Lambda Developer Guide — AWS Documentation
  3. Amazon Kinesis Data Streams — AWS Documentation