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
DRAININGcomo elegível, o drain nunca termina. Abaixo,get_available_workerfiltra porRUNNING. - 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: