있지도 않은 장애를 고쳤습니다 — 정적 매니페스트가 런타임을 판결할 때
모니터링을 점검하기로 한 날이었어요. "우리 관측성 스택 전수 조사해줘"로 시작한 평범한 스프린트였죠. 메트릭 스크랩 잡, 알림 규칙, Grafana 대시보드, Loki 로깅까지 매니페스트를 한 줄씩 읽어 내려갔습니다.
그러다 한 줄에서 멈췄어요.
# infra/k3s/monitoring/alertmanager.yaml
route:
receiver: 'null' # 모든 알림이 여기로 떨어진다
receiver: 'null'. Alertmanager에서 이건 "발화한 알림을 아무 데도 보내지 않고 버린다"는 뜻이에요. ServiceDown, OOMKilled, CircuitBreakerOpen — 17개나 되는 규칙이 다 살아 있는데, 전송 경로가 null로 막혀 있었습니다. 주석에는 "Discord 알림은 Identity 서비스 webhook으로 전환됨"이라고 적혀 있었지만, 호출 그래프를 따라가 보니 그 Identity webhook은 사용자 피드백 전용이었어요. 알림과는 아무 관계가 없었죠.
결론은 명확해 보였습니다.
우리 시스템은 며칠째 어떤 알림도 보내지 못하고 있다.
문제
17개 알림 규칙이 전부 null receiver로 떨어지고 있었다. 장애가 나도 아무에게도 알림이 가지 않는다 — 그렇게 확신했다.
저는 진단했고, 고쳤어요. Alertmanager에 Discord receiver를 실제로 배선하고, route.receiver를 null에서 discord로 바꾸고, secret 볼륨을 마운트했습니다. 거짓 주석도 바로잡았어요. CI는 초록불이었고, 모니터링 게이트(BLOCKING)까지 통과했습니다. PR은 머지됐어요.
스스로 꽤 만족스러웠습니다. "조용히 알림을 삼키고 있던 6일짜리 무발송 장애를 찾아냈다"고 ADR에 적었으니까요.
그게 전부 틀렸다는 걸 알기까지는 하루도 걸리지 않았어요.
제가 읽던 파일은 배포되지 않았어요
다음 날, 운영자(저 자신이지만, 이번엔 서버에 직접 붙은 쪽)가 라이브 클러스터를 진단했습니다. 그리고 한 문장이 모든 걸 무너뜨렸어요.
라이브 Alertmanager는 멀쩡하게 Discord로 알림을 보내고 있는데?
어떻게 이럴 수 있었을까요. 제가 읽은 매니페스트는 분명 receiver: 'null'이었는데.
이유는 배포 토폴로지에 있었습니다. AlgoSu 레포의 infra/k3s/ 디렉토리는 배포되지 않는 참조 미러였어요. 실제로 클러스터에 적용되는 SSOT는 별도 레포인 aether-gitops였고, ArgoCD는 오직 그 레포만 바라보고 있었습니다. AlgoSu의 CI는 머지 때 aether-gitops에 이미지 태그만 bump할 뿐, 매니페스트 자체는 전파하지 않았어요.
제가 읽은 receiver: 'null'은 배포되지도 않는 미러의 낡은 스냅샷이었어요. 라이브 Alertmanager는 이미 몇 달 전(Sprint 130)에 discord-default와 discord-critical 두 개의 receiver로 정상 동작하도록 배선돼 있었습니다. severity로 라우팅하고, v0.28.1을 쓰고 있었죠.
더 나빴던 건 제 "수정"이었어요. 저는 미러에 v0.27.0을 그대로 두고 webhook_url_file 방식을 추가했는데, v0.27.0은 파일 기반 webhook을 지원하지 않습니다. 즉, 만약 그 미러가 진짜 배포본이었다면 제 수정은 동작하지 않는 설정이었어요. 저는 멀쩡한 시스템의 "장애"를 진단하고, 그 위에 작동 불가능한 설정을 덧칠한 셈이었습니다.
머지된 오판을 어떻게 다룰 것인가
이미 머지된 ADR이 "존재하지 않는 장애"를 사실로 기록하고 있었어요. 가장 먼저 든 충동은 그 ADR을 지우는 것이었습니다.
하지만 그러지 않았어요. ADR은 의사결정의 역사고, 틀린 결정도 역사니까요. 본문을 지우면 "왜 그때 그렇게 판단했는가"라는 맥락이 함께 사라집니다. 대신 문서 맨 위에 ERRATA 블록을 얹어, 본문은 그대로 두고 사실만 덮어썼어요. 다음에 이 ADR을 읽는 사람(혹은 에이전트)은 틀린 결론과 그 정정을 동시에 보게 됩니다.
그리고 미러 자체도 손봤어요. 완전히 동기화하는 대신 — 솔로 개발자에게 미러를 라이브와 100% 맞추는 건 유지 부담이 가치보다 큽니다 — "이건 배포되지 않는 참조용입니다"라는 배너를 달고, 깨진 부분만 라이브에 맞췄습니다. 미러를 "진실"이 아니라 "참조"로 격하한 거예요.
결정
틀린 ADR은 지우지 않고 ERRATA로 정정한다. 미러는 비배포 배너를 달아 "참조"로 격하하고, 라이브와 어긋난 부분만 맞춘다.
아, 그리고 애초에 이 모든 조사의 출발점이었던 "로그가 수집이 안 되는 것 같다"는 증상. 그건 진짜였어요. 다만 원인은 파이프라인 배선이 아니라 Loki의 OOM이었습니다. 메모리 한도 512Mi가 5일 주기의 로그 스파이크를 못 버티고 주기적으로 죽고 있었죠. 한도를 1Gi로 올리니 해결됐어요. 정적 구성은 처음부터 옳았습니다. 문제는 런타임 리소스였어요.
이번엔 라이브부터 봤어요
교훈을 얻었으니, 다음 증상은 다르게 접근했습니다. Grafana의 "Service Debug" 대시보드에서 패널 3개(컨테이너 CPU/메모리, Loki 로그 2종)가 no-data로 비어 있었어요.
이번엔 매니페스트를 노려보는 대신 라이브 클러스터를 먼저 쿼리했습니다. 그러자 두 가지 진짜 원인이 드러났어요.
(A) cAdvisor 메트릭이 아예 수집되지 않고 있었습니다. container_cpu_usage_seconds_total이 네임스페이스 무관하게 0 시리즈. Prometheus 설정에 kubelet/cAdvisor를 긁는 scrape 잡이 없었어요. kube-state-metrics가 돌고 있었지만, 그건 kube_* 객체 상태(파드가 몇 개 떠 있나)만 줄 뿐 컨테이너의 실제 리소스 사용량은 주지 않습니다. 둘은 다른 데이터예요.
(B) Loki 라벨 모델이 대시보드와 어긋나 있었습니다. 로그는 멀쩡히 수집되고 있었어요({namespace="algosu"}로 6개 스트림). 그런데 라이브 promtail은 service 라벨을 붙이는데, 대시보드는 존재하지도 않는 pod 라벨을 쿼리하고 있었습니다. 게다가 변수값은 submission-service인데 실제 라벨값은 submission이라 prefix조차 안 맞았죠.
이번엔 순서가 반대였어요. 배포 SSOT(aether-gitops)에서 라이브를 먼저 고치고, 검증으로 컨테이너 메트릭이 0에서 75개 시리즈로 살아나는 걸 확인한 뒤, 그 다음에 비배포 미러를 같은 형태로 맞췄습니다. 라이브 선수정 → 미러 후정합. 이게 드리프트를 다루는 올바른 방향이었어요.
규칙이 있다는 것과 알림이 간다는 것
대시보드를 고치고 나니, 이번엔 알림 규칙 자체를 점검하게 됐어요. 그리고 첫 번째 사건의 교훈("규칙이 있다고 알림이 가는 게 아니다")이 한 번 더, 더 교묘한 형태로 반복됐습니다.
CircuitBreakerOpen 알림은 ai-analysis 서비스의 서킷 브레이커만 감시하고 있었어요. submission과 github-worker도 똑같이 서킷 브레이커 메트릭을 발행하는데, 알림이 없었죠. Saga가 막히거나 GitHub 동기화가 끊겨도 무알림이었던 거예요. 세 서비스를 한 규칙으로 통합했습니다.
더 흥미로운 건 DLQReceived 알림이었어요. 이건 algosu_submission_dlq_messages_total이라는 메트릭을 감시하고 있었는데 —
# submission 소스 전체에서 이 메트릭을 발행하는 코드를 찾으면
$ grep -r "submission_dlq_messages_total" services/submission/src
# (결과 없음)
이 메트릭은 어디에서도 발행되지 않았습니다. submission은 Saga 오케스트레이터라 DLQ 카운터를 발행하지 않아요. DLQ는 소비자 워커(github-worker, ai-analysis)에서 발생하고, 그쪽 메트릭은 실제로 존재했습니다. 규칙은 살아 있는데 가리키는 메트릭이 죽어 있는, 거짓 안전감을 주는 알림이었던 거죠. 대시보드에서는 "DLQ 감시 중"으로 보이지만 영원히 발화하지 않는.
미발행 placeholder를 실제 발행되는 워커 메트릭으로 교체했어요. 이 과정에서 Critic(교차 리뷰 AI)이 두 가지를 더 잡아냈습니다. counter를 raw value > 0으로 비교하면 첫 이벤트 후 영원히 발화한다는 것(→ increase(...[5m]) > 0으로 "최근 이벤트"만), 그리고 prom-client가 라벨 시리즈를 첫 .inc() 때 지연 생성하기 때문에 increase가 비교할 baseline이 없어 첫 DLQ를 놓친다는 것(→ 프로세스 시작 시 라벨을 0으로 초기화). DLQ처럼 드문 critical 이벤트일수록 이 디테일이 생사를 가릅니다.
마지막으로, 알림을 제 채널로 보냈어요
점검에서 마지막 갭이 하나 더 있었습니다. 운영 알림이 사용자 피드백 전용 Discord webhook을 재사용하고 있었어요. 장애 critical 알림과 운영 warning과 사용자 피드백이 전부 같은 "피드백 봇" 채널에 섞여 들어가고 있었죠. critical을 노이즈 속에 묻는 가장 확실한 방법이에요.
그래서 채널을 분리했습니다. #algosu-alerts(일반)와 #algosu-alerts-critical(긴급), 그리고 각각의 webhook. 이건 라이브 클러스터 작업이라, 이번엔 적용 순서가 중요했어요.
그리고 끝까지 검증했어요. alertmanager_notifications_total{integration="discord"}가 0에서 2로, resolve까지 4로 증가하고 failed_total은 0. 메트릭으로 전송 성공은 입증됐지만, 메트릭은 "어느 채널에 도착했는지"는 구분하지 못합니다. 그래서 마지막엔 두 Discord 채널을 직접 눈으로 봤어요. critical은 critical 채널에, warning은 일반 채널에, 교차 없이. 검증은 거기서 끝났습니다.
결과
없는 장애를 고쳤다가, 진짜 원인을 라이브에서 찾고, 알림을 제 채널로 보내기까지. 매니페스트가 아니라 클러스터가 진실이었다.
여섯 스프린트가 가르쳐 준 것
- S231비배포 미러를 배포본으로 오독, '없는 장애'를 진단완료
전수 조사 — 그리고 오판
- S232infra/k3s ≠ 배포 SSOT 확인, 미러 격하 + Loki OOM 실원인완료
정정 — ERRATA
- S234cAdvisor scrape 부재 + promtail 라벨 불일치, 라이브 선수정→미러 후정합완료
진짜 진단 — 라이브 먼저
- S235CB 3서비스 통합 + DLQ placeholder 교체 + 채널 분리 설계완료
알림 갭 보강
- S236critical/일반 2채널 + 양 채널 도착 육안 검증완료
채널 분리 라이브 적용
이 여정에서 반복해서 등장한 원칙은 네 가지였어요.
(i) 정적 구성이 맞다는 것과 런타임이 맞다는 것은 다르다
첫 사건의 알림도, 로그 미수집도, 결국 정적 매니페스트는 옳았어요. 알림 라우팅은 라이브에서 정상이었고, 로깅 파이프라인 배선도 정상이었습니다. 진짜 문제는 런타임에 있었어요 — 배포되지 않은 미러를 읽었거나(존재하지 않는 결함), Loki가 OOM으로 죽고 있었거나(리소스). 매니페스트를 아무리 노려봐도 보이지 않는 종류의 문제예요.
(ii) 어느 매니페스트가 배포되는지부터 확인하라
이 모든 사달의 근원은 단 하나의 가정이었습니다. "내가 읽는 이 파일이 돌아가는 그 파일이다." 모노레포 안의 infra/k3s/가 너무 그럴듯해서, 그게 별도 GitOps 레포로 대체됐다는 걸 의심하지 않았어요. 배포 토폴로지를 먼저 그려야 합니다. 무엇이 SSOT이고, CI가 무엇을 전파하며, ArgoCD가 무엇을 바라보는지.
(iii) 규칙의 존재는 동작의 증거가 아니다
null receiver는 알림을 조용히 삼킵니다 — 제가 그 한 줄을 보고 곧장 "무알림 장애"를 확신한 것도 그래서였어요(다행히 그건 배포되지 않는 미러였지만요). 그리고 정작 배포된 쪽에서는, 발행되지 않는 메트릭을 가리키는 DLQ 알림이 영원히 발화하지 않고 있었습니다. 둘 다 대시보드에서는 "감시 중"으로 보여요. 알림은 규칙이 있다는 것으로 끝나지 않고, 발화 → 라우팅 → 전송 → 도착까지 끝까지 따라가야 검증됩니다.
(iv) 라이브를 먼저 고치고, 미러는 나중에 맞춘다
드리프트를 다루는 방향에는 정답이 있었어요. 비배포 미러를 먼저 고치면 라이브와의 거리는 그대로입니다. 배포 SSOT를 라이브에서 검증하며 고치고, 그 확정된 형태로 미러를 따라오게 해야 해요. 미러는 진실이 아니라 진실의 그림자니까요.
제가 그날 읽은 receiver: 'null'은 거짓말이 아니었어요. 그 파일은 정말로 그렇게 적혀 있었으니까요. 틀린 건 파일이 아니라, 그 파일이 곧 라이브라고 믿은 저였습니다.
매니페스트는 의도를 적은 글이에요. 클러스터는 그 의도가 실행된 결과고요. 둘이 같다는 보장은 어디에도 없습니다 — 특히 그 사이에 GitOps 레포 하나가 끼어 있다면요.
이제 저는 런타임을 판결하기 전에 먼저 물어요. "이게 정말 돌아가고 있는 그건가?"
관련 ADR
이 글의 결정이 기록된 의사결정 문서
모니터링 시스템 전수 조사 + 로그 수집 진단 + Alertmanager Discord receiver 실배선
AlgoSu 관측성 스택(메트릭 8 scrape job·알림 17규칙+recording 2·Grafana 4대시보드·Loki+Promtail 로깅·8차원 검증 CI)을 전수 조사하고, 사용자 관심사인 'service/error 로그 미수집'을 파이프라인 정밀 추적으로 진단. 결론: 로깅 파이프라인은 정적 구성상 올바르게 배선됨(Promtail CRI+JSON→loki:3100, NetworkPolicy loki-ingress 허용·default-deny는 Ingress 전용이라 egress 무제한, Grafana loki uid 정합, level 값 전 서비스 소문자 정합) → 미수집이 사실이면 런타임 원인이며 라이브 진단(운영측) 필요. 진짜 결함은 알림 전송 경로: alertmanager receiver='null'로 17규칙이 전부 드롭되고 있었고, 주석의 'Identity webhook 전환'은 거짓(Identity discord는 feedback 전용). 사용자 확정에 따라 Alertmanager Discord receiver(discord_configs.webhook_url_file) 실배선 + monitoring-secrets에 ALERTMANAGER_DISCORD_WEBHOOK placeholder 추가(실 seal은 운영측). Grafana Error Logs Only 쿼리의 죽은 분기(level=~error|fatal|CRITICAL — 어떤 서비스도 fatal/CRITICAL 미출력)+취약 라인필터를 level 라벨 직접 필터로 정정. 추정 갭 사실검증: github-worker-metrics Service 실존(scrape 유효), Identity discord=feedback, .bak은 git 미추적. 산출물: docs/runbook/monitoring-system-audit.md(인벤토리+커버리지 매트릭스+라이브 진단 절차+갭/디버그/시드). 검증: check-grafana-metrics.mjs 통과.
sprint-232alertmanager 미러 드리프트 정정 + Sprint 231 문서 ERRATA (infra/k3s ≠ 배포 SSOT)
Sprint 231의 핵심 결론('alertmanager receiver:null → 17규칙 전부 드롭/6일 무알림')이 서버 라이브 진단으로 오판으로 판명. 근본 원인: AlgoSu infra/k3s/는 배포되지 않는 참조 미러이고 실제 배포 SSOT는 aether-gitops(algosu/base/monitoring + overlays/prod)다. AlgoSu CI는 aether-gitops에 이미지 태그만 bump하고 매니페스트는 전파하지 않으며, ArgoCD(automated·selfHeal·Synced)는 aether-gitops만 본다. Sprint 231은 이 비배포 미러의 구버전(receiver:null)을 배포본으로 오독했다. 라이브 alertmanager는 Sprint 130 B-1로 이미 정상(alertmanager-native discord-default+discord-critical, identity-discord-secret/webhook-url, v0.28.1). 게다가 Sprint 231이 미러에 넣은 monitoring-secrets/discord_webhook + v0.27.0 + webhook_url_file은 v0.27.0이 file-webhook 미지원이라 작동불가 설정이었다. 실제 '로그 미수집' 원인은 Loki OOM(512Mi 한도, 5일 주기 spike)으로, 서버에서 aether-gitops PR #7로 512Mi→1Gi 상향·검증 완료(C). 본 스프린트: A-lite AlgoSu alertmanager 미러를 라이브로 정정(v0.28.1 + discord-default/critical + identity-discord-secret/webhook-url + 비배포 배너) + sealed-secrets placeholder 제거, B Sprint 231 ADR/런북 ERRATA, B+ loki probe/securityContext 라이브 갭 시드. 배포 무영향(미러·문서 정합 회복). Critic R1 CLEAN. 교훈: 정적 매니페스트(특히 비배포 미러)로 런타임 결함 단정 금지(feedback-source-vs-live-drift).
sprint-234모니터링 드리프트 정리 (Grafana no-data + argocd metrics + promtail/loki SSOT 정합)
Grafana 'AlgoSu Service Debug' 대시보드의 3개 패널(Container CPU/Memory, Loki 로그 2종)이 라이브에서 no-data. 라이브 진단으로 두 근본 원인 확정: (A) Prometheus에 kubelet/cAdvisor scrape_config 부재 → container_* 메트릭 전체 미수집(0 시리즈). (B) 라이브 promtail이 service 라벨을 부여하는데 대시보드가 없는 pod 라벨을 참조 → 로그 패널 항상 0. 배포 SSOT인 aether-gitops에서 PR #8(+0a7156d) 머지·ArgoCD Synced·라이브 3종 검증 통과(container_* 75 시리즈, 로그 패널 스트림 표출)로 라이브 해결. 본 스프린트는 비배포 참조 미러 infra/k3s/monitoring을 라이브 최종 형태에 정합: prometheus-config에 kubernetes-cadvisor/nodes job(apiserver proxy 경유)+SA/ClusterRole/CRB, 대시보드 $service 변수값 Loki service 라벨 기준 통일·Loki 패널 service= 셀렉터·job=~ 12곳, promtail은 service 라벨 정합(Critic R1 P2). 게이트 check-grafana-metrics 통과. Critic R1 [P2](미러 promtail service 라벨 부재)→R2 CLEAN. 스프린트 진행 중 동일 모니터링 드리프트 정리가 확장: AlgoSu #414(argocd-metrics/argocd-server-metrics scrape job + revisionHistoryLimit 10→5)·#415(promtail kubernetes_sd→static_configs SSOT 완전 교체 + loki allow_structured_metadata false→true 짝 정합), 서버 aether-gitops #8(no-data 라이브)·#9(CB 패널 stale 교정 + revisionHistoryLimit, ReplicaSet 126→73)·#10(cloudflared-monitoring GitOps 편입)·curl-test pod 정리 병렬 완료.
sprint-235모니터링 알림 시스템 갭 보강 (CB/DLQ alert 커버리지 + 알림 채널 분리 + 온콜 런북)
모니터링 시스템 전수 조사 + 알림 시스템 점검 결과 발견한 4개 갭을 보강. (1) CircuitBreakerOpen alert가 ai-analysis만 감시 → submission/github-worker 통합(3종 CB 메트릭 모두 실존, rule만 확장). (2) DLQReceived alert가 미발행 placeholder algosu_submission_dlq_messages_total 참조 → submission은 saga orchestrator라 DLQ 카운터 미발행이 사실로 확정, 실 발행 워커 메트릭 algosu_(github_worker|ai_analysis)_dlq_messages_total로 교체. (3) 운영 알림이 identity-discord-secret(feedback 전용 webhook) 재사용으로 같은 Discord 채널 혼재 → alertmanager-discord-secret(2키 webhook-url/webhook-url-critical)로 critical/일반 2채널 분리, 미러는 목표 상태로 선반영(실 봉인·적용은 서버 aether-gitops). (4) 온콜 대응 런북 부재 → oncall-alerts.md(13 alert별 진단 쿼리·대응) + alert-channel-separation.md(채널 분리 라이브 적용 절차) 신규. infra/k3s/monitoring은 비배포 참조 미러 — 라이브 적용분은 런북+이월. 게이트 check-grafana-metrics 전 차원 [OK], check-doc-refs 437 files no broken refs.
sprint-236운영 알림 채널 critical/일반 분리 라이브 적용 (alertmanager-discord-secret)
Sprint 235가 미러에 '목표 상태'로 선반영한 운영 알림 채널 분리를 서버측 aether-gitops에 라이브 적용. 기존엔 alertmanager가 identity-discord-secret(feedback 전용 webhook)을 재사용해 사용자 피드백·운영 warning·critical이 한 채널에 혼재 → 신규 alertmanager-discord-secret(2키: webhook-url=일반, webhook-url-critical=critical)으로 분리. optional:false 마운트 실패 방지를 위해 2-커밋 분리 적용(SealedSecret 선행 → secret 실재 게이트 → alertmanager 매니페스트 교체). end-to-end 검증: 2키 마운트·config 정합·critical/warning 발화 전송(notifications_total 0→2→4, failed 0)·resolve 도달·feedback 격리 모두 통과, PM 육안으로 실 Discord 양 채널 도착 확인. identity-discord-secret은 feedback 전용으로 유지(삭제 금지).
ADR-029