Nunca reemplaces un worker que todavía tiene trabajo abierto. Esa es toda la regla, y son cuatro movimientos: dejas de enviarle tareas, esperas a que termine las que aceptó, despliegas la versión nueva y solo lo devuelves al pool cuando un health check real lo valida. Si te saltas el primer paso, cada CAPTCHA en vuelo se pierde junto con el thread que ya estabas pagando.
La diferencia con un despliegue web normal está en la duración de la unidad de trabajo: un worker HTTP responde en milisegundos, mientras que uno de CAPTCHA puede tener una tarea abierta casi un minuto, porque envía a in.php, sondea res.php y espera.
Elige la estrategia de despliegue antes de tocar la flota
La actualización continua no siempre es la opción correcta. Decide primero, implementa después:
| Estrategia | Tiempo de inactividad | Velocidad de reversión | Complejidad | Cuándo conviene |
|---|---|---|---|---|
| Continua (rolling) | Ninguno | Moderada | Baja | La mayoría de los despliegues |
| Azul-verde | Ninguno | Instantánea | Media | Servicios críticos |
| Canaria | Ninguno | Rápida | Alta | Flotas grandes (más de 50 workers) |
| Recrear | Breve | N/A | Mínima | Entornos de desarrollo |
La opción continua gana casi siempre porque no exige duplicar la infraestructura: la capacidad baja solo en la fracción que actualizas a la vez. El precio es la convivencia temporal de dos versiones, así que la nueva debe entender la cola que dejaron los workers viejos.
Las cuatro fases de una actualización continua
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
Cada línea es una decisión, no un temporizador. W1-drain significa que el router dejó de asignarle tareas aunque el proceso siga vivo; W1-NEW✓, que la versión nueva pasó su health check. Si un worker se atasca drenando, corta con un tope y regístralo.
Calcula el drain timeout con el techo de resolución real
El error más común es copiar el drain timeout de otro servicio. Ajústalo al tipo que resuelve esa flota, según los techos publicados de CaptchaAI: imagen y OCR por debajo de 0,5 s, reCAPTCHA v3 por debajo de 4 s, Cloudflare Turnstile por debajo de 10 s, GeeTest v3 por debajo de 12 s y reCAPTCHA v2 por debajo de 60 s. Suma un margen por la latencia de tu red y el intervalo de sondeo. En la práctica, una flota de Turnstile se drena en 30 segundos y una de reCAPTCHA v2 pide 120; si mezclas tipos, manda el más lento.
CaptchaAI factura por thread concurrente, no por resolución, y cada plan incluye resoluciones ilimitadas por thread. Un drenaje ordenado no te cuesta dinero extra: lo que cuesta es el thread ocupado por una tarea que abandonaste a medias y tendrás que volver a enviar.
Ejemplo: agencia con plan ADVANCE y ventana nocturna
Piensa en una agencia de datos en Ciudad de México que monitorea precios en marketplaces regionales con el plan ADVANCE ($90/mes, 50 threads) repartidos entre diez workers, cinco threads cada uno, y despliega a las 03:00 hora local, cuando el volumen baja.
Con max_unavailable = 1 la flota pierde el 10% de su capacidad por turno: unos tres minutos por worker entre drenaje y arranque, media hora en total, sin una tarea perdida. Al probar max_unavailable = 3 en horario laboral, la cola se acumuló y los reintentos dispararon los tiempos de respuesta. La agresividad se mide contra la capacidad libre que te queda, no contra las ganas de terminar antes.
Un apunte para cualquier flota de extracción de datos web en la región: respeta los términos de servicio del sitio y la normativa de protección de datos aplicable (GDPR y LOPDGDD en España, LFPDPPP en México).
Orquestador en Python: drenaje, health gate y reversión
Este orquestador modela cada worker como una máquina de estados (RUNNING, DRAINING, STOPPED, UPDATING) y enruta tareas solo a los que están en RUNNING. Si el health check posterior al arranque falla, revierte los workers ya actualizados antes de devolver el control:
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}")
Dos detalles importan: drain() sale por tope de tiempo y registra las tareas que quedaron abiertas en vez de bloquear el despliegue, y get_available_worker() toma el lock antes de elegir destino, para que ningún hilo entregue trabajo a un worker que ya está drenando.
Node.js: progreso visible y aborto por umbral de fallos
La versión en JavaScript añade lo que suele faltar en el primer intento: contador de progreso y umbral de aborto. Si falla más del 25% de la flota, el despliegue se detiene solo. Su health check consulta el saldo con action=getbalance, una llamada barata que confirma que el proceso arrancó, leyó la clave API y tiene salida a la red:
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)));
Fallos frecuentes en actualizaciones continuas
| Síntoma | Causa probable | Qué hacer |
|---|---|---|
| Se pierden tareas durante el despliegue | El drain timeout es menor que la tarea más lenta | Sube drain_timeout a la duración máxima real |
| El health check falla siempre tras actualizar | Falta una variable de entorno en la versión nueva | Revierte y valida esa versión en preproducción |
| El despliegue tarda demasiado | maxUnavailable bajo para el tamaño de la flota |
Súbelo a 2 o 3 en flotas grandes |
| Quedan versiones mezcladas en la flota | La reversión se ejecutó a medias | Registra qué workers tocaste y revierte todos, no solo el que falló |
Preguntas frecuentes
¿Necesito más threads para hacer actualizaciones continuas?
No. Los threads son concurrencia del plan completo, no capacidad por worker: mientras uno drena, los demás usan los mismos threads. Solo amplía el plan si tu cola ya iba al límite antes del despliegue.
¿Qué pasa con una tarea que ya envié a la API cuando apago el worker?
La resolución sigue su curso del lado del servidor y el captcha_id se puede consultar en res.php. Si guardas esos IDs en un almacén compartido (Redis, por ejemplo) y no en memoria, el worker nuevo recoge el resultado.
¿Cuánto debe durar el drain timeout?
El techo del tipo más lento que atiende la flota, más un margen. Para reCAPTCHA v2 (techo por debajo de 60 s), 120 segundos es seguro; para Turnstile bastan 30.
¿Cómo revierto si ya actualicé la mitad de la flota?
Revierte todos los workers que tocaste, no solo el que falló: dos versiones activas producen fallos intermitentes difíciles de reproducir. Por eso el orquestador mantiene la lista updated y la recorre entera.
¿Vale la pena resolver un CAPTCHA real como health check?
En sistemas críticos, sí. El saldo confirma conectividad y credenciales, pero solo una resolución real del tipo que te importa demuestra que la versión nueva funciona de punta a punta. Resérvala para el primer worker de cada lote.
Próximos pasos
Empieza por un worker en una ventana de baja demanda: obtén tu API key de CaptchaAI, añade el health gate y mide el drenaje real antes de automatizar el resto.
Guías relacionadas: