초록불은 증거가 아니다 — 거짓 초록 신호와 관측의 정직성 설계

2026-09-19 · 약 67분
SRE관측성CI/CD배포 검증헬스체크모니터링포스트모템
신호가 없는 것보다 나쁜 것은 "정상"이라고 적극적으로 보고하면서 실제로는 아무것도 검증·수행하지 않은 신호다. 실패를 삼키는 코드, 아무것도 돌지 않은 초록 CI, 의존성이 죽었는데 200 을 주는 헬스체크, push 를 배포로 착각하는 파이프라인, 울리지 않는 알림까지 — 거짓 초록이 만들어지는 네 경로를 공개 포스트모템으로 짚고, 검증 장치를 검증하는 법과 리뷰·배포·온콜 체크리스트를 8개 섹션으로 정리했다.

거짓 초록이란 무엇인가 — 신호 없음보다 나쁜 신호

거짓 초록은 검증이나 수행 없이 발행된 "정상" 보고로, 확인을 생략하게 만들기 때문에 신호가 없는 것보다 위험하다. 무검증·오검증·실패 은닉·스테일의 네 경로로 만들어지며, 각각 Knight Capital(2012), CrowdStrike(2024), GitLab(2017), AWS S3(2017) 사고에서 실제 모습을 볼 수 있다.

핵심 요점

  • 거짓 초록의 판별 질문은 하나다: 지키려는 대상이 지금 망가지면 이 신호는 빨개지는가.
  • 신호 없음은 확인하러 가게 만들지만 거짓 초록은 확인을 생략하게 만들어, 탐지 시점이 그 신호가 지키던 것을 처음 실제로 써야 하는 순간까지 밀린다.
  • 거짓 초록이 하나 드러나면 같은 방식으로 만든 초록 전체가 의심받고, Knight Capital의 개장 전 경고 메일 97통처럼 채널 자체가 무시되는 상태로 끝난다.
  • 생성 경로는 네 가지다: ①검증이 0건 실행됐다 ②운영에서 실행될 것과 다른 대상을 검증했다 ③실패가 보고 경로에서 사라졌다 ④마지막 갱신 뒤 현실이 바뀌었다.
  • 실제 사고는 대개 복합형이므로 분류는 이름표가 아니라 신호마다 던지는 네 질문(실행 건수·대상 일치·빨간색의 도달·신호의 나이)으로 쓴다.
  • 감시자는 감시 대상과 실패 도메인을 공유하면 안 되고, 모든 상태값에는 나이가 붙어야 하며, 메트릭 부재까지 알림 조건에 넣어야 알림 규칙 자체가 거짓 초록이 되지 않는다.

정의: 검증 없이 발행된 "정상" 보고

거짓 초록 신호(False Green Signal) 는 시스템이 "정상"이라고 적극적으로 보고하는데, 그 보고를 뒷받침할 검증이나 수행이 실제로는 일어나지 않은 상태다. 초록 CI, 200 OK, "백업 완료", "1,204건 발송 완료"가 모두 후보다. 문제는 신호의 값이 아니라 신호와 현실을 잇는 인과 경로다. 그 경로가 끊겨도 초록은 계속 켜져 있다.

판별 질문은 하나면 된다. "지키려는 대상이 지금 망가지면, 이 신호는 빨개지는가?" "아니오" 또는 "모르겠다"가 나오면 그 신호는 관측이 아니라 장식이다.

신호 없음 거짓 초록
운영자의 믿음 "모른다" "정상이다"
유발하는 행동 확인하러 간다 확인을 생략한다
탐지 주체 공백을 발견한 사람 사고 그 자체
발견 뒤 비용 계측 하나 추가 같은 방식으로 만든 초록 전부 재검증

왜 신호 없음보다 나쁜가

탐지 시간의 상한이 사라진다

빨간 신호의 탐지 시간(MTTD)은 알림 지연이 정한다. 거짓 초록은 아무도 보러 가지 않으므로 상한이 없다. 탐지 시점은 그 신호가 지키던 것을 처음으로 실제로 써야 하는 순간으로 밀린다. 백업이면 복원하는 날, 이중화면 주 장비가 죽는 날이다. Google SRE 책이 "아무도 백업을 원하지 않는다. 원하는 것은 복원이다", "최근 상태를 복구할 수 있는지는 실제로 복구해 봐야만 안다"고 쓴 이유가 이것이다. "백업 성공"은 수행의 신호이지 능력의 신호가 아니다.

신뢰를 소모한다

거짓 초록이 하나 드러나면 같은 방식으로 만든 초록 전체가 의심 대상이 된다. 팀은 모든 초록을 손으로 재확인하거나(자동화의 편익 소멸) 그 채널을 통째로 무시하게 된다. 후자의 끝이 Knight Capital이다. SEC 명령서에 따르면 2012년 8월 1일 개장 전, 내부 시스템이 "Power Peg disabled"를 언급한 자동 메일을 97통 보냈다. 그러나 이 메시지는 경보로 설계되지 않았고 직원들은 평소에도 열어 보지 않았다. 신호는 있었지만 신뢰가 0이었다.

분류 체계: 거짓 초록이 만들어지는 네 경로

유형 신호가 말하는 것 실제로 일어난 일 대표 증상
① 무검증 "확인했다" 확인 절차가 0건 실행됨 테스트 0건 통과, 고정 문자열을 돌려주는 /health, 사람 입으로만 하는 "배포 완료"
② 오검증 "이것을 확인했다" 다른 대상·다른 경로를 확인함 목(mock)만 도는 테스트, 프로세스 생존만 보는 헬스체크, 스테이징 결과로 운영 판정
③ 실패 은닉 "실패 보고가 없다" 실패했지만 보고 경로에서 사라짐 except: pass, || true, 파이프 앞단 종료 코드 유실, 아무도 안 읽는 실패 메일
④ 스테일 "정상이다(였다)" 마지막 갱신 이후 현실이 바뀜 타임스탬프 없는 상태판, 끊긴 수집기의 마지막 값, 몇 달 전 통과 기록에 기댄 승인

① 아무것도 검증하지 않았다 — Knight Capital (2012)

Knight는 신규 코드를 주문 라우터 서버 8대에 단계 배포했는데, 기술자가 1대에 복사를 빠뜨렸다. 두 번째 검토자도, 검토를 요구하는 문서화된 절차도 없었다. "배포 완료"는 어떤 검증도 거치지 않은 선언이었다. 그 1대에 남은 옛 코드가 재사용된 플래그에 반응해 고객 주문 212건을 처리하며 45분간 400만 건 넘게 체결했고 손실은 4억 6천만 달러를 넘었다.

CI에도 같은 구조가 있다. pytest는 수집된 테스트가 없으면 종료 코드 5를 내지만, 래퍼의 || true나 "5는 허용" 분기 하나면 0건 통과가 초록이 된다. 초록의 최소 조건은 검증 건수 > 0 이다.

# ci_gate.py — "통과 0건"은 초록이 아니다
import re, subprocess, sys
r = subprocess.run(["pytest", "-q"], capture_output=True, text=True)
m = re.search(r"(\d+) passed", r.stdout)
if r.returncode != 0 or not m or int(m.group(1)) == 0:
    sys.exit(f"RED: exit={r.returncode}, passed={m and m.group(1)}")

② 다른 것을 검증했다 — CrowdStrike (2024)

근본 원인 분석(RCA)에 따르면 IPC 템플릿 타입은 입력 필드를 21개로 정의했지만 센서 코드가 실제로 넘기는 값은 20개였다. Content Validator는 "21개가 들어온다"는 전제로 새 템플릿 인스턴스를 통과시켰다. 자동화 테스트 12건은 21번째 필드에 와일드카드를 써서 그 필드를 실제로 읽는 경로를 한 번도 밟지 않았다. 검증은 있었고 초록이었지만, 대상이 운영에서 실행될 경로가 아니었다. 예비 보고서는 3월의 테스트, Validator에 대한 신뢰, 앞선 배포들의 성공을 근거로 운영에 내보냈다고 적는다(④와 겹친다). 2024년 7월 19일 04:09~05:27 UTC, 78분간 배포된 파일이 Windows 기기 약 850만 대를 멈췄다.

③ 실패를 삼켰다 — GitLab (2017)

GitLab.com의 pg_dump 백업은 PostgreSQL 9.2 바이너리로 9.6 서버를 덤프하려다 매번 오류로 끝났다. cron 실패 알림 메일은 DMARC 설정 누락으로 수신 서버에서 거부됐다. 포스트모템의 표현대로 "실패하면 알림이 나가게 돼 있었지만, 메일이 거부되어 실패의 흔적이 없었다." 침묵이 초록으로 읽혔다. 운영 DB 디렉터리를 지운 뒤에야 드러났고, 쓸 수 있었던 것은 엔지니어가 우연히 약 6시간 전에 떠 둔 LVM 스냅샷뿐이었다. 프로젝트 약 5,000개, 댓글 5,000개, 신규 계정 700개가 사라졌다.

가장 흔한 은닉 지점은 셸 파이프다.

false | gzip > /dev/null; echo "exit=$?"   # exit=0 — 앞단 실패가 사라진다
set -o pipefail
false | gzip > /dev/null; echo "exit=$?"   # exit=1

④ 과거의 초록이 스테일해졌다 — AWS S3 (2017)

2017년 2월 28일 09:37 PST에 S3 장애가 시작됐지만 AWS는 11:37 PST까지 약 2시간 상태 대시보드의 개별 서비스 상태를 갱신하지 못했고, 그동안 공지는 트위터와 배너 문구로 대신했다. 대시보드 관리 콘솔이 S3에 의존하고 있었기 때문이다. 상태판은 장애 이전에 쓴 값에 머물렀다. 초록 아이콘은 "지금 정상"이 아니라 "마지막으로 쓴 값이 정상"이었다. AWS는 이후 관리 콘솔을 여러 리전에 걸쳐 돌도록 바꿨다. 두 가지 교훈이 남는다. 감시자는 감시 대상과 실패 도메인을 공유하면 안 되고, 모든 상태값에는 나이(age)가 붙어야 한다.

groups:
- name: freshness
  rules:
  - alert: BackupStale
    expr: (time() - backup_last_success_timestamp_seconds > 93600)
      or absent(backup_last_success_timestamp_seconds)
    for: 10m

absent()를 빼면 메트릭이 통째로 사라졌을 때 식이 빈 결과를 내고, 알림 규칙 자체가 거짓 초록이 된다.

실제 사고는 대개 복합형이다. Knight는 ①에 ③이, CrowdStrike는 ②에 ④가 겹쳤다. 그래서 분류의 용도는 사고에 이름표를 붙이는 것이 아니라, 신호 하나마다 네 질문을 던지는 것이다.

  • 검증이 1건 이상 실제로 실행됐는가
  • 그 대상이 운영에서 실행되는 바로 그것인가
  • 실패하면 빨간색이 사람에게 도달하는가
  • 이 초록은 몇 분 전의 것인가

이후 섹션 지도

뒤의 섹션은 이 네 질문을 신호가 만들어지는 자리마다 다시 던진다.

  • CI와 테스트: 0건 통과, 건너뛴 테스트, 목만 검증하는 스위트 (①·②)
  • 배포와 헬스체크: 200 OK가 말하지 않는 것, 생존·준비·능력의 구분 (②)
  • 배치·발송 집계: "N건 완료"가 시도 건수인지 성공 건수인지 (③)
  • 상태판과 알림: 신선도, 하트비트, 감시자의 실패 도메인 분리 (④)
  • 설계 원칙과 체크리스트: 초록을 기본값이 아니라 증명의 결과로 만들고, 일부러 고장 내서 가드가 실제로 빨개지는지 확인하는 법

실패를 삼키는 코드 — 예외·종료코드·카운터

치명적 장애의 대부분은 오류가 없어서가 아니라 이미 신호된 오류를 코드가 삼켜서 생긴다. 예외를 값으로 바꿔 버리는 핸들러, 실패에도 올라가는 카운터, bash 의 종료코드 유실, `| head` 의 SIGPIPE, 로그만 남기고 200 을 주는 API 를 패턴별로 교정 코드와 린트·리뷰 규칙으로 정리한다.

핵심 요점

  • OSDI 2014 연구에서 치명적 장애 48건의 92%는 이미 명시적으로 신호된 비치명 오류를 잘못 처리한 결과였고, 35%는 빈 핸들러·로그만 찍는 핸들러·TODO 같은 사소한 실수였다.
  • 실패를 `{success: false}` 같은 반환값으로 바꾸면 무시가 기본값이 되므로 재-raise 를 기본으로 하고, 로그만 찍는 핸들러는 린트를 통과해도 삼킨 것으로 본다.
  • 성공 카운터는 성공 경로(else)에서만 올리고 시도·성공·실패 세 숫자를 함께 보고하며, 실패율 임계를 넘으면 잡을 비정상 종료시킨다.
  • bash 는 파이프라인의 마지막 종료코드만 돌려주고, 조건 문맥에서 부른 함수 내부와 `local v=$(cmd)` 에서는 `set -e` 가 작동하지 않으므로 `pipefail`·PIPESTATUS·선언과 대입 분리가 필요하다.
  • GitHub Actions 는 `shell: bash` 를 명시해야 `-eo pipefail` 로 실행되며, 기본값에서는 `./test.sh | tee log` 가 테스트 실패에도 초록이다.
  • `| head`·`grep -q` 는 상류를 SIGPIPE 로 중간에 끊으면서 종료코드 0 을 내므로, 완료가 필요한 명령은 파일로 받고 종료코드를 보존한 뒤 요약한다.

오류는 대개 이미 신호됐다 — 삼킨 쪽이 문제다

토론토대 연구진(Yuan 외, OSDI 2014)은 Cassandra·HBase·HDFS·MapReduce·Redis 의 실제 장애 198건을 추적했다. 그중 전체 사용자에 영향을 준 치명적 장애 48건의 92% 는 "소프트웨어가 이미 명시적으로 신호한 비치명 오류를 잘못 처리한 것" 이 원인이었다. 35% 는 더 단순했다. (i) 핸들러가 비어 있거나 로그 한 줄만 찍거나, (ii) 지나치게 넓은 예외를 잡거나, (iii) 핸들러 안에 TODO/FIXME 가 남아 있었다. 58% 는 오류 처리 코드를 단순 테스트만 했어도 잡혔다.

공개 포스트모템도 같은 모양이다.

  • GitLab 2017: 운영 DB 는 PostgreSQL 9.6 인데 백업은 pg_dump 9.2 로 돌아 매번 실패했다. cron 실패 메일은 DMARC 미설정으로 수신 서버에서 거부됐고, 아무도 실패를 몰랐다. 결과는 약 6시간치 데이터(프로젝트 약 5,000개, 계정 약 700개) 유실과 18시간 장애였다.
  • Knight Capital 2012: SEC 명령서에 따르면 개장 전 "Power Peg disabled" 오류 메일이 97통 발송됐지만, 그 메일은 경보로 설계되지 않았고 담당자들도 평소 읽지 않았다. 45분 만에 4.6억 달러를 잃었다.

두 경우 모두 오류는 발생했고 기록도 됐다. 다만 결정에 쓰이는 신호(종료코드·응답코드·카운터·경보)로 전달되지 않았다. 아래는 그 전달이 끊기는 자리들이다.

패턴 1 — catch 후 {success: false}, 호출부는 버린다

def charge(order):
    try:
        gateway.pay(order)
        return {"success": True}
    except Exception as e:          # 넓게 잡고
        log.error("pay failed: %s", e)
        return {"success": False}   # 값으로 바꾼 뒤

charge(order)                       # 호출부는 반환값을 보지 않는다
order.mark_paid()

예외는 무시하려면 코드를 써야 하지만, 반환값은 안 쓰면 무시된다. 실패를 값으로 바꾸는 순간 기본값이 "무시"로 뒤집힌다.

  • 교정: 기본은 재-raise. 복구 가능한 예외만 좁게 잡고, 나머지는 위로 올린다. 결과 객체를 꼭 써야 한다면 호출부에서 분기 없이 버리는 것을 리뷰에서 막는다.
  • 린트: ruff S110(try-except-pass)·BLE001(blind except), TypeScript 는 @typescript-eslint/no-floating-promises. 단, S110 공식 문서의 권고가 "대신 로깅하라"라는 점에 주의하자. 로그만 찍는 핸들러는 위 논문이 꼽은 (i)번 패턴 그대로다. 린트 통과는 삼키지 않았다는 증거가 아니다.

패턴 2 — 실패에도 올라가는 sent++

for u in users:
    try:
        send(u)
    except Exception:
        log.warning("send failed: %s", u)
    sent += 1            # 실패해도 증가 → "3건 발송 완료"
ok, failed = [], []
for u in users:
    try:
        send(u)
    except SendError as e:
        log.warning("send failed: %s", u, exc_info=e)
        failed.append(u)
    else:
        ok.append(u)     # 성공 경로에서만 센다
result = {"attempted": len(users), "sent": len(ok), "failed": len(failed)}
if users and len(failed) / len(users) > 0.05:
    raise RuntimeError(f"notify degraded: {result}")

규칙은 셋이다. ① 성공 카운터는 else 절(성공 경로)에서만 올린다. ② 시도·성공·실패 세 숫자를 함께 보고하고 attempted == sent + failed 를 불변식으로 둔다. ③ 실패율 임계를 넘으면 잡 자체를 비정상 종료시킨다. 외부 배치 API 도 같다. AWS SQS SendMessageBatch 문서는 "HTTP 200 이 돌아와도 배치 오류를 확인하라"고 명시한다. Failed[] 를 안 읽고 요청 건수를 발송 건수로 적으면 같은 버그다.

패턴 3 — 로그에만 남고 응답은 성공인 API

핸들러가 예외를 잡아 log.error 후 200 {"ok": true} 를 돌려주면 5xx 비율·합성 모니터·클라이언트 재시도가 전부 눈이 먼다. 유일한 증거가 "아무도 안 보는 채널"에 쌓인다는 점에서 Knight 의 97통과 구조가 같다.

  • 응답코드는 요청된 부수효과가 일어났는지를 말해야 한다. 부분 성공은 207 또는 본문의 failed[] 로, 비동기 접수는 202 + 상태 조회로 구분한다.
  • 관측 규칙: error 로그 증가율과 5xx 비율이 벌어지면 경보. 둘의 괴리가 곧 삼킨 오류의 양이다.

패턴 4 — bash: 종료코드가 사라지는 자리들

코드 일어나는 일 교정
pg_dump … | gzip > f 파이프라인 상태는 마지막 명령의 것. dump 가 죽어도 0 set -o pipefail, 단계별은 ${PIPESTATUS[*]} (zsh 는 소문자 pipestatus)
cmd || true 모든 실패를 0 으로 세탁 허용 코드만 통과: grep -q x f || [ $? -eq 1 ]
if deploy; then · deploy || … 조건 문맥에서 부른 함수 내부 전체에 set -e 가 꺼진다 함수 안에서 cmd || return 1 을 명시
local v=$(cmd) · export v=$(cmd) local 의 0 이 cmd 실패를 덮는다 (ShellCheck SC2155) local v; v=$(cmd)
echo "n=$(cmd)" 인자 속 명령 치환의 실패는 버려진다 (SC2312) 변수에 먼저 받기, bash 4.4+ shopt -s inherit_errexit

세 번째 행은 직접 확인할 수 있다.

set -e
deploy() { false; echo "계속 진행됨"; }
if deploy; then echo "배포 성공"; fi   # 두 줄 다 출력된다

CI 에도 같은 함정이 있다. GitHub Actions 는 shell 을 지정하지 않으면 bash -e {0}, shell: bash 를 명시해야 bash --noprofile --norc -eo pipefail {0} 로 실행한다. 즉 기본값에서 run: ./test.sh | tee test.log 는 테스트가 실패해도 초록이다.

defaults:
  run:
    shell: bash   # -eo pipefail 이 켜진다

종료코드만 믿지도 말자. pipefail 없이 pg_dump 가 즉시 죽으면 종료코드 0 과 함께 20바이트짜리 "정상" gzip 파일이 남는다(빈 입력의 gzip 헤더). 백업이라면 산출물 자체를 검증한다.

#!/usr/bin/env bash
set -Eeuo pipefail
trap 'echo "FAILED line $LINENO" >&2' ERR
pg_dump "$DB_URL" | gzip > backup.sql.gz.tmp
gzip -t backup.sql.gz.tmp
size=$(wc -c < backup.sql.gz.tmp)
[ "$size" -ge "${MIN_BYTES:-1048576}" ] || { echo "too small: $size" >&2; exit 1; }
mv backup.sql.gz.tmp backup.sql.gz

패턴 5 — | head 가 상류를 죽인다

( for i in 1 2 3 4 5; do echo "step $i"; sleep 0.2; done
  echo finished > marker ) | head -n 1
echo "rc=$? marker=$([ -f marker ] && echo yes || echo no)"
# step 1
# rc=0 marker=no

head 가 첫 줄을 읽고 닫으면 상류는 다음 쓰기에서 SIGPIPE 로 죽는다. 작업은 1/5 지점에서 끊겼는데 종료코드는 0 이다. pipefail 을 켜면 이번엔 141(128+13)이라는 거짓 빨강이 뜨고, 팀은 흔히 || true 로 "해결"해서 진짜 실패까지 함께 삼킨다. grep -q 도 첫 매치에서 닫으므로 같다.

  • 규칙: 완료가 필요한 명령(마이그레이션·테스트·배포·백업)은 조기 종료하는 소비자에 직접 파이프하지 않는다. 파일로 받고, 종료코드를 보존한 뒤, 요약은 파일에서 뽑는다.
rc=0; ./migrate.sh > migrate.log 2>&1 || rc=$?
head -n 20 migrate.log
exit "$rc"

리뷰 체크리스트

  • 모든 catch/except/|| true 에 묻는다: "여기서 실패하면 종료코드·응답코드·카운터 중 무엇이 바뀌는가?" 아무것도 안 바뀌면 삼킨 것이다.
  • || true 와 빈 핸들러에는 허용하는 실패와 이유를 주석으로 강제한다.
  • CI 에 ShellCheck 를 넣고 enable=check-extra-masked-returns 로 SC2312 까지 켠다.
  • "N건 완료" 류 보고는 시도/성공/실패 세 숫자가 없으면 반려한다.
  • 오류 경로를 일부러 밟는 테스트를 하나 둔다. 의존성을 실패시켰을 때 신호가 실제로 빨개지는지 확인하는 것이, 위 논문이 말한 "단순 테스트로 막을 수 있었던 58%"에 해당하는 비용 대비 가장 싼 방어다.

CI 는 초록인데 아무것도 돌지 않았다

CI 의 초록은 '실패가 없었다'는 뜻일 뿐 '검증했다'는 뜻이 아니다. 0건 수집·대량 skip·건너뛴 required 잡·자동 재시도·소유자 권한 테스트·미선언 의존성이 어떻게 초록을 만들어 내는지 짚고, 테스트 수 하한·skip 예산·허용 목록 게이트·clean 환경 재현으로 수행량 자체를 실패 조건에 넣는 설정을 제시한다.

핵심 요점

  • CrowdStrike 2024 RCA 에 따르면 12개 테스트 케이스가 21번째 필드에 모두 와일드카드를 써서 결함 경로가 한 번도 실행되지 않았고, 테스트와 검증기는 계속 초록이었다.
  • pytest 는 0건 수집에 exit 5 를 주지만 `|| true` 나 pipefail 없는 파이프가 이를 삼키며, `go test` 는 `[no test files]`·`[no tests to run]` 모두 exit 0 이다.
  • GitHub Actions 에서 skip 된 잡은 required check 여도 Success 로 보고되므로, 필수 체크는 `always()` 게이트 하나로 모으고 잡별 기대 결과를 허용 목록으로 판정해야 한다.
  • 통과 여부 대신 수행량을 단언한다 — passed 하한은 현재의 약 90% 에서 올리기만 하고, skip·rerun 수는 예산으로 고정해 변경이 PR diff 에 드러나게 한다.
  • 5번에 1번 실패하는 실제 결함은 재시도 2회면 99.2% 확률로 초록이 되므로, 재시도는 인프라 예외에만 허용하고 재시도로 통과한 테스트는 격리 대상으로 다룬다.
  • 미선언 의존성은 캐시 없는 새 venv 에서만, 권한 결함은 superuser·테이블 소유자가 아닌 런타임 역할로 붙은 테스트에서만 드러난다.

초록은 "실패가 없었다"일 뿐, "검증했다"가 아니다

CI 의 초록 체크는 결국 종료 코드 0 하나에서 나온다. 그런데 0 은 "실패한 것이 없다"는 뜻이지 "무언가를 검증했다"는 뜻이 아니다. 테스트가 0건 돌아도, 1,000건이 skip 돼도, 잡이 통째로 건너뛰어져도 실패는 0건이다.

CrowdStrike 의 2024년 7월 사고 RCA 가 이 구조를 그대로 보여 준다. 문제의 IPC Template Type 은 입력 필드를 21개로 정의했지만 센서는 20개만 넘겼다. 개발·릴리스 빌드에서 돌던 12개 테스트 케이스는 21번째 필드에 전부 와일드카드 매칭을 썼고, 그래서 21번째 입력을 실제로 읽는 경로는 한 번도 실행되지 않았다. 테스트도, Content Validator 도, 3~4월의 앞선 배포도 모두 초록이었다. 7월 19일 비-와일드카드 조건이 처음 배포되자 범위 밖 읽기가 터졌다. GitLab 의 2017년 1월 장애도 같은 계열이다. pg_dump 9.2 가 9.6 DB 를 상대로 매번 실패하고 있었지만 실패 알림 메일은 DMARC 문제로 수신 측에서 거부됐고, 복구하려고 연 S3 버킷은 비어 있었다.

두 사례 모두 검증 장치는 있었고, 돌았고, 초록이었다. 없었던 것은 "이 장치가 실제로 그 경로를 밟았는가"에 대한 단언이다.

거짓 초록 여섯 가지

패턴 초록이 되는 경로 드러나는 곳
0건 수집 pytest 는 exit 5 를 주지만 || true, || [ $? -eq 5 ], pytest | tee log 가 삼킨다. go test ./... 의 [no test files], -run 오타의 [no tests to run] 은 둘 다 exit 0. Jest 는 --passWithNoTests 요약 줄의 테스트 수
조건부 skip skipif(not DATABASE_URL) — CI 에 DB 서비스가 빠지면 320 passed, 1000 skipped 로 초록 -rs 출력(기본은 숨김)
잡 skip if: 조건이나 실패한 needs 때문에 skip 된 잡은 required check 여도 Success 로 보고된다 소요 시간 0초인 체크
자동 재시도 5번에 1번 실패하는 경쟁 조건은 --reruns 2 에서 99.2% 확률로 초록(1−0.2³) rerun 횟수
mock·소유자 권한 fake 저장소는 IAM 거부를 흉내 내지 않는다. 테이블 소유자·superuser 로 붙은 테스트는 GRANT 누락과 RLS 를 영영 보지 못한다 운영의 permission denied
미선언 의존성 개발자 venv·러너 캐시에 우연히 깔린 패키지 덕에 import 가 성공한다 새 컨테이너의 ModuleNotFoundError

이 중 세 가지는 GitHub 공식 문서에 적혀 있는 정상 동작인데도 자주 놓친다. 첫째, skip 된 잡은 "Success 로 보고되며, required check 여도 머지를 막지 않는다". 빌드 잡이 실패해 테스트 잡이 skip 되면 그 required check 는 초록이다. 둘째, 워크플로 수준 paths: 필터로 건너뛴 체크는 반대로 Pending 에 머물러 머지를 막는다. 그래서 팀은 흔히 required 지정을 풀어 버리고, 그 순간 필수 체크는 0개가 된다. 셋째는 셸이다. run: 의 셸을 지정하지 않으면 bash -e {0} 로 실행되고, pipefail 은 shell: bash 를 명시해야 붙는다. 기본값에서 pytest | tee log.txt 의 종료 코드는 tee 의 것이다.

방어 1 — 수행량을 단언한다: 테스트 수 하한과 skip 예산

통과 여부가 아니라 몇 건이 실제로 돌았는지를 실패 조건으로 만든다. 아래 conftest.py 는 pytest 9 에서 확인한 형태다.

import os, pytest

MIN_PASSED = int(os.environ.get("MIN_PASSED", "0"))
MAX_SKIPPED = int(os.environ.get("MAX_SKIPPED", "-1"))  # -1 = 로컬, 예산 미적용

@pytest.hookimpl(trylast=True)
def pytest_sessionfinish(session, exitstatus):
    tr = session.config.pluginmanager.get_plugin("terminalreporter")
    passed = len(tr.stats.get("passed", []))
    skipped = len(tr.stats.get("skipped", []))
    bad = []
    if passed < MIN_PASSED:
        bad.append(f"passed={passed} < 하한 {MIN_PASSED}")
    if 0 <= MAX_SKIPPED < skipped:
        bad.append(f"skipped={skipped} > 예산 {MAX_SKIPPED}")
    if bad and session.exitstatus == 0:
        tr.write_line("FALSE-GREEN GUARD: " + "; ".join(bad), red=True)
        session.exitstatus = 1

MIN_PASSED=3 MAX_SKIPPED=0 pytest -rs 로 돌리면 1 passed, 2 skipped 는 exit 1 이 된다. 숫자 기준은 단순하게 잡는다.

  • 하한 = 현재 passed 의 약 90%. 분기마다 올리기만 한다(래칫).
  • skip 예산 = 현재 skip 수 그대로. 늘리려면 PR 에서 숫자를 고쳐야 하므로 리뷰에 드러난다.
  • 환경 의존 skip 은 CI 에서 실패로 뒤집는다. GitHub Actions 는 CI=true 를 기본 주입한다.
@pytest.fixture(scope="session")
def db_url():
    url = os.environ.get("DATABASE_URL")
    if not url and os.environ.get("CI"):
        pytest.fail("CI 에 DATABASE_URL 없음 — skip 금지")
    return url or pytest.skip("로컬: DB 없음")

방어 2 — required check 는 허용 목록으로 판정하는 게이트 하나

경로 필터는 워크플로가 아니라 잡 수준으로 내리고, 브랜치 보호에는 항상 도는 게이트 잡 하나만 등록한다. 핵심은 contains(needs.*.result, 'failure') 같은 거부 목록을 쓰지 않는 것이다. 거부 목록은 "돌았고 통과했다"와 "돌지 않았다"를 구분하지 못한다.

on: { pull_request: {}, merge_group: {} }   # merge queue 를 쓰면 merge_group 필수
defaults: { run: { shell: bash } }          # -eo pipefail
jobs:
  changes:
    runs-on: ubuntu-latest
    outputs: { backend: "${{ steps.f.outputs.backend }}" }
    steps:
      - uses: actions/checkout@v4
      - uses: dorny/paths-filter@v3
        id: f
        with:
          filters: |
            backend: ['src/**', 'tests/**', 'requirements*.txt']
  test:
    needs: changes
    if: needs.changes.outputs.backend == 'true'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: |
          pip install -r requirements.txt -r requirements-test.txt
          MIN_PASSED=300 MAX_SKIPPED=10 python -m pytest -rsR
  ci-gate:                                  # 이 체크 하나만 required
    if: always()
    needs: [changes, test]
    runs-on: ubuntu-latest
    steps:
      - env:
          CHANGES: ${{ needs.changes.result }}
          TEST: ${{ needs.test.result }}
          WANT: ${{ needs.changes.outputs.backend }}
        run: |
          [ "$CHANGES" = success ] || { echo "changes=$CHANGES"; exit 1; }
          if [ "$WANT" = true ]; then EXPECT=success; else EXPECT=skipped; fi
          [ "$TEST" = "$EXPECT" ] || { echo "test=$TEST, 기대=$EXPECT"; exit 1; }

changes 가 실패해 test 가 skip 된 경우, 백엔드가 바뀌었는데 test 가 skip 된 경우 모두 빨강이 된다. "의도된 skip"만 통과한다.

방어 3 — 재시도는 예산이 있는 예외다

Google 의 CI 발표 자료에 따르면 420만 개 테스트 중 약 16% 가 어느 정도 flaky 하고, pass→fail 전이의 84% 가 flaky 에서 나오며, 컴퓨트의 2~16% 가 재실행에 쓰인다. 같은 자료의 한 줄이 더 중요하다 — 개발자는 flaky 실패를 무시하고 제출하며, 때로는 그 판단이 틀린다. 전역 재시도는 그 무시를 자동화한 것이다.

  • 전역 --reruns N 금지. --only-rerun ConnectionResetError 처럼 인프라 예외에만 허용한다.
  • -rR 로 rerun 목록을 남기고 rerun 수에도 예산을 건다(예: 스위트당 5건 초과 시 실패).
  • 재시도로 통과한 테스트는 "통과"가 아니라 격리 대상이다. 이슈를 자동 생성하고 기한(예: 14일)을 둔다.

방어 4 — clean 환경과 최소 권한으로 한 번 더

미선언 의존성은 선언 파일만으로 만든 새 환경에서만 드러난다. 12-factor 의 표현대로 앱은 시스템 전역 패키지의 암묵적 존재에 기대서는 안 된다. 캐시를 끈 잡을 하루 한 번이라도 돌린다.

V=$(mktemp -d) && python3 -m venv "$V" && . "$V/bin/activate"
pip install --no-cache-dir -r requirements.txt -r requirements-test.txt
pip check && python -m pytest -p no:cacheprovider -q

권한은 마이그레이션만 소유자 역할로, 테스트 세션은 런타임 역할로 붙여야 검증된다. PostgreSQL 문서대로 superuser 와 BYPASSRLS 역할은 항상, 테이블 소유자는 기본적으로 RLS 를 우회한다. SELECT … FOR UPDATE 가 UPDATE 권한까지 요구한다는 사실도 소유자로 도는 테스트에서는 절대 드러나지 않는다. 아래 가드를 스위트 첫머리에 둔다.

def test_runtime_role_is_unprivileged(conn):
    row = conn.execute(
        "select rolsuper, rolbypassrls from pg_roles where rolname = current_user"
    ).fetchone()
    assert tuple(row) == (False, False)
    owned = conn.execute(
        "select count(*) from pg_tables where tableowner = current_user"
    ).fetchone()[0]
    assert owned == 0

mock 은 Fowler 가 말한 계약 테스트로 보완한다. 테스트 더블에 건 호출을 실물(스테이징 버킷, 실제 IAM 역할)에도 그대로 던져 결과가 같은지 확인하되, 배포 주기가 아니라 상대 서비스의 변경 주기에 맞춰 별도 스케줄로 돌린다.

체크리스트

  • [ ] CI 로그 요약 줄에 테스트 수가 찍히고, 하한 미달이면 빨강이 되는가
  • [ ] skip·rerun 수에 예산이 있고, 예산 변경이 PR diff 로 보이는가
  • [ ] required check 는 always() 게이트 하나이며 허용 목록으로 판정하는가
  • [ ] run: 에 shell: bash 가 명시돼 있고 || true·continue-on-error 가 테스트 스텝에 없는가
  • [ ] 캐시 없는 clean 설치 잡과 비소유자 역할 테스트가 주기적으로 도는가
  • [ ] 가드 자체를 검증했는가 — tests/ 이름을 바꾼 브랜치, DB 서비스를 뺀 브랜치를 올려 실제로 빨강이 되는지 분기마다 확인한다

헬스체크와 200 응답의 거짓

HTTP 200과 초록 헬스체크는 "프로세스가 응답했다"는 뜻일 뿐 "올바르게 일했다"는 증거가 아니다. 얕은 체크·에러를 담은 200·정적 호스팅의 index.html 폴백·캐시와 304가 검증을 속이는 경로를 짚고, 상태 코드 대신 content-type·고유 마커·음성 대조군으로 판정하는 프로브와 합성 모니터링 설계를 제시한다.

핵심 요점

  • Amazon의 사례처럼 빈 에러 페이지를 빠르게 돌려주는 서버는 헬스체크를 통과할 뿐 아니라 로드밸런서에게 더 많은 트래픽을 받으므로, 헬스체크는 응답 여부가 아니라 응답의 올바름을 봐야 한다.
  • 헬스체크의 깊이는 "이 실패가 이 서버만의 것인가, 플릿 전체에 상관된 것인가"로 정한다 — 로드밸런서와 liveness는 local 체크까지만, 의존성 체크는 임계치를 둔 중앙 시스템이 반응하게 한다.
  • Kubernetes httpGet 프로브는 200~399를 성공으로 치므로 로그인 302나 캐시 304도 초록이 되며, liveness에 의존성 조회를 넣으면 공식 문서가 경고하는 연쇄 재시작이 일어난다.
  • 정적 호스팅의 SPA 폴백은 없는 경로와 라우팅에서 빠진 API를 index.html 200으로 돌려주므로, "없는 경로는 404"와 "비인증 요청은 거부"라는 음성 대조군 없이는 배포·인증 게이트 검증이 항상 통과한다.
  • 판정은 상태 코드 + content-type + 본문의 고유 마커(빌드 해시) 조합으로 하고, 캐시 우회와 리다이렉트 비추종을 명시해 CDN HIT·304·로그인 리다이렉트가 초록을 만들지 못하게 한다.
  • 합성 모니터링은 외부 다지점에서 핵심 거래를 돌리고, 항상 실패하는 엔드포인트로 프로버 자체를 검증하며, no-data를 빨강으로 취급하고, 2017년 S3 장애의 상태 대시보드처럼 감시 경로가 감시 대상에 의존하지 않게 분리한다.

200은 "응답했다"이지 "일했다"가 아니다

Amazon Builders' Library의 「Implementing health checks」는 저자 본인의 사고로 시작한다. 계측 코드를 넣다 만든 버그 때문에 일부 웹 서버가 모든 요청에 빈 에러 페이지를 렌더링하는 상태에 빠졌다. 그 서버는 자신이 고장 난 줄 몰랐고, 모니터링 보고 기능도 함께 죽어 알람이 울리지 않았다. 더 나쁜 것은 빈 페이지를 만드는 서버가 정상 서버보다 훨씬 빨랐다는 점이다. 빠른 서버를 선호하던 로드밸런서는 고장 난 서버에 트래픽을 더 몰아줬다. 글은 이렇게 회고한다 — 기존 헬스체크는 렌더링 프로세스가 떠서 응답하는지 볼 만큼은 깊었지만, 올바르게 응답하는지 볼 만큼 깊지는 않았다.

Google SRE 책도 같은 지점을 짚는다. 네 가지 골든 시그널 중 Errors에는 명시적 실패(HTTP 500)뿐 아니라 암묵적 실패 — "HTTP 200 성공 응답이지만 내용이 틀린 경우" 가 포함된다. 상태 코드만 세는 에러율 대시보드는 이 범주를 구조적으로 0으로 보고한다.

헬스체크 네 층과 각 층이 침묵하는 것

Builders' Library의 분류에 "이 초록이 말해 주지 않는 것"을 붙이면 다음과 같다.

층 검증하는 것 초록이어도 모르는 것 실패 시 자동 조치
Liveness 포트 리슨, HTTP 200 응답 내용, 의존성, 디스크 재시작·제외 가능
Local 디스크 쓰기, 프록시→앱 관통, 보조 프로세스 공유 의존성 제외 가능(그 서버만의 문제)
Dependency DB·인증·메타데이터 갱신 의존성 쪽 장애와 내 쪽 장애의 구분 자동 제외 위험 — 전 서버 동시 실패
Anomaly detection 동료 대비 에러율·지연 이상치 플릿 전체가 같이 틀린 경우 중앙 시스템이 임계치 두고 판단

의사결정 기준은 하나다. "이 실패가 이 서버만의 것인가, 플릿 전체에 상관된 것인가." 의존성 체크를 로드밸런서 헬스체크에 넣으면 소프트 의존성이 하드 의존성으로 승격되고, DB 한 번 흔들릴 때 전 서버가 동시에 빠진다. NLB·ALB·Route 53의 fail-open(전부 unhealthy면 전부에 트래픽)이 안전판이지만, Amazon 스스로 "부분 장애·그레이 장애 전부에서 fail-open이 기대대로 발동한다는 일반 증명이 없다"며 빠르게 반응하는 LB 체크는 local까지로 제한하고, 의존성 체크는 중앙 시스템이 신중히 반응하게 한다고 밝힌다. 얕아서 거짓 초록, 깊어서 연쇄 장애 — 둘 다 설계 실패다.

한 가지 함정이 더 있다. 의존성 점검을 백그라운드 스레드로 돌리고 isHealthy 플래그만 읽게 하면, 그 스레드가 죽는 순간 마지막 초록이 영원히 남는다. 플래그가 아니라 "마지막 성공 시각"을 저장하라.

import time
last_ok = 0.0  # 점검 루프가 '성공했을 때만' time.monotonic() 으로 갱신

def readyz(max_age=15.0):
    age = time.monotonic() - last_ok
    ok = age < max_age          # 점검 스레드가 죽으면 저절로 빨강이 된다
    return (200 if ok else 503), {"status": "ok" if ok else "stale", "check_age_s": round(age, 1)}

프로브 세 개는 서로 다른 질문이다

Kubernetes 공식 문서의 정의를 질문으로 바꾸면 오용이 보인다.

startupProbe:            # "기동이 끝났는가" — 성공 전에는 아래 둘이 실행되지 않는다
  httpGet: { path: /livez, port: 8080 }
  failureThreshold: 30   # 30 × 10s = 최대 5분 유예
  periodSeconds: 10
livenessProbe:           # "재시작해야만 낫는가" (데드락) — 외부 의존성 조회 금지
  httpGet: { path: /livez, port: 8080 }
  failureThreshold: 6
readinessProbe:          # "지금 트래픽을 받아도 되는가"
  httpGet: { path: /readyz, port: 8080 }
  periodSeconds: 5
  • liveness에 DB 핑: DB가 느려지면 멀쩡한 Pod가 일제히 재시작된다. 공식 문서가 "잘못 구현된 liveness는 연쇄 장애를 부른다 — 고부하에서 재시작, 남은 Pod의 부하 증가"라고 경고하는 그 경로다.
  • return 200뿐인 readiness: 커넥션 풀이 고갈돼도, 설정 로드가 실패해도 트래픽을 받는다. 아무것도 검증하지 않는 초록의 전형이다.
  • httpGet의 성공 범위는 200~399다. 인증 미들웨어가 /readyz를 로그인으로 302 리다이렉트해도, 캐시 계층이 304를 돌려줘도 프로브는 성공이다. 헬스 경로는 인증·캐시 체인 밖에 두고 200만 돌려주게 하라.

본문이 거짓말하는 세 가지 경로

① 에러를 200에 담는 API. GraphQL은 리졸버가 실패해도 errors 배열을 본문에 담아 200을 돌려주는 것이 오랜 관행이고, graphql.org는 구형 서버가 data가 전혀 없는 완전 실패에도 200을 쓸 수 있다고 명시한다. 그래서 GraphQL over HTTP 스펙은 application/graphql-response+json 미디어 타입을 권고한다. {"success": false}를 200으로 주는 사내 REST API도 같은 문제다. 게이트웨이·LB·APM의 에러율은 이 실패를 볼 수 없다.

② 정적 호스팅의 not-found 폴백. Cloudflare Workers 정적 자산의 not_found_handling = "single-page-application"은 문서 그대로 "매칭되는 파일이 없으면 /index.html을 200 OK로" 서빙한다. Cloudflare Pages도 최상위 404.html이 없으면 SPA로 간주해 모든 경로를 루트로 매칭한다. nginx의 try_files $uri /index.html도 같다. 여기서 거짓 초록이 세 개 나온다.

  • 배포 스모크의 "/new-page가 200인가"는 페이지가 없어도 참이다.
  • 라우팅에서 누락된 /api/health가 HTML 셸을 200으로 돌려준다. Cloudflare 문서는 브라우저로 /api/date에 가면 HTML을 받는다고 직접 예시하며, run_worker_first에 /api/*를 명시하라고 안내한다.
  • 인증을 Worker(또는 앱 서버) 코드에 두었는데 자산 서빙이 그보다 먼저 응답하면 게이트 코드는 호출조차 되지 않는다. "/admin이 200을 주는가" 식의 도달성 테스트는 게이트가 있든 없든 통과한다. 반드시 비인증 요청이 거부되는지를 단언해야 한다.

③ 캐시와 304. CDN이 HIT을 돌려주면 프로브는 오리진이 아니라 캐시를 검증한 것이다. stale-if-error 계열 설정이 켜져 있으면 오리진이 죽은 뒤에도 초록이 이어진다. 304는 MDN 정의대로 본문이 없어야 하는 응답이라 본문 단언이 검사할 대상 자체가 사라진다. 배포 검증이 "새 버전이 떴는가"를 묻는다면 캐시 우회 쿼리와 Cache-Control: no-cache를 붙이고, Age·cf-cache-status류 헤더를 기록하고, 본문의 빌드 해시를 기대값과 비교해야 한다.

판정 규칙: 상태 + content-type + 고유 마커 + 음성 대조군

#!/usr/bin/env bash
set -euo pipefail
BASE="${BASE:-https://example.com}"
WANT_SHA="${1:?usage: probe.sh <expected-build-sha>}"
body=$(mktemp)

# 1) 양성: 상태 코드 + content-type + 본문 마커(빌드 해시)
meta=$(curl -sS --max-time 5 -H 'Cache-Control: no-cache' \
  -o "$body" -w '%{http_code} %{content_type}' "$BASE/healthz?cb=$RANDOM")
[[ "$meta" == "200 application/json"* ]] || { echo "FAIL meta: $meta"; exit 1; }
python3 - "$body" "$WANT_SHA" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
assert d["status"] == "ok" and not d.get("errors"), d
assert d["build"] == sys.argv[2], f"stale build: {d['build']}"
PY

# 2) 음성 대조군: 없는 경로는 404 여야 한다 (폴백 탐지)
code=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/__nope_$RANDOM$RANDOM")
[[ "$code" == 404 ]] || { echo "FAIL: 없는 경로가 $code"; exit 1; }

# 3) 게이트: 비인증 요청이 보호 경로에서 200 을 받으면 실패
code=$(curl -sS -o /dev/null -w '%{http_code}' "$BASE/admin/")
[[ "$code" =~ ^(401|403|302)$ ]] || { echo "FAIL: /admin/ 비인증 $code"; exit 1; }
echo OK

SPA라서 폴백이 꼭 필요하다면 2)를 "없는 경로의 content-type이 text/html이고 API 마커가 없다"로 바꾸고, 모든 API 경로에 1)의 content-type 단언을 건다. Prometheus를 쓴다면 blackbox_exporter로 같은 규칙을 상시화할 수 있다. 기본값이 관대하다는 점(valid_status_codes 기본 2xx, follow_redirects 기본 true)을 기억하라.

modules:
  http_health_strict:
    prober: http
    timeout: 5s
    http:
      valid_status_codes: [200]
      follow_redirects: false        # 로그인 302 를 따라가 200 을 받는 것을 막는다
      fail_if_header_not_matches:
        - header: Content-Type
          regexp: '^application/json'
      fail_if_body_not_matches_regexp: ['"status":\s*"ok"']
      fail_if_body_matches_regexp: ['"errors":\s*\[', '(?i)<!doctype html']

합성 모니터링 설계 체크리스트

  • 바깥에서 본다. SRE 책의 표현대로 화이트박스는 "도착한 쿼리만" 본다. DNS 오류로 도달하지 못한 요청은 보이지 않는다. 프로버는 서비스와 다른 망·다른 리전의 2~3개 지점에서 돌린다.
  • 읽기만 보지 말고 핵심 거래를 돈다. Azure의 Health Endpoint Monitoring 패턴은 테스트 계정으로 가입→로그인→주문 같은 기능 테스트를 예로 든다. 합성 데이터에는 식별 태그를 달아 정산·통계에서 제외한다.
  • 프로버 자신을 검증한다. 같은 패턴 문서는 설정값이나 난수를 돌려주는 엔드포인트로 에이전트가 제대로 도는지 확인하라고 권한다. 실무적으로는 항상 503인 /healthz/always-fail을 같은 모듈로 찔러 빨강이 나오는지를 알람 조건으로 건다. 빨강을 못 내는 프로버의 초록은 정보가 아니다.
  • 데이터 없음은 빨강이다. 프로버가 죽어 시계열이 끊기면 "실패 0건"이 된다. absent()류 no-data 알람을 반드시 짝으로 둔다.
  • 감시 경로와 감시 대상의 실패 도메인을 분리한다. 2017년 2월 28일 S3 장애에서 AWS는 장애 시작(09:37 PST)부터 11:37까지 약 2시간 동안 Service Health Dashboard의 개별 서비스 상태를 갱신하지 못했다. SHD 관리 콘솔이 S3에 의존했기 때문이다. 세계가 장애를 겪는 동안 공식 상태판은 초록이었고, AWS는 트위터와 배너로 알려야 했다. 이후 콘솔은 다중 리전으로 옮겨졌다. 상태 페이지·프로버·알림 발송 경로가 본 서비스와 같은 클라우드 계정·같은 DNS·같은 인증에 걸려 있는지 점검하라.
  • 임계치 예시: 1분 주기, 3지점 중 2지점이 연속 2회 실패하면 페이지, 1지점만 실패하면 티켓. 단일 지점 단발 실패로 사람을 깨우면 프로브는 곧 음소거되고, 음소거된 프로브가 가장 완벽한 거짓 초록이 된다.

"배포됐다"의 거짓 — push 는 배포가 아니다

머지 성공·파이프라인 exit 0·헬스체크 200은 각각 다른 사실을 증명할 뿐 "새 코드가 서빙 중"을 함의하지 않는다. Knight Capital·CrowdStrike·Cloudflare 사고를 근거로, 인스턴스별 빌드 SHA 대조·능력 프로브·'데이터 없음'을 통과시키지 않는 카나리 롤백 기준이라는 세 가지 판정자를 설계하는 법을 정리한다.

핵심 요점

  • 푸시 성공, 파이프라인 exit 0, 헬스체크 200은 서로 다른 사실이며 Kubernetes 는 롤아웃이 멈춰도 기본 600초 뒤 상태 조건만 기록할 뿐 구버전이 계속 서빙한다.
  • Knight Capital 은 서버 8대 중 1대에 새 코드 복사가 빠진 것을 아무도 검증하지 않아 45분 만에 4억 6천만 달러 이상을 잃었고, 정상 서버 7대를 되돌린 롤백이 피해를 키웠다.
  • CrowdStrike 의 Content Validator 는 입력 21개를 가정하고 통과 판정을 냈으며 단계적 배포가 없어, '검증 통과'가 그대로 전 세계 장애로 이어졌다.
  • 배포 판정은 mtime·가변 태그가 아니라 산출물에 구운 빌드 SHA 와 프로세스 시작 시각을 로드밸런서 뒤가 아닌 인스턴스별로 대조해 내리고, 응답 없음·unknown 은 실패로 처리한다.
  • python-dotenv 의 기본 override=False, PM2 의 보수적 환경 유지, 환경변수로 주입한 ConfigMap 의 비갱신 때문에 .env 를 고치고 재시작해도 프로세스는 옛 설정으로 돈다.
  • 카나리 자동 롤백은 같은 시각의 control 과 비교하고, 빈 결과·NaN 을 성공 조건에 접어 넣지 않아야 측정하지 않은 카나리가 만점으로 승급하는 일을 막는다.

초록불은 세 번 켜지고, 셋은 서로 다른 사실이다

배포에는 "성공"이 최소 세 번 찍힌다. 앞의 둘은 세 번째를 함의하지 않는데도 사람과 대시보드는 셋을 하나로 읽는다.

신호 실제로 증명하는 것 증명하지 않는 것
머지·푸시 성공 원격 저장소에 커밋이 있다 누군가 그것을 빌드·배포했다
파이프라인 exit 0 스크립트의 마지막 명령이 0을 반환했다 새 프로세스가 떠서 트래픽을 받는다
헬스체크 200 어떤 프로세스가 포트에서 응답한다 그 프로세스가 새 코드다

Kubernetes 가 교과서적인 예다. kubectl apply 는 오브젝트가 API 서버에 저장되면 0을 반환한다. 새 파드가 이미지 풀 오류나 readiness 실패로 멈춰도 구 ReplicaSet 이 계속 서빙하고, 공식 문서에 따르면 progressDeadlineSeconds(기본 600초)를 넘겨도 컨트롤러는 ProgressDeadlineExceeded 조건을 기록할 뿐 다른 조치는 하지 않는다. 서비스는 멀쩡하고(구버전이니까), 파이프라인은 초록이고, 새 코드는 어디에도 없다. kubectl rollout status 를 걸어야 비로소 비-0 종료 코드를 받는데, 그것도 "준비됨"까지만 말한다.

공개 사고 세 건

Knight Capital, 2012. SEC 명령서(Release No. 70694)의 사실관계는 이렇다. 신규 RLP 코드를 SMARS 서버 8대에 며칠에 걸쳐 올리던 중 기술자가 한 대에 복사를 빠뜨렸다. 두 번째 기술자의 검토는 없었고, 그런 검토를 요구하는 문서화된 절차도 없었다. 새 코드는 예전 Power Peg 기능의 플래그를 재사용했기 때문에, 개장과 함께 8번째 서버에서는 죽은 코드가 되살아났다. 부모 주문 212건에 대해 45분간 154개 종목, 3억 9,700만 주, 약 400만 건이 체결됐고 손실은 4억 6천만 달러를 넘었다. 두 가지가 더 아프다. 개장 전 08:01부터 "Power Peg disabled" 라는 자동 메일이 97통 나갔지만, 경보로 설계된 메시지가 아니어서 수신자들은 대체로 열어보지 않았다. 그리고 대응 중에 정상 서버 7대에서 새 코드를 제거해 나머지 서버까지 Power Peg 를 타게 만들었다. 롤백도 검증이 필요한 배포다.

CrowdStrike, 2024. Channel File 291 RCA 의 발견 4번은 "Content Validator 에 논리 오류가 있었다"이다. 검증기는 템플릿이 입력 21개를 받는다는 가정으로 통과 판정을 냈지만 센서 코드는 20개만 넘겼다. 발견 6번은 Template Instance 에 단계적 배포가 없었다는 것으로, 대책은 카나리 통과 후 링을 하나씩 넓히고 링마다 bake-in 시간 동안 텔레메트리를 본 뒤 승급 또는 롤백하는 구조다. "검증 통과"가 가장 비싼 거짓 초록이 된 사례다.

Cloudflare, 2019. WAF 규칙은 소프트웨어 릴리스가 거치는 단계적 절차를 우회해 수 초 만에 전 세계로 나갔고, 27분간 트래픽이 약 80% 빠졌다. 차단하지 않는 simulate 모드였지만 정규식은 실행됐다. 테스트 스위트는 초록이었는데, CPU 폭주를 측정하는 항목이 애초에 없었다.

괴리가 생기는 네 지점

  • 스테일 워킹트리·아티팩트. 배포 스크립트가 "지금 디렉터리에 있는 것"을 올린다. 브랜치가 뒤처졌거나 빌드 단계가 캐시된 산출물을 재사용해도 업로드는 성공한다. 대책은 origin/main 의 특정 SHA 를 격리된 체크아웃에서 빌드하고, 그 SHA 를 산출물 안에 굽는 것이다.
  • 가변 태그. Kubernetes 문서는 프로덕션에서 :latest 를 피하라고 하고, 태그가 가리키는 내용이 바뀌면 신·구 코드가 섞인 파드가 뜰 수 있다고 경고한다. image@sha256:… 다이제스트로 고정하라.
  • 재시작 누락. 파일은 교체됐는데 프로세스는 메모리의 옛 코드다. 환경변수로 주입한 ConfigMap 값은 자동 갱신되지 않아 파드 재시작이 필요하고, subPath 마운트도 갱신되지 않는다(공식 문서).
  • env 가 .env 를 덮는다. python-dotenv 의 기본값은 override=False — 이미 있는 환경변수가 이긴다. PM2 는 CLI 재시작 시 환경을 "보수적"으로 유지해 --update-env 없이는 새 값이 들어가지 않는다. .env 를 고치고 재시작까지 했는데 옛 값으로 도는 이유다.

판정자 1 — 빌드 SHA 를 말하는 버전 엔드포인트

판정 기준은 "기대한 SHA 를 모든 인스턴스가 스스로 말하는가" 하나다. 파일 mtime 은 증거가 아니다. git checkout 과 rsync(기본 옵션)는 내용과 무관하게 mtime 을 현재 시각으로 찍는다. 옛 내용이 새 시각을 달고 나타난다.

# app/version.py — BUILD_SHA 는 이미지 빌드 시 ARG→ENV 로 굽는다
import os, time
from fastapi import APIRouter
router = APIRouter()
STARTED_AT = int(time.time())

@router.get("/version")
def version():
    return {"sha": os.environ.get("BUILD_SHA", "unknown"),
            "started_at": STARTED_AT}

started_at 이 재시작 누락을 잡는다. 배포 시작 시각(SINCE)보다 이르면 옛 프로세스다. 검증은 로드밸런서 뒤가 아니라 인스턴스별로 한다 — Knight 의 8번째 서버는 평균 뒤에 숨는다.

#!/usr/bin/env bash
set -euo pipefail
WANT="$1"; SINCE="$2"; shift 2   # verify.sh <sha> <배포시작 epoch> host1 host2 ...
for h in "$@"; do
  for _ in $(seq 1 30); do
    body="$(curl -fsS --max-time 3 "http://$h/version" || true)"
    sha="$(jq -r '.sha // "none"' <<<"$body" 2>/dev/null || echo none)"
    up="$(jq -r '.started_at // 0' <<<"$body" 2>/dev/null || echo 0)"
    [[ "$sha" == "$WANT" && "${up:-0}" -ge "$SINCE" ]] && continue 2
    sleep 5
  done
  echo "FAIL $h: sha=${sha:-none} started_at=${up:-0}" >&2; exit 1
done
echo "OK: $# hosts on $WANT"

응답이 없거나 unknown 이면 실패다. 판정 불가를 통과로 접는 순간 이 스크립트가 새로운 거짓 초록이 된다.

판정자 2 — 기능을 직접 찌르는 능력 프로브

SHA 일치는 "그 코드가 떴다"까지다. 권한·스키마·시크릿 누락은 코드가 맞아도 기능을 죽인다. 그래서 릴리스마다 이번 변경이 추가한 능력을 한 번 실행한다: 새 엔드포인트에 합성 테넌트로 쓰고 다시 읽기, 새 컬럼을 채우는 경로 호출 후 값 확인, 외부 연동이면 샌드박스 계정으로 실제 왕복. /health 가 {"db":"ok"} 를 반환하는 것과 최소 권한 역할로 실제 쿼리가 통과하는 것은 다른 사실이다. 프로브는 실패 시 롤백 트리거에 연결하고, 배포 후에도 주기 실행해 회귀 감시로 재사용한다.

판정자 3 — 카나리의 자동 롤백 기준

Google SRE 워크북은 카나리를 "부분적이고 시간 제한이 있는 배포와 그 평가"로 정의하고, 배포 전/후 비교를 피하라고 한다. 시간이 지표 변동의 가장 큰 원인이라서다. 같은 시각의 control 과 canary 를 비교하고, 지표는 SLI 중심으로 "십여 개 이하", 카나리는 한 번에 하나만 돌린다.

apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata: {name: success-rate}
spec:
  args: [{name: svc}]
  metrics:
  - name: success-rate
    interval: 1m
    count: 10
    failureLimit: 2
    successCondition: len(result) > 0 && !isNaN(result[0]) && result[0] >= 0.99
    provider:
      prometheus:
        address: http://prometheus.monitoring:9090
        query: |
          sum(rate(http_requests_total{service="{{args.svc}}",code!~"5.."}[2m]))
          / sum(rate(http_requests_total{service="{{args.svc}}"}[2m]))

Argo Rollouts 에서 분석이 Failed 면 롤아웃이 중단되고 카나리 가중치가 0으로 돌아가며, Inconclusive 면 그 단계에서 멈춰 사람을 기다린다. 핵심은 successCondition 의 앞 두 항이다. 카나리에 트래픽이 없으면 결과는 빈 배열이나 NaN 인데, 이를 len(result) == 0 || … 로 성공 쪽에 접으면 아무것도 측정하지 않은 카나리가 만점으로 승급한다. Flagger 도 같은 구조다. threshold 회 실패하면 롤백하고, pre-rollout 웹훅이 실패하면 트래픽을 보내기 전에 멈춘다 — 능력 프로브를 꽂을 자리가 여기다.

배포 완료 선언 전 체크리스트

  1. 빌드 입력은 브랜치명이 아니라 SHA, 작업 디렉터리는 격리된 새 체크아웃인가
  2. 산출물 식별자는 불변(이미지 다이제스트·콘텐츠 해시)인가, mtime·태그로 판정하고 있지 않은가
  3. 모든 인스턴스가 기대 SHA 와 배포 이후의 started_at 을 보고하는가
  4. 프로세스가 본 실효 설정이 의도와 같은가 (.env 가 아니라 프로세스 환경 기준)
  5. 이번 변경의 능력 프로브가 최소 권한 신원으로 통과했는가
  6. 카나리 판정에서 "데이터 없음"이 실패 또는 보류로 처리되는가
  7. 롤백 경로도 1~5를 똑같이 통과하는가

조용한 대시보드 — 알림 없음은 정상이 아니다

알림이 울리지 않는 이유는 '정상'만이 아니라 '감시 대상이 멈춤'과 '감시 장치가 죽음'도 있는데, 대시보드는 이 셋을 똑같은 초록으로 그린다. 부재 알림, 성공 핑 기반 데드맨 스위치, 복구 리허설, 외부 하트비트, 예외 전파, 다중 윈도 burn-rate로 '모름 = 정상'이라는 관측 스택의 기본값을 뒤집는 방법을 설정 예와 함께 정리한다.

핵심 요점

  • Prometheus는 빈 알림 식 결과를 정상으로 해석하고 트래픽이 0이면 오류율 식은 NaN이 되므로, absent_over_time·rate()==0·마지막 성공 시각 알림으로 '데이터 없음'을 별도의 장애 상태로 다뤄야 한다.
  • GitLab 2017의 백업 실패 메일은 DMARC에 막혔고 Knight Capital의 오류 메일 97통은 아무도 읽지 않았듯 실패 통지 경로는 그 자체가 실패하므로, 크론·배치는 성공했을 때만 핑을 보내고 핑이 끊기면 외부가 알리는 데드맨 스위치로 감시한다.
  • 백업의 유일한 신호는 복원된 DB에 던진 쿼리 결과이며, 복구 리허설에 소유자와 주기를 정하고 그 성공 핑을 '덤프 존재·복원 가능·데이터 최신'의 증명으로 삼는다.
  • AWS S3 2017에서 상태 대시보드 콘솔이 S3에 의존해 2시간 동안 갱신되지 못한 것처럼 감시자가 감시 대상과 같은 장애 도메인에 있으면 안 되며, 항상 발화하는 Watchdog 알림을 외부 하트비트 서비스로 흘려 알림 파이프라인 자체를 감시한다.
  • API 클라이언트가 401·5xx나 예외를 빈 목록으로 바꾸면 죽은 키가 '결과 0건'이라는 정상 리포트가 되므로, 상태 코드를 예외로 전파하고 반드시 결과가 나와야 하는 고정 질의 프로브를 둔다.
  • 30일 99.9%는 43.2분의 전면 장애를 허용할 만큼 평균은 부분 장애를 가리므로, 14.4배(1h/5m)·6배(6h/30m) 다중 윈도 burn-rate 알림을 쓰되 분모 0 문제를 막는 트래픽 부재 알림을 짝으로 둔다.

침묵은 세 가지 뜻을 가진다

임계값 알림은 "값이 나쁘다"만 잡는다. 그런데 알림이 울리지 않는 이유는 셋이다. ① 정말 정상이다 ② 감시 대상이 일을 멈춰 나쁜 값조차 나오지 않는다 ③ 감시 장치나 통지 경로가 죽었다. 대시보드에서 ②와 ③은 ①과 똑같이 보인다 — 평평한 선, 조용한 채널, 초록 상태판. PromLabs는 이 성질을 한 문장으로 적었다. Prometheus는 빈 알림 식 결과를 "everything is fine"으로 해석한다. 관측 스택의 기본값이 "모름 = 정상"이라는 뜻이고, 아래 패턴은 전부 이 기본값을 뒤집는 작업이다.

침묵의 원인 화면에 보이는 것 잡는 장치
시계열이 사라짐(익스포터·잡 사망) 선이 끊김 absent_over_time(), Grafana No Data 상태
시계열은 있는데 일이 멈춤 평평한 카운터, 오류율 0% rate() == 0, 마지막 성공 시각
크론·배치가 아예 안 돎 아무것도 없음 데드맨 스위치(성공 핑)
알림 파이프라인 사망 알림 0건 Watchdog + 외부 하트비트
클라이언트가 실패를 빈 결과로 변환 "결과 0건" 예외 전파, 정답을 아는 프로브
평균에 묻힌 부분 장애 평균 지표 초록 다중 윈도 burn-rate

패턴 1 — "데이터 없음"을 1급 상태로 만든다

rate()는 대상 시계열이 없으면 0이 아니라 빈 벡터를 돌려준다. errors / total > 0.01 형태의 식은 트래픽이 0이면 분모가 0이라 NaN이 되고, NaN은 어떤 비교도 통과하지 못한다. 서비스가 통째로 죽은 순간에 오류율 알림이 가장 조용해지는 이유다. Prometheus 공식 계측 가이드가 "미리 알 수 있는 시계열은 0으로 초기화해 내보내라"고 권하는 것도 같은 맥락이다.

groups:
- name: silence-is-not-health
  rules:
  - alert: IngestMetricAbsent        # 시계열 자체가 사라졌다
    expr: absent_over_time(ingest_records_total{job="ingest"}[15m])
    for: 5m
    labels: {severity: page}
  - alert: IngestStalled             # 프로세스는 살아 있는데 일을 안 한다
    expr: sum(rate(ingest_records_total{job="ingest"}[15m])) == 0
    for: 15m
    labels: {severity: page}
  - alert: BackupTooOld              # 배치의 핵심 지표는 "마지막 성공 시각"
    expr: time() - backup_last_success_timestamp_seconds{job="pg-backup"} > 2 * 86400
    labels: {severity: page}

absent()는 라벨을 보존하지 못해 "어느 인스턴스가 빠졌는지"는 알려주지 않는다. 대상 목록이 고정이면 up == 0이나 기대 개수 비교(count(up{job="ingest"}) < 3)를 같이 둔다. 배치 임계는 공식 문서 권고대로 최소 2회 실행 주기(일 1회면 48시간)로 잡는다. Grafana Alerting은 No Data의 기본 처리가 별도 DatasourceNoData 알림 발화인데, 시끄럽다고 이를 Normal로 바꾸는 순간 그 규칙은 거짓 초록 생성기가 된다. 간헐 데이터라면 Normal이 아니라 Keep Last State가 맞다.

패턴 2 — 실패를 알리지 말고, 성공을 알려라

GitLab의 2017년 1월 장애에서 pg_dump 백업은 클라이언트 9.2와 서버 9.6의 메이저 버전 불일치로 매번 오류 종료하고 있었다. 실패 통지는 있었다. 다만 이메일이었고, 크론 메일에 DMARC가 적용되지 않아 수신 측에서 거부됐다. 복구하려고 S3 버킷을 열었을 때 그곳은 비어 있었다. Knight Capital도 같은 구조다. SEC 명령서에 따르면 2012년 8월 1일 개장 전 "Power Peg disabled" 오류 메일이 97통 발송됐지만, 그 메일은 시스템 알림으로 설계된 것이 아니었고 담당자들은 평소 열어보지 않았다. 45분 뒤 손실은 4억 6천만 달러를 넘었다.

"실패하면 알린다"는 실패 통지 경로가 살아 있다는 가정 위에 서 있다. 데드맨 스위치는 방향을 뒤집는다. 성공했을 때만 핑을 보내고, 핑이 끊기면 외부가 알린다. 호스트가 꺼져도, 크론탭이 지워져도, 메일이 막혀도 결과는 같다 — 핑이 오지 않는다.

# crontab: 성공(&&)했을 때만 핑. `;` 로 이으면 실패해도 초록이 된다
8 6 * * * /opt/backup.sh && curl -fsS -m 10 --retry 5 -o /dev/null https://hc-ping.com/your-uuid-here

Healthchecks.io 기준으로 /start를 먼저 쏘면 "시작했는데 grace time 안에 끝나지 않음"도 실패로 잡히고, https://hc-ping.com/your-uuid-here/$?로 종료 코드를 그대로 보내면 0 이외는 즉시 실패 처리된다. 핑의 위치가 중요하다. 스크립트 마지막 줄이 아니라 산출물을 검증한 뒤에 둔다.

패턴 3 — 백업은 복구 테스트로만 검증된다

GitLab은 약 6시간 분량의 DB 변경(프로젝트 약 5,000개, 댓글 약 5,000개, 신규 계정 약 700개)을 잃었고 서비스는 약 18시간 멈췄다. 그나마 되살릴 수 있었던 것은 장애 6시간 전에 스테이징 용도로 떠 둔 LVM 스냅샷이 우연히 남아 있었기 때문이다. 포스트모템의 5-why는 이렇게 끝난다. 복구 절차가 정기적으로 테스트되지 않은 이유는 소유자가 없었기 때문이다. "백업 잡 종료 코드 0"도 "파일 존재"도 백업의 신호가 아니다. 유일한 신호는 복원된 DB에 던진 쿼리의 결과다.

#!/usr/bin/env bash
set -euo pipefail
HC=https://hc-ping.com/your-uuid-here
trap 'curl -fsS -m 10 -o /dev/null "$HC/fail"' ERR
curl -fsS -m 10 --retry 5 -o /dev/null "$HC/start"
dropdb --if-exists restore_drill && createdb restore_drill
pg_restore --exit-on-error -d restore_drill "$(ls -t /backups/*.dump | head -1)"
AGE=$(psql -At -d restore_drill -c \
  "select extract(epoch from now() - max(created_at))::int from events")
[ "$AGE" -lt 129600 ]      # 최신 행이 36시간보다 오래됐으면 실패
curl -fsS -m 10 --retry 5 -o /dev/null "$HC"

이 잡을 주 1회 돌리고 체크의 period를 7일로 둔다. 핑 하나가 "덤프가 존재하고, 복원되고, 최신 데이터를 담고 있다"를 한꺼번에 증명한다.

패턴 4 — 감시자가 감시 대상 위에 서 있으면 안 된다

2017년 2월 28일 S3 장애는 09:37 PST에 시작됐지만 AWS 상태 대시보드는 11:37까지 개별 서비스 상태를 갱신하지 못했다. 대시보드 관리 콘솔이 S3에 의존하고 있었기 때문이다. 그 두 시간 동안 개별 서비스 상태 표시는 갱신되지 못한 채 남았고, 실제 공지는 트위터와 배너 문구로 나갔다. 후속 조치는 콘솔의 멀티 리전화였다.

같은 순환은 어디에나 있다. 같은 클러스터 안의 Prometheus, 같은 SMTP를 쓰는 알림, 같은 DNS 뒤의 상태 페이지. kube-prometheus의 Watchdog(expr: vector(1), 항상 발화)은 이 고리를 끊으려는 규칙이다. 알림이 계속 울리는 것이 정상이고, 멈추면 외부 서비스가 호출한다.

route:
  receiver: oncall
  routes:
  - matchers: ['alertname="Watchdog"']
    receiver: deadman
    group_wait: 0s
    group_interval: 1m
    repeat_interval: 1m
receivers:
- name: oncall
- name: deadman
  webhook_configs:
  - url: https://hc-ping.com/your-uuid-here
    send_resolved: false

외부 체크는 period 5분·grace 5분 정도로 넉넉히 잡는다. 점검 질문은 하나다. "이 리전·계정·DNS·메일 서버가 죽으면, 그 사실을 알려줄 경로가 그 밖에 하나라도 있는가."

패턴 5 — "0건"과 "모름"을 같은 값으로 반환하지 않는다

가장 흔한 적극적 거짓 초록은 API 클라이언트 안에 있다. requests는 401·500에도 예외를 던지지 않고, 공식 문서가 명시하듯 r.json()의 성공은 응답의 성공을 뜻하지 않는다. 여기에 except Exception: return []가 붙으면 만료된 키는 "오늘 수집 0건"이라는 멀쩡한 리포트가 된다. 셸도 같다. curl은 -f 없이는 401을 받아도 종료 코드 0이다. Google SRE가 오류 신호에 "200이지만 내용이 틀린 응답"을 암묵적 실패로 포함시킨 이유다.

import requests

class UpstreamUnknown(Exception): ...

def fetch_items(url: str, key: str) -> list:
    r = requests.get(url, headers={"Authorization": f"Bearer {key}"}, timeout=10)
    r.raise_for_status()          # 401/403/5xx 를 "0건"으로 흘리지 않는다
    body = r.json()
    if "items" not in body:       # 200 + 엉뚱한 본문 = 암묵적 실패
        raise UpstreamUnknown(body)
    return body["items"]          # 이제 [] 는 "정말 0건"만 뜻한다

0건이 정상일 수 있는 도메인이라면 정답을 아는 프로브를 하나 둔다. 반드시 1건 이상 나와야 하는 고정 질의를 같은 키로 주기 실행하고, 그 성공을 데드맨 핑으로 보낸다.

패턴 6 — 평균이 만드는 초록과 burn-rate 알림

30일 99.9%는 43.2분의 전면 장애를 허용한다. 월 평균 가용성 그래프는 그 43분 내내 초록에 가깝다. 지연도 같다. Google SRE 책의 예시대로 평균 100ms·초당 1,000건인 서비스에서 1%는 5초가 걸릴 수 있다. 반대편 함정도 있다. "오류율 0.1% 초과 10분" 같은 단순 임계는 월 예산의 0.02%만 태운 사건에도 호출한다. SRE Workbook의 권고는 예산 소진 속도를 길고 짧은 두 창으로 동시에 보는 것이다.

심각도 긴 창 / 짧은 창 burn rate 소진 예산
page 1h / 5m 14.4 2%
page 6h / 30m 6 5%
ticket 3d / 6h 1 10%
- alert: ErrorBudgetBurnFast          # SLO 99.9% → 허용 오류율 0.001
  expr: |
    ( job:slo_errors_per_request:ratio_rate1h{job="api"} > (14.4 * 0.001)
      and job:slo_errors_per_request:ratio_rate5m{job="api"} > (14.4 * 0.001) )
    or
    ( job:slo_errors_per_request:ratio_rate6h{job="api"} > (6 * 0.001)
      and job:slo_errors_per_request:ratio_rate30m{job="api"} > (6 * 0.001) )
  labels: {severity: page}

burn-rate도 비율이므로 패턴 1의 분모 0 문제를 그대로 물려받는다. 트래픽 부재 알림을 반드시 짝으로 두고, 저트래픽 서비스는 Workbook 권고대로 합성 트래픽으로 분모를 만든다.

점검표

  • 모든 알림 규칙에 대해 "이 식이 빈 결과를 내면 무슨 일이 생기는가"에 답할 수 있는가
  • 크론·배치·백업마다 성공 핑이 있고, 그 핑이 산출물 검증 뒤에 있는가
  • 마지막 복구 리허설 날짜와 소유자 이름을 지금 말할 수 있는가
  • 알림 파이프라인이 죽었을 때 울리는 경로가 다른 장애 도메인에 있는가
  • 클라이언트 코드에서 return []·return 0으로 끝나는 except 블록을 검색해 봤는가
  • SLO 알림 옆에 트래픽 부재 알림이 짝으로 있는가

검증 장치를 검증하라 — 가드가 실제로 잡는가

가드·테스트·알림은 통과시킨 횟수가 아니라 실제로 떨어뜨린 증거로 자격을 얻는다. 뮤테이션 테스트, 알려진 불량 픽스처 주입, 양성·음성·부재 경로 대조군, 알림 소방훈련, 그리고 "못 돌았다"와 "문제 없다"를 다른 종료코드로 내는 프로브 설계까지, 검증 장치를 검증하는 절차를 공개 포스트모템과 동작하는 예시로 정리한다.

핵심 요점

  • CrowdStrike 2024에서는 Content Validator와 12건의 자동화 테스트가 모두 같은 가정(입력 21개·21번째 필드 와일드카드) 위에 있었기 때문에 둘 다 초록이면서 문제의 경로를 한 번도 찌르지 않았다.
  • 뮤테이션 점수(killed ÷ 전체 뮤턴트)는 커버리지가 말하지 못하는 "틀리면 잡히는가"를 측정하며, Stryker 기본값 break: null처럼 임계치 없는 도입은 그 자체가 거짓 초록이다.
  • 구글은 전체 점수 대신 변경된 줄의 생존 뮤턴트를 코드 리뷰에 파일당 최대 7개 노출하는 방식으로 약 1,500만 뮤턴트를 운영했고, 개발자가 더 많고 효과적인 테스트를 쓰게 됨을 확인했다.
  • 모든 게이트에는 알려진 불량(반드시 빨강)·알려진 정상(반드시 초록)·부재 경로(반드시 404/거부) 세 대조군을 붙이고, 검증기를 exit 0으로 바꿔치기했을 때 자가 검증이 실패하는지까지 확인한다.
  • 알림은 promtool 규칙 테스트로 식을, 분기별 소방훈련으로 라우팅·수신자·런북·복원을, Watchdog 데드맨 스위치로 훈련 사이의 공백을 검증한다.
  • 프로브의 기본값은 UNKNOWN이어야 하며 "0건 검사"·스킵·내부 예외는 OK(0)와 다른 문구와 종료코드로 보고하고 UNKNOWN도 반드시 라우팅한다.

한 번도 빨개진 적 없는 가드는 가드가 아니다

검증 장치는 "통과시킨 횟수"가 아니라 "떨어뜨린 증거"로 자격을 얻는다. 공개 포스트모템 세 건이 같은 구멍을 보여 준다.

  • CrowdStrike 2024: 공식 RCA에 따르면 템플릿 정의는 입력 21개를 기대했지만 센서 코드는 20개만 공급했다. Content Validator는 "21개가 온다"는 같은 가정 위에서 검사해 불량을 통과시켰고, 자동화 테스트 12건은 전부 21번째 필드에 와일드카드를 써서 그 필드를 한 번도 읽지 않았다. 둘 다 초록이었지만 문제의 경로를 찌른 적이 없었다.
  • GitLab 2017: pg_dump 백업은 9.2 바이너리로 9.6 DB에 붙다 매번 실패했고, 실패를 알리는 cron 메일은 DMARC 문제로 거부됐다. 복구하려고 연 S3 버킷은 비어 있었다. 포스트모템은 "소유자가 없어 아무도 이 절차를 테스트할 책임이 없었다"고 적는다.
  • Knight Capital 2012: SEC 명령서에 따르면 개장 전 "Power Peg disabled"를 언급한 자동 메일 97통이 나갔지만, 알림으로 설계된 메일이 아니어서 아무도 제때 읽지 않았다. 이후 약 45분간 400만 건 체결, 손실 4.6억 달러.

그래서 모든 가드에 세 가지를 묻는다. ① 고장을 넣으면 빨개지는가 ② 빨개진 사실이 사람에게 닿는가 ③ 가드가 못 돌았을 때 초록과 구분되는가.

1. 뮤테이션 테스트 — 테스트를 테스트한다

코드에 작은 결함(뮤턴트)을 자동으로 심고 테스트가 실패하는지 본다. 실패하면 killed, 그대로 통과하면 survived. 뮤테이션 점수 = killed ÷ 전체 뮤턴트다. 라인 커버리지는 "실행됐다"만, 뮤테이션 점수는 "틀리면 잡힌다"를 말한다.

도구 대상 실무 포인트
mutmut Python pip install mutmut && mutmut run → mutmut browse로 생존 뮤턴트 확인, mutate_only_covered_lines로 범위 축소
Stryker JS/TS·C#·Scala thresholds 기본값 high 80 / low 60 / break null
PIT JVM 기본 뮤테이터 CONDITIONALS_BOUNDARY, NEGATE_CONDITIONALS, VOID_METHOD_CALLS, NULL_RETURNS 등

Stryker의 break: null은 점수가 0이어도 빌드가 초록이라는 뜻이다. 도입만 하고 끝내면 그 자체가 거짓 초록이므로 "thresholds": {"high": 85, "low": 70, "break": 70}처럼 값을 줘서 미달 시 exit 1로 빌드를 깨뜨린다.

전체 점수를 KPI로 삼을 필요는 없다. 구글은 점수 대신 변경된 줄에만 줄당 뮤턴트 1개를 만들어, 살아남은 것을 코드 리뷰 지적 사항으로(파일당 최대 7개) 띄운다. 6년간 약 1,500만 뮤턴트를 분석한 ICSE 2021 논문은 이렇게 노출된 개발자가 테스트를 더 많이·더 효과적으로 쓰고, 뮤턴트가 실제 결함과 결합돼 있음을 보였다. 우선순위는 가드 코드 먼저다 — 입력 검증기, 권한 검사, 헬스체크 판정, 발송·과금 카운터. 여기서 살아남은 NEGATE_CONDITIONALS 하나는 "조건을 뒤집어도 어떤 테스트도 모른다"는 뜻이다.

2. 카나리 실패 주입 — 알려진 불량품을 매번 흘려보낸다

뮤테이션 도구를 붙이기 어려운 셸 스크립트·CI 게이트·정책 검사는 알려진 불량 픽스처로 같은 효과를 낸다.

#!/usr/bin/env bash
# guard-selftest.sh — 검증기가 '알려진 불량'을 거부하는지 매 빌드 확인
set -uo pipefail
if ./validate.sh fixtures/known-bad.json >/dev/null 2>&1; then
  echo "SELFTEST FAIL: 검증기가 불량 입력을 통과시켰다" >&2; exit 1
fi
./validate.sh fixtures/known-good.json >/dev/null 2>&1 \
  || { echo "SELFTEST FAIL: 정상 입력을 거부했다" >&2; exit 1; }
echo "SELFTEST OK: bad 거부 / good 통과"

validate.sh를 exit 0 한 줄로 바꿔치기하면 이 스크립트가 rc=1로 죽어야 한다. 그 실험까지 해 봐야 자가 검증이 끝난다. CrowdStrike의 완화책도 같은 형태다 — 모든 필드에 비-와일드카드 조건 테스트를 의무화했다.

알림 규칙은 promtool test rules로 고장 시계열에서 발화하는지(양성), 정상 시계열에서 조용한지(음성)를 CI에서 확인한다.

rule_files: [alerts.yml]
evaluation_interval: 1m
tests:
  - input_series:
      - series: 'up{job="api"}'
        values: '0+0x14'   # 다운 → 반드시 발화
    alert_rule_test:
      - eval_time: 10m
        alertname: InstanceDown
        exp_alerts:
          - exp_labels: { severity: page, job: api }
  - input_series:
      - series: 'up{job="api"}'
        values: '1+0x14'   # 정상 → 반드시 침묵
    alert_rule_test:
      - eval_time: 10m
        alertname: InstanceDown
        exp_alerts: []

3. 양성·음성 대조군 — 존재하는 경로만 찌르면 못 잡는다

대조군 기대 없을 때 생기는 거짓 초록
양성(known-bad) 반드시 빨강 아무것도 안 보는 검사가 영원히 통과
음성(known-good) 반드시 초록 상시 오탐 → 알림 피로 → 진짜 신호가 묻힘
부재 경로 404·거부 SPA·CDN 폴백이 모든 경로에 200 → 헬스체크 200이 무의미
bogus="/__no_such_path_$(date +%s)"
code=$(curl -s -o /dev/null -w '%{http_code}' "https://api.example.com$bogus")
[ "$code" = "404" ] || { echo "UNKNOWN - 없는 경로가 $code: 이 호스트의 200은 증거가 아니다"; exit 3; }

권한 테스트도 같다. 소유자 토큰으로 "된다"만 확인하면 "남의 것은 안 된다"는 한 번도 검증되지 않는다. 허용 1건마다 거부 1건을 짝지어라.

4. 알림 소방훈련과 카오스 실험

규칙 단위 테스트는 "식이 맞다"까지만 증명한다. 라우팅·수신자·온콜 앱·런북은 실제로 울려 봐야 안다. 카오스 엔지니어링 원칙의 절차가 그대로 쓰인다: 정상 상태를 측정 가능한 출력으로 정의 → 대조군·실험군 모두 유지된다는 가설 → 현실을 닮은 고장 주입 → 두 군의 차이로 가설 반증. AWS Well-Architected(REL12-BP05)는 "절차를 문서화만 하고 연습하지 않는 것"을 안티패턴 첫 줄에 둔다. 구글 SRE 책은 분기에 한 번도 쓰이지 않는 알림 설정을 제거 후보로 본다 — 발화한 적 없는 알림은 검증된 적도 없다.

분기 1회, 90분 훈련 순서:

  1. 페이지급 알림 3~5개를 고르고 가설을 쓴다: "X를 죽이면 N분 안에 Y에게 페이지가 간다".
  2. 스테이징이나 인스턴스 1대로 폭발 반경을 제한하고 중단 조건을 정한다.
  3. 주입: 프로세스 kill, 디스크 채우기, 백업 cron 비활성화, 만료 임박 인증서.
  4. 계측: 탐지까지, 확인(ack)까지 걸린 시간, 실제 수신자, 런북 링크 유효성.
  5. 안 울렸거나 엉뚱한 곳에 울린 건을 버그로 등록한다. 백업은 복원까지 한다 — GitLab은 사후에 복원 자동 테스트와 백업 모니터링을 추가했다.

훈련 사이 공백은 데드맨 스위치로 메운다. kube-prometheus의 Watchdog은 항상 발화하는 알림이고, 이것이 끊기면 외부 서비스가 경보한다 — 알림 파이프라인의 상시 양성 대조군이다.

5. "못 돌았다"와 "문제 없다"는 다른 문구·다른 종료코드

Monitoring Plugins 가이드라인은 OK(0)를 "플러그인이 서비스를 검사할 수 있었고 정상으로 보였다"로, UNKNOWN(3)을 잘못된 인자나 플러그인 내부 실패로 정의한다. OK의 전제조건은 "검사를 수행했다"는 사실이다. pytest도 수집된 테스트가 0개면 0이 아니라 종료코드 5를 낸다.

#!/usr/bin/env python3
# 백업 신선도 프로브. 0=OK 2=CRITICAL 3=UNKNOWN(프로브가 못 돌았다)
import sys, time, pathlib
MAX_AGE_H, MIN_BYTES = 26, 1_000_000

def main(d):
    root = pathlib.Path(d)
    if not root.is_dir():
        print(f"UNKNOWN - {d} 를 읽지 못함: 검사 불가"); return 3
    files = list(root.glob("*.dump"))
    if not files:
        print("CRITICAL - 백업 0개: '검사 대상 없음'은 정상이 아니다"); return 2
    newest = max(files, key=lambda p: p.stat().st_mtime)
    age_h = (time.time() - newest.stat().st_mtime) / 3600
    size = newest.stat().st_size
    if age_h > MAX_AGE_H or size < MIN_BYTES:
        print(f"CRITICAL - {newest.name} age={age_h:.1f}h size={size}B"); return 2
    print(f"OK - {newest.name} age={age_h:.1f}h size={size}B (검사 {len(files)}개)"); return 0

if __name__ == "__main__":
    try:
        sys.exit(main(sys.argv[1]))
    except Exception as e:  # 프로브 자신의 고장이 0으로 끝나면 안 된다
        print(f"UNKNOWN - 프로브 내부 오류: {e!r}"); sys.exit(3)

인자를 빼먹어도 IndexError가 UNKNOWN(3)으로 떨어진다. 운영 규칙은 네 가지다.

  • 기본값은 UNKNOWN. 양성 증거(검사 건수 > 0, 최신 타임스탬프)를 확보한 뒤에만 OK를 낸다.
  • "0건 검사"·스킵은 OK가 아니다. 출력에 검사 건수를 항상 찍는다.
  • 0으로 뭉개지 않는다. 최상위 except: pass, || true, CI의 continue-on-error가 붙은 가드는 가드가 아니다.
  • UNKNOWN도 라우팅한다. 3회 연속이면 CRITICAL로 승격하고, 프로브는 감시 대상과 운명을 공유하지 않는 곳에서 돌린다. 2017년 S3 장애 때 AWS 상태 대시보드는 관리 콘솔이 S3에 의존한 탓에 11:37 PST까지 개별 서비스 상태를 갱신하지 못했다.

신호 정직성 설계 원칙과 리뷰 체크리스트

거짓 초록은 대개 버그가 아니라 '검사가 돌지 않았을 때 무엇을 보고할지' 정하지 않은 기본값에서 나온다. fail-closed, 증상 검증, 개수·해시 확인, unknown 상태, 센 값 기반 메시지의 다섯 원칙과 PR 리뷰·배포·온콜 체크리스트, 비용 대비 효과 순 도입표, '왜 초록이었나' 포스트모템 항목으로 이를 메우는 방법을 정리한다.

핵심 요점

  • 판단할 수 없는 경우(skip·타임아웃·입력 없음)의 기본값은 성공이 아니라 실패 또는 unknown 이어야 하며, GitHub 에서 조건부 skip 된 job 은 Success 로 보고되므로 skip 을 실패로 세는 게이트 job 이 필요하다.
  • 배포 검증은 스크립트 종료 코드가 아니라 인스턴스별 build SHA 와 응답 대수 대조, 사용자 경로 스모크로 해야 하며 Knight Capital 은 8대 중 1대 누락을 확인하지 못해 45분 만에 4억 6천만 달러 이상을 잃었다.
  • 백업·산출물은 존재 여부가 아니라 크기·행 수·해시·복원 결과로 판정해야 하고, GitLab 2017 사례에서는 pg_dump 실패 메일조차 DMARC 문제로 도착하지 않았다.
  • 시계열이 사라지면 임계값 알림은 울리지 않으므로 last_success 타임스탬프와 absent()/stale 알림으로 부재를 명시적 상태로 만들어야 한다.
  • 성공 메시지의 숫자는 하류가 수락을 확인한 건수에서만 만들고 분모를 항상 함께 표시하며, 0건은 성공이 아닌 별도 상태로 돌려준다.
  • 포스트모템에 '왜 초록이었나' 절과 '거짓 초록 시간' 지표를 추가하고 분기마다 가드를 일부러 깨뜨려 빨강이 되는지 확인한다.

거짓 초록은 버그가 아니라 "정해지지 않은 기본값"이다

검사가 돌지 않았을 때, 셀 대상이 없었을 때, 예외를 삼켰을 때 시스템이 무엇을 보고할지 아무도 정하지 않으면 그 빈칸은 거의 항상 "성공"이 채운다. 아래 다섯 원칙은 그 빈칸을 설계로 메우는 규칙이다.

원칙 1. fail-closed 기본값

판단할 수 없으면(입력 없음·파싱 실패·타임아웃·skip) 결과는 실패이거나 unknown 이어야 한다. GitHub 공식 문서는 조건문으로 skip 된 job 이 "Success" 를 보고하고 머지를 막지 않는다고 명시한다. 필수 체크가 돌지 않았는데 초록이 된다는 뜻이다. CrowdStrike 2024 예비 사고 보고서도 같은 구조다. Content Validator 의 버그로 문제 있는 템플릿 인스턴스가 검증을 통과했고, 회사는 "Validator 의 검사에 대한 신뢰와 이전의 성공적 배포"를 근거로 프로덕션에 내보냈다고 적었다.

skip 을 성공으로 세지 않는 게이트 job 하나면 CI 쪽은 닫힌다.

  gate:
    if: always()
    needs: [unit, integration, migration-check]
    runs-on: ubuntu-latest
    steps:
      - run: |
          echo '${{ toJSON(needs) }}' | python3 -c '
          import json, sys
          bad = {k: v["result"] for k, v in json.load(sys.stdin).items() if v["result"] != "success"}
          sys.exit(f"not green: {bad}" if bad else 0)'

브랜치 보호의 필수 체크는 개별 job 이 아니라 이 gate 하나로 건다. 셸 스크립트는 set -euo pipefail 을 기본으로 두고, || true 와 continue-on-error: true 는 사유 주석 없이는 리뷰에서 반려한다.

원칙 2. 토큰이 아니라 증상으로 검증

"배포 스크립트가 exit 0 으로 끝났다"는 토큰이고, "모든 서버가 같은 코드를 실행 중이다"가 증상이다. SEC 명령서에 따르면 Knight Capital 은 8대의 SMARS 서버 중 1대에 새 코드를 복사하지 않았고, 이를 확인하는 두 번째 기술자 리뷰도, SMARS 로 들어온 주문과 나간 주문을 비교하는 통제도 없었다. 결과는 부모 주문 212건에서 45분간 약 400만 건 체결, 손실 4억 6천만 달러 이상이다. Google SRE 책은 블랙박스 모니터링을 "사용자가 보는 그대로의 외부 동작"을 시험하는 것으로 정의하고, 원인보다 증상을 잡는 데 훨씬 더 많은 노력을 쓰라고 권한다. 배포 검증은 grep 0건이나 로그 문자열이 아니라 사용자 경로 실행과 인스턴스별 build SHA 집계로 한다.

원칙 3. 존재 ≠ 준비

파일이 있다는 것과 복원된다는 것은 다른 사실이다. GitLab 2017 포스트모템에서 pg_dump 는 9.6 DB 를 9.2 바이너리로 덤프하려다 오류로 종료되고 있었고, 크론 실패 메일은 DMARC 미설정으로 수신 측에서 거부됐다. Azure 디스크 스냅샷은 DB 서버에 켜져 있지 않았고 LVM 스냅샷은 24시간 전 것이었다. 약 6시간 분량(프로젝트 5,000여 개, 댓글 5,000여 개, 신규 계정 700여 개)이 사라졌고 복구에 18시간쯤 걸렸다. 점검은 ls 가 아니라 크기·행 수·해시·복원 결과로 한다. "최신 덤프가 26시간 이내이고, 전일 대비 크기가 ±20% 안이며, 스테이징 복원 후 핵심 테이블 행 수가 일치한다"처럼 수치로 쓴다.

원칙 4. 부재를 명시적 상태로: unknown ≠ ok

AWS 의 2017년 S3 장애 요약에 따르면 Service Health Dashboard 관리 콘솔이 S3 에 의존하고 있어, 09:37 PST 에 시작된 장애의 개별 서비스 상태를 11:37 까지 갱신하지 못했다. 상태판의 개별 서비스 표시는 두 시간 동안 장애 이전 값 그대로였다. 지표도 같다. rate(errors[5m]) > 0.01 은 시계열이 사라지면 빈 결과를 돌려주고, 알림은 울리지 않는다. Prometheus 문서는 absent() 를 "해당 시계열이 존재하지 않을 때 알림을 거는 용도"라고 설명한다.

groups:
- name: signal-honesty
  rules:
  - alert: BackupSignalMissing          # 신호 자체가 없다 = unknown
    expr: absent(backup_last_success_timestamp_seconds{job="pg-backup"})
    for: 15m
  - alert: BackupStale                  # 26시간째 성공 없음
    expr: time() - backup_last_success_timestamp_seconds{job="pg-backup"} > 93600
    for: 5m

상태 enum 은 최소 ok / failing / unknown 세 값으로 두고, 대시보드는 unknown 을 회색으로 그린다. 초록으로 칠하지 않는다.

원칙 5. 성공 메시지는 실제로 센 값에서만

"1,200건 발송 완료"의 1,200 이 len(대상목록) 이면 그 문장은 계획이지 결과가 아니다. 숫자는 하류가 수락을 확인한 건수에서만 만든다.

def summarize(results, expected):
    sent = sum(1 for r in results if r.accepted)   # 공급자가 수락한 건만 센다
    failed = len(results) - sent
    skipped = expected - len(results)
    if expected == 0:
        return 2, "UNKNOWN: 대상 0건 (조회 실패인지 실제 0건인지 미확인)"
    if failed or skipped:
        return 1, f"PARTIAL: {sent}/{expected} 발송 · 실패 {failed} · 미시도 {skipped}"
    return 0, f"OK: {sent}/{expected} 발송"

분모(expected)를 항상 같이 찍고, 0건은 성공이 아니라 별도 상태로 돌려준다.

3국면 체크리스트

PR 리뷰

  • [ ] 예외를 잡은 뒤의 반환값이 성공과 구별되는가 (except: return [], || true 검색)
  • [ ] 새 가드가 실제로 빨강이 되는 것을 본 증거가 있는가 (일부러 깨뜨린 커밋의 실패 링크)
  • [ ] 조건부로 skip 되는 필수 job 이 있는가, 게이트 job 이 skip 을 실패로 세는가
  • [ ] 설정·시크릿·의존 서비스가 없을 때 기본 동작이 "검증 생략 후 통과"인가 "거부"인가
  • [ ] 성공 로그·알림의 숫자가 요청 수인가, 확인된 결과 수인가. 분모가 같이 나오는가
  • [ ] 테스트가 fake 저장소·소유자 권한으로만 도는가. 실제 권한에 닿는 경로가 하나라도 있는가
  • [ ] 헬스 엔드포인트가 DB·큐·모델 로드를 실제로 확인하는가, 상수 200 인가
  • [ ] 주기 작업에 last_success_timestamp 지표가 있는가 (없으면 부재 알림을 걸 수 없다)

배포

  • [ ] 기대 SHA 와 각 인스턴스가 보고하는 SHA 를 대조하고, 응답한 대수도 기대 대수와 비교했는가 (Knight 는 7/8)
  • [ ] 스모크 테스트가 사용자 경로(인증→쓰기→읽기)를 최소 권한 계정으로 도는가
  • [ ] 마이그레이션 버전을 도구 출력이 아니라 DB 에서 직접 조회했는가
  • [ ] 카나리 승급 조건이 "오류 없음"이 아니라 "기대 트래픽이 있었고 오류율이 임계 이하"인가 (트래픽 0 은 unknown)
  • [ ] 단계 배포인가. CrowdStrike 가 시정 조치로 내건 것도 카나리에서 시작하는 단계적 배포다
  • [ ] 롤백 경로를 이번 릴리스에서 실제로 실행해 봤는가
  • [ ] 옛 플래그·죽은 코드를 재사용하지 않았는가 (Knight 는 폐기된 Power Peg 플래그를 새 기능에 재사용했다)
  • [ ] 크론·배치는 배포 후 첫 실행의 last_success 갱신까지 확인해야 완료다

온콜

  • [ ] 대시보드가 초록이면 먼저 마지막 샘플 시각을 본다
  • [ ] 상태 페이지·알림 경로가 감시 대상과 같은 의존성을 공유하는가 (AWS SHD)
  • [ ] 알림 경로를 주 1회 합성 실패로 끝까지 시험하는가 (GitLab 의 실패 메일은 아무에게도 도착하지 않았다)
  • [ ] 항상 울리는 워치독 알림이 끊기면 호출되는 dead man's switch 가 있는가
  • [ ] 아무도 읽지 않는 자동 메일에 중요한 신호가 섞여 있지 않은가. Knight 는 개장 전 "Power Peg disabled" 메일 97통을 받았지만 알림으로 설계된 메일이 아니어서 검토되지 않았다
  • [ ] 백업 복원 리허설 결과(행 수·체크섬)가 지표로 남는가
  • [ ] 화이트박스 지표 하나로 "정상"을 선언하지 않고 블랙박스 프로브와 교차 확인하는가

도입 우선순위

비용은 서비스 5~10개 규모 팀을 가정한 대략의 추정이다.

순위 조치 비용 막는 거짓 초록
1 set -euo pipefail, || true·continue-on-error 전수 검색 반나절 중간 실패가 exit 0 으로 끝남
2 CI 게이트 job (skip ≠ success) 1~2시간 돌지 않은 필수 체크
3 주기 작업에 last_success + absent/stale 알림 작업당 1시간 조용히 멈춘 크론·백업
4 배포 후 SHA·대수 대조 1일 부분 배포
5 성공 메시지를 센 값+분모로 교체 기능당 수 시간 "N건 완료" 허위 보고
6 알림 경로 합성 테스트 + dead man's switch 1~2일 끊어진 알림 파이프
7 가드 뮤테이션 점검(일부러 실패 주입) 가드당 30분 아무것도 잡지 않는 가드
8 분기별 복원 리허설 자동화 1~2주 복원되지 않는 백업
9 상태 페이지 의존성 분리(다중 리전) 수 주 장애 중에도 초록인 상태판

1~3번은 하루 안에 끝나고, 돌지 않은 체크·삼켜진 실패·멈춘 크론처럼 가장 흔한 유형부터 닫는다. 8~9번은 비싸지만 데이터 손실과 고객 신뢰가 걸린 곳이라 미루기만 하면 안 된다.

조직 습관: 포스트모템에 "왜 초록이었나"를 넣는다

Google SRE 책의 예시 포스트모템 양식에는 Detection 항목과 "잘된 점 / 잘못된 점 / 운이 좋았던 점"이 있고, 같은 책은 모니터링 실패(사람이 수동으로 발견한 경우)를 포스트모템 개시 조건으로 꼽는다. 여기에 한 절을 더한다.

  • 왜 초록이었나: 사고 시작 시점에 초록이던 신호를 전부 적는다(CI, 헬스체크, 대시보드, 배포 로그, 성공 알림). 신호마다 ① 실제로 무엇을 측정했나 ② 이번 실패가 그 범위에 들어가야 했나 ③ 범위에 넣는 액션 아이템, 또는 범위 밖이라면 신호 이름을 정직하게 고치는 액션 아이템을 단다.
  • 탐지 경로: 자동 알림 / 내부 사람 / 고객 중 무엇이었나. 고객이 먼저 알았다면 심각도를 한 단계 올린다.
  • 거짓 초록 시간: 실제 장애 시작부터 첫 빨강 신호까지. MTTD 와 따로 추적한다. AWS 사례에서 상태판 기준 이 값은 2시간이었고, GitLab 의 pg_dump 실패는 복구하려고 백업을 찾은 순간에야 드러났다.
  • 분기별 가드 점검일: 필수 가드를 하나씩 일부러 깨뜨려 빨강이 되는지 확인한다. 빨강이 되지 않는 가드는 삭제하거나 고친다. 남겨 두는 것이 가장 나쁘다.
이 글은 AI 리서치 파이프라인으로 작성되고 사람이 검수했습니다. 섹션마다 1차 출처를 표기합니다.