DevOps & Scaling

CaptchaAI за балансировщиком нагрузки: шаблоны архитектуры

Один воркер, который решает CAPTCHA по одной задаче за раз, упирается в потолок в районе нескольких сотен запросов в час — дальше очередь растёт быстрее, чем API успевает отвечать. Балансировщик нагрузки снимает этот потолок не за счёт более мощной машины, а за счёт горизонтального распределения запросов между несколькими воркерами: выше пропускная способность, есть аварийное переключение при падении узла, а масштабирование под пиковый парсинг становится предсказуемым.

Как устроена схема

Стандартная архитектура выглядит так: несколько скраперов отправляют запросы на балансировщик, тот распределяет их между воркерами, а каждый воркер уже сам обращается к CaptchaAI API и опрашивает результат.

[Scraper 1] ──┐                      ┌── [Worker 1] ──→ CaptchaAI API
[Scraper 2] ──┤── [Load Balancer] ──┤── [Worker 2] ──→ CaptchaAI API
[Scraper 3] ──┘                      └── [Worker 3] ──→ CaptchaAI API

Настройка Nginx под решение CAPTCHA

Циклический перебор (базовый вариант)

Самая простая схема маршрутизации — round-robin: Nginx поочерёдно направляет запросы каждому апстриму. Годится, если время решения у всех воркеров примерно одинаковое.

upstream captcha_workers {
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080;
}

server {
    listen 80;
    server_name captcha.internal;

    location /solve {
        proxy_pass http://captcha_workers;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_connect_timeout 10s;
        proxy_read_timeout 300s;  # CAPTCHA solving can take minutes
    }

    location /health {
        proxy_pass http://captcha_workers;
        proxy_connect_timeout 5s;
        proxy_read_timeout 5s;
    }
}

Наименьшее количество соединений — лучший выбор для CAPTCHA

Время решения CAPTCHA сильно варьируется: от пяти секунд до пары минут в зависимости от типа задачи. При round-robin воркер, которому досталась долгая задача, продолжает получать новые запросы наравне с остальными и превращается в узкое место. Директива least_conn направляет трафик туда, где меньше активных соединений, и снимает этот перекос.

upstream captcha_workers {
    least_conn;  # Route to worker with fewest active connections
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080 weight=2;  # Higher capacity worker

    # Health checks
    server 10.0.1.10:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.1.12:8080 max_fails=3 fail_timeout=30s;
}

Резервные воркеры на случай отказа

Директива backup держит часть воркеров в резерве: они включаются в маршрутизацию только тогда, когда основные узлы недоступны, и не забирают трафик в штатном режиме.

upstream captcha_workers {
    least_conn;
    server 10.0.1.10:8080;
    server 10.0.1.11:8080;
    server 10.0.1.12:8080 backup;  # Only used when others are down
}

Воркер как API-сервер

Python (Flask)

Каждый воркер — это лёгкий HTTP-сервер: он принимает задачу от балансировщика, отправляет её в CaptchaAI API и опрашивает res.php до готовности решения. Ниже — минимальная реализация на Flask с лимитом одновременных задач и /health-эндпоинтом, который балансировщик использует для маршрутизации.

import os
import time
import threading
import requests
from flask import Flask, request, jsonify

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
app = Flask(__name__)

# Track active tasks for load reporting
active_tasks = 0
tasks_lock = threading.Lock()
max_concurrent = int(os.environ.get("MAX_CONCURRENT", "20"))


@app.route("/solve", methods=["POST"])
def solve():
    global active_tasks
    with tasks_lock:
        if active_tasks >= max_concurrent:
            return jsonify({"error": "WORKER_AT_CAPACITY"}), 503
        active_tasks += 1

    try:
        data = request.json
        result = solve_captcha(data)
        return jsonify(result)
    finally:
        with tasks_lock:
            active_tasks -= 1


@app.route("/health")
def health():
    with tasks_lock:
        load = active_tasks / max_concurrent
    return jsonify({
        "status": "healthy" if load < 0.9 else "overloaded",
        "active_tasks": active_tasks,
        "max_concurrent": max_concurrent,
        "load_pct": round(load * 100, 1)
    }), 200 if load < 0.9 else 503


def solve_captcha(data):
    session = requests.Session()
    payload = {
        "key": API_KEY,
        "method": data.get("method", "userrecaptcha"),
        "googlekey": data.get("sitekey"),
        "pageurl": data.get("pageurl"),
        "json": 1
    }

    if data.get("proxy"):
        payload["proxy"] = data["proxy"]
        payload["proxytype"] = data.get("proxytype", "HTTP")

    resp = session.post("https://ocr.captchaai.com/in.php", data=payload)
    result = resp.json()
    if result.get("status") != 1:
        return {"error": result.get("request")}

    captcha_id = result["request"]
    for _ in range(60):
        time.sleep(5)
        poll = session.get("https://ocr.captchaai.com/res.php", params={
            "key": API_KEY, "action": "get", "id": captcha_id, "json": 1
        }).json()
        if poll.get("status") == 1:
            return {"solution": poll["request"], "captcha_id": captcha_id}
        if poll.get("request") != "CAPCHA_NOT_READY":
            return {"error": poll.get("request")}

    return {"error": "TIMEOUT"}


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=8080, threaded=True)

JavaScript (Express)

Тот же воркер на Node.js с Express и Axios — логика идентична: очередь задач, лимит activeTasks и /health для отчёта о текущей загрузке.

const express = require("express");
const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;
const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT || "20", 10);
const PORT = parseInt(process.env.PORT || "8080", 10);

let activeTasks = 0;
const app = express();
app.use(express.json());

app.post("/solve", async (req, res) => {
  if (activeTasks >= MAX_CONCURRENT) {
    return res.status(503).json({ error: "WORKER_AT_CAPACITY" });
  }
  activeTasks++;

  try {
    const result = await solveCaptcha(req.body);
    res.json(result);
  } catch (err) {
    res.status(500).json({ error: err.message });
  } finally {
    activeTasks--;
  }
});

app.get("/health", (req, res) => {
  const load = activeTasks / MAX_CONCURRENT;
  const status = load < 0.9 ? "healthy" : "overloaded";
  res
    .status(load < 0.9 ? 200 : 503)
    .json({ status, activeTasks, maxConcurrent: MAX_CONCURRENT, loadPct: Math.round(load * 100) });
});

async function solveCaptcha(data) {
  const submitResp = await axios.post("https://ocr.captchaai.com/in.php", null, {
    params: {
      key: API_KEY,
      method: data.method || "userrecaptcha",
      googlekey: data.sitekey,
      pageurl: data.pageurl,
      json: 1,
    },
  });

  if (submitResp.data.status !== 1) {
    return { error: submitResp.data.request };
  }

  const captchaId = submitResp.data.request;
  for (let i = 0; i < 60; i++) {
    await new Promise((r) => setTimeout(r, 5000));
    const pollResp = await axios.get("https://ocr.captchaai.com/res.php", {
      params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
    });

    if (pollResp.data.status === 1) {
      return { solution: pollResp.data.request, captchaId };
    }
    if (pollResp.data.request !== "CAPCHA_NOT_READY") {
      return { error: pollResp.data.request };
    }
  }
  return { error: "TIMEOUT" };
}

app.listen(PORT, () => console.log(`Worker listening on port ${PORT}`));

Какую стратегию маршрутизации выбрать

Round-robin — не лучший выбор именно для решения CAPTCHA из-за разброса времени задач. Вот как соотносятся основные стратегии:

Стратегия Как работает Когда использовать
Циклический перебор (round-robin) Запросы идут по кругу Воркеры с одинаковой производительностью
Наименьшее число соединений (least_conn) Запрос уходит наименее загруженному воркеру Решение CAPTCHA — время задачи непостоянно
Взвешенный (weighted) Пропорционально весу воркера Воркеры разной мощности
IP-хеш Один клиент — всегда один воркер Нужна привязка сессии
Случайный Случайный выбор воркера Простая равномерная нагрузка

Рекомендация: для решения CAPTCHA используйте наименьшее количество соединений. Продолжительность задачи колеблется в диапазоне 5–120 секунд, и циклический перебор при таком разбросе создаёт неравномерную нагрузку.

Как выбрать алгоритм под конкретную нагрузку

  • Циклический перебор подходит, когда воркеры однородны, а время решения держится в узком диапазоне задержек.
  • Наименьшее количество соединений нужно там, где продолжительность решения сильно варьируется — иначе длинные задачи будут скапливаться на одном воркере.
  • Резервную маршрутизацию и привязку к источнику (IP-хеш) оставляйте только для изоляции сбоев или сценариев, чувствительных к сессии.

Балансировка на стороне клиента, если внешний LB не нужен

Если разворачивать отдельный балансировщик пока нецелесообразно — кластер маленький, инфраструктура ограничена — маршрутизацию можно реализовать прямо в клиенте по тому же принципу «наименьшее число соединений»:

import random
import requests

class ClientLoadBalancer:
    def __init__(self, workers):
        self.workers = [
            {"url": url, "healthy": True, "active": 0}
            for url in workers
        ]

    def get_worker(self):
        healthy = [w for w in self.workers if w["healthy"]]
        if not healthy:
            raise Exception("No healthy workers")
        return min(healthy, key=lambda w: w["active"])

    def solve(self, task):
        worker = self.get_worker()
        worker["active"] += 1
        try:
            resp = requests.post(
                f"{worker['url']}/solve",
                json=task,
                timeout=300
            )
            if resp.status_code == 503:
                worker["healthy"] = False
                return self.solve(task)  # Retry on another worker
            return resp.json()
        except requests.RequestException:
            worker["healthy"] = False
            return self.solve(task)
        finally:
            worker["active"] -= 1


lb = ClientLoadBalancer([
    "http://10.0.1.10:8080",
    "http://10.0.1.11:8080",
    "http://10.0.1.12:8080"
])
result = lb.solve({"sitekey": "6Le-wvkS...", "pageurl": "https://example.com"})

Мультирегиональные воркеры и задержка

Команды, которые обслуживают читателей из России, Беларуси, Казахстана и Украины, часто разносят воркеры по нескольким облачным регионам — например, европейскому дата-центру и площадке в Казахстане, — чтобы держать время приёма-передачи (RTT) до CaptchaAI API в разумных пределах и не терять время при нестабильном мобильном соединении у части скраперов. В такой схеме каждый регион держит собственный локальный балансировщик, а перед ними стоит глобальный маршрутизатор — например, AWS Global Accelerator или Cloudflare Load Balancing, — который направляет трафик в ближайший здоровый регион. Локальные /health-эндпоинты остаются прежними: глобальный слой лишь выбирает, в какой регион отправить запрос.

Типичные проблемы и их устранение

Проблема Причина Решение
502 Bad Gateway Воркер упал или не запустился Проверьте логи воркера и привязку порта
Неравномерная нагрузка Round-robin при разной длительности задач Переключитесь на least_conn
Health-check «зелёный», а воркер перегружен Проверка не учитывает текущую нагрузку Возвращайте процент загрузки в ответе /health, как в примерах выше
Тайм-аут соединения proxy_read_timeout слишком мал Ставьте 300 с и больше — решение CAPTCHA может занять минуты

Часто задаваемые вопросы

Сколько воркеров нужно на 1000 задач CAPTCHA в час?

Ориентируйтесь на лимит active_tasks и среднее время решения. Если один воркер держит 20 одновременных задач при среднем времени решения около 30 секунд, это порядка 2000–2400 задач в час на один воркер. Для 1000 задач в час хватит одного-двух воркеров с запасом, а под пиковую нагрузку закладывайте три и больше.

Нужна ли привязка сессии (sticky sessions) для CAPTCHA-воркеров?

Нет. Запрос на решение CAPTCHA не хранит состояние — любой воркер может обработать любую задачу, потому что вся сессия описывается параметрами самого запроса (sitekey, pageurl, proxy). Привязка к конкретному воркеру только усилит перекос нагрузки.

Как добавить воркер в кластер без даунтайма?

Добавьте сервер в блок upstream и перезагрузите конфигурацию (nginx -s reload) — это не разрывает уже открытые соединения. Новый воркер сразу начинает получать трафик по текущей стратегии маршрутизации, будь то round-robin или least_conn.

Почему health-check в Nginx показывает воркер живым, хотя он перегружен?

Обычно /health отвечает 200 при живом процессе, но не учитывает текущую загрузку. Решение — возвращать 503, когда load_pct приближается к 90%, как показано в примерах Flask и Express выше: тогда балансировщик реально исключает перегруженный узел из маршрутизации.

Что дальше

Комментарии для этой статьи отключены.