DevOps y Escalado

Kubernetes para resolver CAPTCHA a escala con colas de tareas

¿Cómo resuelves decenas de miles de CAPTCHA sin que un único proceso se convierta en el cuello de botella? La respuesta práctica es repartir el trabajo: una cola central acumula las tareas y un grupo de pods worker las consume en paralelo, creciendo o encogiéndose según cuántas quedan pendientes. Kubernetes aporta justo eso —autoescalado y autorreparación— y CaptchaAI resuelve cada CAPTCHA detrás de una sola API. En esta guía montamos ese sistema paso a paso: workers que sacan tareas de una cola de Redis, un HPA que reacciona a la profundidad de la cola y un productor que encola trabajo desde tu backend.

Este patrón brilla cuando la carga llega en ráfagas. Piensa en una agencia de datos en Madrid o Ciudad de México que procesa de un día para otro la cola de un scraping autorizado, o en un equipo que valida el flujo de checkout de su propia tienda antes de un lanzamiento. El volumen no es constante: pasa de casi cero a miles de tareas por hora y vuelve a bajar. Mantener una flota fija encendida las 24 horas no tiene sentido; escalar pods bajo demanda, sí.


Arquitectura para resolver CAPTCHA a escala

El diseño tiene tres piezas y un único punto de entrada a CaptchaAI:

Producer → Redis Queue → Worker Pods (auto-scaled) → CaptchaAI API
                              ↓
                       Results Store (Redis)

El productor solo escribe en Redis y no sabe nada de los workers. Los workers tampoco se conocen entre sí: cada uno hace blpop sobre la misma lista, así que Redis reparte las tareas de forma natural y añadir un pod aumenta el ritmo de consumo. Los resultados vuelven a Redis en un hash, desde donde el productor los recoge por su id.

Despliegue de los pods worker

Arrancamos con tres réplicas y límites de recursos ajustados: cada worker pasa la mayor parte del tiempo esperando la respuesta de la API, no quemando CPU, así que 128–256 Mi de memoria y una fracción de vCPU bastan por pod.

# k8s/worker-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: captcha-worker
  labels:
    app: captcha-worker
spec:
  replicas: 3
  selector:
    matchLabels:
      app: captcha-worker
  template:
    metadata:
      labels:
        app: captcha-worker
    spec:
      containers:

        - name: worker
          image: your-registry/captcha-worker:latest
          env:

            - name: CAPTCHAAI_KEY
              valueFrom:
                secretKeyRef:
                  name: captchaai-secret
                  key: api-key

            - name: REDIS_URL
              value: "redis://redis-service:6379"
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
            limits:
              memory: "256Mi"
              cpu: "250m"

Fíjate en que la API key no aparece en el manifiesto: se inyecta como variable de entorno desde un secreto, que creamos a continuación.

Guardar la API key en un secreto

Nunca metas la clave en la imagen ni en el YAML del Deployment. Kubernetes tiene un objeto pensado justo para esto:

kubectl create secret generic captchaai-secret \
  --from-literal=api-key=YOUR_API_KEY

El Deployment ya referencia este secreto con secretKeyRef, de modo que rotar la clave se reduce a recrear el secreto y reiniciar los pods, sin tocar el código ni reconstruir la imagen.

Redis como cola compartida

Redis hace de cola de entrada y de almacén de resultados. Para empezar basta con una réplica; en producción querrás Redis gestionado o con persistencia, pero la mecánica es idéntica:

# k8s/redis.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:

        - name: redis
          image: redis:7-alpine
          ports:

            - containerPort: 6379
          resources:
            requests:
              memory: "128Mi"
              cpu: "100m"
---
apiVersion: v1
kind: Service
metadata:
  name: redis-service
spec:
  selector:
    app: redis
  ports:

    - port: 6379

El Service expone Redis dentro del clúster en redis-service:6379, exactamente la URL que los workers reciben por REDIS_URL.

El bucle del worker

Aquí está el corazón del sistema. Cada worker se bloquea a la espera de la cola, resuelve la tarea contra CaptchaAI y escribe el resultado con su duración y el nombre del pod que lo procesó —muy útil para depurar después:

# worker.py
import os
import json
import time
import redis
import requests


class CaptchaWorker:
    """Kubernetes worker that processes CAPTCHA tasks from Redis."""

    def __init__(self):
        self.api_key = os.environ["CAPTCHAAI_KEY"]
        self.redis = redis.from_url(
            os.environ.get("REDIS_URL", "redis://localhost:6379"),
        )
        self.base = "https://ocr.captchaai.com"

    def run(self):
        """Main worker loop."""
        hostname = os.environ.get("HOSTNAME", "unknown")
        print(f"Worker {hostname} started")

        while True:
            result = self.redis.blpop("captcha:queue", timeout=30)
            if result is None:
                continue

            _, raw = result
            task = json.loads(raw)
            task_id = task.get("id", "unknown")

            print(f"[{hostname}] Processing {task_id}")
            start = time.time()

            try:
                token = self._solve(task["method"], task["params"])
                duration = time.time() - start
                self.redis.hset("captcha:results", task_id, json.dumps({
                    "status": "success",
                    "token": token,
                    "duration": f"{duration:.1f}s",
                    "worker": hostname,
                }))
                print(f"[{hostname}] {task_id} solved in {duration:.1f}s")

            except Exception as e:
                self.redis.hset("captcha:results", task_id, json.dumps({
                    "status": "error",
                    "error": str(e),
                    "worker": hostname,
                }))
                print(f"[{hostname}] {task_id} failed: {e}")

            # Update queue length metric
            queue_len = self.redis.llen("captcha:queue")
            self.redis.set("captcha:queue_length", queue_len)

    def _solve(self, method, params, timeout=120):
        resp = requests.post(f"{self.base}/in.php", data={
            "key": self.api_key,
            "method": method,
            "json": 1,
            **params,
        }, timeout=30)
        result = resp.json()

        if result.get("status") != 1:
            raise RuntimeError(result.get("request"))

        captcha_id = result["request"]

        start = time.time()
        while time.time() - start < timeout:
            time.sleep(5)
            resp = requests.get(f"{self.base}/res.php", params={
                "key": self.api_key,
                "action": "get",
                "id": captcha_id,
                "json": 1,
            }, timeout=15)
            data = resp.json()
            if data["request"] != "CAPCHA_NOT_READY":
                if data.get("status") == 1:
                    return data["request"]
                raise RuntimeError(data["request"])

        raise TimeoutError("Solve timeout")


if __name__ == "__main__":
    CaptchaWorker().run()

El método _solve sigue el flujo estándar de la API de CaptchaAI: envía la tarea a in.php, recibe un id y consulta res.php cada cinco segundos hasta que el token está listo o se agota el timeout. Como CaptchaAI factura por thread concurrente y no por resolución, cada worker puede sondear y reintentar sin coste extra; lo único que consumes es tiempo de thread.

Autoescalado según la profundidad de la cola

Una flota fija desaprovecha las ráfagas y malgasta dinero en los valles. El HorizontalPodAutoscaler resuelve esto escalando por una métrica externa: la longitud de la cola de Redis.

# k8s/hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: captcha-worker-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: captcha-worker
  minReplicas: 2
  maxReplicas: 20
  metrics:

    - type: External
      external:
        metric:
          name: redis_queue_length
          selector:
            matchLabels:
              queue: captcha
        target:
          type: AverageValue
          averageValue: "10"

Con averageValue: "10", Kubernetes intenta mantener unas diez tareas pendientes por pod: si la cola crece, añade réplicas hasta veinte; si se vacía, baja hasta dos. Para que esto funcione necesitas un adaptador de métricas (KEDA o Prometheus Adapter) que exponga redis_queue_length al HPA.

Encolar tareas desde el productor

El productor es deliberadamente simple: asigna un id corto a cada tarea, la empuja a la cola y luego recoge los resultados por ese id.

import json
import uuid
import redis


def submit_tasks(redis_url, tasks):
    """Submit CAPTCHA tasks to the queue."""
    r = redis.from_url(redis_url)
    task_ids = []

    for task in tasks:
        task_id = str(uuid.uuid4())[:8]
        task["id"] = task_id
        r.rpush("captcha:queue", json.dumps(task))
        task_ids.append(task_id)

    return task_ids


def get_results(redis_url, task_ids, timeout=180):
    """Wait for and collect results."""
    r = redis.from_url(redis_url)
    results = {}
    deadline = time.time() + timeout

    while len(results) < len(task_ids) and time.time() < deadline:
        for tid in task_ids:
            if tid in results:
                continue
            raw = r.hget("captcha:results", tid)
            if raw:
                results[tid] = json.loads(raw)
        time.sleep(1)

    return results

Al desacoplar productor y workers encolas diez o diez mil tareas con el mismo código; el HPA dimensiona la flota.

Cuántos threads necesitas para escalar CAPTCHA

El límite real de tu throughput no es Kubernetes, sino cuántos threads incluye tu plan de CaptchaAI. Un thread es un CAPTCHA en vuelo; en cuanto termina, queda libre para el siguiente. La regla práctica es alinear la concurrencia total de tu clúster —pods por resoluciones simultáneas de cada uno— con los threads de tu plan:

  • BASIC ($15/mes, 5 threads) para pruebas y volúmenes bajos.
  • STANDARD ($30/mes, 15 threads) o ADVANCE ($90/mes, 50 threads) para cargas medias con ráfagas.
  • PREMIUM ($170/mes, 100 threads) y superiores cuando el maxReplicas del HPA empuja de verdad.

Si tus workers piden más resoluciones simultáneas que threads tienes, las solicitudes extra esperan turno: no fallan, pero la latencia sube. Para agencias y freelancers que facturan en monedas locales volátiles, este costo mensual predecible en USD es más fácil de presupuestar que un pago por resolución.

Solución de problemas

Problema Causa Solución
Los workers no arrancan Secreto no creado Ejecuta el comando kubectl create secret
Pods en CrashLoopBackOff Faltan variables de entorno o Redis Revisa los registros con kubectl logs
El HPA no escala Métricas externas no configuradas Instala el adaptador de métricas (KEDA)
La cola crece pero no se procesa Workers inactivos o caídos Revisa el estado del pod y reinícialo

Preguntas frecuentes

¿Cuántos threads de mi plan necesito para esta flota?

Depende de cuántas resoluciones simultáneas hagan tus pods a la vez. Multiplica el número de réplicas por las resoluciones concurrentes de cada worker y contrata un plan con al menos esos threads; por encima de ese número, las tareas extra simplemente esperan en la cola.

¿Qué pasa con una tarea si un pod se cae a mitad de resolución?

Como el worker usa blpop, la tarea sale de la cola al empezar. Si el pod muere antes de escribir el resultado, esa tarea se pierde salvo que añadas confirmación explícita; para cargas críticas usa un patrón de cola confiable (por ejemplo BLMOVE a una lista de "en proceso") o KEDA con reintentos.

¿Puedo escalar los workers a cero cuando no hay trabajo?

Con el HPA nativo no, porque su mínimo práctico es uno o dos pods. KEDA sí permite escalar a cero y volver a arrancar cuando la cola de Redis recupera tareas, lo que ahorra recursos en cargas intermitentes.

¿Sirve la misma arquitectura para distintos tipos de CAPTCHA?

Sí, porque el worker pasa method y params genéricos a la API. CaptchaAI resuelve reCAPTCHA v2 y v3, Cloudflare Turnstile y GeeTest v3, entre otros tipos compatibles; solo cambias los parámetros de la tarea que encolas, no la arquitectura.


Guías relacionadas


¿Necesitas procesar miles de CAPTCHA al día? Prueba CaptchaAI en tu clúster de Kubernetes.

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