DevOps & Scaling

Сине-зеленое развертывание инфраструктуры решения CAPTCHA

Обновление CAPTCHA-воркеров не должно тормозить очередь даже на секунду — это оплаченные потоки и риск для SLA. Сине-зеленая схема закрывает окно простоя: рядом со старым (BLUE) пулом поднимается новый (GREEN), трафик переключается одной командой, а откат — тот же свитч назад, а не повторный деплой.

Архитектура: два зеркальных пула воркеров

BLUE и GREEN работают на одинаковой конфигурации и ходят в один CaptchaAI API; маршрутизатор решает, какой пул активен:

Перед переключением оба пула должны пройти health-check с одинаковым результатом — иначе canary-тест на GREEN ничего не докажет: он просто пройдёт на пуле, который не готов принимать боевой трафик. Для команд, разворачивающих воркеры в европейских регионах или в Казахстане/Центральной Азии, разница в RTT до ocr.captchaai.com между BLUE и GREEN — тоже сигнал: если GREEN стоит в другом дата-центре, добавьте отдельный порог по времени решения, а не только по доле ошибок.

                    ┌─────────────────────┐
[Scraper Clients] → │   Traffic Router    │
                    └──────┬──────┬───────┘
                           │      │
                     Active│      │Standby
                           ▼      ▼
                    ┌───────┐  ┌───────┐
                    │ BLUE  │  │ GREEN │
                    │Workers│  │Workers│
                    └───┬───┘  └───┬───┘
                        │          │
                        └────┬─────┘
                             ▼
                    [CaptchaAI API]

Сколько потоков держать про запас

Standby не обязан быть таким же большим, как прод — хватает пройти canary и первые минуты после свитча. Держите его на тарифе ниже, но с запасом потоков на пиковую нагрузку canary-теста:

Пул Тариф Потоки Роль
Прод (BLUE активен) ADVANCE ($90/мес) 50 принимает весь боевой трафик
Standby (GREEN) STANDARD ($30/мес) 15 canary-тест + первые минуты после свитча

Как только GREEN становится активным, поднимайте его тариф до ADVANCE и переводите бывший BLUE на STANDARD — так к следующему переключению резерв снова готов.

Как это устроено в коде

Маршрутизатор на Python и его аналог на JavaScript: оба считают статистику по пулу, гоняют canary перед свитчем и откатываются одним вызовом.

Python — маршрутизатор blue/green

import os
import time
import threading
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class CaptchaWorkerPool:
    """Represents one environment (blue or green)."""

    def __init__(self, name, config):
        self.name = name
        self.config = config
        self.session = requests.Session()
        self.tasks_solved = 0
        self.errors = 0
        self.healthy = True

    def solve(self, task):
        resp = self.session.post("https://ocr.captchaai.com/in.php", data={
            "key": API_KEY,
            "method": task.get("method", "userrecaptcha"),
            "googlekey": task["sitekey"],
            "pageurl": task["pageurl"],
            "json": 1
        })
        data = resp.json()
        if data.get("status") != 1:
            self.errors += 1
            return {"error": data.get("request")}

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

        self.errors += 1
        return {"error": "TIMEOUT"}

    @property
    def error_rate(self):
        total = self.tasks_solved + self.errors
        return self.errors / total if total > 0 else 0.0

    @property
    def stats(self):
        return {
            "name": self.name,
            "solved": self.tasks_solved,
            "errors": self.errors,
            "error_rate": round(self.error_rate, 4),
            "healthy": self.healthy
        }


class BlueGreenRouter:
    def __init__(self, blue_config, green_config):
        self.blue = CaptchaWorkerPool("blue", blue_config)
        self.green = CaptchaWorkerPool("green", green_config)
        self.active = self.blue
        self.standby = self.green
        self.lock = threading.Lock()

    def solve(self, task):
        """Route task to the active environment."""
        with self.lock:
            pool = self.active
        return pool.solve(task)

    def switch(self):
        """Swap active and standby environments."""
        with self.lock:
            self.active, self.standby = self.standby, self.active
            print(f"Switched: {self.active.name} is now ACTIVE")
        return self.active.name

    def rollback(self):
        """Switch back to the previous environment."""
        return self.switch()

    def canary_test(self, test_tasks, threshold=0.9):
        """Run test tasks on standby before switching."""
        successes = 0
        for task in test_tasks:
            result = self.standby.solve(task)
            if "solution" in result:
                successes += 1

        success_rate = successes / len(test_tasks) if test_tasks else 0
        passed = success_rate >= threshold
        print(
            f"Canary test: {successes}/{len(test_tasks)} "
            f"({success_rate:.0%}) — {'PASS' if passed else 'FAIL'}"
        )
        return passed

    @property
    def status(self):
        return {
            "active": self.active.stats,
            "standby": self.standby.stats
        }


# Usage
router = BlueGreenRouter(
    blue_config={"version": "1.2.0", "workers": 4},
    green_config={"version": "1.3.0", "workers": 4}
)

# Canary test before switching
test_tasks = [
    {"sitekey": "6Le-wvkS...", "pageurl": "https://example.com/test"}
]

if router.canary_test(test_tasks, threshold=0.8):
    router.switch()
    print(f"Now active: {router.status['active']['name']}")
else:
    print("Canary failed — staying on current environment")

JavaScript — автосвитчер с canary-проверкой

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;

class BlueGreenDeployment {
  constructor() {
    this.environments = {
      blue: { name: "blue", version: null, solved: 0, errors: 0 },
      green: { name: "green", version: null, solved: 0, errors: 0 },
    };
    this.activeEnv = "blue";
  }

  get active() {
    return this.environments[this.activeEnv];
  }
  get standby() {
    return this.environments[this.activeEnv === "blue" ? "green" : "blue"];
  }

  async deploy(version, config = {}) {
    const target = this.standby;
    target.version = version;
    target.solved = 0;
    target.errors = 0;

    console.log(`Deployed v${version} to ${target.name} (standby)`);

    // Run canary checks
    const canaryPassed = await this.canaryCheck(config.canaryTasks || []);
    if (!canaryPassed && config.canaryTasks?.length > 0) {
      console.log("Canary check failed — aborting deployment");
      return { success: false, reason: "canary_failed" };
    }

    // Switch traffic
    this.activeEnv = target.name;
    console.log(`Switched traffic to ${target.name} (v${version})`);

    // Monitor for rollback
    if (config.monitorDuration) {
      const stable = await this.monitorAfterSwitch(config.monitorDuration);
      if (!stable) {
        this.rollback();
        return { success: false, reason: "post_deploy_errors" };
      }
    }

    return { success: true, active: this.activeEnv };
  }

  async canaryCheck(tasks) {
    if (tasks.length === 0) return true;

    let successes = 0;
    for (const task of tasks) {
      try {
        await this.solveCaptcha(task);
        successes++;
      } catch (err) {
        console.log(`Canary task failed: ${err.message}`);
      }
    }

    const rate = successes / tasks.length;
    console.log(`Canary: ${successes}/${tasks.length} (${(rate * 100).toFixed(0)}%)`);
    return rate >= 0.8;
  }

  async monitorAfterSwitch(durationMs) {
    const start = Date.now();
    const checkInterval = 10000;

    while (Date.now() - start < durationMs) {
      await new Promise((r) => setTimeout(r, checkInterval));
      const errorRate = this.active.errors /
        Math.max(1, this.active.solved + this.active.errors);

      if (errorRate > 0.2) {
        console.log(`Error rate ${(errorRate * 100).toFixed(1)}% — triggering rollback`);
        return false;
      }
    }
    return true;
  }

  rollback() {
    const previous = this.activeEnv === "blue" ? "green" : "blue";
    console.log(`Rolling back: ${this.activeEnv} → ${previous}`);
    this.activeEnv = previous === "blue" ? "blue" : "green";
  }

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

    if (submitResp.data.status !== 1) {
      this.active.errors++;
      throw new 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) {
        this.active.solved++;
        return pollResp.data.request;
      }
      if (pollResp.data.request !== "CAPCHA_NOT_READY") {
        this.active.errors++;
        throw new Error(pollResp.data.request);
      }
    }
    this.active.errors++;
    throw new Error("TIMEOUT");
  }
}

// Deploy new version with canary and monitoring
const deployer = new BlueGreenDeployment();

deployer
  .deploy("1.3.0", {
    canaryTasks: [
      { sitekey: "6Le-wvkS...", pageurl: "https://example.com/test" },
    ],
    monitorDuration: 60000, // Monitor for 1 minute after switch
  })
  .then((result) => console.log("Deploy result:", result));

Процесс переключения трафика

Пять шагов от выкатки до вывода старой среды, с триггером отката на каждом этапе:

Шаг Действие Триггер отката
1 Выкатить новую версию в standby-пул Ошибка сборки
2 Прогнать canary-тесты на standby Доля успешных решений менее 80%
3 Переключить трафик на новую версию
4 Следить за долей ошибок 5 минут Доля ошибок более 20%
5 Вывести старую среду из эксплуатации

Порог отката задавайте по метрикам продакшена за последние недели, а не по интуиции. Слишком жёсткий порог откатывает деплой на нормальных всплесках ошибок; слишком мягкий пропускает реальную деградацию.

Мониторинг и критерии автоотката

Доли ошибок недостаточно — при свитче стоит следить ещё за двумя сигналами:

  • Время решения — если GREEN отвечает заметно медленнее BLUE на тех же типах CAPTCHA, это первый признак проблемы с воркерами или сетью, задолго до роста ошибок.
  • Глубина очереди — растущая очередь на GREEN при стабильной доле ошибок означает, что пул не успевает обрабатывать задачи в темпе прода.
  • Повторные CAPCHA_NOT_READY — если опрос res.php регулярно упирается в лимит попыток без итогового статуса, это чаще инфраструктурная проблема (сеть, лимиты API), а не повод для отката на BLUE.

Если сработали сразу два сигнала из трёх — откатывайтесь без дополнительного разбора; если только один и он на границе порога — дайте ещё пару минут наблюдения.

Типичные проблемы и что с ними делать

Частые причины срывов при свитче:

Проблема Причина Решение
Canary проходит, а прод — нет Тестовые задачи проще боевых Берите задачи из продакшен-очереди
Частые откаты Слишком жесткие пороги Увеличьте порог и время наблюдения
Переключение проходит грязно Остались in-flight-запросы к старому пулу Дайте задачам дожиться, потом отключайте
BLUE и GREEN нездоровы одновременно Общая зависимость — сеть или CaptchaAI API Circuit breaker; откат тут не поможет

Частые вопросы

Какие задачи брать для canary, чтобы результату можно было доверять?

Реальные из продакшен-очереди: тот же микс типов CAPTCHA, sitekey и pageurl. Синтетика проще боевых задач — canary проходит, прод нет.

Что делать, если BLUE и GREEN одновременно нездоровы?

Не откатывать: причина обычно общая — сеть или лимиты CaptchaAI API. Нужен circuit breaker, а не свитч между средами.

Чем сине-зеленое развертывание отличается от canary-деплоя?

Blue-green переключает 100% трафика разом; canary наращивает долю постепенно — 1% → 10% → 100%. Первое проще, второе безопаснее.

Нужны ли отдельные API-ключи для BLUE и GREEN?

Не обязательно — большинство команд использует один API-ключ CaptchaAI для обоих пулов и различает их по логам маршрутизатора (поле name в статистике пула). Заведите второй ключ, если хотите свериться с расходом потоков по каждому пулу отдельно в панели управления.

Нужно ли держать standby-пул постоянно поднятым, а не поднимать его перед деплоем?

Да, если canary-тест должен быть быстрым: холодный старт воркеров добавляет к переключению лишние минуты, за которые вы не наблюдаете реальное поведение GREEN. Держите standby тёплым на STANDARD — доплата небольшая, а экономия времени при инциденте того стоит.

Следующие шаги

Мини-плейбук переключения трафика

  • Health-check BLUE и GREEN держите зеркальными; трафик — только после проверки обоих.
  • Переключайте поэтапно, сверяя задержку и долю ошибок с базовым уровнем.
  • Порог превышен — откат немедленно, без разбора на проде.
Комментарии для этой статьи отключены.