WAF 误杀了正常请求,这种破事做过 Web 业务的人多少都碰过。我在一次把业务切到 CloudFront 后面时遇到了更麻烦的版本:用户登录、下单、上传文件这些正常请求被 WAF 拦截,源站根本没收到,业务数据出现了缺口,事后对账才发现少了一堆订单和埋点日志。后来用 CloudFront + Lambda@Edge 双函数架构把数据缺口补了回来,这篇文章就把整套方案和踩过的坑讲清楚,适合正在被"误杀"折磨的运维、后端和 SRE 同学参考。
1. 先搞清楚:WAF 误杀到底让业务丢了什么
1.1 典型误杀场景复盘
先说几类我在线上真实遇到过的误杀,你大概率也见过。
第一类是登录接口。登录框提交用户名字段,正常用户填了一个包含 "or" 或引号的字符串,比如名字叫 "O'Brien",后端拼接的查询逻辑就可能触发 WAF 的注入规则,整条请求被拦。更常见的是用户输入 admin' -- 这种在安全测试里常见、但普通用户也可能因为键盘误触打出来的内容,直接命中 WAF 的 SQL 注入特征,返回 403。结果是用户怎么都登不上,前端误以为账号被锁,实际上请求根本没到源站。
第二类是上传模块。业务需要用户上传简历、附件、甚至包含代码片段的文本文件,文件内容里一旦出现 <?php、<script>、select from 这类关键词,整个上传请求会被判定为攻击载荷。最典型的就是你传了一个正常的包含 "一句话" 三个字的说明文档,因为内容里正好有某个规则的触发条件,直接命中,上传失败。热词里提到的"上传成功了访问的时候被 WAF 拦截了"就是这种:文件已经传上去了,但访问路径里带有敏感文件名或参数,WAF 在 GET 请求时又拦了一道,业务方以为文件丢失,其实是访问被截断了。
第三类是带复杂参数的 JSON 接口。POST 请求的 body 里有一个字段值是正常的富文本内容,里面有 <img src=x onerror=...> 这种标签,或者 Markdown 里的 URL 带特殊字符,WAF 经常把这类语义误判为 XSS。更麻烦的是,如果接口是给 App 埋点用的,用户请求被拦后数据丢了,日志系统又不会主动报错,直到数据分析发现漏斗异常才追查出来。
1.2 为什么不能直接关 WAF 或简单加白名单
遇到误杀的第一反应多半是"把这规则关了吧",或者"给这个 IP 加白名单"。这两种操作我都做过,最后都后悔了。
直接关 WAF 规则,等于把一个重要的安全层去掉。你关掉的可能是某条误报的注入规则,但同一条规则恰恰拦住了真正扫描接口的恶意请求。安全团队复盘的时候一定会问:误杀损失大,还是被攻击的损失大?答案是都不能放弃,只能做精细化处置。
只加 IP 白名单更不靠谱。办公网、App 用户出口 IP 是动态的,运营商 NAT 池可能几万人共享一个出口 IP,把这个 IP 放进白名单等于给攻击者开了个口子。更别说现在很多攻击源就是用正常云厂商 IP 发起的,你根本没法靠 IP 区分好人坏人。
所以正确的方向是:不动 WAF 的整体防护能力,在它前面加一层"业务感知",让真正合规的请求尽量少触发误报,同时在误杀已经发生时,把丢失的数据想办法补回来。这就是后面这套双函数架构存在的意义。
1.3 "补数据"的本质:请求根本没到源站
理解补数据,先要理解一个关键事实:WAF 误杀后,请求在源站之前就被终断了。业务服务器没有收到这个请求,数据库没有写入记录,日志系统没有任何痕迹。你去看业务日志,会发现这段时间里这段流量"消失"了。
这和普通的接口报错完全不同。接口 500 是源站收到了请求但处理失败,你至少有日志可以排查,有异常可以告警。而 WAF 拦截是请求在入口就被丢弃,业务侧完全无感。后果是:
- 登录事件、下单请求、埋点数据全部丢失,数据分析口径不准。
- 用户看到 403 页面,体验受损,但业务方不知道有多少人受影响。
- 运营数据对账时发现缺口,却找不到根因。
所以补数据的本质,是把"被 WAF 丢弃但业务合法"的请求,从边缘层重新捞回来,要么让请求重新到达源站,要么把请求中的业务数据写入一个补偿通道。这样业务侧拿到的数据才是完整的,而不是对着一张残缺的表做各种猜测。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双函数架构:在边缘层把数据缺口补回来
2.1 为什么选 CloudFront + Lambda@Edge
解决误杀和数据补偿,可以在 WAF 层做自定义规则,可以在源站做 Nginx 处理,但最优雅的位置其实是 CDN 边缘节点。尤其是你已经有 CloudFront 在前面挡着的时候,用 Lambda@Edge 处理有天然优势。
第一,请求在到达源站之前,CloudFront 边缘节点是最先接触流量的地方。在这里处理请求改写,可以避免请求带着"原样特征"进入源站前的 WAF,从源头减少误判。
第二,Lambda@Edge 默认分布在全球各边缘节点,请求不用绕行到中心区域,处理延迟很小。你不可能把一段逻辑搬到每个机房,但边缘函数可以。
第三,Lambda@Edge 有多个触发时机。viewer-request 在用户请求到达边缘时触发,origin-response 在源站返回响应时触发,viewer-response 在响应返回给用户前触发。这意味着你可以在请求链路的多个阶段做不同的事情,这正是双函数架构的基础。
有人可能会问:为什么不在源站直接写个中间件做补偿?因为源站在 WAF 后面,被拦截的请求根本到不了源站,中间件根本看不到这些请求。只有在 WAF 前面的边缘层才能"看到"拦截发生,才有机会干预。
2.2 两个函数如何分工
这套架构的核心是两个 Lambda@Edge 函数,分别在请求侧和响应侧工作。
函数 A:请求修复函数(viewer-request 阶段)。它的职责有四个:识别已知误杀特征、对请求做规范化改写、给合规请求打上业务标记、记录请求上下文。简单说,它是"尽可能让合法请求活着到达源站"的前置处理层。
函数 B:补偿触发函数(viewer-response 阶段)。它的职责是:识别 WAF 拦截响应(通常看状态码和响应头特征),取出函数 A 预存的请求上下文,把这个请求的关键信息写入补偿队列。简单说,它是"发现请求已经死了,把尸体解剖存证"的后置兜底层。
为什么必须拆成两个函数,而不是一个函数在请求阶段处理完所有事?因为 Lambda@Edge 在 viewer-response 阶段拿不到原始请求体。如果用户在请求阶段把 body 传给业务,但响应阶段的事件的 request 对象里只有 URL、方法和请求头,body 数据已经拿不到了。所以函数 A 必须在请求阶段就把请求体、参数这些关键信息预存下来,函数 B 在响应阶段根据请求标识去查,才能完成补偿。
这两个函数之间通过一个请求 ID 关联。函数 A 在改写请求时生成一个 UUID,放到自定义 Header 里,同时把完整请求上下文写入 DynamoDB 或者 Redis;函数 B 在响应阶段从响应头或者请求头里取出这个 ID,再去查上下文,写入补偿队列。整个过程不需要两个函数直接通信,靠的是共享存储,解耦又可靠。
2.3 一次请求从进入到落库的完整链路
画一下完整链路,方便你对照理解。
用户发起请求后,CloudFront 边缘节点收到请求,先执行函数 A。函数 A 检查请求是否匹配已知误杀特征。如果匹配,就做规范化处理,比如对 QueryString 做安全的重新编码、把 body 里的危险字符转为 Unicode 转义、移除可能触发规则的空格和注释符,同时添加内部标记 Header。处理完的请求继续走。
请求到达源站前的 WAF,此时由于请求已经被规范化,误判率下降。如果 WAF 放行,源站正常处理请求并返回 200,函数 B 在响应阶段什么都不做。如果 WAF 仍然拦截,返回 403 或者带有拦截标识的页面,函数 B 捕获到异常信号,从共享存储里取出函数 A 留下的请求上下文,写入补偿队列。
补偿队列里的数据不会马上消失。之后会有一个异步的重放任务,定时的扫描补偿队列,把请求重放到源站的一个内部补偿接口,或者直接将业务字段写入数据仓库和日志系统。重放成功后会打上 completed 标记,失败的会进入重试队列,避免数据丢失。
这个链路里,函数 A 是"防患于未然",函数 B 是"亡羊补牢",两者配合才能把误杀的影响降到最低。
3. 关键实现:请求修复函数怎么写
3.1 识别规则:哪些请求属于"误杀模式"
函数 A 的第一步不是改写,而是判断这个请求是不是值得特殊处理。我的经验是把误杀模式做成可配置的规则列表,而不是在代码里硬编码。
规则列表至少包含三部分:匹配路径、匹配方法、误杀特征描述。比如:
- 登录接口
/api/login,POST 方法,特征是用户名字段中包含单引号或or、and等 SQL 关键字。 - 上传接口
/api/upload,POST 方法,特征是文件名或文件内容包含 HTML 标签或 PHP 标记。 - 埋点接口
/api/track,POST 方法,特征是参数值包含<script>或者 URL 编码后的危险字符组合。
规则可以放在 DynamoDB 里,函数 A 每次启动时读取,也可以做成简单的 JSON 配置放到 Lambda 代码同级的文件中。我建议一开始用文件配置就行,等规则多了再迁到 DynamoDB。
要注意的是,匹配规则不能过宽。我只对已知的、历史上发生误杀的路径和特征做处理,绝不放开对所有请求的改写。因为任何请求改写都有风险,改写的范围越广,出问题的面就越大。规则宁缺毋滥。
另外,函数 A 判断完全依靠模式匹配,误伤的可能性也存在。比如用户本来就想提交一个包含 OR 关键字的值,结果被我们规范化了,业务侧拿到手不是用户原始输入,这是有风险的。所以规范化必须可逆:转义后的值在源站拿到后要能还原,或者修改字段本身不影响业务语义。我后面会讲具体做法。
3.2 请求改写:规范化、Header 标记、安全编码
请求改写的核心是"在不改变业务语义的前提下,去掉触发 WAF 误判的特征"。
我常用的三个手段:
第一,QueryString 重新编码。很多误杀是因为 URL 里的参数值中有未编码的特殊字符,比如 ?name=O'Brien,单引号可能触发注入规则。做法是在函数 A 里对 QueryString 做 parse 和重新 encodeURIComponent,让特殊字符变成 %27 这种安全形态。WAF 对编码后的内容基本不敏感,而源站拿到后解码和原来的值完全一致,业务无感。
第二,请求体字段脱敏。对于 POST 请求,把 body 里危险字段的值做安全转义。举个例子,用户提交的文本包含 <script>,你可以把 < 转成 \u003c。转义之后,WAF 的 XSS 检测规则不会命中,但源站业务逻辑如果支持逆转换,就把原文还原了。这里要特别注意:业务侧必须配合,不能只改边缘不改源站。
第三,添加内部标记 Header。规范化本身不能完全规避所有 WAF 判断,加一个 x-biz-verified: true 的头,源站可以据此判断这是一个经过了业务侧合规检查的请求。但这里有一个安全前提:源站必须只信任来自 CloudFront 的请求,也就是源站和 CloudFront 之间配置了自定义 Header 鉴权,防止外部请求伪造这个头直接访问源站。
实现的时候还要小心一个细节:QueryString 解析时可能会破坏 URL 参数顺序,如果业务侧对参数顺序敏感(一般不会),改写前要把顺序保留下来。
3.3 完整代码示例(Node.js 版)
下面是一个完整的函数 A 示例,使用 Node.js 18 运行时。可以看到它做了三件事:规则匹配、QueryString 编码、写入预存记录。
javascript复制'use strict';
const crypto = require('crypto');
// 预存请求上下文到 DynamoDB 的表
const TABLE_NAME = 'waf-request-context';
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient({
region: 'us-east-1'
});
// 已知误杀模式规则列表
const RULES = [
{
path: '/api/login',
method: 'POST',
field: 'username',
// 常见触发 WAF 注入规则的特征
patterns: [/('|\bOR\b|\bAND\b|--|#)/i],
// 对字段做安全转义
sanitize: (value) =>
value.replace(/'/g, '%27').replace(/--/g, '%2D%2D')
},
{
path: '/api/upload',
method: 'POST',
field: 'content',
patterns: [/(<script|<\?php|javascript:)/i],
sanitize: (value) =>
value.replace(/</g, '\\u003c').replace(/>/g, '\\u003e')
}
];
exports.handler = (event, context, callback) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// 只有匹配规则列表的请求才处理
const rule = RULES.find(
(r) => r.method === request.method && request.uri.startsWith(r.path)
);
if (!rule) {
return callback(null, request);
}
// 改写 QueryString,对特殊字符重新编码
if (request.querystring) {
const params = new URLSearchParams(request.querystring);
// 解析后重新编码
request.querystring = params.toString();
}
// 改写请求体
if (request.body && request.body.data) {
try {
let body = Buffer.from(request.body.data, 'base64').toString('utf-8');
const jsonBody = JSON.parse(body);
if (typeof jsonBody[rule.field] === 'string') {
jsonBody[rule.field] = rule.sanitize(jsonBody[rule.field]);
}
request.body.data = Buffer.from(JSON.stringify(jsonBody)).toString('base64');
} catch (err) {
// 解析失败不做处理,避免请求被误改
console.error('skip body rewrite:', err);
}
}
// 生成请求 ID,后续补偿函数通过它取上下文
const requestId = crypto.randomUUID();
headers['x-biz-request-id'] = [
{ key: 'x-biz-request-id', value: requestId }
];
// 对改写了内容且确实匹配误杀特征的请求,打上业务验证标记
headers['x-biz-verified'] = [
{ key: 'x-biz-verified', value: 'true' }
];
// 异步把完整上下文存入 DynamoDB(如果同步做会拖慢请求)
const ttl = Math.floor(Date.now() / 1000) + 1800;
const params = {
TableName: TABLE_NAME,
Item: {
requestId,
uri: request.uri,
method: request.method,
querystring: request.querystring,
clientIp: request.clientIp,
headers: JSON.stringify(headers),
// 请求体也存下来,供后续补偿重放使用
body: request.body ? request.body.data : null,
status: 'pending',
ttl
}
};
dynamodb.put(params, (err) => {
if (err) {
console.error('save context failed:', err);
}
});
callback(null, request);
};
这段代码有几个关键点需要注意。
第一,网络请求是同步的,写入 DynamoDB 是异步的,所以函数 A 不会因为写库而增加用户的等待时间。DynamoDB 的写入失败不要影响请求本身的转发,最多就是补偿阶段拿不到上下文。
第二,请求体的 base64 编解码要按 CloudFront 的格式处理。CloudFront 传给 Lambda@Edge 的 body 数据是 base64 编码过的,你处理时需要先解码再编码。
第三,TTL 设置半小时就够了。补数据一般实时性要求高,半小时内没处理完说明补偿链路出问题了,留着过期数据反而浪费存储。
4. 关键实现:补偿函数与异步重放
4.1 响应阶段捕获 WAF 拦截信号
函数 B 要解决的核心问题是:如何判断这个响应是被 WAF 拦截,而不是源站真的返回了 403。
我的做法是两个信号同时判断,避免误触发。首先是状态码,WAF 拦截一般返回 403 或者 405;其次是响应头,大多数 WAF 会带一个标识性的 Header,比如 x-waf-block: true,或者 server: waf 之类的特征。在代码里同时检查这两个信号,才能减少误判。
也有一些 WAF 拦截后返回的是 302 重定向到验证页,这种情况要单独处理。我建议先在测试环境打一颗真实的误杀请求,把 WAF 拦截响应的完整 Header 抓下来,再在函数 B 里硬编码或配置化匹配。
函数 B 在 viewer-response 阶段的代码并不复杂,主要工作是拼装信息、写入补偿队列。
javascript复制'use strict';
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient({
region: 'us-east-1'
});
const TABLE_NAME = 'waf-request-context';
exports.handler = (event, context, callback) => {
const { request, response } = event.Records[0].cf;
// 只处理 403 且响应头里带有 WAF 标识的响应
const isWafBlock =
response.status === '403' &&
response.headers &&
(response.headers['x-waf-block'] || response.headers['x-waf-intercept']);
if (!isWafBlock) {
return callback(null, response);
}
// 从请求头里取函数 A 写入的请求 ID
let requestId = null;
if (request.headers['x-biz-request-id']) {
requestId = request.headers['x-biz-request-id'][0].value;
}
if (!requestId) {
// 没有请求 ID,说明请求没有经过函数 A,无法补偿,交给业务方线下处理
return callback(null, response);
}
// 把补偿记录写入 DDB,标记为等待重放
const params = {
TableName: TABLE_NAME,
Key: { requestId },
UpdateExpression: 'SET #s = :s, interceptedAt = :t',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: {
':s': 'compensating',
':t': Date.now()
}
};
dynamodb.update(params, (err) => {
if (err) {
console.error('update context status failed:', err);
}
});
callback(null, response);
};
这个函数本身做的事不多:发现拦截、取出请求 ID、更新状态。真正的数据处理在后面的重放任务里。
4.2 补偿队列设计:不是所有数据都适合直接重放
补偿队列我建议直接用 DynamoDB 表,因为函数 A 已经写了一张 waf-request-context 表,函数 B 更新状态,重放任务扫描状态字段即可。这样一套存储解决所有问题,不需要再引入 SQS 或 Redis。
表结构大概是:requestId 作为主键,uri、method、querystring、body、status(pending/compensating/completed/failed)、ttl。补偿任务每 60 秒扫一次 status = compensating 的记录。
但这里有一个关键问题:这些被拦截的请求,哪些可以直接重放到源站,哪些只能提取数据写入数据仓库?
比如登录接口,重放一个登录请求意义不大,因为用户可能已经放弃了登录,强行重放反而会产生脏数据。这时候应该只提取请求中的用户信息、时间、IP,写入一个独立的"数据补偿表",供数据分析和报表使用。
而埋点接口、日志上报接口这类无状态请求,重放是安全的。直接把请求搬到源站内部补偿接口,让正常的数据链路跑一遍即可。
所以我在重放任务里做了一层分流:根据 uri 前缀判断是"重放类型"还是"数据提取类型"。重放类型调用源站的真实接口;数据提取类型只解析请求上下文,把关键字段写入补偿数据表。这个逻辑在代码里用配置维护,确保不同类型的接口走不同通道。
4.3 重放任务与幂等控制
重放任务我建议独立部署一个普通的 Lambda 函数,用 CloudWatch Events 定时触发,不要再用 Lambda@Edge。因为它不在用户请求链路上,不需要边缘部署,而且可以进行长耗时、网络调用。
核心逻辑三件事:扫描待补偿记录、调用源站补偿接口、更新状态。每一步都要做好异常处理和幂等。
幂等是这里最容易翻车的点。如果补数据任务失败了又重新执行,同一个请求可能被插入两次。我在重放前先检查目标表或源站接口中是否已有同样的 requestId。如果源站接口支持幂等键,就把 requestId 作为幂等键传入;如果不支持,需要在业务库加一个唯一约束。这个一定要在方案设计时就和业务方对齐,否则后面会产出大量重复数据。
重放的请求带有原始上下文,要特别注意脱敏和权限控制。补偿接口绝对不能暴露在公网,我通过安全组或者私有网络访问,源站还要校验一个专用的 header 才接受补偿请求,防止接口被恶意刷。
4.4 完整代码示例与触发关系
下面是一个示例性质的重放任务逻辑,定时触发,处理补偿请求。
javascript复制'use strict';
const AWS = require('aws-sdk');
const https = require('https');
const dynamodb = new AWS.DynamoDB.DocumentClient({ region: 'us-east-1' });
const TABLE_NAME = 'waf-request-context';
const COMPENSATE_API = process.env.COMPENSATE_API; // 源站内部补偿接口
const REQUEST_ID_HEADER = process.env.REQUEST_ID_HEADER || 'x-replay-request-id';
exports.handler = async () => {
// 扫描状态为 compensating 的记录
const items = await scanItems();
for (const item of items) {
try {
// 按路径分流
if (item.uri.startsWith('/api/track') || item.uri.startsWith('/api/log')) {
await callCompensateApi(item);
} else {
await writeCompensationRecord(item);
}
await updateStatus(item.requestId, 'completed');
} catch (err) {
console.error('replay failed:', item.requestId, err);
await updateStatus(item.requestId, 'failed');
}
}
};
async function callCompensateApi(item) {
const body = Buffer.from(item.body || '', 'base64');
const headers = {
'Content-Type': 'application/json',
[REQUEST_ID_HEADER]: item.requestId
};
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: COMPENSATE_API,
path: item.uri,
method: item.method || 'POST',
headers
},
(res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve();
} else {
reject(new Error(`compensate api returned ${res.statusCode}`));
}
}
);
req.on('error', reject);
req.end(body);
});
}
async function scanItems() {
const params = {
TableName: TABLE_NAME,
FilterExpression: '#s = :status',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: { ':status': 'compensating' }
};
const result = await dynamodb.scan(params).promise();
return result.Items || [];
}
async function updateStatus(requestId, status) {
const params = {
TableName: TABLE_NAME,
Key: { requestId },
UpdateExpression: 'SET #s = :s, updatedAt = :t',
ExpressionAttributeNames: { '#s': 'status' },
ExpressionAttributeValues: { ':s': status, ':t': Date.now() }
};
await dynamodb.update(params).promise();
}
这个任务本身不复杂,但有个坑:DynamoDB 的 scan 全表扫描性能一般,数据量大了要改成按时间范围查询,或者直接用 SQS 队列,函数 B 拦截时直接发一条消息到 SQS,重放任务从 SQS 拉取。我更推荐后者,可以避免 scan 的性能瓶颈,也更符合事件驱动的思路。
5. 部署与验证:从灰度到全量
5.1 Lambda@Edge 关联 CloudFront 的配置流程
这套架构的部署有几个硬性条件,搞错了函数根本不会生效。
第一,Lambda@Edge 函数必须创建在 us-east-1 区域。不是你想部署在哪就在哪,CloudFront 只认这个区域。你在其他区域创建的函数是关联不上的,这一点新手最容易踩。
第二,CloudFront 关联的是函数的特定版本,不是 $LATEST。你在控制台创建函数后,需要发布一个新版本,然后选择那个版本进行关联。这样后续函数迭代不会影响线上流量,只有显式更新关联的版本才生效。
第三,要给 CloudFront 添加事件触发器。函数 A 选择 Viewer Request 事件类型,函数 B 选择 Viewer Response。如果还需要处理源站返回的拦截响应,可以再考虑关联 Origin Response,但通常 Viewer Response 就够用了。
配置完成后,建议先在一个独立的 Distribution 上做验证,确认无误后再切换到主域名。毕竟边缘函数有问题影响的是全球流量,回滚起来虽然快,但影响范围已经出现了。
5.2 两个最容易踩的坑:函数版本、超时限制
Lambda@Edge 有两个限制直接影响方案设计,我单独拿出来说。
第一个是函数超时。viewer-request 和 viewer-response 阶段的函数超时上限是 5 秒,origin 阶段是 30 秒。这意味着函数 A 里做规则匹配和简单的字符串处理没问题,但如果要做复杂的网络调用,基本不可能。所以我把写 DynamoDB 设计成异步的,函数 B 也只是更新状态,重放任务放到定时任务里,这样每个边缘函数都足够轻量。
第二是环境变量的支持问题。Lambda@Edge 的 Viewer 事件不支持环境变量,你没法在函数配置里通过环境变量动态切换配置。我的做法是规则列表放代码里,或者从 DynamoDB 读取;与业务相关的配置通过 TableName 硬编码。这个限制逼着你用外部化配置的思路,其实也是好事,配置变更不用发版本。
另外还有一个隐性问题:Lambda@Edge 的函数体最终会被复制到每个边缘节点,冷启动时下载和初始化都需要时间。如果你的函数体非常大(比如引入了重型 npm 依赖),冷启动会明显变慢,影响整个请求时延。所以这两个函数我只使用了 crypto 和 aws-sdk,够用且体积小。
5.3 数据核对与回滚策略
上线前后一定要做一个数据核对,这是整个方案的"验收标准",没有它就不知道补的数据到底对不对。
具体做法:在灰度阶段,选取一个低峰时间段,从 WAF 日志里拉出被拦截的请求清单,和补偿队列里的记录做对比。比对维度包括请求时间、URI、客户端 IP,看补偿任务是否把每一条被拦截的合法请求都处理了。如果没有处理,查看失败原因,通常是补偿接口超时或者字段解析不完整。
回滚策略同样重要。函数 A 改写请求如果出现问题,会导致正常请求也被改坏。上线前要确保 CloudFront 关联的 Lambda 版本可以快速回退到 $LATEST 或者上一个稳定版本。我建议保留一个"空操作"版本的函数:不识别规则、不改写、不加 Header,只是透传请求。出问题时,把 CloudFront 关联的版本切换到空操作版本,请求链路就恢复原状了,不需要动 WAF 的配置。
6. 线上问题速查与避坑实录
6.1 常见问题排查表
到这里,我把这套架构在实际上线过程中遇到的高频问题整理成一张表,方便你直接对照排查。
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 请求还是被 WAF 拦截 | 函数 A 的规则没匹配到当前请求 | 先确认函数 A 日志里有没有打印该请求的 URI,确认规则是否生效 |
| 请求被改写后源站报错 | 改写逻辑破坏了业务字段 | 看源站日志,确认请求体是否被正确解析,必要时关掉 body 改写 |
| 补偿队列里有数据但重放失败 | 补偿接口鉴权不通过或超时 | 先用 curl 模拟补偿接口调用,确定接口本身可用性 |
| 重放产生了重复数据 | 幂等键未生效 | 检查源站是否已按 requestId 去重,没有就要加唯一约束 |
| DynamoDB 表数据增长很快 | TTL 设置不合理 | 确认每个请求的 ttl 字段是否自动计算,过期的记录会自动清理 |
还有一个常见问题:函数 A 里生成的 requestId 在响应阶段取不到。这通常是因为响应阶段的事件对象里的 request 头来自原始请求,而函数 A 添加的头如果在源站或 WAF 被剥离了,响应阶段就拿不到。解决办法是把 requestId 同时放到响应头的某个字段里,函数 B 优先从响应头读取,读取不到再找请求头。
6.2 三条保命经验
最后分享几条我自己从事故里总结的保命经验,不一定写进文档,但关键时刻能救你。
第一,误杀规则的匹配面一定要窄。我最初把规则写得比较激进,结果有的正常请求里恰好包含敏感关键词,被函数改写后业务侧拿到不是原始值,产生了一批脏数据。后来改成"路径 + 方法 + 字段"三重匹配,宁可漏掉一部分误杀场景,也不能误改正常请求。因为漏掉的场景可以用补偿函数兜住,但被误改的请求数据是补不回来的。
第二,在 WAF 控制台的误报屏蔽设置里做"最小化"配置。云厂商的 WAF 控制台一般都提供误报屏蔽功能,你可以把确认误报的 URL 加进白名单。但我的原则是:只加具体的接口和规则 ID,绝不做全局白名单。比如清除了某条规则对 /api/login 的误判,就只对这条规则加白,其他攻击规则仍然生效。架构里的函数 A 处理的是那些"还不能确定是否值得加白"的动态场景,而控制台的误报屏蔽处理的是"已经确认无误"的固定场景,两者各管一摊。
第三,函数的日志和监控必须从一开始就接入。Lambda@Edge 的日志可以在 CloudWatch Logs 里查看,但函数分布在全球多个边缘节点,日志会分散存储。建议在函数 A 和函数 B 里主动打印关键字段,包括 requestId、URI、状态码和告警信息。这样出了问题,你能立即知道是哪个节点、哪个请求出了问题,而不是大海捞针式的翻日志。
这套方案上线运行之后,对我的意义不只是解决了误杀的问题,更让我意识到边缘层能做的不只是缓存和加速,它完全可以成为业务请求的"容错层"。误杀、逃票、降级这些原本要回源站处理的异常场景,都能在边缘提前拦截和处理。你如果也在被 WAF 误杀和数据缺失折磨,可以按这个思路先搭一版简单的验证,跑通之后再逐步完善规则和补偿逻辑,效果会比我这里写的还要贴合你的业务。
