# Gatus Docker Compose 實測：服務回傳 503 後，告警會觸發並在復原時解除嗎？

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

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

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

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

## 先定義 Gatus 眼中的「故障」

Gatus 會主動存取目標端點，再按照條件運算式判斷狀態，不必把監控代理程式嵌入受監控的應用程式。官方[固定版本 README](https://github.com/TwiN/gatus/blob/ae605f291928ed9b3bdbe60ae5528cc57631433b/README.md) 也提供其他協定的用法；不過這次實際完成驗證的是 **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`：只有 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：

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

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

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

```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（連線遭拒） | 啟動時的瞬態錯誤，不是 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、第一次探測失敗」，我不會直接把告警門檻調大，先把原因分清楚：

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。原紀錄仍然存在，驗證流程回報：

```text
SQLITE_RESTART_HISTORY_PRESERVED
```

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

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