DevOps & Scaling

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

Descrever a frota de workers de CAPTCHA em Terraform resolve dois problemas de uma vez: ninguém mais sobe container na mão e a concorrência vira um número revisado em pull request. O que decide o desenho, porém, não é o tipo de instância — é quantas resoluções podem estar em voo ao mesmo tempo, porque a CaptchaAI cobra por thread simultânea, não por resolução.

O esqueleto abaixo roda em ECS Fargate na AWS: estado remoto, chave no Secrets Manager, definição de tarefa, escalonamento por fila e comandos do dia a dia. O mesmo módulo sobe em dev, staging e produção trocando só o arquivo de variáveis.

Threads do plano definem o teto da frota

Faça esta conta antes do primeiro recurso: as tarefas em voo são worker_count × captchaai_concurrency. Acima das threads contratadas, container extra não vira vazão — vira fila mais longa e conta de computação maior.

Frota (workers × concorrência) Tarefas em voo Plano com threads suficientes
1 × 5 5 BASIC (US$ 15/mês, 5 threads)
3 × 5 15 STANDARD (US$ 30/mês, 15 threads)
5 × 10 50 ADVANCE (US$ 90/mês, 50 threads)
5 × 20 100 PREMIUM (US$ 170/mês, 100 threads)

Todos os planos incluem resoluções ilimitadas por thread no mês, sem cobrança por CAPTCHA. No Terraform você ajusta concorrência, não custo por requisição.

Estrutura do repositório Terraform

O layout mínimo para mais de um ambiente tem duas peças: um módulo reutilizável e um .tfvars por ambiente.

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

O módulo captcha-worker recebe tudo por variável — é o que permite a mesma definição subir com um container em dev e vinte em produção.

Base do projeto: provider, estado e variáveis

Provider AWS e backend remoto com trava

Estado no S3, trava no DynamoDB. Sem essa dupla, dois terraform apply simultâneos — o seu e o do pipeline de CI — corrompem o registro da frota.

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

Variáveis de entrada

CPU, memória, tamanho da frota e concorrência entram como variáveis. A única que conversa direto com o seu plano é captchaai_concurrency: trate-a como limite contratual, não como parâmetro de tuning.

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

A chave de API fora do repositório

A chave não entra no .tfvars nem em variável de ambiente em texto puro: fica no Secrets Manager e o ECS a injeta no container em execução. O arquivo de estado, porém, registra em texto claro tudo o que o Terraform lê, inclusive o data source abaixo — daí o encrypt = true no backend.

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

Cluster ECS Fargate e definição de tarefa

O serviço roda em Fargate com containerInsights ligado. Concorrência e polling entram como variáveis de ambiente comuns; só a chave passa pelo bloco secrets. Os logs vão para o CloudWatch com o prefixo worker, o que separa erro de resolução de erro de infraestrutura.

# 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]
  }
}

Escalonamento automático pela profundidade da fila

São duas políticas de step scaling: a frota sobe dois containers quando a fila cresce e desce um por vez quando ela esvazia. O cooldown assimétrico (120 s para subir, 300 s para descer) evita o efeito sanfona. Mantenha max_workers num valor que, multiplicado pela concorrência, caiba nas threads do plano.

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

Um arquivo de variáveis por ambiente

Dev existe para validar sintaxe, permissões e o caminho do segredo, não para medir vazão. Um container com concorrência 3 já cumpre o papel.

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

Produção usa cinco containers com concorrência 20: cem tarefas em voo, que pedem um plano com 100 threads.

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

O código que roda dentro do container

O worker é simples: lê a chave do ambiente, trata SIGTERM e faz o ciclo de envio e consulta contra in.php e res.php. Repare no laço de polling — até 60 consultas espaçadas por POLL_INTERVAL — e em CAPCHA_NOT_READY, o único estado que significa "continue esperando".

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

O desligamento gracioso evita descartar trabalho em andamento: o container em drenagem termina a tarefa que pegou antes de sair.

Comandos do Terraform no dia a dia

A sequência não muda entre ambientes; muda o arquivo de variáveis. Rode o plan com o mesmo -var-file do apply que vem depois — boa parte dos incidentes de IaC nasce dessa troca.

# 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

Cenário: uma equipe de QA em São Paulo

Um time brasileiro roda testes de formulário em https://staging.example.com e resolve o reCAPTCHA v2 do próprio ambiente antes de cada release. Dois detalhes locais entram em cena, e ambos viram parâmetro do módulo.

Rede: subir o cluster em sa-east-1 encurta o RTT entre os workers e o resto do ambiente de testes, embora o tempo de resolução dependa do tipo de CAPTCHA, não da região. Retenção de log: se a suíte grava payloads com dados pessoais, defina o retention_in_days do grupo do CloudWatch e trate o prazo como decisão de compliance, alinhada à LGPD (RGPD, em Portugal).

Quando algo dá errado

Sintoma Causa provável O que fazer
Segredo não encontrado no deploy Criado, mas ainda sem valor Preencha o valor antes do terraform apply
Container reinicia ao subir Variável de ambiente ausente ou tag errada Confira os logs no CloudWatch e a tag no ECR
A frota não cresce com a fila cheia Alarme do CloudWatch ausente Verifique o ARN do alarme no step scaling
Erro de trava de estado apply anterior interrompido Libere com terraform force-unlock <lock-id>
Tarefas esperando sem erro Concorrência acima das threads do plano Reduza captchaai_concurrency ou suba de plano

Perguntas frequentes

Quantas threads meu plano precisa ter para a frota do .tfvars?

Multiplique worker_count por captchaai_concurrency e escolha o plano que ofereça esse número de threads ou mais. Cinco containers com concorrência 10 pedem 50 threads — é o ADVANCE (US$ 90/mês, 50 threads).

Como guardo a chave de API sem que ela apareça no arquivo de estado?

Crie o segredo pelo Terraform e preencha o valor fora dele, pela CLI da AWS. Qualquer data source que leia a versão do segredo grava o conteúdo em texto claro no terraform.tfstate. Prefira passar só o ARN para a definição de tarefa.

O escalonamento automático reage sozinho ao volume?

Reage à métrica que você apontar. As políticas de step scaling só disparam com um alarme do CloudWatch associado, normalmente sobre a profundidade da fila. Sem alarme, nada acontece.

Preciso de navegador headless dentro dos workers?

Não para o fluxo de API. O worker envia sitekey e pageurl por HTTP e recebe o token; o navegador só entra quando o teste precisa devolver o token ao formulário. Por isso o container cabe em 512 MB.

Próximas etapas

Comece pequeno: dev com um container e concorrência 3, para validar permissões e o caminho do segredo; depois promova o mesmo módulo trocando o -var-file. Pegue sua chave de API na CaptchaAI e descreva a frota no Terraform.

Guias relacionados:

Os comentários estão desativados para este artigo.