DevOps y Escalado

Terraform + CaptchaAI: infraestructura como código para workers de CAPTCHA

Si hoy alguien borrara por accidente tu grupo de workers de resolución de CAPTCHA, ¿en cuánto tiempo lo tendrías de vuelta? Con Terraform la respuesta es "un terraform apply". Cluster, roles IAM, secretos, políticas de escalado y variables por entorno viven en archivos versionados en Git, se revisan en un pull request como cualquier otro cambio y se reproducen igual en dev, staging y producción.

Aquí tienes la ruta completa: estructura del repositorio, API key sin texto plano, workers en ECS Fargate, autoescalado y despliegue. HCL y worker en Python listos para copiar.

Por qué declarar los workers en lugar de crearlos a mano

Un worker de CAPTCHA necesita una credencial sensible, una concurrencia que cuadre con los threads contratados y un ciclo de vida irregular: lo levantas para una campaña de scraping, lo apagas al terminar y lo reabres semanas después con otro tamaño. Ahí falla la consola web: nadie recuerda qué variable traía el servicio anterior.

El caso típico en la región: una agencia de datos en Bogotá o Barcelona levanta un entorno para un cliente durante ocho semanas, factura en pesos o euros y paga la API en USD. Con todo declarado, montar el entorno del siguiente cliente es copiar un archivo de variables y cambiar tres números, y un terraform destroy al cerrar deja el entorno a cero, sin nada encendido generando factura. Bajo GDPR/LOPDGDD o la LFPDPPP mexicana, el estado en Git aporta la trazabilidad que pide una auditoría.

Cómo queda organizado el repositorio

Esta estructura separa la raíz del módulo reutilizable y de los valores de cada entorno:

terraform/
├── main.tf              # Provider config
├── variables.tf         # Input variables
├── outputs.tf           # Output values
├── modules/
│   └── captcha-worker/
│       ├── main.tf      # ECS/EC2 resources
│       ├── variables.tf # Module inputs
│       └── outputs.tf   # Module outputs
├── environments/
│   ├── dev.tfvars
│   ├── staging.tfvars
│   └── production.tfvars

La regla práctica: la raíz nunca contiene números mágicos. Todo lo que cambie entre entornos pasa por variables.tf y se rellena desde un .tfvars.

Configuración base: provider y backend remoto

Este archivo fija la versión de Terraform, el provider de AWS y el backend remoto. El estado en S3 con bloqueo en DynamoDB evita que dos personas apliquen a la vez:

# main.tf
terraform {
  required_version = ">= 1.5"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "captcha-workers/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

provider "aws" {
  region = var.aws_region
}

Variables de entrada

Fíjate en captchaai_concurrency: son las tareas de CAPTCHA en vuelo por worker, y hay que multiplicarla por el número de workers para no exceder tus threads.

# variables.tf
variable "aws_region" {
  description = "AWS region for deployment"
  type        = string
  default     = "us-east-1"
}

variable "environment" {
  description = "Environment name (dev, staging, production)"
  type        = string
}

variable "worker_count" {
  description = "Number of CAPTCHA solving workers"
  type        = number
  default     = 3
}

variable "worker_cpu" {
  description = "CPU units for each worker (1024 = 1 vCPU)"
  type        = number
  default     = 512
}

variable "worker_memory" {
  description = "Memory in MB for each worker"
  type        = number
  default     = 1024
}

variable "max_workers" {
  description = "Maximum workers for auto-scaling"
  type        = number
  default     = 10
}

variable "captchaai_concurrency" {
  description = "Concurrent CAPTCHA tasks per worker"
  type        = number
  default     = 10
}

Ese cálculo es lo que más veces se hace mal. CaptchaAI factura por thread concurrente, con resoluciones ilimitadas dentro del mes, no por CAPTCHA resuelto. Con STANDARD ($30/mes, 15 threads) y worker_count = 3, el techo de captchaai_concurrency es 5: el valor por defecto de 10 te dejaría en 30, el doble de lo contratado. Producción —5 workers con concurrencia 20— pide 100 threads: PREMIUM ($170/mes, 100 threads). Más no acelera nada, solo encola tareas.

Gestión de la API key

La clave nunca va en el HCL ni en el archivo de variables. Declara el secreto en AWS Secrets Manager y pásalo a la tarea por referencia, para que el valor no aparezca en el plan ni en el estado:

# secrets.tf — Store API key in AWS Secrets Manager
resource "aws_secretsmanager_secret" "captchaai_api_key" {
  name        = "${var.environment}/captchaai-api-key"
  description = "CaptchaAI API key for CAPTCHA solving workers"
}

# Reference secret in ECS task (never in plain text)
data "aws_secretsmanager_secret_version" "captchaai_api_key" {
  secret_id = aws_secretsmanager_secret.captchaai_api_key.id
}

Crea el valor fuera de Terraform, por consola o CLI, antes del primer apply: declarar el contenedor es cosa del código; conocer su contenido no.

El grupo de workers en ECS Fargate

Observa la separación entre environment —no sensible— y secrets —referencias a Secrets Manager—, más los logs a CloudWatch, tu única ventana cuando algo falla:

# ecs.tf — Fargate-based CAPTCHA workers
resource "aws_ecs_cluster" "captcha" {
  name = "captcha-workers-${var.environment}"

  setting {
    name  = "containerInsights"
    value = "enabled"
  }
}

resource "aws_ecs_task_definition" "captcha_worker" {
  family                   = "captcha-worker-${var.environment}"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = var.worker_cpu
  memory                   = var.worker_memory
  execution_role_arn       = aws_iam_role.ecs_execution.arn
  task_role_arn            = aws_iam_role.ecs_task.arn

  container_definitions = jsonencode([
    {
      name  = "captcha-worker"
      image = "${aws_ecr_repository.captcha_worker.repository_url}:latest"

      environment = [
        { name = "CAPTCHAAI_CONCURRENCY", value = tostring(var.captchaai_concurrency) },
        { name = "CAPTCHAAI_POLL_INTERVAL", value = "5" },
        { name = "ENVIRONMENT", value = var.environment },
      ]

      secrets = [
        {
          name      = "CAPTCHAAI_API_KEY"
          valueFrom = aws_secretsmanager_secret.captchaai_api_key.arn
        }
      ]

      logConfiguration = {
        logDriver = "awslogs"
        options = {
          "awslogs-group"         = aws_cloudwatch_log_group.captcha.name
          "awslogs-region"        = var.aws_region
          "awslogs-stream-prefix" = "worker"
        }
      }
    }
  ])
}

resource "aws_ecs_service" "captcha_worker" {
  name            = "captcha-workers"
  cluster         = aws_ecs_cluster.captcha.id
  task_definition = aws_ecs_task_definition.captcha_worker.arn
  desired_count   = var.worker_count
  launch_type     = "FARGATE"

  network_configuration {
    subnets         = var.private_subnets
    security_groups = [aws_security_group.captcha_worker.id]
  }
}

Autoescalado según la profundidad de la cola

El pool sube de dos en dos cuando la cola se acumula y baja de una en una cuando queda ocioso; el cooldown más largo en la bajada evita el efecto acordeón:

# autoscaling.tf
resource "aws_appautoscaling_target" "captcha" {
  max_capacity       = var.max_workers
  min_capacity       = var.worker_count
  resource_id        = "service/${aws_ecs_cluster.captcha.name}/${aws_ecs_service.captcha_worker.name}"
  scalable_dimension = "ecs:service:DesiredCount"
  service_namespace  = "ecs"
}

# Scale up when queue is deep
resource "aws_appautoscaling_policy" "scale_up" {
  name               = "captcha-scale-up"
  policy_type        = "StepScaling"
  resource_id        = aws_appautoscaling_target.captcha.resource_id
  scalable_dimension = aws_appautoscaling_target.captcha.scalable_dimension
  service_namespace  = aws_appautoscaling_target.captcha.service_namespace

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 120

    step_adjustment {
      scaling_adjustment          = 2
      metric_interval_lower_bound = 0
    }
  }
}

# Scale down when idle
resource "aws_appautoscaling_policy" "scale_down" {
  name               = "captcha-scale-down"
  policy_type        = "StepScaling"
  resource_id        = aws_appautoscaling_target.captcha.resource_id
  scalable_dimension = aws_appautoscaling_target.captcha.scalable_dimension
  service_namespace  = aws_appautoscaling_target.captcha.service_namespace

  step_scaling_policy_configuration {
    adjustment_type         = "ChangeInCapacity"
    cooldown                = 300

    step_adjustment {
      scaling_adjustment          = -1
      metric_interval_upper_bound = 0
    }
  }
}

Cuidado con el costo: max_workers debe seguir cabiendo en tus threads. Veinte workers con concurrencia 20 piden 400 threads. Ajusta el techo a tu plan, no al revés.

Variables por entorno

Mismo esquema, distintos números. Dev arranca minúsculo para que probar salga casi gratis:

# environments/dev.tfvars
environment           = "dev"
worker_count          = 1
max_workers           = 3
worker_cpu            = 256
worker_memory         = 512
captchaai_concurrency = 3

Producción usa el doble de CPU y memoria, y un rango de escalado mucho más amplio:

# environments/production.tfvars
environment           = "production"
worker_count          = 5
max_workers           = 20
worker_cpu            = 1024
worker_memory         = 2048
captchaai_concurrency = 20

El worker que corre dentro del contenedor

La imagen ejecuta este script: lee clave y concurrencia del entorno, atiende SIGTERM para apagarse limpio cuando Fargate reemplaza la tarea, envía el trabajo a in.php y sondea res.php.

"""captcha_worker.py — The container runs this."""
import os
import time
import signal
import requests

API_KEY = os.environ["CAPTCHAAI_API_KEY"]
CONCURRENCY = int(os.environ.get("CAPTCHAAI_CONCURRENCY", "10"))
POLL_INTERVAL = int(os.environ.get("CAPTCHAAI_POLL_INTERVAL", "5"))

running = True

def shutdown_handler(signum, frame):
    global running
    print("Graceful shutdown initiated")
    running = False

signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)

session = requests.Session()

def solve_captcha(sitekey, pageurl):
    resp = session.post("https://ocr.captchaai.com/in.php", data={
        "key": API_KEY,
        "method": "userrecaptcha",
        "googlekey": sitekey,
        "pageurl": 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(POLL_INTERVAL)
        result = 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"}

# Main loop — pull tasks from SQS or Redis
print(f"Worker started: concurrency={CONCURRENCY}")
while running:
    # Pull tasks from your queue here
    time.sleep(1)

print("Worker shutdown complete")

El bucle usa reCAPTCHA v2 con el método userrecaptcha; el mismo esqueleto sirve para otros tipos cambiando method. El servicio cubre reCAPTCHA v2 y v3, Cloudflare Turnstile y Cloudflare Challenge, GeeTest v3, imagen/OCR y grid, más CaptchaFox (beta), Friendly Captcha (beta) y Lemin (beta); hCaptcha y FunCaptcha no son compatibles y GeeTest v4 figura como próximamente.

El shutdown_handler evita perder tareas a medio resolver: sin él, cada terraform apply que toque la definición de tarea te cuesta las resoluciones en vuelo.

Comandos de despliegue

Del backend inicializado al entorno de pruebas borrado:

# Initialize
terraform init

# Plan for production
terraform plan -var-file=environments/production.tfvars

# Apply
terraform apply -var-file=environments/production.tfvars

# Destroy (dev cleanup)
terraform destroy -var-file=environments/dev.tfvars

Guarda la salida de terraform plan y revísala en el pull request: exigir aprobación humana en producción separa un cambio de tamaño de un borrado accidental.

Problemas frecuentes y cómo salir de ellos

Síntoma Causa habitual Qué hacer
Secreto no encontrado al desplegar Declarado pero sin valor Crea el valor en Secrets Manager antes del apply
Los workers reinician al arrancar Falta una variable o el tag de imagen es erróneo Revisa CloudWatch y el tag en ECR
El autoescalado nunca se dispara Alarma inexistente o métrica errónea Comprueba el ARN de la alarma en la política
Error de bloqueo de estado apply anterior interrumpido terraform force-unlock <lock-id>
Muchos tiempos de espera agotados Concurrencia total sobre tus threads Baja captchaai_concurrency o sube de plan

Preguntas frecuentes

¿Cuántos threads necesito para el pool que voy a declarar?

Multiplica worker_count por captchaai_concurrency para el piso y repite con max_workers para el techo. Elige el plan que cubra el techo: BASIC ($15/mes, 5 threads), STANDARD ($30/mes, 15), ADVANCE ($90/mes, 50), PREMIUM ($170/mes, 100) y así hasta VIP-3 ($7,500/mes, 5.000).

¿Puedo guardar la API key en un archivo .tfvars?

No. Los .tfvars acaban en Git y el estado guarda en claro cualquier valor que pase por una variable. Usa Secrets Manager y pasa el secreto por referencia.

¿Terraform o Docker Compose para empezar?

Depende de la escala. Con uno o dos workers en una máquina, Docker Compose da resultados en minutos. En cuanto necesites varios entornos o autoescalado, Terraform paga su curva rápido.

¿Cómo evito que un terraform destroy se lleve producción por delante?

Usa backends de estado separados por entorno y exige siempre -var-file explícito. Añadir prevent_destroy al cluster y limitar por IAM quién aplica en producción cierra el hueco.

¿Funciona lo mismo en GCP o Azure?

Sí. Cambia el provider, los recursos —Cloud Run o GKE en GCP, Container Instances o AKS en Azure— y el gestor de secretos. Los módulos y el worker en Python no cambian.

Empieza por el entorno de dev

Obtén tu clave API de CaptchaAI, guárdala en Secrets Manager y aplica el .tfvars de dev con un solo worker. Cuando el primer token vuelva desde res.php, pasar a producción es cambiar tres números.

Guías relacionadas:

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