DevOps & Scaling

Terraform + CaptchaAI: инфраструктура как код для CAPTCHA-работников

Пул воркеров, которые решают CAPTCHA, описывается в Terraform целиком: контейнер, число задач, политики масштабирования и API-ключ CaptchaAI в менеджере секретов. Дальше окружение поднимается одной командой terraform apply, а после нагрузочного прогона так же аккуратно сносится terraform destroy — без забытых ECS-сервисов, которые ещё месяц тикают в счёте.

Ручная сборка через консоль AWS выглядит быстрее ровно один раз. На втором окружении никто уже не помнит, почему в dev таймаут опроса 5 секунд, а в staging — 15. Код в git отвечает на такой вопрос сразу, скриншот консоли — никогда.

Что даёт описание CAPTCHA-инфраструктуры в Terraform

Три вещи, ради которых стоит переносить пул воркеров в код:

  • Воспроизводимость. Тот же .tf-файл и тот же .tfvars дают одинаковое окружение в другом регионе или аккаунте — dev в европейском регионе и прод ближе к целевым сайтам перестают расходиться конфигурациями.
  • Ревью изменений. terraform plan в pull request показывает, что именно поменяется: число задач, лимит памяти, политика масштабирования. Это обычное код-ревью, а не переписка в чате.
  • Контроль расходов. Ночной парсинг закончился — terraform destroy на dev-окружении, и платить не за что.

Раскладка каталогов

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

Модуль captcha-worker держит всё, что относится к пулу решения CAPTCHA; корневые файлы отвечают только за провайдера и передачу переменных. Так тот же модуль подключается во второй регион правкой одного блока.

Провайдер и удалённое состояние

Начните с бэкенда. Локальный terraform.tfstate на ноутбуке инженера — частая причина расхождений: двое применяют изменения параллельно и затирают работу друг друга. S3 плюс таблица блокировок в DynamoDB закрывают это.

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

Переменные модуля

Все параметры пула вынесены в переменные, чтобы разница между окружениями сводилась к одному .tfvars-файлу. Обратите внимание на captchaai_concurrency: это число одновременных задач CAPTCHA на воркер, и оно должно сходиться с числом потоков тарифа — расчёт ниже.

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

API-ключ CaptchaAI: только через менеджер секретов

Ключ не попадает ни в .tf-файлы, ни в git. Terraform создаёт запись в AWS Secrets Manager, а ECS подставляет значение в контейнер на старте.

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

Значение секрета заполняется отдельно — вручную или через CI, но не в Terraform-коде: иначе ключ окажется в state-файле открытым текстом. Заодно проверьте, что уходит в CloudWatch: собирайте только те данные, которые вы вправе обрабатывать (ориентир — 152-ФЗ «О персональных данных», для трансграничных сценариев — GDPR). API-ключ в логах — типичная утечка на ровном месте.

Кластер ECS Fargate

Fargate избавляет от управления инстансами: вы описываете задачу, ёмкость приходит сама. Для решения CAPTCHA это удобно — очередь то пустая, то на несколько тысяч задач.

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

Без включённого containerInsights политики масштабирования не на что опереть.

Автомасштабирование по глубине очереди

Масштабировать такие воркеры по CPU бесполезно: контейнер почти всё время ждёт ответа внешнего API и по процессору простаивает. Правильный сигнал — длина очереди задач.

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

Разные значения cooldown не случайны: вверх пул растёт быстро (120 с), вниз сжимается медленно (300 с) — чтобы не отпускать воркеры на короткой паузе в очереди.

Переменные для каждой среды

Dev-окружение держит один воркер и три параллельные задачи — этого достаточно, чтобы проверить интеграцию:

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

Прод считается от реальной нагрузки:

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

Сколько потоков заложить в тариф

captchaai_concurrency — это не абстрактное число, а ваш договор с тарифом. CaptchaAI тарифицируется по одновременным потокам, а не за каждое решение; в пределах месяца число решений на поток не ограничено. Один поток — одна задача CAPTCHA «в полёте»; как только она завершилась, поток берёт следующую.

Простая арифметика для прод-конфигурации выше: 5 воркеров × 20 одновременных задач = 100 потоков в пике. Это ровно тариф PREMIUM ($170/мес, 100 потоков). Уменьшите captchaai_concurrency до 10 — и хватит ADVANCE ($90/мес, 50 потоков). Для команды, которая только начинает и гоняет интеграционные тесты в staging, стартовая точка — BASIC ($15/мес, 5 потоков), то есть один воркер с captchaai_concurrency = 5.

Для агентств и фрилансеров, которые выставляют счета в национальной валюте, а инфраструктуру оплачивают в долларах, фиксированный месячный платёж планируется проще оплаты за каждое решение. Потолок max_workers выставляйте с оглядкой на купленные потоки: иначе автомасштабирование поднимет задачи, которым потоков не хватит, и они будут ждать в очереди.

Код воркера

Контейнер запускает вот это. Логика простая: отправить задачу в in.php, получить ID, опрашивать res.php до готовности токена. Отдельно реализован корректный останов по SIGTERM — ECS присылает его при снижении числа задач, и воркер должен успеть дописать текущее решение, а не оборваться на середине.

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

Пример выше отправляет userrecaptcha для reCAPTCHA v2 — токен приходит в поле g-recaptcha-response. Для других типов меняется только параметр method и набор полей: разбор по типам есть в руководствах по reCAPTCHA v2, Cloudflare Turnstile и GeeTest v3. Учтите ограничения матрицы поддержки: hCaptcha и FunCaptcha сервис не решает, GeeTest v4 заявлен как «скоро», а CaptchaFox (beta), Friendly Captcha (beta) и Lemin (beta) доступны в бета-режиме.

Команды развёртывания

# 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

Правило для команды: terraform plan с -var-file окружения обязателен перед каждым apply, а вывод плана прикладывается к pull request.

Разбор типичных ошибок

Симптом Причина Что сделать
Secrets Manager can't find the specified secret при деплое Запись секрета создана, но значение не заполнено Записать значение ключа до terraform apply
Задачи падают сразу после старта Нет переменных окружения или неверный тег образа Смотреть логи в CloudWatch, сверить тег в ECR
Автомасштабирование не срабатывает Нет CloudWatch-алерта или выбрана не та метрика Проверить ARN алерта в политике масштабирования
Error acquiring the state lock Предыдущий apply прервали Снять блокировку: terraform force-unlock <lock-id>
Задачи стоят в очереди, воркеры простаивают Потоков тарифа меньше, чем суммарный captchaai_concurrency Снизить параллелизм или перейти на тариф выше

Частые вопросы

Fargate или EC2 для воркеров решения CAPTCHA?

Fargate — когда нагрузка рваная: платите за время работы задачи и не следите за инстансами. EC2 с зарезервированными инстансами выгоднее при ровном круглосуточном потоке. Практичный путь — начать на Fargate и перенести на EC2 только стабильно загруженный пул.

Как хранить состояние Terraform, если над проектом работают несколько инженеров?

Только удалённый бэкенд с блокировкой: S3 плюс DynamoDB, как выше. Для каждого окружения — свой ключ состояния или workspace. Локальный state-файл в такой команде рано или поздно приведёт к затиранию чужих изменений.

Почему API-ключ нельзя просто положить в переменную Terraform?

Любое значение из конфигурации попадает в state-файл открытым текстом, даже если ресурс помечен как чувствительный. State лежит в S3, и доступ к бакету становится доступом к ключу. Terraform создаёт только запись в Secrets Manager; значение пишется отдельно — вручную или из CI.

Как понять, сколько потоков CaptchaAI нужно купить?

Посчитайте пиковое число одновременных задач: число воркеров × captchaai_concurrency. Полученное значение и есть требуемое число потоков. Если пик редкий и короткий, дешевле оставить тариф ниже и позволить очереди подождать — задачи не теряются, растёт только время ожидания.

Что делать, если воркер получает CAPCHA_NOT_READY слишком долго?

Это нормальный ответ: решение ещё не готово. Проблема появляется, когда цикл опроса упирается в лимит попыток. Увеличьте число итераций или интервал POLL_INTERVAL, а заодно проверьте, что таймаут задачи ECS больше суммарного времени опроса — иначе контейнер остановят раньше, чем придёт токен.


Что дальше

Поднимите dev-окружение на одном воркере, убедитесь, что токен приходит, и только потом включайте автомасштабирование.

Комментарии для этой статьи отключены.