Gatus を立ち上げれば、ヘルスチェックの画面に緑のステータスが表示される。けれども、それだけで監視が動くと判断するのは早い。監視対象の HTTP エンドポイントが連続して失敗したとき、通知は何回目の失敗で発火するのか。200 に戻ったら復旧通知も送られるのか。Gatus のコンテナを再起動したら過去の履歴は残るのか。この三つを実際に確かめたかった。

Gatus v5.37.0 と Docker Compose を使い、HTTP 200 → 503 → 200 を切り替えられる小さなテストサービスを用意した。同じ隔離ネットワーク内の Webhook 受信側で通知を記録する。実行したところ、連続する二つの 503 の後に TRIGGERED、復旧した二つの 200 の後に RESOLVED を受信できた。SQLite の過去のチェック履歴も Gatus コンテナの再起動後に残った。

ただし、これは模擬 HTTP サービスを使った一回の制御された実験だ。本番アプリケーションの障害や Slack・メールなど外部通知先への配信、複数ホスト間の監視は検証していない。

何を障害として扱うか、まず条件を決める

Gatus は対象アプリケーションにエージェントを組み込まず、設定したエンドポイントへ定期的にアクセスし、その結果を条件式で評価できる。HTTP 以外の機能は固定コミットの公式 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 は HTTP 200 だけを正常とみなす。アプリケーションが 204 を正常応答に使うなら、この条件も変える必要がある。
  • failure-threshold: 2 では連続 2 回の失敗でアラートの条件を満たす。success-threshold: 2 と send-on-resolved: true によって復旧側の判定と通知を設定する。
  • alerting.custom は JSON をテスト受信側に HTTP POST する。外部のチャットやメールに届くことを確認するものではない。

[ALERT_TRIGGERED_OR_RESOLVED] と [ENDPOINT_NAME] は Gatus のテンプレート変数。通知内容をあえて単純にしたのは、画面の色の変化ではなく、受信側に実際に二種類のイベントが届いたかを確認するためだ。

本物のアプリケーションに置き換えるときはコンテナネットワークに注意したい。通常の隔離ネットワークでは、Gatus コンテナ内の localhost はそのコンテナ自身のネットワーク名前空間を指し、ホスト OS や別コンテナを自動で指すわけではない。同じ 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 の四つを置く。実際に確認した処理を読みやすくした Python 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、Docker 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 が連続して二つ記録されたことを確認する。その後で元の健康状態へ戻し、Webhook の受信ログを確認する。

$ 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であり、外部配信の SLA ではない。

起動直後に実際に遭遇した接続拒否

障害を注入するより前、最初のチェックには状態 0 と connection refused が残っていた。その後は二つの正常な 200 が続く。つまり 0 は「HTTP 0 を返すサーバー」ではなく、有効な HTTP 応答を受信できなかったという結果だ。

コンテナが動き始めたことと、ターゲットサービスがチェックを受け付けられる状態になったことは別だ。

Docker Compose の up -d --wait はコンテナの稼働状態、または宣言済みの healthcheck の結果を待つ。ところが、この Compose 設定では二つのサービスにhealthcheck を定義していない。したがってコマンドが成功しても、Gatus の最初の /health 要求が必ず成功するとは限らない。対象サービスがまだ初期化中ということもある。

「Container は Up なのに最初だけ失敗する」場合、すぐに閾値を増やして見えなくするのではなく、次の順で切り分けたい。

  1. docker compose ... ps と logs gatus でプロセスの起動と設定の読み込みを確認する。
  2. Gatus と同じコンテナネットワークから /health にアクセスし、準備不足、DNS、ポート、実際の HTTP 503 を区別する。
  3. ステータス API で連続する記録を見て、起動時の瞬間的な失敗を後から注入した障害と区別する。
  4. Gatus が障害を認識しているのに通知が届かないとき、alerting.custom、受信側への経路、イベントのログを確認する。

これは観測に基づく実用的な切り分け方法で、四種類すべての不具合が今回発生したという意味ではない。実際にあったのは起動時の一度の接続拒否で、それ以降の 200 → 503 → 200 と通知は期待したとおりだった。

本物のサービスを監視するなら、ヘルスチェックの意味も先に決めたい。起動途中、依存先の停止、機能の縮退、本当に応答できない状態をどう表すのか。アプリが何でも 200 を返すなら、Gatus の [STATUS] == 200 だけでは問題を見抜けない。

SQLite の履歴は再起動後も残った。ただし volume の削除は別

今回は /data/gatus.db に SQLite の履歴を置き、gatus_data という名前付き volume で保持した。先に保存済みステータスの正確なタイムスタンプを記録し、Gatus コンテナだけを再起動してから再び同じ API を照会したところ、元の記録を参照できた。検証スクリプトの結果は次のとおり。

SQLITE_RESTART_HISTORY_PRESERVED

これが証明するのは、データ volume を残した状態でのコンテナ再起動で履歴が消えなかったということだけだ。データベースのバックアップ、ディスク故障からの復旧、災害復旧まで実証したわけではない。

実験が終わったら、破棄してよいこの 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 はこのプロジェクトの名前付き volume と SQLite の履歴も削除する。長期運用する Gatus の通常メンテナンスで実行するコマンドではない。volume を保持し、別途バックアップ・復元手順を設計して試しておく必要がある。

本番監視として使うなら、まだ試したいことがある。Gatus 自体が停止したら誰が検知するのか。外部通知先が落ちた場合はどうなるのか。複数エンドポイントの同時障害はどう扱われるのか。別ホスト間のネットワーク障害とアプリケーション障害を区別できるのか。これらは今回の小さな実験だけでは答えられない。

今回確認できた範囲ははっきりしている。Gatus v5.37.0、隔離した二つのコンテナ、制御可能な HTTP エンドポイントの組み合わせで、障害閾値、復旧 Webhook、SQLite 再起動後の履歴を実際に観測した。本番のサービスを監視対象にする前には、対象のヘルスチェックの意味と外部通知経路を、それぞれ現実的な故障条件で改めて検証したい。