Gatus 裝好以後,健康檢查頁面很快就能顯示綠色。但我更想確定另一件事:被監控的 HTTP 端點連續失敗時,告警究竟何時觸發?服務恢復到 200 後,解除通知會不會真的送達?如果 Gatus 容器重啟,先前的檢查紀錄還在不在?

我用 Gatus v5.37.0 和 Docker Compose 做了一輪可控測試:讓同一個小型 HTTP 服務依序回傳 200、503、200,並透過隔離網路內的 Webhook 接收端保存事件。結果是連續兩次 503 後收到了 TRIGGERED,連續兩次恢復 200 後收到了 RESOLVED。SQLite 中的既有狀態紀錄也在 Gatus 容器重啟後保留下來。

這次測試用的是模擬健康檢查端點,不是正式環境中的業務服務;沒有驗證 Slack、Email、外部簡訊或跨主機監控的實際送達。

先定義 Gatus 眼中的「故障」

Gatus 會主動存取目標端點,再按照條件運算式判斷狀態,不必把監控代理程式嵌入受監控的應用程式。官方固定版本 README 也提供其他協定的用法;不過這次實際完成驗證的是 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:只有 200 被視為健康。如果你的服務用 204 表示成功,必須依實際 API 語意調整條件。
  • failure-threshold: 2:連續兩次失敗才符合觸發條件;success-threshold: 2 與 send-on-resolved: true 則用於復原狀態與解除通知。
  • alerting.custom:將 JSON POST 到測試接收端;這不能證明任何外部通知平台已收到訊息。

[ALERT_TRIGGERED_OR_RESOLVED] 與 [ENDPOINT_NAME] 是 Gatus 的模板變數。我刻意把通知格式保持得很簡單,因為這裡要驗證的是接收端確實收到觸發與解除事件,而不是儀表板上的顏色有沒有改變。

將健康檢查網址換成真實服務時,要先避開一個常見誤區:一般隔離容器網路裡,Gatus 容器內的 localhost 指向 Gatus 容器自己的網路命名空間,不是自動指向宿主機或其他容器。同一個 Compose 網路的服務可透過服務名稱解析;跨主機則另外涉及 DNS、TLS、認證和路由。後者並沒有包含在本次實測裡。

用兩個容器重現 200 → 503 → 200

為了不影響真實應用,我準備了一個很小的 Python fixture。GET /health 讀取狀態檔案,決定回應 200 還是 503;POST /alert 則把 Gatus 的 Webhook 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()

對應的 Docker 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。這次 digest-lock.env 固定了 Linux/amd64 的鏡像摘要:

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 後,再把 fixture 恢復健康,檢視接收端的事件檔:

$ 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(連線遭拒)啟動時的瞬態錯誤,不是 HTTP 0
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 與觸發事件只有毫秒級差距;第二次復原 200 後也出現對應的解除事件。這組探測時間線與實際接收事件的對照,比只附上一張顯示綠色的監控畫面更有說服力。

但不能因此推導出「正式環境發生故障後 30 秒內一定收到告警」。探測排程、請求耗時、網路與目的端可用性都會影響時間。這裡驗證的是隔離 Docker 網路中的合成故障與內部 Webhook,不是外部簡訊、郵件、Slack 的送達 SLA。

真正遇到的問題:容器已啟動,第一次檢查卻被拒絕連線

最初那筆狀態 0 值得特別保留。當時還沒開始注入故障,Gatus 就記下了一次 connection refused,隨後才出現兩筆正常的 200。這不是「Gatus 收到了 HTTP 0」,而是當時沒有取得有效的 HTTP 回應。

「容器已啟動」與「服務已經準備好接受健康探測」並不是同一件事。

Docker Compose 的 up -d --wait 會等待服務執行,或讓已宣告的健康檢查通過;但這份 Compose 沒有對兩個服務宣告 healthcheck。因此指令順利返回,並不保證 Gatus 第一次探測時,目標 /health 已可連線。服務可能仍在初始化。

如果遇到「Container Up、第一次探測失敗」,我不會直接把告警門檻調大,先把原因分清楚:

  1. 使用 docker compose ... ps 與 logs gatus,檢查容器進程和設定載入。
  2. 從相同容器網路請求目標 /health,分辨未就緒、DNS、連接埠或真正的 HTTP 503。
  3. 查看狀態 API 的連續紀錄,將啟動瞬態與後續人工切換的 503 分開。
  4. 若 Gatus 已辨識失敗,卻沒有通知,才接著查 alerting.custom、接收路由與 Webhook 紀錄。

這是根據真實觀測整理的排障方式,不代表四種問題都在此次環境發生過。實際出現的是啟動時一次連線遭拒;後續健康、故障、復原與事件接收都符合預期。

正式把 Gatus 接到應用程式之前,也要先想清楚健康端點的語意:啟動中、相依服務中斷、功能降級、真正無法服務,各自該回傳什麼?如果應用程式一律回 200,Gatus 使用 [STATUS] == 200 也無法替你判斷業務是否真的可用。

SQLite 重啟後保住歷史;刪除資料卷則是另一回事

本次 Gatus 將歷史寫入 /data/gatus.db,並透過 gatus_data 命名資料卷保留。實驗先記住一筆既有狀態結果的精確時間戳,只重啟 Gatus 容器,接著再查詢相同的狀態 API。原紀錄仍然存在,驗證流程回報:

SQLITE_RESTART_HISTORY_PRESERVED

這只證明本次保留資料卷的容器重啟沒有清除既有狀態歷史,不代表已完成資料庫備份、硬碟損壞復原或異地容災測試。

測試結束後,僅針對該可丟棄的 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 v5.37.0、兩個隔離容器與可控制的 HTTP 端點上,失敗門檻、解除通知、SQLite 重啟歷史都留下了可核對的觀測。要把它用在真實服務,仍應分別驗證健康端點語意與外部通知鏈路。