Gatus를 설치하면 대시보드에 초록색 상태가 뜬다. 하지만 그것만으로 장애 감시가 제대로 작동한다고 말하기는 어렵다. HTTP 엔드포인트가 연속으로 실패했을 때 몇 번째 실패에서 경보가 발생하는지, 다시 200을 반환하면 복구 알림이 실제로 전달되는지, Gatus 컨테이너를 재시작해도 지난 점검 기록이 남는지를 확인하고 싶었다.

Gatus v5.37.0과 Docker Compose로 작은 실험을 진행했다. 테스트 HTTP 서비스가 200 → 503 → 200을 반환하도록 상태를 바꾸고, 같은 격리 네트워크의 Webhook 수신기가 알림을 저장하게 했다. 실제로 연속된 두 번의 503 뒤에 TRIGGERED, 연속된 두 번의 복구 200 뒤에 RESOLVED를 확인했다. Gatus 컨테이너만 재시작했을 때도 SQLite의 이전 상태 기록이 보존되었다.

이 실험은 합성 HTTP 테스트 서비스와 내부 Webhook을 사용했다. 실제 프로덕션 장애, 외부 Slack/이메일 전달, 다른 서버를 거치는 모니터링이나 가용성 SLA까지 검증한 것은 아니다.

어떤 응답을 장애로 볼지 먼저 정한다

Gatus는 애플리케이션 내부에 별도의 에이전트를 넣지 않고 대상 주소를 주기적으로 호출해 조건식으로 상태를 판단한다. HTTP 외의 프로토콜은 커밋을 고정한 공식 README에서 확인할 수 있다. 이번에 직접 실행한 부분은 HTTP 상태 코드 조건과 Custom 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: 테스트 수신기로 JSON을 HTTP POST한다. 외부 메시징 플랫폼의 전송 성공을 의미하지는 않는다.

[ALERT_TRIGGERED_OR_RESOLVED]와 [ENDPOINT_NAME]는 Gatus 템플릿 변수다. JSON을 일부러 단순하게 만들었다. 대시보드 색상이 바뀌었다는 사실보다 수신기가 실제로 발생·해제 이벤트를 받았는지 확인하는 것이 목적이기 때문이다.

실제 서비스로 URL을 바꿀 때는 컨테이너 네트워크부터 조심해야 한다. 일반적인 격리 Docker 네트워크에서 Gatus 컨테이너의 localhost는 Gatus 컨테이너 자신의 네트워크 네임스페이스를 가리킨다. 호스트나 다른 컨테이너를 자동으로 가리키지 않는다. 같은 Compose 네트워크라면 서비스 DNS 이름을 사용할 수 있고, 다른 호스트라면 DNS, TLS, 인증과 연결 가능성을 따로 점검해야 한다. 후자는 이번 실험 범위 밖이다.

두 컨테이너로 200 → 503 → 200을 재현하기

실제 업무 서비스를 끊는 대신 Python으로 작은 fixture를 만들었다. GET /health는 상태 파일을 읽어 200 또는 503을 반환하고, POST /alert는 Gatus가 보낸 Webhook JSON을 로컬 NDJSON 로그로 저장한다. 이렇게 하면 애플리케이션 자체의 복잡한 장애와 별개로 감시·알림 경로만 관찰할 수 있다.

언제든 삭제할 수 있는 별도 실험 디렉터리에 compose.yaml, config/config.yaml, fixture/server.py, digest-lock.env 파일을 준비한다. 아래는 실제로 검토한 테스트 로직을 복사 가능한 예제로 정리한 것이다.

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 UI만 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를 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 장비에서 이 두 digest를 그대로 재사용하면 안 된다. 먼저 해당 아키텍처의 이미지 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를 정상 상태로 복구한 뒤 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의 수신이지, 외부 Slack/이메일/SMS의 SLA가 아니다.

실제로 마주친 문제: 컨테이너는 실행 중인데 첫 점검이 연결 거부

장애를 주입하기 전 첫 결과가 0이었다는 점이 흥미로웠다. Gatus가 처음 요청했을 때 connection refused를 기록했고, 그 후 정상 200 두 번이 이어졌다. 이는 “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 전환과 알림은 예상대로 동작했다.

운영 환경에서는 건강 상태 API의 의미도 명확히 해야 한다. 기동 중, 외부 의존성 장애, 기능 저하, 서비스 불가가 어떻게 구분되는가? 애플리케이션이 무조건 200을 반환한다면 Gatus의 [STATUS] == 200 조건 역시 실제 업무 상태를 구별할 수 없다.

SQLite 기록은 재시작 후에도 남았지만 볼륨 삭제는 다르다

Gatus는 이 설정에서 체크 기록을 /data/gatus.db에 저장하고, gatus_data라는 named volume으로 보존했다. 실험에서는 기존 체크 결과의 정확한 타임스탬프를 하나 확보한 후 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가 프로젝트의 named volume과 SQLite 기록까지 삭제한다는 것이다. 장기간 운영할 Gatus의 일상적인 유지보수에 이 명령을 사용하면 안 된다. 볼륨을 유지하고 백업·복구 절차는 별도로 설계하고 검증해야 한다.

실제 운영용 감시로 쓰려면 아직 검증할 질문이 남아 있다. Gatus 자신이 중단되면 누가 이를 감지할까? 외부 Webhook 수신기가 다운되면 어떻게 될까? 여러 엔드포인트가 동시에 실패하면 어떻게 처리될까? 서버 간 네트워크 장애와 실제 앱 장애를 구별할 수 있을까? 이번 작은 실험만으로 답을 보장할 수는 없다.

확인한 범위는 분명하다. Gatus v5.37.0, 격리된 두 컨테이너, 제어 가능한 HTTP 엔드포인트에서 연속 실패 임계값, 복구 Webhook, SQLite 재시작 후 기록 유지를 실제로 관측했다. 진짜 서비스를 연결할 계획이라면 먼저 그 서비스의 건강 상태 API와 외부 알림 전달 경로를 각각 현실적인 장애 조건에서 검증하는 편이 좋다.