Как решать капчу в Google Cloud Run без ошибки 504

Решение капчи в Cloud Run может оборваться в трёх местах, и в двух из них ваш код вообще не видит ошибки. Buildpack для Python запускает приложение с настройками gunicorn по умолчанию, а gunicorn убивает воркер, который занят 30 секунд, что при решении reCAPTCHA случается часто. Собственный таймаут запроса Cloud Run по умолчанию равен 300 секундам, ровно как таймаут опроса reCAPTCHA в SDK, поэтому эту гонку всегда выигрывает 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 из точки входа buildpack по умолчанию | 30 секунд | Воркер убивается и перезапускается посреди решения, а вызывающая сторона получает ошибку сервера |
| Таймаут запроса Cloud Run | 300 секунд, можно поднять до 3600 | Вызывающая сторона получает 504, а контейнер продолжает обрабатывать запрос |
| Таймаут опроса reCAPTCHA в CapSkip | 300 секунд | Клиент бросает TimeoutException, который ваш код может обработать |
Начнём с gunicorn. При деплое Python из исходников точка входа buildpack по умолчанию запускает gunicorn на порту 8080 без каких-либо других настроек, а значит, один воркер, один поток и таймаут воркера gunicorn в 30 секунд. Gunicorn убивает и перезапускает воркер, который молчит дольше этого предела, а синхронный воркер, ждущий одно медленное решение, как раз молчит. Поэтому reCAPTCHA, которой нужно 40 секунд, так и не возвращается.
Теперь про ничью. Cloud Run закрывает соединение на 300 секундах и возвращает 504, а в документации Google отмечено, что инстанс при этом не завершается, так что ваш код может продолжать обрабатывать запрос, которого уже никто не ждёт. Таймаут SDK тоже равен 300 секундам, но его отсчёт начинается позже, когда запрос уже пришёл и задача отправлена. Таймер Cloud Run всегда срабатывает первым, и TimeoutException, который объяснил бы, что произошло, до вызывающей стороны так и не доходит. Google в своём руководстве по таймауту запросов советует ставить лимит выше ожидаемого времени выполнения и заодно проверить собственный таймаут вашего фреймворка. Таймаут gunicorn и есть тот самый таймаут фреймворка.
Шаг 1: пишем сервис
Небольшое приложение Flask с одним маршрутом, сохранённое как main.py. Создайте клиент один раз, при импорте. Он хранит только свои настройки, поэтому его могут разделять все потоки воркера.
# 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Жёсткое чтение хоста без значения по умолчанию выбрано намеренно. Если переменной нет, импорт падает, gunicorn не может запустить воркер, и новая ревизия так и не начинает обслуживать трафик. Такой сбой гораздо понятнее, чем ревизия, которая чисто деплоится, а потом на первом же настоящем запросе бросает NetworkException, стучась в loopback.
Шаг 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 и оставляет отсчёт времени на Cloud Run, а восемь потоков позволяют одному инстансу вести восемь решений одновременно. Именно эти настройки приводит в качестве примера собственная документация Google по buildpack, и при переопределении точки входа по умолчанию gunicorn должен быть указан в requirements.txt. Порт записан как 8080, а не через переменную PORT, потому что ваша собственная оболочка подставила бы вместо этой переменной пустую строку ещё до того, как её увидит gcloud. Cloud Run отправляет трафик на 8080, если вы не укажете иное.
- Таймаут запроса в 400 секунд стоит выше клиентских 300 с хорошим запасом. Отсчёт клиента начинается только после отправки задачи, а на свежем инстансе за Cloud NAT первое соединение может занять минуту.
- Строка concurrency не даёт запросам скапливаться в очереди там, где Cloud Run их не видит. Сервис, развёрнутый через gcloud, по умолчанию принимает до 80 одновременных запросов на vCPU. При восьми потоках запросы с девятого по восьмидесятый встают в очередь внутри 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 (job). Задание не держит ожидающий 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 означает, что до решателя не удалось достучаться, чтобы передать ему задачу, и повтор может сработать. 504 от вашего собственного кода, пришедший сразу после 300 секунд, означает, что ответ не вернулся вовремя, потому что решатель работал медленно или отвалился, уже взяв задачу. 422 означает, что CapSkip отклонил задачу, обычно из-за неверного sitekey, URL или API-ключа, и прямой повтор, скорее всего, получит тот же ответ. Все четыре исключения наследуются от CapSkipError, если вам удобнее ловить что-то одно.
Тот, кто получает токен, должен использовать его сразу. Токен reCAPTCHA живёт около двух минут, а подробно это окно разбирает руководство по сервису распознавания reCAPTCHA v2. Та же схема работает и для других типов, которые принимают другие аргументы и возвращают другие поля; все методы, которые предоставляет пакет для Python, перечислены на странице сервиса распознавания капч для Python.
Частые ошибки и что они означают
| Что вы видите | Причина | Исправить |
|---|---|---|
| WORKER TIMEOUT в логах и ошибка сервера примерно через 30 секунд после начала решения | Точка входа buildpack по умолчанию, которая запускает gunicorn с таймаутом воркера в 30 секунд | Задайте GOOGLE_ENTRYPOINT с таймаутом 0 и несколькими потоками |
| 504 на 300 секундах, а строки лога от того же решения продолжают приходить и после | Таймаут запроса Cloud Run равен таймауту опроса клиента, и таймер Cloud Run запустился первым | Деплойте с таймаутом 400 секунд |
| Задержка растёт под нагрузкой, а число инстансов не меняется | Cloud Run отправляет до 80 запросов на vCPU серверу, у которого потоков гораздо меньше | Выставьте concurrency по числу потоков gunicorn |
| NetworkException с текстом bad response: 404 | Клиент создан без хоста, поэтому он обратился к 127.0.0.1 на порту 8080, а внутри контейнера там находится ваш собственный сервис. Сам клиент CAPSKIP_HOST не читает | Передайте хост из окружения, как это делает main.py |
| После деплоя новая ревизия так и не становится готовой | CAPSKIP_HOST не задан, или gunicorn отсутствует в requirements.txt | Задайте переменную в сервисе и добавьте gunicorn к остальным зависимостям |
| С вашего ноутбука решения проходят, а из сервиса нет | Ваш домашний адрес файрвол пропускает, а адреса Google нет | Дайте сервису статический исходящий IP и разрешите именно его |
| Решения зависают до таймаута после подключения VPC | Весь трафик идёт через VPC без шлюза Cloud NAT, и выхода наружу у него нет | Создайте шлюз NAT на подсети сервиса |
| В логе файрвола виден адрес Google, который вы не резервировали | Настройка исходящего трафика всё ещё private-ranges-only, поэтому публичный трафик идёт мимо NAT | Обновите сервис, указав для исходящего трафика all-traffic |
FAQ
Может ли сам CapSkip работать в Cloud Run?
Нет, и это не нужно. CapSkip представляет собой приложение для Windows, которое работает на вашем железе, а пакет Python в вашем контейнере служит для него тонким клиентом поверх HTTP. Включите режим Server, дайте сервису адрес, и он будет обращаться к решателю ровно так же, как это сделал бы скрипт на том же столе. Решение остаётся на вашей машине, и поэтому же никто не считает, сколько капч вы решаете.
Что лучше подходит для решения капчи: сервис или задание?
Сервис, когда что-то ждёт токен и сразу его использует, например парсер, который вызывает ваш эндпоинт посреди обхода. Задание, когда работа представляет собой пакет, которого никто не ждёт: у задания вообще нет таймаута запроса, а таймаут его задач уходит далеко за час. В любом случае токен должен использовать тот процесс, у которого он на руках, и в пределах пары минут. Задание, которое решает сотню капч и складывает токены куда-то на потом, сделало сотню решений впустую. Тот же компромисс возникает и в AWS, и руководство по AWS Lambda разбирает его с очередью на входе.
Как пустить к решателю только мой сервис?
Пустите весь исходящий трафик сервиса через VPC со шлюзом Cloud NAT, за которым закреплён зарезервированный адрес, а затем разрешите этот единственный адрес в своём файрволе для порта решателя. Туннель Cloud VPN дотягивается до решателя в вашей собственной сети, вообще не открывая порт в интернет. Держите порт закрытым для всего остального, а API-ключ считайте вторым замком, а не единственным.
Дорого ли обходится запрос, который ждёт решения?
Cloud Run тарифицирует инстанс, пока тот обрабатывает запросы, и запрос, ожидающий ответа из сети, тоже считается. Держать это в разумных пределах помогает параллелизм. Восемь решений, которые ждут бок о бок на одном инстансе, расходуют оплачиваемое время одного инстанса, тогда как при одном запросе на инстанс их запустилось бы восемь. В этом и состоит вторая половина довода за то, чтобы дать gunicorn восемь потоков вместо одного. Само решение ничего не стоит за каждую капчу, потому что выполняется на вашей собственной машине.
Коротко
Деплою решения капчи в Cloud Run нужны четыре настройки, и три из них живут в команде деплоя, а не в вашем коде. Замените точку входа buildpack по умолчанию, чтобы gunicorn перестал убивать воркеры на 30 секундах, и дайте ему потоки. Поставьте таймаут запроса выше клиентских 300 секунд, чтобы ваша собственная ошибка пришла раньше, чем 504 от Cloud Run. Выровняйте concurrency по этим потокам, чтобы дополнительная нагрузка запускала новые инстансы, а не копилась в очереди. И передайте адрес режима Server клиенту сами, а затем пустите сервис через VPC с Cloud NAT и исходящим трафиком all-traffic, чтобы вашему файрволу нужно было разрешить ровно один адрес.
Напоследок про экономику, потому что именно благодаря ей по кодам повтора выше можно действовать спокойно. Повторный запрос стоит вам нескольких секунд Cloud Run и больше ничего: распознавание капчи само работает на железе, за которое вы уже заплатили, поэтому неудачное решение никогда не принесёт вам ни от кого счёта за отдельную капчу.
