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

aws lambda captcha - How to Solve CAPTCHAs in AWS Lambda Without Timing Out

在 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 扔掉,永远不会出现在任何人开给你的账单上。