如何在 AWS Lambda 中识别验证码而不被超时打断

在 AWS Lambda 里做验证码识别会在两个地方出问题,而这两个地方都不是你的代码。API Gateway 等函数等到 29 秒就不再等了,所以一次要花 40 秒的 reCAPTCHA,会在函数还在干活的时候就给调用方返回 504。另外,Lambda 沙箱里的 127.0.0.1 指的就是这个沙箱,所以指向回环地址的客户端找不到任何在监听的东西。CapSkip 跑在你自己拥有的机器上,而在这套架构里,那台机器永远不会是运行你函数的那一台。先把地址修对,再把识别挪出请求路径。
你需要什么
- CapSkip 运行在一台你能控制的 Windows 机器上。它是一个桌面应用,不会跑在 Lambda 里面。在这里,函数只是客户端,仅此而已。
- Python 3.10 或更高版本的 Lambda 运行时,CapSkip 包放在部署包里,或者放在一个层里。
- 打开服务器模式。本地模式在 127.0.0.1 上应答,只服务于这一台设备,这对跑在 AWS 上的函数毫无用处。服务器模式监听你的网络地址或公网 IP,让函数能通过同一套 API 访问到它,两者都设置在 连接设置。建议使用静态公网 IP,并为 AWS 出来的那一个地址配一条防火墙规则。
- 从函数能访问到那个地址的网络通路。第 2 步会讲两种形态,因为挂到 VPC 上的函数和没挂 VPC 的函数表现并不一样。
为什么 API Gateway 的 29 秒超时决定了整个设计
在 AWS Lambda 上识别一个验证码,必须同时塞进三条限制里。动手写代码之前先把它们列出来,因为它们合在一起会直接排除掉那个最显而易见的设计。
| Limit | 值 | 能调高吗 |
|---|---|---|
| API Gateway 集成超时 | 默认 29 秒 | 区域级和私有 REST API 可以,通过配额申请。AWS 提醒,这次提升可能会占用你账户的限流配额 |
| Lambda 函数超时 | 默认 3 秒,标准函数最长 900 秒 | 可以,上限就是那 15 分钟 |
| CapSkip 轮询超时 | reCAPTCHA、Turnstile 和极验(GeeTest)为 300 秒,图片验证码和 ALTCHA 为 120 秒 | 可以,两者都是构造函数选项 |
所以一次同步 API 调用盖不住一次慢的 reCAPTCHA。函数本身有余量,它前面的网关没有,于是调用方看到的是 504,而识别还在跑,也还在计费。
大家接下来会想到的那个变通做法更糟。提前返回、把识别丢到后台线程上接着做是行不通的,因为处理函数一返回,Lambda 就会冻结执行环境。AWS 说得很直白:函数结束时尚未完成的后台进程或回调,会在 Lambda 复用这个环境时恢复执行。是恢复,不是继续。你的线程会在几分钟后醒来,停在为某个验证码轮询到一半的位置,而那个验证码的 token 早就过期了,它醒来时所处的那次调用还和它毫无关系。什么错误都不会报。这份工作只是落到了错误的地方。AWS 对这套生命周期的完整说明,见 它的执行环境指南.
第 1 步:打包 SDK 并配置函数
装到一个目录里,连同你的处理函数一起打成 zip;或者装到一个名为 python 的目录里,把它打成 zip 并作为层挂上去。平台和解释器要锁定成函数运行时的那一套,而不是你笔记本上的那一套,否则冷启动时导入会失败,日志里还看不到任何有用的信息。不加这些参数的话,pip 会按你本地的 Python 去解析 wheel,而为更新的解释器构建的 wheel 在这个运行时上加载不了。
# pip install capskip pip install capskip --target package/ \ --platform manylinux2014_x86_64 --implementation cp \ --python-version 3.12 --only-binary=:all: cp lambda_function.py package/ cd package && zip -r ../function.zip . > /dev/null && cd .. aws lambda update-function-code \ --function-name solve-captcha --zip-file fileb://function.zip
然后把超时和连接信息放进配置里,而不是写在代码里,这样同一个包既能连测试环境的识别工具,也能连生产环境的。
# Timeout in seconds, and the Server mode address
aws lambda update-function-configuration \
--function-name solve-captcha \
--timeout 330 \
--environment "Variables={CAPSKIP_HOST=203.0.113.10,CAPSKIP_PORT=8080}"把函数超时设得比客户端自己的轮询超时略高一点,不要更低。低了的话,Lambda 会先杀掉这次调用,你在 CloudWatch 里只会拿到一条干巴巴的任务超时,而不是那个本来能告诉你发生了什么的 TimeoutException。
第 2 步:给函数配上 NAT gateway 和 Elastic IP
这一步决定了整套东西能不能跑通,而答案取决于一个你可能压根没把它当成网络配置的设置项。
| 函数配置 | 它能访问到什么 | 防火墙上要放行什么 |
|---|---|---|
| 没有挂到 VPC 上 | 直接就能访问公网 | 没什么可放行的。出站流量来自 AWS 自己的地址,而且会变,所以没有哪个单独的 IP 能加进白名单 |
| 挂到 VPC 上,没有 NAT gateway | 只有那个 VPC 里面的东西。你的识别工具不在里面 | 什么都不用。连接是超时,而不是被拒绝 |
| 挂到 VPC 上,并通过 NAT gateway 路由 | 公网,而且来自同一个地址 | NAT gateway 的 Elastic IP,这正是你想要的形态 |
前两行 Lambda 的文档直接写了:函数默认具备公网访问能力;而一旦把它挂到 VPC 上,在函数所在的子网有一条出网路由之前,它能访问的就只剩那个 VPC 里的资源。这条出网路由就是放在公有子网里的一个 NAT gateway,说明见 Lambda 互联网访问指南。真正有用的是它的副作用。NAT gateway 持有一个 Elastic IP,所以每一次识别请求都从同一个稳定地址到达你的机器,你的防火墙规则可以只写一行。
把函数挂到私有子网上,不要挂到公有子网。这就是那个在已经有 NAT gateway 的情况下仍然导致挂起的坑:挂在公有子网上的函数没有公网访问能力,不管路由表怎么写,数据包就是哪儿也去不了。同一份指南把这句话重复了两遍。
NAT gateway 是拿到一个稳定源地址最简单的形态,但不是唯一的形态。从同一个 VPC 出发的 Site-to-Site VPN 或 Direct Connect,能访问到你自己网络里的识别工具,而完全不必把它的端口暴露到公网上;如果那台机器所在的位置你不太愿意开端口,这两种方案都值得花时间搭。不管走哪条路,都要让识别工具的端口对其他所有来源保持关闭。服务器模式依然是你自己的硬件,依然不计用量:它只改变识别工具监听在哪里,好让同一台桌面机之外的东西也能调用它。
第 3 步:把识别挪出请求路径
有了上面那几条限制,AWS Lambda 上的验证码任务就该放在队列上,而不是放在请求里。响应 API Gateway 的那个处理函数,不应该是做识别的那个:接下任务,写进队列,立刻回应。再由第二个函数去读队列并干活,它的超时按验证码来设,而不是按一个网页请求来设。
import json, os, uuid, boto3
sqs = boto3.client("sqs")
QUEUE_URL = os.environ["QUEUE_URL"]
def lambda_handler(event, context):
"""API Gateway calls this. It never solves anything."""
body = json.loads(event["body"])
job_id = str(uuid.uuid4())
sqs.send_message(
QueueUrl=QUEUE_URL,
MessageBody=json.dumps({
"job_id": job_id,
"sitekey": body["sitekey"],
"pageurl": body["pageurl"],
}),
)
return {"statusCode": 202,
"body": json.dumps({"job_id": job_id})}任务 id 的存在,是为了让调用方之后有个东西可以查询。消费端要设计成自己把活干完,而不是把 token 交回去,因为一个还要等第二趟 HTTP 往返的 token,通常在路上就过期了。
用队列,而不是异步调用。Lambda 默认会把失败的异步调用重试两次,而验证码识别恰恰是不该盲目重试的东西:第二次尝试用的还是那个 sitekey,可它所在的页面上下文早就变了,而且不管成不成,识别的代价你都要付。队列不会消灭重试,它只是让重试变得可见、可控。你会拿到一个自己说了算的可见性超时、一条重驱动策略,以及一个死信队列,反复失败的任务会落到那里,你随时能去看。AWS 建议最大接收次数至少设为五,这样在消息被搁置之前,还留有一次被限流重试的余地。
把队列的可见性超时设成消费函数超时的至少六倍,AWS 出于同样的限流原因也是这么建议的。这个大小关系不是可选项:Lambda 会校验事件源映射,如果函数超时大于可见性超时,它会直接拒绝。按上面那个 330 秒的函数算,可见性超时要接近 1980 秒。
完整可运行示例
这是消费端。它在处理函数外面只构造一次客户端,这样热环境会复用它,而不是每来一条消息就重连一次。
# pip install capskip
import json, os
from urllib.parse import urlencode
from urllib.request import urlopen
from capskip import (CapSkip, ApiException, NetworkException,
TimeoutException, ValidationException)
# Built at cold start and reused while the environment stays warm.
solver = CapSkip(
host=os.environ["CAPSKIP_HOST"], # Server mode address
port=int(os.environ.get("CAPSKIP_PORT", 8080)),
recaptchaTimeout=300,
)
def lambda_handler(event, context):
failures = []
for record in event["Records"]:
job = json.loads(record["body"])
try:
result = solver.recaptcha(
sitekey=job["sitekey"],
url=job["pageurl"],
)
except NetworkException:
# No route to the solver. Retry this one message.
failures.append({"itemIdentifier": record["messageId"]})
continue
except (ApiException, TimeoutException, ValidationException) as exc:
print("giving up on this job:", exc)
continue
# Use the token here. It is short lived, so do not park it.
urlopen(job["pageurl"], data=urlencode(
{"g-recaptcha-response": result["code"]}).encode())
# Needs ReportBatchItemFailures on the event source mapping.
return {"batchItemFailures": failures}上报失败的那条消息,而不是抛异常。抛异常会让整批失败,SQS 随后会把这一批里的每条消息都退回队列,包括你已经识别完的那些,这正是下面表格警告的重复识别问题。部分批次响应只重试真正失败的那条记录,而它要生效,还需要在事件源映射上启用 ReportBatchItemFailures。
对 NetworkException 重试,其余三个直接吞掉。到识别工具没有路由,意味着这个任务以后还有机会成功;而一个识别不了的验证码、一次超时或者一个错误的参数,每次尝试都会以同样的方式失败,重试只是把同样的时间再花一遍。如果你更愿意只捕获一样东西,SDK 的这四个异常都派生自 CapSkipError。
那一次提交才是整个设计的意义所在:在同一次调用里,把这个 token 本来要用来做的事做掉。一个 reCAPTCHA token 大约只有两分钟有效期,所以把它写进数据库、留给后面某一步去取,通常取到的都是已经过期的东西。关于这个时间窗口的细节,见 reCAPTCHA v2 识别指南;每个 SDK 调用背后的原始端点,文档见 API 参考文档.
常见错误及其含义
| 你所看到的 | 原因 | 修复 |
|---|---|---|
| 29 秒后 API Gateway 返回 504,而 CloudWatch 显示函数还在运行 | 是集成超时,不是函数超时 | 立即回应请求,把识别放到队列上做 |
| 日志里出现 Task timed out after 3.00 seconds | 是默认的函数超时,在它咬你一口之前没人会去改 | 把它调到客户端轮询超时之上 |
| 抛出 NetworkException,里面写着 127.0.0.1 | 沙箱里的回环地址指向的是沙箱本身,而 CapSkip 不在里面 | 切换到服务器模式,并设置 host 环境变量 |
| 连接一直挂着,直到函数超时 | 函数挂在一个没有出网路由的 VPC 上,所以数据包哪儿也去不了,而不是被拒绝 | 加一个 NAT gateway。把函数从 VPC 上摘下来同样能恢复公网访问,但那样你的防火墙就没法只放行单个地址了 |
| 已经有了 NAT gateway,却还是同样地挂住 | 函数挂的是公有子网,而不是那些私有子网 | 把它挂到私有子网上,路由指向 NAT gateway 的是这些子网 |
| 在你的笔记本上能通,从函数里就不行 | 防火墙放行的是你家里的地址,没有放行 AWS 那个 | 放行 NAT gateway 的 Elastic IP |
| 一批里的每个任务都被识别了两次 | 有一条记录抛了异常,于是 SQS 把整批都退了回来,包括那些已经成功的记录 | 改为上报失败的那条记录,而不是抛异常,并启用 ReportBatchItemFailures |
| Lambda 拒绝创建事件源映射 | 函数超时大于队列的可见性超时,而 Lambda 会校验这一点 | 把可见性超时调到函数超时的至少六倍 |
| 一次识别在毫不相干的另一次调用里完成了 | 处理函数返回时后台线程被冻结,下一次调用时又被解冻 | 返回之前把识别做完。这里没有发完就不管这种玩法 |
| 抛出 TimeoutException,里面写着 300 秒 | CapSkip 没有在 reCAPTCHA 轮询超时内给出答案 | 检查识别工具是否在运行且没有饱和。调高上限只会让同样的结果来得更晚 |
| 手写的轮询循环里返回 CAPCHA_NOT_READY | 答案还没准备好,这是一个正常的中间状态,不是错误 | 交给 SDK 去轮询,或者读一读 该错误码的指南 |
| 日志里出现 Unable to import module lambda_function, no module named capskip | 包是按错误的架构或者错误的解释器装的,又或者它在层里的路径不对 | 安装时带上 platform、implementation 和 python version 这几个参数,并把层的内容放在 zip 根目录下的 python 目录里 |
常见问题
CapSkip 本身能跑在 Lambda 里面吗?
不能,而且也不需要。CapSkip 是一个 Windows 应用,跑在你自己拥有的硬件上,而你函数里的 SDK 只是它的一个 HTTP 瘦客户端。打开服务器模式,把函数指向那个地址,函数调用它的方式和同一张桌子上的脚本完全一样。识别始终留在你自己的机器上,这也是为什么识别次数不会被任何人计量。
在 API Gateway 后面到底能不能做识别?
有时候可以,而这取决于验证码类型,不取决于你的配置。一个图片验证码或者一次 ALTCHA 工作量证明常常一两秒就完成,塞进 29 秒里还很宽裕。reCAPTCHA 或者 Turnstile 挑战页则经常做不到,一旦做不到,调用方就拿到 504,而工作还在继续,也还在计费。如果整个产品就是一个同步端点,那就为你的 REST API 申请提高集成超时,并实测你自己的流量究竟要花多久。不过真正不会在凌晨三点给你惊喜的,仍然是队列那种形态。
怎么才能只让我的函数访问到识别工具?
把函数挂到 VPC 上,让它的出站流量走 NAT gateway,然后在防火墙上放行这个网关的 Elastic IP。这是拿到一个稳定源地址最简单的形态,因为 VPC 之外的函数是从 AWS 自己的地址出去的,而这些地址会在你不知情时变化。Site-to-Site VPN 或 Direct Connect 能做到同样的事,而且完全不必向公网开端口。把识别工具的端口对其他所有来源关死,并把 API 密钥当成第二道锁,而不是唯一的一道。
识别时间长会让函数变贵吗?
Lambda 按墙上时钟的时长计费,所以一个坐在那里等答案的函数,和一个在做算术的函数按同样的费率付钱。这是支持队列方案的第二个理由:消费端不需要很大的内存规格,因为它是在等网络,而不是在计算,而且它等的时候上游也没有被阻塞。识别本身按每个验证码算是不花钱的,因为它发生在你自己的机器上。同样的权衡在别的托管平台上也会出现, Azure Functions 指南 把那边的对应做法走了一遍。
简短版结论
在 AWS Lambda 上做验证码识别需要三个决定,而它们全都在你写处理函数之前就定下来了。把 CapSkip 切换到服务器模式,因为 Lambda 沙箱里的回环地址什么也访问不到。把函数挂到 VPC 上并通过 NAT gateway 路由出去,这样你的防火墙只需要放行一个 Elastic IP。然后别再在请求路径上做识别:API Gateway 只给你 29 秒,一次慢的 reCAPTCHA 需要更多,而提前返回也帮不上忙,因为你的处理函数一返回,执行环境就跟着冻结了。把任务写进队列,在一个超时高于客户端超时的消费端里完成识别,并在挣到这个 token 的那一次调用里就把它用掉。
Python 包暴露的每一个方法,以及每个方法各自接受哪些选项,都列在 Python 验证码识别页面.
最后说一点经济账,因为正是它让这套队列设计变得让人安心。一个被丢掉的任务只花掉你那几毫秒的 Lambda 时间,除此之外什么都不花:这里的 验证码绕过 工作发生在你早就付过钱的硬件上,所以重试一个任务,或者把一个过期的 token 扔掉,永远不会出现在任何人开给你的账单上。
