DevOps y Escalado

CaptchaAI detrás de un load balancer: patrones de arquitectura

Un único proceso worker resolviendo CAPTCHA se queda corto en cuanto tu pipeline supera unos cientos de tareas por minuto: las solicitudes se encolan y una sola caída lo detiene todo. La solución es repartir el trabajo entre varios workers detrás de un load balancer, para distribuir la carga, ganar failover automático y escalar horizontalmente sin tocar tus scrapers. Esta guía cubre los patrones para desplegar workers de CaptchaAI detrás de un balanceador: qué algoritmo elegir, cómo configurar NGINX y el servidor worker, y cómo diagnosticar los fallos habituales.

El patrón: workers detrás del balanceador

Tus scrapers no hablan directamente con la API de CaptchaAI: envían cada tarea a un endpoint interno (/solve), el balanceador la reparte entre los workers y es cada worker quien llama a la API, sondea el resultado y lo devuelve. Añadir o retirar workers no toca a los scrapers: solo cambias la lista del upstream.

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

Qué algoritmo de balanceo elegir

No todos los repartos rinden igual cuando la duración de las tareas varía tanto:

Algoritmo Cuándo usarlo Riesgo principal
Round-robin Todos los workers tienen capacidad y latencia parecidas Un worker lento sigue recibiendo tráfico al mismo ritmo
least_conn Los tiempos de resolución varían mucho entre tareas Necesita health checks fiables para no favorecer a workers degradados
backup Quieres capacidad de emergencia sin usarla a diario El worker de respaldo puede quedar frío si nunca recibe tráfico
Sticky / afinidad El worker conserva estado local o una sesión de navegador Puede desequilibrar la carga si la afinidad no expira

Una agencia que monitoriza precios para varios clientes tiene picos irregulares: least_conn evita que un worker atascado en una tarea larga acapare las solicitudes nuevas.

Configuración de NGINX

Reparto round-robin (por defecto)

El punto de partida; el proxy_read_timeout es generoso porque una resolución puede tardar minutos.

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;
    }
}

least_conn: el mejor reparto para resolver CAPTCHA

Como la duración es impredecible, enrutar al worker con menos conexiones activas reparte mejor que el round-robin. Sube el weight de los más potentes y añade max_fails.

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;
}

Workers de respaldo para failover

Un worker backup solo entra cuando los demás caen: tu red de seguridad.

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
}

El servidor worker (API interna)

Cada worker expone dos endpoints: /solve, que envía la tarea y sondea el resultado, y /health, que reporta su carga para sacarlo de rotación cuando se satura.

Python (Flask)

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)

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}`));

Estrategias de enrutamiento comparadas

Las estrategias habituales de un balanceador y para qué encaja cada una:

Estrategia Cómo funciona Mejor para
Round-robin Rotación secuencial Workers de igual capacidad
least_conn Enruta al menos cargado Resolución de CAPTCHA (duración variable)
Ponderado (weight) Proporcional al peso asignado Workers de capacidad mixta
IP hash Mismo cliente → mismo worker Cuando necesitas afinidad de sesión
Aleatorio Selección al azar Carga simple y repartida de forma uniforme

Recomendación: usa least_conn para resolver CAPTCHA. Como la duración varía (de 5 a 120 segundos), el round-robin genera una carga desigual.

Balanceo del lado del cliente

Cuando no puedes montar un balanceador externo, mueve el enrutamiento al cliente. Esta clase envía cada tarea al worker menos cargado y descarta el que responda 503 o falle:

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"})

Diagnóstico de problemas comunes

Síntoma Causa Solución
502 Bad Gateway El worker se cayó o no arrancó Revisa los logs del worker; comprueba el binding del puerto
Carga repartida de forma desigual Round-robin con tareas de duración variable Cambia a least_conn
Health check con falso positivo El check pasa pero el worker está al límite Incluye el porcentaje de carga en la respuesta de /health
Connection timeout proxy_read_timeout demasiado corto Súbelo a 300 s o más para resolver CAPTCHA

Preguntas frecuentes

¿Cuántos workers y threads de CaptchaAI necesito para mi volumen?

Depende de tu concurrencia. CaptchaAI factura por thread concurrente (un thread es un CAPTCHA en curso), con resoluciones ilimitadas por thread. Dimensiona primero los threads: ADVANCE ($90/mes, 50 threads) cubre 50 resoluciones simultáneas. Reparte esa concurrencia entre 2 a 4 workers para el failover; más workers no dan throughput extra si no subes los threads del plan.

¿Cómo evito que el balanceador corte las tareas de resolución largas?

Sube los tiempos de espera. Como una resolución puede tardar más de un minuto, configura proxy_read_timeout en 300 s o más en NGINX y usa un timeout equivalente en el cliente. Con los valores por defecto se cortaría antes de tiempo.

¿Debo usar sesiones sticky con workers de CAPTCHA?

No. Las solicitudes de resolución no tienen estado: cualquier worker atiende cualquier tarea, así que la afinidad de sesión solo crearía una distribución desigual.

¿Cómo reparto workers en varias regiones?

Usa un balanceador global (AWS Global Accelerator, Cloudflare Load Balancing) que enrute a la región sana más cercana, con un balanceador local por región para acortar la latencia.

Artículos relacionados


Escala tu resolución con CaptchaAI

Reparte tu resolución de CAPTCHA entre varios workers y gana failover. Consigue tu clave API de CaptchaAI y despliégala detrás de un load balancer.

Guías relacionadas:

Los comentarios están deshabilitados para este artículo.