# Gatus con Docker Compose: prueba de alertas HTTP 503, recuperación e historial SQLite

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

Gatus se pone en marcha y el panel muestra una comprobación de salud en verde. Parece que la supervisión ya funciona, pero aún falta una prueba bastante más útil: **¿cuándo se dispara realmente la alerta si un endpoint HTTP empieza a fallar?** ¿Llega una notificación de recuperación cuando vuelve a responder 200? ¿Se conserva el historial si reinicio el contenedor de Gatus?

Para comprobarlo ejecuté [Gatus](https://github.com/TwiN/gatus) v5.37.0 con Docker Compose y un servicio HTTP deliberadamente pequeño. Podía cambiar su respuesta de 200 a 503 y otra vez a 200. Un receptor Webhook en la misma red aislada guardaba los eventos. Después de dos comprobaciones 503 consecutivas recibí `TRIGGERED`, y después de dos respuestas 200 de recuperación recibí `RESOLVED`. Además, un registro anterior de SQLite continuó disponible después de reiniciar únicamente el contenedor de Gatus.

Fue una prueba **con un endpoint sintético**, no con una aplicación en producción. No probé Slack, correo, entrega de avisos a servicios externos, supervisión entre hosts ni compromisos de disponibilidad.

## Antes de probar, definir qué considera Gatus un fallo

Gatus consulta periódicamente una URL y evalúa la respuesta con expresiones de condición. No hace falta instalar un agente dentro de la aplicación supervisada. El [README oficial fijado a un commit](https://github.com/TwiN/gatus/blob/ae605f291928ed9b3bdbe60ae5528cc57631433b/README.md) documenta otros protocolos; aquí solo he verificado **condiciones sobre el estado HTTP y un Webhook personalizado**.

Esta es la parte principal de `config/config.yaml` utilizada en el experimento. `fixture` es el nombre del servicio de Compose cuyo estado podemos modificar:

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

Los ajustes decisivos son estos:

- `interval: 15s`: programa una comprobación aproximadamente cada 15 segundos. No es una garantía de ejecución exacta al milisegundo.
- `[STATUS] == 200`: solo el estado HTTP 200 se interpreta como saludable. Si tu API utiliza 204 como respuesta válida, debes modificar la condición.
- `failure-threshold: 2`: exige dos fallos consecutivos para activar la alerta. `success-threshold: 2` y `send-on-resolved: true` configuran la detección de recuperación y su aviso.
- `alerting.custom`: hace un POST con JSON a nuestro receptor de pruebas. No demuestra que un sistema de notificaciones externo haya entregado el mensaje.

`[ALERT_TRIGGERED_OR_RESOLVED]` y `[ENDPOINT_NAME]` son variables de plantilla de Gatus. Elegí un JSON sencillo porque quería confirmar **que el receptor había recibido los dos eventos**, no limitarme a observar que la interfaz cambiaba de color.

Al sustituir la URL de prueba por una aplicación real hay que entender bien la red de contenedores. En un contenedor Gatus con red aislada convencional, `localhost` hace referencia **al espacio de nombres de red del propio contenedor**, no automáticamente al host Docker ni a otro servicio. En la misma red de Compose se puede utilizar el nombre DNS del servicio; para otros servidores hay que comprobar también DNS, TLS, autenticación y rutas. Esta prueba no abarcó destinos remotos.

## Reproducir 200 → 503 → 200 con dos contenedores

No quise provocar una caída de una aplicación real. Preparé un pequeño servicio Python: `GET /health` lee un archivo de estado y responde 200 o 503; `POST /alert` guarda los mensajes Webhook de Gatus en un registro JSON por líneas. Así el camino de supervisión y aviso queda separado de las averías propias de una aplicación compleja.

En un **directorio de laboratorio independiente que puedas destruir por completo**, prepara cuatro archivos: `compose.yaml`, `config/config.yaml`, `fixture/server.py` y `digest-lock.env`. Este es un ejemplo copiable basado en la lógica del fixture inspeccionado:

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

El archivo Compose mantiene el aislamiento y los límites del ensayo. `internal: true` limita la red de ambos servicios; el fixture no publica puertos al host y el panel de Gatus solo se vincula a un puerto dinámico de `127.0.0.1`. Los valores de CPU y memoria son **límites configurados**, no mediciones reales de consumo.

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

Tampoco utilicé la etiqueta cambiante `latest`. Estos fueron los digests de imágenes Linux/amd64 registrados en `digest-lock.env`:

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

El host de prueba ejecutaba Linux/amd64, Docker Engine 29.9.0 y Docker Compose v5.6.0. El procedimiento original también inspeccionó la plataforma efectiva de las imágenes. **No reutilices directamente estos digests en una máquina ARM** sin comprobar antes los manifests correspondientes.

Para repetir el ensayo manualmente, esta secuencia es práctica. **No afirmo que el experimento original ejecutara literalmente cada comando con esta redacción**: son instrucciones de reproducción preparadas para el lector.

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

Una vez iniciado, consulta la API de estados de Gatus desde el contenedor fixture. Espera a que haya **dos muestras nuevas** con código 200 antes de inyectar el fallo:

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

Confirma que la API registra dos nuevas respuestas 503 consecutivas. Solo entonces restaura el estado saludable y revisa los avisos recibidos:

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

No basta con ejecutar `sleep 30` y suponer que cada fase ha terminado. El código de comprobación utilizado originalmente consultaba la API repetidamente y avanzaba cuando encontraba muestras realmente nuevas. Es importante, sobre todo porque el tiempo de inicialización de los servicios puede variar.

## Lo que quedó registrado: alerta tras el segundo 503 y cierre tras el segundo 200

En la ejecución del 9 de octubre de 2026, la API de Gatus conservó siete observaciones. Todas las horas están en UTC y los milisegundos proceden del resumen verificado del experimento.

| Hora (UTC) | Estado | Qué ocurrió |
| --- | --- | --- |
| 14:39:41.844 | 0 (conexión rechazada) | Fallo transitorio de arranque, no HTTP 0 |
| 14:39:56.896 | 200 | Comprobación saludable 1 |
| 14:40:11.896 | 200 | Comprobación saludable 2 |
| 14:40:26.853 | 503 | Fallo simulado 1 |
| 14:40:41.895 | 503 | Fallo simulado 2 |
| 14:40:56.895 | 200 | Recuperación 1 |
| 14:41:11.853 | 200 | Recuperación 2 |

Durante la misma ejecución, el receptor Webhook local guardó lo siguiente:

| Evento | Recepción (UTC) |
| --- | --- |
| `TRIGGERED` | 14:40:41.907 |
| `RESOLVED` | 14:41:11.861 |

La segunda muestra 503 y la recepción de `TRIGGERED` están separadas por apenas unos milisegundos. Con la recuperación ocurrió lo mismo: el segundo 200 fue seguido por `RESOLVED`. **La línea temporal junto a los mensajes recibidos** ofrece una prueba más útil que una simple captura de un panel verde.

Pero no sería correcto convertir esto en «un fallo de producción siempre genera una alerta en menos de 30 segundos». El intervalo entre sondeos, su duración, la planificación, la red y la disponibilidad del destino también influyen. Aquí solo quedó probada **la entrega del Webhook local en una red Docker aislada ante un fallo sintético**, no la entrega externa ni un SLA.

## El problema real que apareció antes de introducir el fallo

La primera observación, incluso antes de cambiar el fixture a «down», fue `0` con una conexión rechazada. Después aparecieron dos respuestas 200 correctas. No significa que el servicio haya devuelto «HTTP 0»: significa que **no se obtuvo ninguna respuesta HTTP válida** en aquel primer intento.

Conviene distinguir «el contenedor está ejecutándose» de «el servicio ya está preparado para contestar su endpoint de salud».

Docker Compose `up -d --wait` espera a que los contenedores estén en marcha o superen las comprobaciones de salud declaradas. Pero esta configuración **no define healthcheck para ninguno de los dos servicios**. Por tanto, que el comando termine correctamente no demuestra que `/health` estuviera disponible en el primer sondeo de Gatus. El servicio objetivo todavía podría estar inicializándose.

Si ves «Container Up, primera comprobación fallida», antes de subir el umbral para ocultar el problema seguiría este orden:

1. Revisar `docker compose ... ps` y `logs gatus` para confirmar el arranque del proceso y la carga de la configuración.
2. Consultar `/health` **desde la misma red de contenedores** que utiliza Gatus. Distinguir falta de preparación, DNS, puerto o un HTTP 503 auténtico.
3. Leer varias muestras consecutivas de la API de estados y separar el fallo transitorio de arranque de los fallos 503 introducidos deliberadamente después.
4. Si Gatus detecta el fallo pero no llega ningún aviso, revisar `alerting.custom`, la ruta al receptor y el archivo de eventos Webhook.

Es un método de diagnóstico elaborado a partir de lo observado. **No quiere decir que las cuatro clases de averías ocurrieran** en esta máquina de ensayo. Lo que apareció fue una conexión rechazada durante el arranque; después, las comprobaciones 200 → 503 → 200 y los mensajes coincidieron con la configuración.

En una aplicación real también hay que definir el significado de `/health`. ¿Qué devuelve durante el arranque, cuando falla una dependencia, cuando una función está degradada o cuando el servicio realmente no puede operar? Si responde 200 ante todo, la condición `[STATUS] == 200` tampoco permite a Gatus distinguir esos casos.

## SQLite conservó el historial tras reiniciar; borrar el volumen es diferente

Gatus guardó el historial en `/data/gatus.db`, respaldado por el volumen con nombre `gatus_data`. El ensayo obtuvo la **marca de tiempo exacta** de un resultado ya almacenado, reinició únicamente el contenedor Gatus y volvió a consultar la misma API. La entrada anterior seguía allí y el procedimiento indicó:

```text
SQLITE_RESTART_HISTORY_PRESERVED
```

Esto demuestra únicamente que **el reinicio de ese contenedor manteniendo su volumen de datos no borró aquel historial**. No demuestra que se haya probado una estrategia completa de backups, recuperación tras pérdida de disco ni recuperación ante desastres.

Al terminar se limpiaron solo los recursos del proyecto Compose desechable y se comprobó que no quedaran contenedores con su etiqueta de proyecto. Si de verdad quieres destruir todo ese laboratorio, puedes utilizar:

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

Atención: `down --volumes` **también borra el volumen con nombre y el historial SQLite de este proyecto**. No lo uses como operación habitual de mantenimiento de un Gatus que debas conservar. Mantén el volumen y diseña y ensaya aparte la copia de seguridad y la restauración de la base de datos.

Antes de depender de esta solución para supervisión de producción, todavía probaría otros escenarios. ¿Quién detecta la caída del propio Gatus? ¿Qué pasa si el destino externo de los avisos no responde? ¿Cómo se comportan varios endpoints fallando simultáneamente? ¿Se puede distinguir un fallo de red entre servidores de una avería de la aplicación? Un único ensayo controlado no permite responder con certeza.

El resultado confirmado es bastante concreto: con Gatus v5.37.0, dos contenedores aislados y un endpoint HTTP controlable, observé **el umbral de errores consecutivos, la notificación de recuperación y la conservación del historial SQLite tras un reinicio**. Antes de considerar supervisado un servicio de verdad, probaría por separado el significado de su endpoint de salud y toda la ruta de notificaciones con fallos realistas.
