Tutorials

Конечные точки проверки работоспособности для работников, решающих CAPTCHA

Под с CAPTCHA-воркером в кластере выглядит абсолютно здоровым — процесс запущен, CPU в норме, — а задачи при этом не решаются уже 10 минут: закончился баланс на API-ключе или воркер завис в бесконечном ретрае. Без health-check эндпоинтов Kubernetes и балансировщик нагрузки этого не увидят и продолжат слать трафик на мёртвый под. Три вида проверок — liveness, readiness и dependency — дают оркестратору честный сигнал: когда перезапускать контейнер, когда убрать под из ротации и когда просто деградировать, а не падать целиком.

Liveness, readiness, dependency: зачем нужны три разных проверки

Каждая проверка отвечает на свой вопрос и по-своему реагирует на сбой:

Проверка Вопрос Реакция при отказе
Liveness Процесс вообще жив? Перезапустить контейнер
Readiness Может принять новую задачу? Прекратить маршрутизацию трафика
Dependency Внешние сервисы отвечают? Деградировать плавно, не падать

Ниже — рабочая реализация всех трёх на Flask и Express, плюс манифест Kubernetes, который их использует.

Python: health-эндпоинты на Flask

Пример хранит счётчики решённых и упавших задач в потокобезопасном классе WorkerHealth, кэширует баланс CaptchaAI на 60 секунд и поднимает три маршрута — /health/live, /health/ready, /health/dependencies.

import requests
import time
import threading
from flask import Flask, jsonify
from dataclasses import dataclass, field

API_KEY = "YOUR_API_KEY"
RESULT_URL = "https://ocr.captchaai.com/res.php"

app = Flask(__name__)


@dataclass
class WorkerHealth:
    """Tracks worker health metrics."""
    started_at: float = field(default_factory=time.monotonic)
    last_solve_at: float = 0.0
    total_solved: int = 0
    total_failed: int = 0
    consecutive_failures: int = 0
    balance: float | None = None
    balance_checked_at: float = 0.0
    _lock: threading.Lock = field(default_factory=threading.Lock)

    def record_success(self):
        with self._lock:
            self.total_solved += 1
            self.last_solve_at = time.monotonic()
            self.consecutive_failures = 0

    def record_failure(self):
        with self._lock:
            self.total_failed += 1
            self.consecutive_failures += 1

    @property
    def success_rate(self) -> float:
        total = self.total_solved + self.total_failed
        return self.total_solved / total if total > 0 else 1.0

    @property
    def seconds_since_last_solve(self) -> float:
        if self.last_solve_at == 0:
            return time.monotonic() - self.started_at
        return time.monotonic() - self.last_solve_at


health = WorkerHealth()

# Thresholds
MAX_CONSECUTIVE_FAILURES = 10
MAX_SECONDS_WITHOUT_SOLVE = 600  # 10 minutes
MIN_BALANCE = 1.0


def check_balance() -> float | None:
    """Check CaptchaAI balance."""
    now = time.monotonic()
    # Cache balance for 60 seconds
    if health.balance is not None and now - health.balance_checked_at < 60:
        return health.balance

    try:
        resp = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "getbalance", "json": 1,
        }, timeout=10).json()
        health.balance = float(resp.get("request", 0))
        health.balance_checked_at = now
        return health.balance
    except Exception:
        return health.balance  # Return cached value on error


@app.route("/health/live")
def liveness():
    """Liveness probe — is the process responsive?"""
    return jsonify({"status": "ok", "uptime_s": int(time.monotonic() - health.started_at)}), 200


@app.route("/health/ready")
def readiness():
    """Readiness probe — can the worker accept tasks?"""
    issues = []

    # Check consecutive failures
    if health.consecutive_failures >= MAX_CONSECUTIVE_FAILURES:
        issues.append(f"consecutive_failures={health.consecutive_failures}")

    # Check time since last solve
    if health.total_solved > 0 and health.seconds_since_last_solve > MAX_SECONDS_WITHOUT_SOLVE:
        issues.append(f"no_solve_for={int(health.seconds_since_last_solve)}s")

    # Check balance
    balance = check_balance()
    if balance is not None and balance < MIN_BALANCE:
        issues.append(f"low_balance=${balance:.2f}")

    if issues:
        return jsonify({
            "status": "not_ready",
            "issues": issues,
            "stats": {
                "solved": health.total_solved,
                "failed": health.total_failed,
                "success_rate": round(health.success_rate, 3),
            },
        }), 503

    return jsonify({
        "status": "ready",
        "stats": {
            "solved": health.total_solved,
            "failed": health.total_failed,
            "success_rate": round(health.success_rate, 3),
            "balance": balance,
        },
    }), 200


@app.route("/health/dependencies")
def dependencies():
    """Check upstream dependencies."""
    checks = {}

    # CaptchaAI API reachability
    try:
        resp = requests.get(RESULT_URL, params={
            "key": API_KEY, "action": "getbalance", "json": 1,
        }, timeout=10)
        checks["captchaai_api"] = {
            "status": "ok" if resp.status_code == 200 else "degraded",
            "response_ms": int(resp.elapsed.total_seconds() * 1000),
        }
    except Exception as e:
        checks["captchaai_api"] = {"status": "down", "error": str(e)}

    all_ok = all(c["status"] == "ok" for c in checks.values())
    return jsonify({
        "status": "ok" if all_ok else "degraded",
        "checks": checks,
    }), 200 if all_ok else 503


# --- Worker loop (runs in background) ---

def worker_loop():
    """Simulated CAPTCHA solving worker."""
    while True:
        try:
            # ... solve CAPTCHA logic ...
            health.record_success()
        except Exception:
            health.record_failure()
        time.sleep(1)


threading.Thread(target=worker_loop, daemon=True).start()

/health/ready отдаёт 503, если подряд накопилось десять ошибок, если решений не было дольше 10 минут или если баланс API-ключа упал ниже $1 — в любом из трёх случаев под лучше временно вывести из ротации, чем продолжать слать в него новые задачи.

Node.js: health-эндпоинты на Express

Та же логика на Express: объект health ведёт статистику прямо в памяти процесса, checkBalance() опрашивает res.php не чаще раза в минуту, а три маршрута отдают тот же контракт ok / not_ready / degraded, что и Flask-версия — удобно, если часть воркеров написана на Python, а часть на Node.js.

const express = require("express");

const API_KEY = "YOUR_API_KEY";
const RESULT_URL = "https://ocr.captchaai.com/res.php";

const app = express();

const health = {
  startedAt: Date.now(),
  lastSolveAt: 0,
  totalSolved: 0,
  totalFailed: 0,
  consecutiveFailures: 0,
  balance: null,
  balanceCheckedAt: 0,

  recordSuccess() {
    this.totalSolved++;
    this.lastSolveAt = Date.now();
    this.consecutiveFailures = 0;
  },

  recordFailure() {
    this.totalFailed++;
    this.consecutiveFailures++;
  },

  get successRate() {
    const total = this.totalSolved + this.totalFailed;
    return total > 0 ? this.totalSolved / total : 1;
  },
};

async function checkBalance() {
  if (health.balance !== null && Date.now() - health.balanceCheckedAt < 60000) {
    return health.balance;
  }
  try {
    const url = `${RESULT_URL}?key=${API_KEY}&action=getbalance&json=1`;
    const resp = await (await fetch(url)).json();
    health.balance = parseFloat(resp.request);
    health.balanceCheckedAt = Date.now();
    return health.balance;
  } catch {
    return health.balance;
  }
}

app.get("/health/live", (req, res) => {
  res.json({ status: "ok", uptimeMs: Date.now() - health.startedAt });
});

app.get("/health/ready", async (req, res) => {
  const issues = [];

  if (health.consecutiveFailures >= 10) {
    issues.push(`consecutive_failures=${health.consecutiveFailures}`);
  }

  if (health.totalSolved > 0) {
    const silentMs = Date.now() - health.lastSolveAt;
    if (silentMs > 600_000) {
      issues.push(`no_solve_for=${Math.round(silentMs / 1000)}s`);
    }
  }

  const balance = await checkBalance();
  if (balance !== null && balance < 1.0) {
    issues.push(`low_balance=$${balance.toFixed(2)}`);
  }

  const stats = {
    solved: health.totalSolved,
    failed: health.totalFailed,
    successRate: Math.round(health.successRate * 1000) / 1000,
    balance,
  };

  if (issues.length > 0) {
    return res.status(503).json({ status: "not_ready", issues, stats });
  }
  res.json({ status: "ready", stats });
});

app.get("/health/dependencies", async (req, res) => {
  const checks = {};
  try {
    const start = Date.now();
    const url = `${RESULT_URL}?key=${API_KEY}&action=getbalance&json=1`;
    const resp = await fetch(url);
    checks.captchaaiApi = {
      status: resp.ok ? "ok" : "degraded",
      responseMs: Date.now() - start,
    };
  } catch (e) {
    checks.captchaaiApi = { status: "down", error: e.message };
  }

  const allOk = Object.values(checks).every((c) => c.status === "ok");
  res.status(allOk ? 200 : 503).json({
    status: allOk ? "ok" : "degraded",
    checks,
  });
});

app.listen(8080, () => console.log("Health server on :8080"));

Kubernetes: манифест с liveness- и readiness-пробами

Манифест ниже подключает оба эндпоинта к поду: livenessProbe перезапускает контейнер после трёх подряд неудачных проверок с интервалом в 15 секунд, а readinessProbe убирает под из сервиса уже после двух неудач с интервалом в 10 секунд. Readiness всегда настраивают строже liveness — иначе трафик продолжит идти в под, который ещё не готов его принимать.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: captcha-worker
spec:
  replicas: 3
  template:
    spec:
      containers:

        - name: worker
          image: captcha-worker:latest
          ports:

            - containerPort: 8080
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 2

Коды ответов по каждому эндпоинту

Эндпоинт 200 503
/health/live процесс отвечает процесс завис — нужен перезапуск
/health/ready готов принимать задачи прекратить отправку задач
/health/dependencies все зависимости в порядке восходящий сервис деградировал

Типичные проблемы и их решения

Проблема Причина Решение
Под с воркером постоянно перезапускается Порог liveness выставлен слишком строго Увеличьте failureThreshold или periodSeconds
Сразу после старта воркер помечен not-ready Решений ещё не было, а «слишком долгая тишина» уже засчитывается Считайте seconds_since_last_solve только после первого решённого токена
Проверка баланса тормозит health-эндпоинт API-запрос уходит на каждый вызов, без кэша Кэшируйте баланс с TTL (обычно достаточно 60 секунд)
Сам health-эндпоинт падает Необработанное исключение внутри одной из проверок Оборачивайте каждую проверку в try/except и возвращайте degraded вместо 500
Ложные срабатывания dependency-проверки Кратковременный сетевой сбой во время запроса баланса Отдавайте закэшированное значение по схеме stale-while-revalidate

Когда воркеры разнесены по регионам

Если часть воркеров развёрнута в европейских дата-центрах, а часть — ближе к Центральной Азии, RTT до ocr.captchaai.com у них будет заметно отличаться. Команда, которая держит одинаковый 60-секундный TTL кэша баланса для всех нод, иногда ловит ложный low_balance именно из-за медленного ответа сети, а не из-за реального баланса на счёте. Разумнее развести balance_checked_at по региону разворачивания или увеличить таймаут запроса для воркеров с более длинным RTT — а не занижать сам порог MIN_BALANCE.

Пороги для дежурной команды

  • Используйте readiness, чтобы блокировать новую работу, liveness — чтобы запускать перезапуск, и алерты на постепенное снижение throughput — чтобы заметить деградацию до того, как readiness вообще сработает.
  • Привязывайте пороги к глубине очереди, доле недавних ошибок и доступности зависимостей, а не только ко времени безостановочной работы процесса.
  • Держите текущие значения порогов на виду у дежурной команды: если пороги меняются молча, health-статус превращается в шум, а не в сигнал, на который реально реагируют.

FAQ

Как часто Kubernetes должен опрашивать liveness и readiness?

Liveness — раз в 10–30 секунд с порогом в 3 неудачи подряд. Readiness — раз в 5–10 секунд с порогом в 2 неудачи: readiness должен реагировать быстрее, потому что от него напрямую зависит, пойдёт ли трафик в под прямо сейчас.

Нужно ли readiness-проверке напрямую дёргать API CaptchaAI?

Только через кэш. Проверка баланса уместна в /health/ready и /health/dependencies, но каждый вызов должен идти в кэш с TTL, а не в res.php напрямую — иначе сам health-check станет узким местом. /health/live вообще не должен ходить наружу: он обязан отвечать мгновенно, просто подтверждая, что процесс жив.

Что делать, если /health/ready постоянно отдаёт 503 сразу после деплоя?

Это почти всегда ложное срабатывание: у свежего пода total_solved ещё нулевой, а логика «решений не было слишком долго» начинает отсчёт с момента старта процесса. Считайте seconds_since_last_solve только после первого успешно решённого токена — до этого момента под не должен получать штраф за отсутствие истории.

Как выбрать MIN_BALANCE, чтобы не поймать деградацию слишком поздно?

Ориентируйтесь на число параллельных потоков и на то, как быстро расходуется баланс. Воркер на плане ADVANCE ($90/мес, 50 потоков), который разом гоняет пару сотен задач в час, может пройти путь от $1 до нуля буквально за пару минут — этого едва хватит на алерт и ручное пополнение. Для нагруженных пулов разумнее поднять MIN_BALANCE до $5–10, чтобы дежурный успел отреагировать, а не читал постфактум лог с уже пустым балансом.

Что читать дальше

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