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

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

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

先让 Gatus 知道什么才算故障

Gatus 不需要侵入业务应用。它定期访问目标端点,再用条件表达式判断是否健康。官方 v5.37.0 固定版本文档 还介绍了 HTTP 以外的监控协议;但这次实际跑通的是 HTTP 状态码检查 + 自定义 Webhook,不混写其他协议的效果。

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

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 是已检查过的实测逻辑的可复制版本:

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 或内存使用量。

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 的实测值如下:

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。实验运行时也检查了镜像实际平台。

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

$ 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 记录后,再切换故障;不要在服务还没准备好时直接开始计数:

$ 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,再恢复健康并查看通知接收器:

$ 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.8440(连接拒绝)刚启动时的一次失败;0 不是 HTTP 响应码
14:39:56.896200健康探测 1
14:40:11.896200健康探测 2
14:40:26.853503故障探测 1
14:40:41.895503故障探测 2
14:40:56.895200恢复探测 1
14:41:11.853200恢复探测 2

同一轮测试中,本地 Webhook 接收器记录:

事件接收时间(UTC)
TRIGGERED14:40:41.907
RESOLVED14: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;那条旧记录依然存在,验证流程给出了:

SQLITE_RESTART_HISTORY_PRESERVED

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

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

$ 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 能运行就认定监控已经上线。