DevOps & Scaling

Rolling update em frotas de workers de resolução de CAPTCHA

A resposta curta: dá para trocar a versão de uma frota inteira de workers de CAPTCHA sem perder nenhuma tarefa, desde que cada instância passe por três etapas na ordem certa — parar de aceitar trabalho novo, esperar o que está em voo terminar (drain) e só então subir e validar a versão nova antes de tocar na próxima máquina.

O erro que derruba tarefas quase nunca é o deploy em si. Uma tarefa de CAPTCHA não é uma requisição de 200 ms: você envia o desafio para in.php, recebe um captcha_id e consulta res.php por dezenas de segundos até o token voltar. Um SIGKILL nesse intervalo não devolve nada — o formulário falha e a thread já foi consumida. Uma frota de 8 workers com 2 tarefas ativas cada perde 16 resoluções.

Por que o drain é a etapa que não pode ser pulada

O drain é o intervalo em que o worker continua vivo, mas invisível para o roteador: não recebe trabalho novo e termina o que tem em mãos. Sem essa janela, qualquer atualização vira um recreate disfarçado.

Três detalhes definem se ele funciona de verdade:

  • O roteador precisa respeitar o estado. Se a função que escolhe o worker enxerga uma instância em DRAINING como elegível, o drain nunca termina. Abaixo, get_available_worker filtra por RUNNING.
  • O timeout precisa caber na tarefa mais lenta. Não adianta 30 s se um reCAPTCHA v2 leva de 30 a 90 segundos. Meça o P99 e some folga.
  • O contador de tarefas ativas tem que ser confiável. Decremente sempre em um finally; fora dele, uma exceção prende o contador e o drain roda até o timeout.

Como o ciclo se comporta na frota

O diagrama mostra a progressão de quatro workers, um a um: a frota nunca fica abaixo de três instâncias ativas.

Workers: [W1-old] [W2-old] [W3-old] [W4-old]

Step 1:  [W1-drain] [W2-old]  [W3-old]  [W4-old]
Step 2:  [W1-NEW✓]  [W2-old]  [W3-old]  [W4-old]
Step 3:  [W1-NEW✓]  [W2-drain] [W3-old]  [W4-old]
Step 4:  [W1-NEW✓]  [W2-NEW✓]  [W3-old]  [W4-old]
  ...until all updated

Orquestrador em Python com health gate e rollback

O script modela cada worker com estado explícito e implementa o ciclo drain → deploy → health check. Se o health check falhar, o orquestrador para e reverte as instâncias já atualizadas — versões misturadas são piores do que a frota inteira na versão antiga.

import os
import time
import signal
import threading
import requests
from dataclasses import dataclass, field
from enum import Enum

API_KEY = os.environ["CAPTCHAAI_API_KEY"]


class WorkerState(Enum):
    RUNNING = "running"
    DRAINING = "draining"
    STOPPED = "stopped"
    UPDATING = "updating"


@dataclass
class Worker:
    worker_id: str
    version: str
    state: WorkerState = WorkerState.RUNNING
    active_tasks: int = 0
    tasks_completed: int = 0
    session: requests.Session = field(default_factory=requests.Session)

    def solve(self, task):
        if self.state != WorkerState.RUNNING:
            return {"error": "WORKER_NOT_ACCEPTING"}

        self.active_tasks += 1
        try:
            result = self._do_solve(task)
            self.tasks_completed += 1
            return result
        finally:
            self.active_tasks -= 1

    def _do_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:
            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:
                return {"solution": result["request"]}
            if result.get("request") != "CAPCHA_NOT_READY":
                return {"error": result.get("request")}
        return {"error": "TIMEOUT"}

    def drain(self, timeout=120):
        """Stop accepting tasks and wait for active tasks to complete."""
        self.state = WorkerState.DRAINING
        start = time.time()
        while self.active_tasks > 0:
            if time.time() - start > timeout:
                print(f"Worker {self.worker_id}: drain timeout with "
                      f"{self.active_tasks} tasks remaining")
                break
            time.sleep(1)
        self.state = WorkerState.STOPPED

    @property
    def is_healthy(self):
        return self.state == WorkerState.RUNNING


class RollingUpdateOrchestrator:
    def __init__(self, workers):
        self.workers = {w.worker_id: w for w in workers}
        self.lock = threading.Lock()

    def get_available_worker(self):
        """Route tasks only to RUNNING workers."""
        with self.lock:
            for worker in self.workers.values():
                if worker.state == WorkerState.RUNNING:
                    return worker
        return None

    def rolling_update(self, new_version, health_check_fn=None,
                       max_unavailable=1, drain_timeout=120):
        """Update workers one at a time with health gates."""
        worker_ids = list(self.workers.keys())
        updated = []
        failed = []

        for i in range(0, len(worker_ids), max_unavailable):
            batch = worker_ids[i:i + max_unavailable]

            for wid in batch:
                worker = self.workers[wid]
                print(f"[{wid}] Draining (v{worker.version})...")

                # Step 1: Drain active tasks
                worker.drain(timeout=drain_timeout)

                # Step 2: "Deploy" new version
                print(f"[{wid}] Deploying v{new_version}...")
                worker.state = WorkerState.UPDATING
                worker.version = new_version
                time.sleep(2)  # Simulate deployment

                # Step 3: Start and health check
                worker.state = WorkerState.RUNNING
                if health_check_fn:
                    healthy = health_check_fn(worker)
                    if not healthy:
                        print(f"[{wid}] Health check FAILED — rolling back")
                        failed.append(wid)
                        self._rollback(updated)
                        return {
                            "status": "rolled_back",
                            "failed_at": wid,
                            "updated": updated,
                        }

                updated.append(wid)
                print(f"[{wid}] Updated to v{new_version} ✓")

        return {"status": "complete", "updated": updated, "failed": failed}

    def _rollback(self, updated_ids):
        """Roll back already-updated workers."""
        for wid in updated_ids:
            worker = self.workers[wid]
            print(f"[{wid}] Rolling back...")
            worker.state = WorkerState.STOPPED
            time.sleep(1)
            worker.version = "rollback"
            worker.state = WorkerState.RUNNING

    @property
    def status(self):
        return {
            wid: {
                "version": w.version,
                "state": w.state.value,
                "active_tasks": w.active_tasks,
            }
            for wid, w in self.workers.items()
        }


# Create fleet
workers = [Worker(f"w{i}", "1.2.0") for i in range(6)]
orchestrator = RollingUpdateOrchestrator(workers)


def health_check(worker):
    """Verify worker can solve a test CAPTCHA."""
    # In production, send a real test task
    return worker.state == WorkerState.RUNNING


# Execute rolling update
result = orchestrator.rolling_update(
    new_version="1.3.0",
    health_check_fn=health_check,
    max_unavailable=1,
    drain_timeout=60
)
print(f"Rolling update result: {result}")

Dois pontos merecem atenção. O max_unavailable controla o lote: com 1, a frota perde no máximo uma instância por vez. E o health_check_fn é injetado de fora — em produção ele deve enviar uma tarefa real, não conferir se o processo subiu. Um worker com a chave errada responde ao ping e falha em toda resolução.

Variante em Node.js com acompanhamento de progresso

Em frotas maiores, você quer ver o avanço e abortar cedo quando a falha se repete. Esta versão consulta o saldo via res.php e cancela o rollout quando mais de 25% dos workers falham na validação.

const axios = require("axios");

const API_KEY = process.env.CAPTCHAAI_API_KEY;

class RollingUpdater {
  constructor(workerCount, currentVersion) {
    this.workers = Array.from({ length: workerCount }, (_, i) => ({
      id: `worker-${i}`,
      version: currentVersion,
      state: "running",
      activeTasks: 0,
    }));
    this.progress = { total: workerCount, completed: 0, failed: 0 };
  }

  async update(newVersion, options = {}) {
    const {
      maxUnavailable = 1,
      drainTimeout = 60000,
      healthCheckRetries = 3,
    } = options;

    console.log(
      `Starting rolling update: v${this.workers[0].version} → v${newVersion}`
    );

    for (let i = 0; i < this.workers.length; i += maxUnavailable) {
      const batch = this.workers.slice(i, i + maxUnavailable);

      for (const worker of batch) {
        try {
          // Drain
          console.log(`[${worker.id}] Draining...`);
          worker.state = "draining";
          await this.waitForDrain(worker, drainTimeout);

          // Deploy
          console.log(`[${worker.id}] Deploying v${newVersion}...`);
          worker.state = "updating";
          worker.version = newVersion;

          // Health check
          worker.state = "running";
          const healthy = await this.healthCheck(worker, healthCheckRetries);

          if (!healthy) {
            worker.state = "failed";
            this.progress.failed++;
            console.log(`[${worker.id}] FAILED health check`);

            if (this.progress.failed > Math.floor(this.workers.length * 0.25)) {
              console.log("Too many failures — aborting rolling update");
              return { status: "aborted", progress: this.progress };
            }
            continue;
          }

          this.progress.completed++;
          console.log(
            `[${worker.id}] Updated ✓ (${this.progress.completed}/${this.progress.total})`
          );
        } catch (err) {
          console.error(`[${worker.id}] Error: ${err.message}`);
          this.progress.failed++;
        }
      }
    }

    return { status: "complete", progress: this.progress };
  }

  async waitForDrain(worker, timeout) {
    const start = Date.now();
    while (worker.activeTasks > 0 && Date.now() - start < timeout) {
      await new Promise((r) => setTimeout(r, 1000));
    }
  }

  async healthCheck(worker, retries) {
    for (let attempt = 0; attempt < retries; attempt++) {
      try {
        const resp = await axios.get("https://ocr.captchaai.com/res.php", {
          params: { key: API_KEY, action: "getbalance", json: 1 },
          timeout: 10000,
        });
        if (resp.data.status === 1) return true;
      } catch {
        // Retry
      }
      await new Promise((r) => setTimeout(r, 5000));
    }
    return false;
  }
}

// Execute
const updater = new RollingUpdater(8, "1.2.0");
updater
  .update("1.3.0", { maxUnavailable: 2, drainTimeout: 30000 })
  .then((result) => console.log("Result:", JSON.stringify(result, null, 2)));

O healthCheckRetries existe porque a primeira consulta depois de um deploy costuma falhar por motivo bobo: pool de conexões frio, DNS não resolvido, contêiner sem rede. Três tentativas com 5 s de intervalo eliminam quase todo falso negativo.

Cenário prático: janela de deploy com a frota em São Paulo

Um time de QA em São Paulo roda 12 workers em sa-east-1 para validar formulários em https://staging.example.com/checkout, no plano ADVANCE (US$ 90/mês, 50 threads). O tráfego de validação se concentra entre 9h e 12h, quando o time abre os PRs do dia.

A escolha é direta: rodar o rolling update fora dessa janela, com maxUnavailable em 2 (cerca de 17% da frota) e drain_timeout de 120 s. São 6 lotes de 2 workers, cerca de 15 minutos, e a frota nunca cai abaixo de 10 instâncias ativas. Dentro do pico, o inverso: maxUnavailable em 1, com rollout mais longo em troca de capacidade preservada. O teste roda em ambiente próprio, com dados fictícios — se a automação registrar dado pessoal, considere as obrigações da LGPD antes de guardar log.

Comparação entre as estratégias de deploy

Estratégia Indisponibilidade Velocidade de rollback Complexidade Mais adequada para
Rolling Nenhuma Moderada Baixa A maioria dos deploys
Blue-Green Nenhuma Instantânea Média Serviços críticos
Canary Nenhuma Rápida Alta Frotas grandes (mais de 50 workers)
Recreate Breve N/A Mínima Ambientes de desenvolvimento

O rolling update é o padrão porque não exige capacidade duplicada. O deploy blue-green troca rollback moderado por reversão instantânea, ao custo de dois ambientes.

Diagnóstico quando o rollout trava

Sintoma Causa provável O que fazer
Tarefas descartadas na atualização Timeout de drain menor que a tarefa mais lenta Aumente drain_timeout; meça a duração máxima real
Health check falha sempre Bug ou configuração errada na versão nova Reverta e valide em staging antes de repetir
Rollout demorando demais maxUnavailable baixo para o tamanho da frota Suba para 2 ou 3 em frotas grandes
Versões misturadas na frota Rollback parcial, sem registro do que subiu Guarde a lista de atualizados e reverta todos
Drain nunca termina Contador preso ou roteador ignorando o estado Confirme o decremento no finally e o filtro por RUNNING

Quando o worker passa no health check e ainda assim erra toda resolução, a causa costuma ser configuração: chave de API de outro ambiente ou variável não propagada ao contêiner.

Perguntas frequentes

Preciso mesmo de rolling update se minha frota tem só 3 workers?

Sim, e por um motivo diferente do de uma frota grande. Com 3 workers, tirar um custa 33% da capacidade; derrubar todos custa 100% e todas as tarefas em voo. Com max_unavailable=1, você mantém dois terços da vazão durante o deploy.

O que exatamente o health check deve verificar?

Uma resolução real, não um ping. Envie uma tarefa do tipo que a frota atende — um reCAPTCHA v2 em homologação — e confirme que o token volta. A consulta de saldo valida credencial e conectividade, não a resolução.

O plano da CaptchaAI muda alguma coisa no meu rollout?

Indiretamente. Como a cobrança é por thread concorrente, e não por resolução, tirar um worker de circulação não gera custo extra. O que muda é o teto de concorrência: em BASIC (US$ 15/mês, 5 threads) uma frota reduzida encosta no limite bem antes de uma em CORPORATE (US$ 240/mês, 150 threads).

Dá para usar esse padrão em workers que resolvem hCaptcha?

O padrão de deploy é agnóstico ao tipo de desafio, mas vale a correção: a CaptchaAI não resolve hCaptcha nem FunCaptcha, e o GeeTest v4 aparece apenas como em breve. A frota deste guia atende reCAPTCHA v2 e v3, Cloudflare Turnstile e Challenge, GeeTest v3, imagem/OCR e grade de imagens, além de CaptchaFox, Friendly Captcha e Lemin (os três em beta).

Próximos passos

Coloque o ciclo em produção — obtenha sua chave de API da CaptchaAI e valide o primeiro worker com uma resolução real.

Guias relacionados:

Os comentários estão desativados para este artigo.