# Gatus mit Docker Compose im Test: HTTP-503-Alarm, Entwarnung und SQLite-Historie

> Canonical: https://vxi.me/de/posts/gatus-docker-compose-http-503-alert-recovery/
> Locale: de-de
> Published: 2026-10-10
> Updated: 2026-10-10
> Markdown: https://vxi.me/de/posts/gatus-docker-compose-http-503-alert-recovery.md

Gatus ist schnell gestartet, und das Dashboard zeigt grüne Gesundheitsprüfungen. Doch ein grüner Status beweist noch nicht, dass die Alarmierung bei einem echten Fehler funktioniert. Mich interessierten drei konkrete Fragen: **Nach wie vielen fehlgeschlagenen HTTP-Prüfungen kommt ein Alarm?** Folgt eine Entwarnung, wenn der Dienst wieder 200 liefert? Und bleiben ältere Messpunkte nach einem Neustart des Gatus-Containers erhalten?

Dafür habe ich [Gatus](https://github.com/TwiN/gatus) v5.37.0 mit Docker Compose und einem bewusst kleinen HTTP-Testdienst ausgeführt. Dieser Dienst ließ sich von 200 auf 503 und wieder auf 200 umschalten. Ein Webhook-Empfänger im selben isolierten Docker-Netzwerk speicherte die Benachrichtigungen. Auf zwei aufeinanderfolgende 503-Prüfungen folgte tatsächlich `TRIGGERED`, auf zwei erfolgreiche Wiederherstellungsprüfungen `RESOLVED`. Ein bestehender SQLite-Historieneintrag war außerdem nach dem Neustart des Gatus-Containers weiterhin abrufbar.

Wichtig ist die Grenze dieser Aussage: Getestet wurde **ein synthetischer HTTP-Endpunkt**, keine produktive Anwendung. Der Versuch umfasst weder Slack und E-Mail noch externe Alarmzustellung, mehrere Hosts oder ein produktives Verfügbarkeits-SLA.

## Zuerst festlegen, was Gatus als Fehler erkennen soll

Gatus ruft einen konfigurierten Endpunkt regelmäßig ab und bewertet die Antwort anhand von Bedingungen. Ein zusätzlicher Agent in der überwachten Anwendung ist dafür nicht nötig. Die [offizielle README am festgehaltenen Commit](https://github.com/TwiN/gatus/blob/ae605f291928ed9b3bdbe60ae5528cc57631433b/README.md) beschreibt auch andere Protokolle. In diesem Versuch wurden aber ausschließlich **HTTP-Statusbedingungen und ein benutzerdefinierter Webhook** ausgeführt.

So sah der relevante Teil der getesteten `config/config.yaml` aus. `fixture` ist der Compose-Dienstname für den kontrollierbaren HTTP-Endpunkt:

```yaml
storage:
  type: sqlite
  path: /data/gatus.db

alerting:
  custom:
    url: "http://fixture:8000/alert"
    method: POST
    headers:
      Content-Type: application/json
    body: |
      {"event":"[ALERT_TRIGGERED_OR_RESOLVED]","endpoint":"[ENDPOINT_NAME]"}

endpoints:
  - name: controlled-fixture
    group: gatus-v34
    url: "http://fixture:8000/health"
    interval: 15s
    conditions:
      - "[STATUS] == 200"
    alerts:
      - type: custom
        failure-threshold: 2
        success-threshold: 2
        send-on-resolved: true
        description: "Only a disposable synthetic fixture; no real infrastructure"
```

An den folgenden Werten hängt das Verhalten:

- `interval: 15s`: Eine Prüfung wird ungefähr alle 15 Sekunden eingeplant. Daraus ergibt sich kein millisekundengenauer Termin.
- `[STATUS] == 200`: Nur HTTP 200 ist hier gesund. Falls ein echter Dienst 204 als Erfolg verwendet, muss man die Bedingung anpassen.
- `failure-threshold: 2`: Zwei aufeinanderfolgende Fehler sind notwendig, um den Alarm auszulösen. `success-threshold: 2` und `send-on-resolved: true` steuern die Erkennung der Erholung und die Entwarnung.
- `alerting.custom`: Die Ereignisse werden als HTTP POST an den internen Testempfänger gesendet. Die Zustellung an einen externen Kommunikationsdienst ist damit nicht belegt.

`[ALERT_TRIGGERED_OR_RESOLVED]` und `[ENDPOINT_NAME]` sind Gatus-Vorlagenvariablen. Die JSON-Nachricht ist absichtlich schlicht gehalten. Ich wollte nachweisen, dass **beide Ereignistypen beim Empfänger ankommen**, und nicht nur eine Farbänderung im Dashboard sehen.

Wer die Testadresse später gegen einen echten Health-Endpunkt austauscht, muss die Container-Netzwerke beachten. In einem normalen isolierten Docker-Netz bezeichnet `localhost` **den Netzwerk-Namespace des Gatus-Containers selbst**, nicht automatisch den Host oder einen anderen Container. Dienste im selben Compose-Netz sind über ihre Servicenamen erreichbar. Bei Zielen auf anderen Maschinen kommen DNS, TLS, Authentifizierung und Routing dazu. Eine solche Cross-Host-Konfiguration wurde hier nicht erprobt.

## 200 → 503 → 200 mit zwei Containern reproduzieren

Ich wollte dafür keinen echten Anwendungsdienst abschalten. Stattdessen übernimmt ein kleines Python-Fixture zwei Aufgaben: `GET /health` liest eine Zustandsdatei und gibt je nach Inhalt 200 oder 503 zurück; `POST /alert` schreibt den Gatus-Webhook als JSON-Zeile in eine lokale Datei. So lassen sich Überwachung und Alarmweg getrennt von komplexen Anwendungsfehlern betrachten.

In einem **eigenen, vollständig entbehrlichen Testverzeichnis** benötigt man `compose.yaml`, `config/config.yaml`, `fixture/server.py` und `digest-lock.env`. Hier ist die geprüfte Fixture-Logik als kopierbares Python-Beispiel:

```python
import datetime
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlsplit

STATE = Path("/state")
STATE.mkdir(parents=True, exist_ok=True)
MODE = STATE / "mode"
EVENTS = STATE / "alerts.ndjson"
MAX_BODY = 16 * 1024

class Handler(BaseHTTPRequestHandler):
    def do_GET(self):
        if urlsplit(self.path).path != "/health":
            self.send_error(404)
            return
        healthy = (MODE.read_text().strip() if MODE.exists() else "up") == "up"
        body = b'{"status":"UP"}' if healthy else b'{"status":"DOWN"}'
        self.send_response(200 if healthy else 503)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)

    def do_POST(self):
        if urlsplit(self.path).path != "/alert":
            self.send_error(404)
            return
        try:
            size = int(self.headers.get("Content-Length", "-1"))
        except ValueError:
            size = -1
        if not (0 <= size <= MAX_BODY):
            self.send_error(413)
            return
        try:
            payload = json.loads(self.rfile.read(size))
        except (UnicodeError, json.JSONDecodeError):
            self.send_error(400)
            return
        record = {
            "at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
            "payload": payload,
        }
        with EVENTS.open("a", encoding="utf-8") as out:
            out.write(json.dumps(record, ensure_ascii=False) + "\n")
        self.send_response(204)
        self.end_headers()

    def log_message(self, fmt, *args):
        pass

ThreadingHTTPServer(("0.0.0.0", 8000), Handler).serve_forever()
```

Die Docker-Compose-Konfiguration übernimmt die im Versuch verwendeten Begrenzungen. `internal: true` isoliert das gemeinsame Netzwerk. Das Fixture veröffentlicht keinen Port auf dem Host; nur die Gatus-Oberfläche erhält einen dynamischen Port an `127.0.0.1`. CPU- und RAM-Werte sind **konfigurierte Obergrenzen**, keine gemessenen Verbrauchswerte.

```yaml
services:
  fixture:
    image: ${FIXTURE_IMAGE:?Set a pinned fixture image}
    command: ["python", "/app/server.py"]
    read_only: true
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
    tmpfs: ["/tmp"]
    mem_limit: 96m
    cpus: "0.25"
    pids_limit: 64
    volumes:
      - ./fixture/server.py:/app/server.py:ro
      - fixture_state:/state
    networks: ["isolated"]
    restart: "no"

  gatus:
    image: ${GATUS_IMAGE:?Set a pinned Gatus image}
    environment:
      GATUS_CONFIG_PATH: /config/config.yaml
    read_only: true
    cap_drop: ["ALL"]
    security_opt: ["no-new-privileges:true"]
    tmpfs: ["/tmp"]
    mem_limit: 256m
    cpus: "0.50"
    pids_limit: 128
    volumes:
      - ./config/config.yaml:/config/config.yaml:ro
      - gatus_data:/data
    ports:
      - "127.0.0.1::8080"
    networks: ["isolated"]
    depends_on: ["fixture"]
    restart: "no"

networks:
  isolated:
    internal: true

volumes:
  fixture_state:
  gatus_data:
```

Statt des veränderlichen Tags `latest` kamen fest angegebene Image-Digests zum Einsatz. Diese Werte standen für Linux/amd64 in der `digest-lock.env`:

```dotenv
GATUS_IMAGE=ghcr.io/twin/gatus@sha256:094eb186e55235db367e90e9d56140e5897b7044c41de23cde4cc18e6da1242e
FIXTURE_IMAGE=python@sha256:f3ebba2ace255c93267a0278da88c7f1044432991abc4e6ad20d22e34dd0f8ee
```

Der Testhost verwendete Linux/amd64, Docker Engine 29.9.0 und Docker Compose v5.6.0. Der ausführende Test prüfte außerdem die tatsächlich aufgelöste Image-Plattform. **Auf ARM-Systemen sollte man die beiden Digests nicht blind übernehmen.** Zuerst muss das passende Image-Manifest geprüft werden.

Für eine manuelle Wiederholung sind diese Befehle eine sinnvolle Reihenfolge. Sie sind **eine Anleitung für Leser und kein wörtliches Protokoll sämtlicher ursprünglich ausgeführter Befehle**:

```console
$ docker compose --env-file digest-lock.env -p gatus-lab config --quiet
$ docker compose --env-file digest-lock.env -p gatus-lab up -d --wait --wait-timeout 60
$ docker compose --env-file digest-lock.env -p gatus-lab ps
$ docker compose --env-file digest-lock.env -p gatus-lab logs --tail 80 gatus
```

Anschließend kann man von innerhalb des Fixture-Containers die Gatus-Status-API abfragen. Erst nachdem zwei **neue** 200-Ergebnisse gespeichert wurden, sollte der simulierte Fehler eingeschaltet werden:

```console
$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture python -c 'import urllib.request; print(urllib.request.urlopen("http://gatus:8080/api/v1/endpoints/statuses").read().decode())'
$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture sh -c 'printf down >/state/mode'
```

Sobald die API zwei neue, aufeinanderfolgende 503-Werte enthält, setzt man das Fixture wieder auf gesund und liest die empfangenen Webhook-Ereignisse:

```console
$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture sh -c 'printf up >/state/mode'
$ docker compose --env-file digest-lock.env -p gatus-lab exec -T fixture cat /state/alerts.ndjson
```

Ein festes `sleep 30` ist dafür kein zuverlässiger Nachweis. Auch der ursprüngliche Prüfcode hat die API wiederholt abgefragt und erst bei neu aufgezeichneten Messpunkten zum nächsten Schritt gewechselt. Das verhindert, dass man Startverzögerungen mit dem eigens ausgelösten Fehler verwechselt.

## Messwerte: Alarm nach dem zweiten 503, Entwarnung nach dem zweiten 200

Bei der Ausführung vom 9. Oktober 2026 wurden in der Gatus-Status-API sieben Beobachtungen protokolliert. Die Zeiten sind UTC; die Millisekunden stammen aus der geprüften Versuchszusammenfassung.

| Zeit (UTC) | Status | Beobachtung |
| --- | --- | --- |
| 14:39:41.844 | 0 (Verbindung abgelehnt) | Vorübergehender Startfehler, kein HTTP-Status 0 |
| 14:39:56.896 | 200 | Gesundheitsprüfung 1 |
| 14:40:11.896 | 200 | Gesundheitsprüfung 2 |
| 14:40:26.853 | 503 | Simulierter Fehler 1 |
| 14:40:41.895 | 503 | Simulierter Fehler 2 |
| 14:40:56.895 | 200 | Wiederherstellung 1 |
| 14:41:11.853 | 200 | Wiederherstellung 2 |

Im selben Lauf zeichnete der lokale Webhook-Empfänger zwei Ereignisse auf:

| Ereignis | Empfangszeit (UTC) |
| --- | --- |
| `TRIGGERED` | 14:40:41.907 |
| `RESOLVED` | 14:41:11.861 |

Der zweite 503-Messpunkt und der Eingang der Auslösung liegen nur wenige Millisekunden auseinander. Nach dem zweiten 200 wurde ebenso die Entwarnung empfangen. **Prüfzeitpunkte und Empfangsereignisse nebeneinander** zeigen hier mehr als ein Screenshot eines grünen Dashboards.

Diese Zeiten sind aber kein Beweis für die pauschale Aussage „In Produktion wird jeder Fehler innerhalb von 30 Sekunden gemeldet“. Prüfplanung, Laufzeit der Requests, Netzwerk und Erreichbarkeit des Empfängers spielen eine Rolle. Geprüft wurde **ein synthetischer Fehler innerhalb eines isolierten Docker-Netzwerks mit lokalem Webhook**, nicht die Ende-zu-Ende-Zustellung über Slack, E-Mail oder SMS.

## Das tatsächlich aufgetretene Problem: Container läuft, erste Prüfung scheitert

Bereits vor der absichtlichen Fehlerinjektion meldete die allererste Prüfung Status `0` und eine abgelehnte Verbindung. Erst danach folgten zwei reguläre 200-Antworten. Es wäre falsch, hier von einer „HTTP-0-Antwort“ zu sprechen: Es gab schlicht **keine gültige HTTP-Statusantwort**.

Ein laufender Container ist nicht gleichbedeutend mit einem Dienst, der schon bereit ist, Health-Requests zu beantworten.

Docker Compose `up -d --wait` wartet darauf, dass Container laufen oder gegebenenfalls deklarierte Healthchecks bestehen. Die beiden Dienste dieser Compose-Datei hatten aber **keine Healthchecks definiert**. Ein erfolgreich beendeter `up`-Befehl sagt daher nicht aus, dass Gatus den Ziel-Endpunkt bereits bei der ersten Abfrage erreichen konnte. Das Fixture kann sich noch im Startvorgang befinden.

Wenn „Container Up, erste Prüfung fehlgeschlagen“ auftritt, würde ich nicht sofort die Fehlerschwelle erhöhen und das Problem damit verdecken. Sinnvoller ist diese Reihenfolge:

1. Mit `docker compose ... ps` und `logs gatus` den Prozessstart und das Einlesen der Konfiguration prüfen.
2. Den Ziel-Endpunkt `/health` **aus demselben Container-Netzwerk** anfragen. So trennt man fehlende Startbereitschaft von DNS-, Port- oder tatsächlichen HTTP-503-Problemen.
3. Mehrere aufeinanderfolgende Einträge der Status-API betrachten, statt einen Startausreißer mit späteren absichtlich ausgelösten 503-Werten zu vermischen.
4. Falls Gatus einen Fehler erkannt hat, aber keine Nachricht eingeht, `alerting.custom`, die Route und die Webhook-Ereignisdatei kontrollieren.

Das ist ein aus dem beobachteten Verhalten abgeleiteter Diagnoseweg. Es bedeutet **nicht**, dass alle vier möglichen Fehlerursachen tatsächlich auf dem Testrechner aufgetreten sind. Beobachtet wurde eine einzelne Verbindungsverweigerung beim Start. Danach stimmten die gesunden, fehlerhaften und wiederhergestellten Prüfungen samt Webhook-Ereignissen überein.

Für produktive Anwendungen muss man außerdem definieren, was `/health` aussagt: Start läuft noch, Abhängigkeit fehlt, Funktion ist eingeschränkt oder die Anwendung ist wirklich nicht erreichbar. Wenn der Dienst alles mit HTTP 200 beantwortet, kann Gatus über `[STATUS] == 200` diese Zustände nicht auseinanderhalten.

## SQLite-Historie nach Neustart erhalten – Volume-Löschung ist etwas anderes

Gatus schrieb die Prüfhistorie unter `/data/gatus.db` in das benannte Volume `gatus_data`. Der Versuch merkte sich zunächst den **exakten Zeitstempel** eines bereits gespeicherten Prüfergebnisses, startete ausschließlich den Gatus-Container neu und fragte anschließend die Status-API erneut ab. Der alte Eintrag war weiterhin vorhanden. Der Prüflauf gab aus:

```text
SQLITE_RESTART_HISTORY_PRESERVED
```

Damit ist nur belegt, dass beim **Neustart dieses Containers mit erhaltenem Daten-Volume keine alte Prüfaufzeichnung verloren ging**. Es ist kein bestandener Test für vollständige Datenbank-Backups, Plattenausfälle oder Disaster Recovery.

Nach dem Versuch wurde ausschließlich das entbehrliche Compose-Projekt bereinigt und kontrolliert, dass keine Container mit diesem Projektlabel mehr vorhanden waren. Nur wenn das Projekt wirklich weg darf, eignen sich diese Befehle:

```console
$ docker compose --env-file digest-lock.env -p gatus-lab down --volumes --remove-orphans
$ docker ps -aq --filter "label=com.docker.compose.project=gatus-lab"
```

Achtung: `down --volumes` **entfernt auch die benannten Volumes dieses Projekts einschließlich der SQLite-Historie**. Für eine langfristige Gatus-Installation ist das kein regulärer Wartungsschritt. Das Daten-Volume muss erhalten bleiben; Backups und Wiederherstellungen sollten separat entworfen und praktisch geprobt werden.

Vor einem produktiven Einsatz wären weitere Fragen offen: Wer erkennt einen Ausfall von Gatus selbst? Was passiert, wenn das externe Benachrichtigungsziel nicht erreichbar ist? Wie verhalten sich mehrere gleichzeitige Endpunktfehler? Kann man den Ausfall eines Netzwerks zwischen Hosts von einem Anwendungsfehler unterscheiden? Dieser einzelne Versuch liefert dazu keine belastbaren Antworten.

Was sich dagegen tatsächlich beobachten ließ, ist klar umrissen: Mit Gatus v5.37.0, zwei isolierten Containern und einem steuerbaren HTTP-Endpunkt funktionierten **Fehlerschwelle, Wiederherstellungs-Webhook und der Erhalt der SQLite-Historie beim Neustart**. Bevor ich einen echten Dienst als zuverlässig überwacht betrachte, würde ich dessen Health-Semantik und den externen Benachrichtigungsweg unter realistischen Fehlerbedingungen getrennt prüfen.
