# Gatus con Docker Compose: test di HTTP 503, avvisi di ripristino e SQLite

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

Gatus si avvia e la dashboard mostra un controllo verde. È facile interpretarlo come «il monitoraggio funziona». Ma la domanda che mi interessava era più precisa: **dopo quanti errori HTTP parte davvero l'allarme?** Se il servizio torna a rispondere 200, arriva un evento di ripristino? E se riavvio Gatus, i vecchi controlli rimangono nella cronologia?

Ho provato [Gatus](https://github.com/TwiN/gatus) v5.37.0 con Docker Compose e un piccolo servizio HTTP che potevo controllare direttamente. Prima rispondeva 200, poi 503, infine di nuovo 200. Un ricevitore Webhook nella stessa rete Docker isolata conservava gli eventi. Dopo due 503 consecutivi ho osservato `TRIGGERED`, e dopo due nuovi 200 di ripristino è arrivato `RESOLVED`. Anche un record storico SQLite è rimasto accessibile dopo il riavvio del solo container Gatus.

Questa prova riguarda **un endpoint HTTP sintetico**, non un'applicazione in produzione. Non dimostra la consegna di notifiche a Slack o email, il monitoraggio tra più host, né alcuno SLA operativo.

## Decidere prima cosa deve essere considerato un guasto

Gatus interroga periodicamente un endpoint e valuta le risposte tramite condizioni configurabili, senza richiedere un agente inserito dentro l'applicazione monitorata. Il [README ufficiale fissato a uno specifico commit](https://github.com/TwiN/gatus/blob/ae605f291928ed9b3bdbe60ae5528cc57631433b/README.md) descrive anche protocolli diversi. In questa prova, però, ho verificato soltanto **condizioni sullo stato HTTP e un Webhook personalizzato**.

Questa è la configurazione principale di `config/config.yaml` usata nel test. `fixture` è il nome del servizio Compose che rappresenta l'endpoint modificabile:

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

Le impostazioni determinanti sono:

- `interval: 15s`: programma un controllo circa ogni 15 secondi; non è una garanzia temporale precisa al millisecondo.
- `[STATUS] == 200`: qui soltanto HTTP 200 è considerato sano. Se un'API reale usa 204 come esito valido, occorre adeguare la condizione.
- `failure-threshold: 2`: richiede due errori consecutivi per far scattare l'allarme. `success-threshold: 2` e `send-on-resolved: true` regolano la verifica del recupero e la notifica di risoluzione.
- `alerting.custom`: invia il messaggio JSON con un HTTP POST al ricevitore del laboratorio, senza verificare alcun canale di notifiche esterno.

`[ALERT_TRIGGERED_OR_RESOLVED]` e `[ENDPOINT_NAME]` sono variabili di template di Gatus. Ho tenuto il JSON volutamente semplice perché volevo controllare **se l'endpoint destinatario riceveva davvero entrambe le transizioni**, non soltanto se l'interfaccia diventava rossa o verde.

Quando si sostituisce l'URL di prova con un servizio reale, la rete dei container può trarre in inganno. In una normale rete Docker isolata, `localhost` all'interno di Gatus si riferisce **allo spazio dei nomi di rete di quel container**, non automaticamente all'host o a un altro container. All'interno della stessa rete Compose si usano i nomi DNS dei servizi. Su host distinti bisogna inoltre controllare DNS, TLS, autenticazione e raggiungibilità. Quest'ultimo caso non è stato riprodotto qui.

## Riprodurre 200 → 503 → 200 usando due container

Non volevo spegnere un servizio applicativo autentico. Ho quindi preparato un piccolo fixture Python: `GET /health` legge un file di stato e restituisce 200 oppure 503; `POST /alert` aggiunge i messaggi Webhook di Gatus a un file JSON con un record per riga. In questo modo la catena di monitoraggio e avviso resta separata dai possibili guasti di un'applicazione complessa.

In una **directory di laboratorio indipendente e sacrificabile** servono quattro file: `compose.yaml`, `config/config.yaml`, `fixture/server.py` e `digest-lock.env`. Questo esempio riproduce la logica Python verificata, presentata in modo che si possa copiare:

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

Il Compose utilizzato mantiene le condizioni di isolamento dell'esperimento. `internal: true` limita la rete condivisa; il fixture non espone porte sull'host e l'interfaccia di Gatus è associata solo a una porta dinamica su `127.0.0.1`. I valori di CPU e RAM rappresentano **limiti configurati**, non consumi misurati.

```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:
```

Non ho usato un tag variabile come `latest`. Per questa esecuzione sono stati fissati in `digest-lock.env` i digest delle immagini Linux/amd64:

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

L'host del test era Linux/amd64, con Docker Engine 29.9.0 e Docker Compose v5.6.0. Durante l'esecuzione è stata verificata anche l'architettura effettiva delle immagini. **Non riutilizzare direttamente questi due digest su ARM**: verifica prima i manifest dell'architettura desiderata.

Per ripetere manualmente l'esperimento, questi comandi offrono una sequenza semplice. Sono istruzioni per il lettore e **non una trascrizione letterale di tutti i comandi eseguiti dallo script originale**:

```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
```

Dopo l'avvio, dal container fixture possiamo interrogare l'API degli stati di Gatus. Prima di introdurre il guasto, bisogna aspettare due campioni **nuovi** con risposta 200:

```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'
```

Attendi poi due nuove risposte 503 consecutive nell'API. A quel punto ripristina lo stato sano del fixture e leggi gli eventi ricevuti dal Webhook:

```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
```

Non mi affiderei a un semplice `sleep 30` per decidere che una fase è conclusa. Anche il test originale interrogava ripetutamente l'API e aspettava risultati nuovi. È una precauzione utile perché il tempo di avvio e la pianificazione dei controlli possono variare.

## I risultati osservati: allarme al secondo 503, risoluzione al secondo 200

Nell'esecuzione del 9 ottobre 2026, l'API di Gatus ha registrato sette osservazioni. Gli orari riportati sono UTC e i millisecondi provengono dal riepilogo verificato del test.

| Ora (UTC) | Stato | Osservazione |
| --- | --- | --- |
| 14:39:41.844 | 0 (connessione rifiutata) | Errore temporaneo all'avvio, non un HTTP 0 |
| 14:39:56.896 | 200 | Controllo sano 1 |
| 14:40:11.896 | 200 | Controllo sano 2 |
| 14:40:26.853 | 503 | Guasto simulato 1 |
| 14:40:41.895 | 503 | Guasto simulato 2 |
| 14:40:56.895 | 200 | Recupero 1 |
| 14:41:11.853 | 200 | Recupero 2 |

Nella stessa prova il ricevitore Webhook locale ha salvato:

| Evento | Ricezione (UTC) |
| --- | --- |
| `TRIGGERED` | 14:40:41.907 |
| `RESOLVED` | 14:41:11.861 |

Il secondo 503 e la ricezione del trigger sono separati da pochi millisecondi. Anche l'avviso di risoluzione è arrivato subito dopo il secondo controllo 200. **La sequenza dei controlli accanto agli eventi ricevuti** è una prova molto più concreta di uno screenshot con la dashboard verde.

Attenzione però: da questi dati non deriva una promessa del tipo «ogni guasto in produzione viene segnalato entro 30 secondi». I tempi dipendono dalla pianificazione dei controlli, dalla latenza delle richieste, dalla rete e dalla disponibilità del destinatario. Qui abbiamo verificato **un guasto sintetico e il Webhook su una rete Docker isolata**, non la consegna end-to-end tramite email, Slack o SMS.

## Il problema incontrato davvero: container avviato, primo controllo rifiutato

Ancora prima di simulare il guasto, la primissima osservazione era `0` con «connection refused». Poi sono comparsi due 200 corretti. Non si è trattato di un server che restituiva «HTTP 0»: **non è arrivata alcuna risposta HTTP valida**.

«Il container è in esecuzione» e «il servizio è pronto a rispondere al controllo di salute» non sono equivalenti.

Docker Compose `up -d --wait` attende che i servizi siano in esecuzione o che superino eventuali healthcheck dichiarati. In questo file Compose, però, **nessuno dei due servizi ha un healthcheck**. Il completamento del comando non dimostra che il target `/health` fosse già accessibile alla prima richiesta di Gatus. L'applicazione potrebbe essere ancora in fase di inizializzazione.

Se incontri «Container Up, primo controllo fallito», non alzerei subito la soglia degli errori per nascondere il problema. Preferisco questa sequenza di diagnosi:

1. Controllare `docker compose ... ps` e `logs gatus` per verificare l'avvio del processo e il caricamento della configurazione.
2. Richiedere `/health` **dalla stessa rete di container** usata da Gatus. Distinguere un'app non ancora pronta da problemi DNS, porte o da un vero HTTP 503.
3. Esaminare più campioni consecutivi nell'API di stato e separare l'errore iniziale transitorio dai 503 iniettati apposta in seguito.
4. Se Gatus rileva il guasto ma non arrivano avvisi, verificare `alerting.custom`, la rotta verso il destinatario e il file degli eventi Webhook.

È una strategia di diagnosi ricavata dal test. **Non significa che tutti e quattro i problemi siano comparsi** sulla macchina utilizzata. L'evento reale inatteso è stata una sola connessione rifiutata all'avvio; le successive risposte 200 → 503 → 200 e i relativi avvisi hanno rispettato la configurazione.

Su una vera applicazione va chiarito anche cosa significhi essere «healthy». Il servizio è ancora in avvio? Una dipendenza è assente? Una funzionalità è degradata? È davvero inutilizzabile? Se l'endpoint risponde sempre 200, neppure la condizione `[STATUS] == 200` di Gatus potrà distinguere queste situazioni.

## SQLite conserva la cronologia dopo un riavvio, non dopo la cancellazione del volume

In questo setup Gatus scriveva la cronologia in `/data/gatus.db`, sul volume con nome `gatus_data`. Il test ha prima acquisito il **timestamp esatto** di un controllo già salvato, poi ha riavviato soltanto il container Gatus e ha interrogato di nuovo la stessa API. Il vecchio record era ancora disponibile e la procedura ha stampato:

```text
SQLITE_RESTART_HISTORY_PRESERVED
```

Questo dimostra soltanto che **riavviare il container conservando il volume dei dati non ha cancellato quella cronologia**. Non equivale a un backup verificato, al recupero dopo la perdita del disco o a un sistema di disaster recovery collaudato.

Alla fine sono state eliminate le risorse appartenenti soltanto al progetto Compose usa-e-getta. Il controllo sui label ha confermato l'assenza di container residui per quel progetto. Quando si intende davvero distruggere il laboratorio, i comandi sono:

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

C'è un dettaglio importante: `down --volumes` **elimina anche il volume con nome del progetto e la cronologia SQLite**. Non è un comando di manutenzione ordinaria da usare su un Gatus destinato a rimanere online. In quel caso bisogna conservare il volume e predisporre e provare separatamente backup e ripristino del database.

Prima di considerare affidabile il monitoraggio in produzione restano diversi scenari da verificare. Chi si accorge che Gatus stesso si è fermato? Cosa succede se il destinatario esterno dei Webhook non risponde? Come si comportano più endpoint che falliscono contemporaneamente? Possiamo distinguere un problema di rete tra host da un errore dell'applicazione? Questo singolo esperimento non offre risposte garantite.

Il risultato accertato è circoscritto ma utile: con Gatus v5.37.0, due container isolati e un endpoint HTTP controllabile ho osservato **l'intervento della soglia di errore, il Webhook di ripristino e il mantenimento della cronologia SQLite dopo il riavvio**. Prima di usarlo su un servizio vero, verificherei separatamente il significato del suo endpoint di salute e il percorso completo delle notifiche in condizioni di guasto realistiche.
