Tutorials

Health-Check-Endpunkte für CAPTCHA-Worker

Ein Health-Check-Endpunkt beantwortet dem Orchestrator genau eine Frage: Darf dieser Worker weiter CAPTCHA-Aufgaben bekommen? Für einen CAPTCHA-Löser reicht dafür ein simpler „Prozess läuft"-Test nicht aus. Ein Worker kann sauber auf HTTP antworten und trotzdem nutzlos sein – weil das Guthaben aufgebraucht ist, der Upstream nicht erreichbar ist oder die letzte erfolgreiche Lösung zehn Minuten zurückliegt.

Genau darin liegt der Unterschied zwischen „läuft noch" und „liefert noch brauchbare Arbeit". Wer beides über getrennte Endpunkte sichtbar macht, gibt Load Balancer und Kubernetes die Information, die sie zum Umleiten oder Neustarten brauchen – bevor fehlgeschlagene Aufgaben bei Ihren Nutzern landen. Dieser Leitfaden zeigt drei Endpunkte, die genau diese Trennung abbilden: /health/live, /health/ready und /health/dependencies, jeweils in Python (Flask) und Node.js (Express), plus die passende Kubernetes-Konfiguration.

Liveness, Readiness und Dependency: die drei Check-Typen

Produktionsreife Worker exponieren nicht einen, sondern drei Endpunkte. Jeder beantwortet eine andere Frage und löst eine andere Reaktion aus. Diese Trennung ist der Kern des gesamten Musters – wer Liveness und Readiness in einen Endpunkt zusammenzieht, bekommt entweder Endlos-Neustarts oder Traffic auf tote Worker.

Check-Typ Frage Reaktion im Fehlerfall
Liveness Läuft der Prozess überhaupt noch? Container neu starten
Readiness Kann der Worker jetzt Arbeit annehmen? Traffic-Weiterleitung stoppen
Dependency Sind Upstream-Dienste erreichbar? Kontrolliert degradieren

Der wichtigste Punkt: Ein eingefrorener Prozess schlägt bei der Liveness-Probe fehl und wird neu gestartet. Ein Worker mit leerem Guthaben läuft dagegen einwandfrei – er soll nur keine neuen Aufgaben mehr bekommen, bis das Guthaben aufgefüllt ist. Das ist ein Readiness-Fall, kein Neustart-Fall. Ein Neustart würde hier nichts lösen und im schlimmsten Fall eine Neustart-Schleife auslösen.

Welcher Check zählt in welchem Setup?

Nicht jede Deployment-Form braucht alle drei Endpunkte gleich dringend. Die folgende Zuordnung hilft bei der Priorisierung.

Worker-Setup Wichtigster Check Warum
Einzelner Worker unter Supervisor (systemd, PM2) Liveness + Readiness Prozessfehler und nicht mehr nutzbare Worker sauber trennen
Kubernetes oder Load Balancer Liveness + Readiness + Dependency Routing und Neustarts hängen direkt an dieser Trennung
Queue-basierte Batch-Worker Readiness vor Liveness Der Prozess läuft, nimmt aber womöglich keine neuen Aufgaben mehr an

Python: Health-Endpunkte mit Flask

Die Flask-Variante hält die Health-Metriken in einer thread-sicheren WorkerHealth-Datenklasse. Der Worker-Loop meldet nach jeder Aufgabe record_success() oder record_failure(); die drei Routen lesen diesen Zustand nur aus. Wichtig sind drei Details: Die Liveness-Route macht keinen einzigen externen Aufruf und antwortet dadurch sofort. Das Guthaben (getbalance) wird 60 Sekunden zwischengespeichert, damit nicht jede Probe eine API-Anfrage auslöst. Und die Readiness-Route liefert bei einem Problem den HTTP-Code 503 statt 200 – erst das veranlasst Kubernetes, den Worker aus dem Routing zu nehmen.

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

Node.js: Health-Endpunkte mit Express

Wer den Worker-Stack in Node.js betreibt, bildet dasselbe Muster mit Express ab. Die Logik ist identisch: /health/live antwortet ohne externen Aufruf, /health/ready prüft aufeinanderfolgende Fehler, Zeit seit der letzten Lösung und Guthaben, /health/dependencies misst zusätzlich die Antwortzeit der CaptchaAI-API. Auch hier gilt die 60-Sekunden-Cache-Regel für das Guthaben und der 503-Code als Signal an den Load Balancer.

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- und Readiness-Probes konfigurieren

In der Deployment-Spezifikation verweisen livenessProbe und readinessProbe auf die beiden HTTP-Pfade. Entscheidend sind die Zeitparameter: initialDelaySeconds gibt dem Prozess Zeit zum Hochfahren, periodSeconds steuert das Prüfintervall, failureThreshold legt fest, wie viele Fehlversuche in Folge nötig sind, bevor Kubernetes reagiert. Die Liveness-Probe darf großzügiger eingestellt sein (längeres Intervall, höherer Threshold) – ein zu strenger Wert löst unnötige Neustarts aus.

Ein praxisnahes Beispiel aus der DACH-Region: drei Worker-Pods auf einem verwalteten Kubernetes-Cluster bei Hetzner Cloud, davor ein Load Balancer. Fällt bei einem Pod das Guthaben unter die Schwelle, meldet dessen /health/ready einen 503; Kubernetes nimmt den Pod aus dem Service-Endpoint, die beiden anderen tragen die Last weiter. Sobald das Guthaben nachgeladen ist, wechselt die Probe zurück auf 200 und der Pod erhält wieder Traffic – ohne manuellen Eingriff.

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

HTTP-Statuscodes der Health-Endpunkte

Die gesamte Steuerung läuft über zwei Statuscodes: 200 heißt „alles in Ordnung", 503 heißt „bitte pausieren". Diese Semantik verstehen Kubernetes und praktisch jeder Load Balancer ohne weitere Konfiguration.

Endpunkt 200 503
/health/live Prozess reagiert Prozess eingefroren – neu starten
/health/ready Kann Aufgaben annehmen Keine Aufgaben mehr senden
/health/dependencies Alle Abhängigkeiten erreichbar Upstream degradiert

Typische Fehler und ihre Ursachen

Die meisten Health-Check-Probleme entstehen nicht am Worker selbst, sondern an falsch gesetzten Schwellen und Intervallen. Diese fünf Muster decken den Großteil der Support-Anfragen ab.

Problem Ursache Lösung
Worker wird ständig neu gestartet Liveness-Schwelle zu streng gesetzt failureThreshold oder periodSeconds erhöhen
Worker beim Start als „not ready" markiert Noch keine Lösung – wird fälschlich als „zu lange inaktiv" gewertet seconds_since_last_solve erst nach der ersten Lösung prüfen
Health-Endpunkt wird langsam API-Aufruf bei jeder Anfrage Guthaben mit TTL zwischenspeichern (60 Sekunden empfohlen)
Health-Endpunkt stürzt selbst ab Nicht abgefangene Ausnahme im Check Jeden Check in try/except kapseln und degraded statt 500 zurückgeben
Falsch-negative Dependency-Prüfung Kurzer Netzwerkaussetzer beim Guthaben-Abruf Zwischengespeicherten Wert nutzen (stale-while-revalidate)

Häufige Fragen

Wie oft sollte Kubernetes die Health-Endpunkte abfragen?

Liveness alle 10–30 Sekunden mit failureThreshold: 3, Readiness alle 5–10 Sekunden mit failureThreshold: 2. Häufigere Probes erkennen Probleme schneller, erzeugen aber mehr Last – für CAPTCHA-Worker ist dieser Overhead in der Regel vernachlässigbar.

Kann ich Liveness und Readiness über denselben Endpunkt abbilden?

Besser nicht. Werden beide Prüfungen zusammengezogen, führt jeder Readiness-Fehler – etwa leeres Guthaben – zu einem Neustart, obwohl der Prozess einwandfrei läuft. Halten Sie /health/live frei von externen Aufrufen und dadurch schnell; Guthaben- und Erreichbarkeitsprüfungen gehören in /health/ready und /health/dependencies. Nur mit getrennten Endpunkten trennt Kubernetes Neustart und Traffic-Umleitung sauber.

Warum wird das Guthaben zwischengespeichert?

Weil getbalance ein Netzwerkaufruf ist. Ohne Cache würde jede Readiness-Probe – bei kurzen Intervallen mehrmals pro Minute und pro Worker – eine eigene API-Anfrage auslösen. Ein Cache von 60 Sekunden hält die Angabe aktuell genug und entlastet zugleich den Endpunkt.

Was bedeutet ein niedriges Guthaben für die Readiness?

Fällt das Guthaben unter die gesetzte Schwelle (MIN_BALANCE), meldet /health/ready einen 503, und der Worker bekommt keine neuen Aufgaben mehr. Da CaptchaAI Thread-basiert abrechnet – jeder Tarif ab BASIC (15 $/Monat, 5 Threads) enthält unbegrenzte Lösungen pro Thread –, geht es hier um das verbleibende Guthaben, nicht um ein Lösungslimit. Nach dem Aufladen wechselt die Probe automatisch zurück auf 200.

Wie überwache ich die Health-Werte über viele Worker hinweg?

Exponieren Sie neben den Health-Endpunkten eine /metrics-Route im Prometheus-Format und aggregieren Sie die Werte in Grafana. So sehen Sie Erfolgsquote, Guthaben und Inaktivität flottenweit statt pro Pod.


Verwandte Leitfäden

Kommentare sind für diesen Artikel deaktiviert.