如何在 Google Cloud Run 上识别验证码而不遇到 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 时间,除此之外什么都不花: 验证码识别工具 本身运行在你早已付过钱的硬件上,所以一次失败的识别永远不会让任何人按验证码向你多收一笔钱。
