Gatus con Docker Compose: test di HTTP 503, avvisi di ripristino e SQLite
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 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 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:
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: 2esend-on-resolved: trueregolano 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:
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.
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:
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:
$ 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:
$ 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:
$ 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:
- Controllare
docker compose ... pselogs gatusper verificare l’avvio del processo e il caricamento della configurazione. - Richiedere
/healthdalla stessa rete di container usata da Gatus. Distinguere un’app non ancora pronta da problemi DNS, porte o da un vero HTTP 503. - Esaminare più campioni consecutivi nell’API di stato e separare l’errore iniziale transitorio dai 503 iniettati apposta in seguito.
- 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:
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:
$ 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.