WAF 误杀了正常请求怎么补数据?CloudFront + Lambda@Edge 双函数架构实战

12 阅读4分钟

WAF 误杀了正常请求怎么补数据?CloudFront + Lambda@Edge 双函数架构实战

被 WAF 拦了一批正常请求,body 没存下来,怎么办?最近看到亚马逊云科技官博的一个方案挺有意思——在 CDN 层用两个 Lambda@Edge 函数,一个存 body,一个记日志,全程不改源站代码。

亚马逊云科技官博今天(3/31)发了一篇实战文章:用 Amazon CloudFront 双 Lambda@Edge 架构,在不改源站代码的前提下,完整记录被拦截和出错的请求(含 headers 和 body),然后异步重放补数。

这思路挺巧的,拆解一下。

先说痛点

做过 CDN + WAF 架构的都遇到过这几个问题:

  1. WAF 误杀:安全规则有时候拦的是正常业务请求,事后想找回原始请求,只有日志里的 URL 和 status code,body 没了
  2. 源站临时挂了:返回 500/502 的请求,CDN 层面只能看到"失败了",具体请求内容不知道
  3. 改源站不现实:源站可能在第三方云、可能是供应商的系统、可能没代码权限

传统方案要么改源站代码加中间件,要么用 CloudFront 实时日志(但不含 body)。这次的方案在 CDN 层解决了所有问题。

架构核心:双 Lambda@Edge

整个方案用两个 Lambda@Edge 函数配合:

请求 → CloudFront → [WAF 检查]
                       ↓ 通过
              origin-request Lambda@Edge
              (把 request body 存进自定义 header)
                       ↓
                    源站处理
                       ↓
              origin-response Lambda@Edge
              (检测 4xx/5xx → 记录完整请求到 CloudWatch Logs)
                       ↓
              CloudWatch Logs → Kinesis Data Firehose → S3

关键设计:

1. body 怎么传递

Lambda@Edge 在 origin-request 阶段能拿到 request body,但 origin-response 阶段拿不到。所以第一个函数把 body 塞进自定义 header X-Original-Body,第二个函数从 header 里读出来。

// origin-request Lambda@Edge
exports.handler = async (event) => {
    const request = event.Records[0].cf.request;
    
    if (request.body && request.body.data) {
        // 把 body 存进自定义 header
        request.headers['x-original-body'] = [{
            key: 'X-Original-Body',
            value: request.body.data
        }];
    }
    
    return request;
};

2. 只记失败的

origin-response 阶段检查状态码,只有 4xx/5xx 才触发记录逻辑。成功请求零开销。

// origin-response Lambda@Edge
exports.handler = async (event) => {
    const response = event.Records[0].cf.response;
    const request = event.Records[0].cf.request;
    const status = parseInt(response.status);
    
    if (status >= 400) {
        const failedRequest = {
            timestamp: new Date().toISOString(),
            uri: request.uri,
            method: request.method,
            headers: request.headers,
            body: request.headers['x-original-body'] 
                  ? request.headers['x-original-body'][0].value 
                  : null,
            responseStatus: status
        };
        
        // 写入 CloudWatch Logs
        console.log(JSON.stringify(failedRequest));
    }
    
    return response;
};

3. 日志怎么归档

Lambda@Edge 的日志自动进 Amazon CloudWatch Logs,加个 Subscription Filter 把日志投递到 Amazon Kinesis Data Firehose,再落到 Amazon S3。整条链路全 Serverless,不用管服务器。

# CloudFormation 片段
Resources:
  LogSubscription:
    Type: AWS::Logs::SubscriptionFilter
    Properties:
      DestinationArn: !GetAtt DeliveryStream.Arn
      FilterPattern: '"failedRequest"'
      LogGroupName: !Ref LambdaLogGroup
      
  DeliveryStream:
    Type: AWS::KinesisFirehose::DeliveryStream
    Properties:
      S3DestinationConfiguration:
        BucketARN: !GetAtt FailedRequestsBucket.Arn
        BufferingHints:
          IntervalInSeconds: 60
          SizeInMBs: 5

WAF 拦截的请求怎么办

被 AWS WAF 拦截的请求根本到不了 origin-request 阶段。但 WAF 自己有日志,包含 headers 和 body 前 8KB。同样通过 CloudWatch Logs → Kinesis → S3 归档。

两条路径最终汇聚到同一个 S3 桶,补数脚本从 S3 读取后统一处理。

补数重放脚本

import json
import boto3
import requests

s3 = boto3.client('s3')

def replay_failed_requests(bucket, prefix, target_url):
    """从 S3 读取失败请求日志并重放"""
    paginator = s3.get_paginator('list_objects_v2')
    
    for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
        for obj in page.get('Contents', []):
            response = s3.get_object(Bucket=bucket, Key=obj['Key'])
            
            for line in response['Body'].read().decode().split('\n'):
                if not line.strip():
                    continue
                    
                record = json.loads(line)
                
                # 重放请求
                replay_response = requests.request(
                    method=record['method'],
                    url=f"{target_url}{record['uri']}",
                    headers={k: v[0]['value'] for k, v in record['headers'].items()
                            if k.lower() not in ('host', 'x-original-body')},
                    data=record.get('body')
                )
                
                print(f"重放 {record['uri']}: {replay_response.status_code}")

成本分析

这方案的成本很可控:

组件计费方式估算(月 100 万请求,1% 失败率)
Lambda@Edge按调用次数 + 执行时间origin-request 处理所有请求,origin-response 只记失败的
CloudWatch Logs按数据量只有失败请求产生日志
Kinesis Data Firehose按数据量和日志量成正比
S3存储 + 请求日志文件通常很小

1% 失败率意味着每月只记录 1 万条请求,日志数据量可能就几十 MB。

实际用这个方案的场景

除了官博提到的 WAF 误杀和源站故障,还有几个常见场景:

  1. API 网关限流补偿:前端 CDN 层限流返回 429,记录被限的请求,低峰期重放
  2. 灰度发布回滚:新版本出 bug 导致 500,记录这段时间的请求,回滚后重放
  3. 跨云架构调试:源站在其他云上,出了问题想在亚马逊云科技侧看完整请求信息

用 OpenClaw 做运维自动化的话,可以写个 Skill 定期扫描 S3 里的失败请求日志,自动判断是否需要重放、通知值班人员、或者直接执行重放脚本。

几个注意点

  1. body 大小限制:Lambda@Edge 的 request body 默认上限 1MB(可调到 40KB 在 viewer-request,1MB 在 origin-request)。超大 body 需要截断
  2. 性能影响:origin-request 阶段给所有请求加了一次 header 操作,延迟增加约 1-5ms
  3. 安全考虑:存储了完整请求 body,可能包含敏感数据。S3 桶必须加密,设置生命周期策略自动清理
  4. 区域限制:Lambda@Edge 只能部署在 us-east-1,但会自动复制到全球边缘节点

亚马逊云科技官博原文:aws.amazon.com/cn/blogs/ch… Amazon CloudFront:aws.amazon.com/cn/cloudfro… AWS Lambda@Edge:docs.aws.amazon.com/AmazonCloud… OpenClaw:github.com/openclaw/op…