如何在 Google Cloud Run 上识别验证码而不遇到 504

cloud run captcha - How to Solve CAPTCHAs on Google Cloud Run Without a 504

在 Cloud Run 上做验证码识别,可能在三个地方失败,其中两个地方你的代码根本看不到错误。Python buildpack 用 gunicorn 的默认配置启动你的应用,而 gunicorn 会杀掉持续忙碌 30 秒的 worker,一次 reCAPTCHA 识别经常就要这么久。Cloud Run 自己的请求超时默认为 300 秒,恰好等于 SDK 的 reCAPTCHA 轮询超时,所以这场赛跑总是 504 先到。另外,容器里的 127.0.0.1 指的就是容器本身。CapSkip 运行在你自己拥有的 Windows 机器上,服务只是客户端。下面这套部署能把这三个问题一并解决。

你需要什么

  • CapSkip 运行在一台你能控制的 Windows 机器上。它是一个桌面应用,不会跑在 Cloud Run 里面。服务只是通过 HTTP 调用它,仅此而已。
  • 已开启 Server 模式。Local 模式只在 127.0.0.1 上响应,仅供本机使用,这对 Google 网络里的容器毫无用处。Server 模式监听你的网络地址或公网 IP,让服务能通过同一套 API 访问到它,两者都位于 连接设置中。建议使用静态公网 IP,并为 Google 过来的那一个地址配一条防火墙规则。
  • 一个从源码部署的 Python 服务,其 requirements.txt 中列有 capskip、flask 和 gunicorn。CapSkip 包需要 Python 3.10 或更高版本。
  • gcloud CLI,以及第 3 步要用的一个 VPC 网络,该网络需在服务所在的区域里有一个子网。

为什么三个超时决定了 Cloud Run 验证码部署的成败

每次识别都有三个计时器同时在走,而在默认部署下,最先触发的偏偏是不该触发的那个。

计时器默认值触发时会发生什么
gunicorn worker 超时,来自 buildpack 的默认入口点30 秒worker 在识别中途被杀掉并重启,调用方收到一个服务器错误
Cloud Run 请求超时300 秒,最高可调到 3600 秒调用方收到 504,而容器还在继续处理这个请求
CapSkip reCAPTCHA 轮询超时300 秒客户端抛出一个你的代码可以处理的 TimeoutException

先说 gunicorn。对于 Python 源码部署,buildpack 的默认入口点是绑定到 8080 端口的 gunicorn,其他什么都没设置,这意味着一个 worker、一个线程,以及 gunicorn 的 30 秒 worker 超时。worker 沉默超过这个时限,gunicorn 就会把它杀掉并重启,而一个正在等一次慢识别的同步 worker 恰恰是沉默的。所以一次要花 40 秒的 reCAPTCHA 永远等不到结果。

再说那个平局。Cloud Run 在 300 秒时关闭连接并返回 504,而 Google 的文档指出,实例并不会被终止,所以你的代码可能还在继续处理一个已经没人在等的请求。SDK 的超时同样是 300 秒,但它的计时开始得更晚,要等请求到达、任务提交之后才开始。于是 Cloud Run 的计时器总是先触发,那个本可以告诉你发生了什么的 TimeoutException 永远到不了调用方。Google 的 请求超时指南 建议把上限设得高于预期的执行时间,同时也要检查你所用框架自身的超时。gunicorn 的超时就是这里说的框架超时。

第 1 步:编写服务

一个只有一个路由的小型 Flask 应用,保存为 main.py。客户端在导入时构建一次即可。它只保存自己的配置,所以 worker 里的每个线程都可以共用它。

# pip install capskip flask gunicorn
import os
from flask import Flask, jsonify, request
from capskip import CapSkip

app = Flask(__name__)

# The client does not read CAPSKIP_HOST by itself: pass it in.
solver = CapSkip(
    host=os.environ["CAPSKIP_HOST"],       # Server mode address
    port=int(os.environ.get("CAPSKIP_PORT", "8080")),
    apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
)

@app.post("/solve")
def solve():
    job = request.get_json(force=True)
    result = solver.recaptcha(sitekey=job["sitekey"], url=job["pageurl"])
    return jsonify(token=result["code"])   # use it straight away

用硬性查找而不是带默认值的方式读取 host,是有意为之。如果这个变量缺失,导入就会失败,gunicorn 无法启动 worker,新的修订版本也就永远不会开始提供服务。比起一个部署得干干净净、却在第一个真实请求时因连向回环地址而抛出 NetworkException 的修订版本,这种失败要清楚得多。

第 2 步:用正确的入口点、超时和并发数进行部署

上表中 Cloud Run 验证码服务需要的每一项修复都写在部署命令里,代码里一项都没有。

# Run from the folder holding main.py and requirements.txt
gcloud run deploy solve-captcha \
  --source . \
  --region us-central1 \
  --no-allow-unauthenticated \
  --set-build-env-vars GOOGLE_ENTRYPOINT="gunicorn --bind :8080 --workers 1 --threads 8 --timeout 0 main:app" \
  --timeout 400 \
  --concurrency 8 \
  --set-env-vars CAPSKIP_HOST=203.0.113.10,CAPSKIP_PORT=8080,CAPSKIP_API_KEY=YOUR_API_KEY

在 Windows PowerShell 中,请把每行末尾的反斜杠换成反引号。下面是每一行改变了什么:

  • 入口点这一行就是针对 gunicorn 的修复。超时设为 0 会关闭 gunicorn 的 worker 超时,把计时交给 Cloud Run;8 个线程则让一个实例可以同时跑 8 次识别。这正是 Google 自己的 buildpack 文档里用作示例的配置;另外,覆盖默认入口点时,必须在 requirements.txt 里列出 gunicorn。端口直接写成 8080,而不是 PORT 变量,因为你本地的 shell 会在 gcloud 看到它之前就先把这个变量展开,而且展开成空值。除非你另行指定,Cloud Run 都会把流量发到 8080。
  • 400 秒的请求超时比客户端的 300 秒高出一截,留足了余量。客户端的计时要等任务提交后才开始,而在 Cloud NAT 后面的新实例上,第一次连接就可能要花上一分钟。
  • 并发这一行可以防止请求在 Cloud Run 看不见的地方排队。用 gcloud 部署的服务默认每个 vCPU 最多接受 80 个并发请求。只有 8 个线程时,第 9 到第 80 个请求会在 gunicorn 里排队,而 Cloud Run 的计时器已经在走了,实例看上去却还绰绰有余。让并发数与线程数一致,Cloud Run 就会改为启动另一个实例。
  • 环境变量携带你 CapSkip 机器的 Server 模式地址及其 API 密钥,main.py 会显式读取它们。等这套跑通之后,把密钥放进 Secret Manager 并使用 set-secrets 参数会更整洁。
  • no-allow-unauthenticated 这一行让端点保持私有,所以只有拥有调用该服务权限的调用方,才能通过它占用你识别工具的时间。

第 3 步:给服务一个静态出站 IP

默认情况下,Cloud Run 服务从 Google 的动态地址池访问互联网,所以你的防火墙没有一个单独的 IP 可以放行。官方文档给出的解决办法是:让服务的出站流量经过一个 VPC 网络,并由持有预留静态地址的 Cloud NAT 网关转发出去。

# Reserve one address and put Cloud NAT in front of the subnet
gcloud compute routers create capskip-router \
  --network default --region us-central1
gcloud compute addresses create capskip-egress --region us-central1
gcloud compute routers nats create capskip-nat \
  --router capskip-router --region us-central1 \
  --nat-custom-subnet-ip-ranges default \
  --nat-external-ip-pool capskip-egress

# Send ALL of the service's outbound traffic through that VPC
gcloud run services update solve-captcha --region us-central1 \
  --network default --subnet default --vpc-egress all-traffic

最后一个参数是大家最容易漏掉的。默认的出站设置是 private-ranges-only,它只会把发往私有地址的流量送进 VPC。你的识别工具在公网 IP 上,所以不设 all-traffic 的话,识别请求照样从动态地址池出去,NAT 地址永远不会出现在你的防火墙日志里。完整的配置流程请参阅 Google 的 静态出站 IP 指南.

预留地址到位之后,在 Windows 机器的防火墙上针对识别工具的端口放行这个地址,别的一律不放。连到你自己网络的 Cloud VPN 隧道也能达到同样的效果,而且完全不必把端口暴露到公网上。不管走哪条路,Server 模式依然是你自己的硬件,依然不计量。它只改变识别工具监听的位置,好让同一台桌面机之外的东西也能调用它。

为什么先响应、后完成识别行不通

面对慢识别,一个很诱人的变通做法是立刻回应调用方,然后在后台线程里把活干完。在 Cloud Run 默认的按请求计费模式下,只有实例正在处理请求时才会分配 CPU。响应发出之后仍在轮询的线程,只有在该实例上恰好有别的请求在处理时才能分到 CPU,而空闲的实例随时可能被关停。这份工作要么卡住,要么消失,而且没有任何东西告诉你是哪一种。

如果调用方真的等不了,就把这份工作改放到 Cloud Run 作业里。作业没有等着它的 HTTP 请求,每个任务默认可以运行 10 分钟,最长可达 168 小时;而且作业接受与服务相同的网络和出站参数,所以设置了这些参数的作业会从同一个 NAT 地址出去。

完整可运行示例

还是同一个服务,现在它会告诉调用方哪些失败值得重试。

# pip install capskip flask gunicorn
import os
from flask import Flask, jsonify, request
from capskip import (CapSkip, ApiException, NetworkException,
                     TimeoutException, ValidationException)

app = Flask(__name__)

solver = CapSkip(
    host=os.environ["CAPSKIP_HOST"],
    port=int(os.environ.get("CAPSKIP_PORT", "8080")),
    apiKey=os.environ.get("CAPSKIP_API_KEY", "capskip"),
    recaptchaTimeout=300,   # keep it below the Cloud Run --timeout
)

@app.post("/solve")
def solve():
    job = request.get_json(force=True)
    try:
        result = solver.recaptcha(sitekey=job["sitekey"], url=job["pageurl"])
    except NetworkException as exc:
        # Worth retrying. Log the detail, but do not echo the
        # solver's address back to the caller.
        app.logger.warning("solver unreachable: %s", exc)
        return jsonify(error="solver unreachable"), 503
    except TimeoutException:
        return jsonify(error="no answer inside 300 seconds"), 504
    except (ApiException, ValidationException) as exc:
        # Same input, same failure: do not retry.
        return jsonify(error=str(exc)), 422
    return jsonify(token=result["code"])

这些状态码的选择,是为了让调用方不用读错误信息就能据此行动。503 表示无法连到识别工具来接下这个任务,重试可能会成功。由你自己的代码返回、恰好在 300 秒之后到达的 504,表示没有及时拿到答案,原因是识别工具太慢,或者接下任务后掉线了。422 表示 CapSkip 拒绝了这个任务,通常是因为 sitekey、URL 或 API 密钥有误,原样重试往往还是同样的结果。如果你更愿意只捕获一样东西,这四个异常都派生自 CapSkipError。

拿到 token 的一方应当立刻使用它。一个 reCAPTCHA token 大约只有两分钟有效期,关于这个时间窗口的细节,见 reCAPTCHA v2 识别指南。其他类型也是同样的结构,只是接受的参数不同、返回的字段不同;Python 包暴露的每一个方法都列在 Python 验证码识别页面.

常见错误及其含义

你所看到的原因修复
日志里出现 WORKER TIMEOUT,识别进行到大约 30 秒时返回服务器错误buildpack 的默认入口点,以 30 秒的 worker 超时运行 gunicorn设置 GOOGLE_ENTRYPOINT,把超时设为 0 并配几个线程
300 秒时返回 504,之后同一次识别的日志行还在陆续出现Cloud Run 的请求超时等于客户端的轮询超时,而 Cloud Run 的计时器先开始计时部署时把超时设为 400 秒
负载升高时延迟跟着上升,实例数却一直不变Cloud Run 按每个 vCPU 最多 80 个请求发给一个线程数少得多的服务器把并发数设成与 gunicorn 线程数一致
抛出 NetworkException,内容为 bad response: 404构建客户端时没有指定 host,所以它调用了 127.0.0.1 的 8080 端口,而在容器里那就是你自己的服务。它不会自己读取 CAPSKIP_HOST像 main.py 那样,从环境变量中取出 host 传进去
部署之后,新的修订版本一直无法就绪没有设置 CAPSKIP_HOST,或者 requirements.txt 里缺少 gunicorn在服务上设置这个变量,并把 gunicorn 和其他依赖一起列出来
识别在你的笔记本上能成功,在服务里却失败防火墙放行的是你家里的地址,没有放行 Google 的地址给服务一个静态出站 IP,并放行这个地址
接入 VPC 之后,识别一直挂起直到超时所有流量都走一个没有 Cloud NAT 网关的 VPC,所以根本出不去在服务所在的子网上创建 NAT 网关
防火墙日志里出现一个不是你预留的 Google 地址出站设置仍是 private-ranges-only,所以公网流量绕过了 NAT把服务的出站设置更新为 all-traffic

常见问题

CapSkip 本身能在 Cloud Run 上运行吗?

不能,而且也不需要。CapSkip 是一个 Windows 应用,运行在你自己拥有的硬件上,而你容器里的 Python 包只是它的一个 HTTP 瘦客户端。打开 Server 模式,把地址交给服务,服务调用识别工具的方式就和同一张桌子上的脚本完全一样。识别始终留在你自己的机器上,这也是为什么没有人会计量你识别了多少个验证码。

识别验证码,用服务还是用作业更合适?

如果有东西在等 token 并会立刻用掉它,比如一个在抓取途中调用你端点的爬虫,就用服务。如果工作是一批没人在等的任务,就用作业,因为作业根本没有请求超时,而它的任务超时远远超过一小时。无论哪种,token 都必须由持有它的进程在几分钟内用掉。一个识别了一百个验证码、再把 token 存到某处留待以后使用的作业,等于白白做了一百次识别。同样的权衡在 AWS 上也会遇到, AWS Lambda 指南 里用前置队列的方案把它完整讲了一遍。

怎么才能只让我的服务访问到识别工具?

让服务的全部出站流量经过一个 VPC,由持有预留地址的 Cloud NAT 网关转发,然后在防火墙上针对识别工具的端口只放行这一个地址。Cloud VPN 隧道可以访问到你自己网络里的识别工具,完全不必向公网开放端口。让端口对其他所有来源保持关闭,并把 API 密钥当成第二道锁,而不是唯一的一道。

一个等待识别结果的请求会花很多钱吗?

Cloud Run 在实例处理请求期间对其计费,而等待网络的请求也算在内。让这笔费用保持合理的正是并发。8 次识别在同一个实例上并排等待,只占用一个实例的计费时长;如果每个实例只处理一个请求,就要启动 8 个实例。这是给 gunicorn 配 8 个线程而不是 1 个的另一半理由。识别本身不按验证码收取任何费用,因为它运行在你自己的机器上。

简短版结论

在 Cloud Run 上部署验证码识别需要四项设置,其中三项写在部署命令里,而不是你的代码里。替换 buildpack 的默认入口点,让 gunicorn 不再在 30 秒时杀掉 worker,并给它配上线程。把请求超时设得高于客户端的 300 秒,让你自己的错误先于 Cloud Run 的 504 到达。让并发数与这些线程数一致,这样多出来的负载会启动新实例,而不是排队。最后,自己把 Server 模式地址传给客户端,再让服务经过带 Cloud NAT 的 VPC 并使用 all-traffic 出站设置,这样你的防火墙只需放行一个地址。

最后说一点经济账,因为正是它让你可以放心地依据上面那些状态码去重试。一次被重试的请求只花掉你几秒 Cloud Run 时间,除此之外什么都不花: 验证码识别工具 本身运行在你早已付过钱的硬件上,所以一次失败的识别永远不会让任何人按验证码向你多收一笔钱。