Gatus Docker Compose 實測:服務回傳 503 後,告警會觸發並在復原時解除嗎?
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.844 | 0(連線遭拒) | 啟動時的瞬態錯誤,不是 HTTP 0 |
| 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 與觸發事件只有毫秒級差距;第二次復原 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、第一次探測失敗」,我不會直接把告警門檻調大,先把原因分清楚:
- 使用
docker compose ... ps與logs gatus,檢查容器進程和設定載入。 - 從相同容器網路請求目標
/health,分辨未就緒、DNS、連接埠或真正的 HTTP 503。 - 查看狀態 API 的連續紀錄,將啟動瞬態與後續人工切換的 503 分開。
- 若 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 重啟歷史都留下了可核對的觀測。要把它用在真實服務,仍應分別驗證健康端點語意與外部通知鏈路。