# Gatus Docker Compose 实测：服务返回 503 后，告警会触发并在恢复时结束吗？

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

Gatus 装好以后，健康检查页面能显示绿色。但我更想知道另一件事：被监控的接口连续失败时，告警到底什么时候发出？接口恢复 200 以后，通知会不会跟着解除？如果重启 Gatus，之前的检查记录还在不在？

我用 [Gatus](https://github.com/TwiN/gatus) v5.37.0 做了一次可控实验：Docker Compose 里运行 Gatus 和一个小型 HTTP 服务，让后者按顺序返回 200、503、200，并用同一隔离网络里的 Webhook 接收器记录告警。实际拿到了两次 503 对应的 `TRIGGERED`、恢复后对应的 `RESOLVED`，还验证了 SQLite 历史在 Gatus 容器重启后保留。

这篇只讲 **Gatus 部署后怎样配置、运行、验证告警，以及实际遇到的启动问题**。测试对象是模拟健康接口，不是真实生产业务；邮件、Slack、跨主机通知等能力没有在这次实验中验证。

## 先让 Gatus 知道什么才算故障

Gatus 不需要侵入业务应用。它定期访问目标端点，再用条件表达式判断是否健康。官方 [v5.37.0 固定版本文档](https://github.com/TwiN/gatus/blob/ae605f291928ed9b3bdbe60ae5528cc57631433b/README.md) 还介绍了 HTTP 以外的监控协议；但这次实际跑通的是 **HTTP 状态码检查 + 自定义 Webhook**，不混写其他协议的效果。

下面是本次实测的 `config/config.yaml` 核心配置。`fixture` 是 Compose 网络中的服务名，可以把它理解为一个可控制健康状态的测试应用：

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

这几项足以决定实验行为：

- `interval: 15s`：每 15 秒安排一次探测；不能把它当成精确到毫秒的定时保证。
- `[STATUS] == 200`：这里只接受 HTTP 200。业务若把 204 当成功，就要按实际健康接口定义修改条件。
- `failure-threshold: 2`：连续两次失败才进入本次配置的告警触发条件；`success-threshold: 2` 配合 `send-on-resolved: true` 用于发送恢复通知。
- `alerting.custom`：把事件 POST 到实验接收器，而不是向真实通讯软件推送。

`[ALERT_TRIGGERED_OR_RESOLVED]` 和 `[ENDPOINT_NAME]` 是 Gatus 的模板变量。这里用一个很朴素的 JSON 消息，就是为了确认 **触发和解除是否确实到达接收端**，而不仅是监控页面换了颜色。

真实应用的健康 URL 可以替换为业务接口，但有一个常见的容器网络误区：Gatus 容器里的 `localhost` 指向 **Gatus 自己**，并不自动指向宿主机或者另一个容器。若服务位于相同 Compose 网络，应使用那个服务的 DNS 名称；跨机器时则需单独确认网络、DNS、TLS 和认证。这些是部署建议，不是本次跨主机实测结论。

## 用两个容器复现：健康、故障、恢复

为了让结果可重复，我没有动真实业务服务，而是准备了一个只做两件事的 Python fixture：`GET /health` 根据状态文件返回 200 或 503；`POST /alert` 把 Gatus 发来的 JSON 通知写入本地记录。这样可以把监控链路与真实服务自身的复杂故障分开。

要复现，需要在 **可以销毁的独立实验目录** 放入 `compose.yaml`、`config/config.yaml`、`fixture/server.py` 和 `digest-lock.env`。下面这份 fixture 是已检查过的实测逻辑的可复制版本：

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

与之配套的 Compose 保留了实验中实际使用的网络和资源限制。`internal: true` 把这两个容器的网络隔离开，`fixture` 不映射宿主机端口；Gatus 控制台仅绑定 `127.0.0.1` 的动态宿主机端口。资源数值是配置上限，并非实测的 CPU 或内存使用量。

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

镜像不要随手换成会漂移的 `latest`。这次固定了 Linux/amd64 镜像摘要，`digest-lock.env` 的实测值如下：

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

测试主机为 Linux/amd64，使用 Docker Engine 29.9.0 / Compose v5.6.0。**ARM 主机不要直接照搬这两个摘要**，先从镜像仓库核对对应架构的 manifest。实验运行时也检查了镜像实际平台。

以下是给读者手动复查整理的命令，**不是声称原始实验逐字执行了这些命令**：

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

刚启动时可以从 fixture 容器请求 Gatus 的状态 API。观察到两条新的 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'
```

确认 API 里出现**连续两条新的 503**，再恢复健康并查看通知接收器：

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

关键是按新探测记录判断阶段完成，而不是凭 `sleep 30` 猜测。脚本中的原始逻辑也是轮询状态 API，判断每一阶段是否出现新的连续样本。

## 实际记录：第二次 503 后触发，第二次恢复 200 后解除

2026 年 10 月 9 日的那次运行，Gatus 状态 API 留下了下面七次观测（UTC，毫秒级时间是从真实运行结果整理的）：

| 时间（UTC） | 状态 | 含义 |
| --- | --- | --- |
| 14:39:41.844 | 0（连接拒绝） | 刚启动时的一次失败；0 不是 HTTP 响应码 |
| 14:39:56.896 | 200 | 健康探测 1 |
| 14:40:11.896 | 200 | 健康探测 2 |
| 14:40:26.853 | 503 | 故障探测 1 |
| 14:40:41.895 | 503 | 故障探测 2 |
| 14:40:56.895 | 200 | 恢复探测 1 |
| 14:41:11.853 | 200 | 恢复探测 2 |

同一轮测试中，本地 Webhook 接收器记录：

| 事件 | 接收时间（UTC） |
| --- | --- |
| `TRIGGERED` | 14:40:41.907 |
| `RESOLVED` | 14:41:11.861 |

这两条事件与状态采样吻合：第一次 503 后尚未看到触发事件，第二次 503 后接收器记录到触发；恢复阶段则在第二次 200 后记录解除。比起只截一张绿色面板，这组时间戳更能说明配置的阈值在这次实验中确实生效。

但它不能直接换算成“生产故障 30 秒内必达”。探测轮次、服务请求耗时、调度、网络条件、告警目标端的可用性都会影响最终通知时间；这里记录的是 **本机隔离网络里的合成故障与通知接收**，不是外部通讯渠道的 SLA。

## 真正遇到的问题：容器已经启动，第一轮探测却连接拒绝

最开始那条状态 `0` 是值得注意的地方：故障注入还没开始，Gatus 就记下一次连接拒绝。后来才有两次正常 200。它不是“Gatus 错报了一个 HTTP 0”，而是那次请求没有得到有效 HTTP 状态码。

这里有两个不同层面的“已经启动”：

Docker Compose 的 `up -d --wait` 负责等待容器运行或其已声明的健康检查达标；这份实验 Compose **没有给两个服务声明 healthcheck**，因此不能把命令返回当成 `/health` 从首轮起可访问的证据。另一方面，即使监控页面已经显示，目标应用也可能还在初始化。

在使用 Gatus 时，遇到“容器 Up，但第一个探测失败”，我会先确认故障是否持续，而不是立刻加大阈值把它藏起来。实用的排查顺序是：

1. `docker compose ... ps` 和 `logs gatus`：确认 Gatus 进程与配置加载没有问题。
2. 从 **Gatus 所在的容器网络** 请求目标 `/health`：分清服务未就绪、DNS 解析、端口不通，还是健康接口真正返回 503。
3. 查看状态 API 中的连续结果：把启动瞬态与后面手工切换出来的 503 分成不同阶段。
4. 若 Gatus 已识别失败但通知没有到达，再检查 `alerting.custom` 的 URL、接收器路由和事件记录。

这是一套根据此次现象整理的排查方法，并不表示四类错误都在本次机器上发生过。此次实际观察到的是 **一次启动连接拒绝**，随后新产生的 200 / 503 / 200 序列和告警事件都符合预期。

以后把 Gatus 连接真实服务时，建议先定义健康检查语义：服务正在启动、依赖不可用、功能降级、真正不可用，分别应该返回什么？如果业务自身把所有情况都压成 200，Gatus 也无法凭 `[STATUS] == 200` 分辨服务是否真的可用。

## SQLite 重启历史保住了，但删除数据卷仍会丢失

Gatus 在这次配置里把历史写到 `/data/gatus.db`，由 `gatus_data` 数据卷承载。实验先取得一条已有状态结果的 **精确时间戳**，重启 Gatus 容器，再查询相同的状态 API；那条旧记录依然存在，验证流程给出了：

```text
SQLITE_RESTART_HISTORY_PRESERVED
```

这说明本次“重启容器但保留数据卷”没有清掉检查历史。它不代表 Gatus 已具备完整备份和恢复能力，更不能证明宿主机磁盘损坏后还能找回数据。

实验结束时还做了项目范围内的清理，并检查这个 Compose 项目标签下没有剩余容器。用于可丢弃实验的命令如下：

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

**`down --volumes` 会删除这个项目的数据卷，连同刚才的 SQLite 历史一起删除。** 它只适用于这类准备销毁的实验；正式使用 Gatus 时应保留数据卷，并另外设计、演练数据库备份恢复。这篇没有做真实备份恢复测试。

如果你准备把 Gatus 长期用于监控，还值得继续验证几件事：Gatus 自身宕机时谁来发现，外部通知目标失败如何处理，多端点同时告警时会发生什么，跨主机网络异常是否能区分于业务故障。这些都不能从一次小型实验直接推导出可靠性结论。

这次我最终确认的是一个相当具体的边界：在固定的 Gatus v5.37.0、两容器隔离环境和一个可控 HTTP 端点上，**失败阈值、恢复通知和 SQLite 重启历史都得到了实际观测**。如果要据此监控真正的业务，下一步应该先把目标服务的健康接口和通知链路分别验清楚，而不是仅因为 Gatus 能运行就认定监控已经上线。
