V2 서비스 장애 알림 체계 설계 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki

V2 서비스 장애 알림 체계 설계

V1에서는 장애를 사용자 CS 문의로 먼저 알았다. CloudWatch 알람은 있었지만 SNS 토픽에 구독자가 없어 아무에게도 전달되지 않았다. V2는 요청 경로에서 장애를 판단할 지점을 정하고, 지점마다 알림 규칙을 두어 Discord로 개발팀에 보낸다. 목표는 사용자보다 먼저 장애를 아는 것이다.

모니터링 도구 선정과 수집 구조는 V2 중앙 집중식 모니터링 시스템 설계를 따른다. 이 문서는 그 위의 알림 계층만 다룬다.

1. V1의 알림은 왜 동작하지 않았는가?
2. 장애는 어디에서 판단하는가?
3. 알림 경로를 왜 두 개로 나누는가?
4. 어떤 규칙이 어느 채널로 가는가?
5. 어떤 순서로 켜고 어떻게 검증하는가?


1. V1 알림 체계의 한계

항목 V1 상태 결과
알람 수신자 SNS moyeota-alerts 토픽에 구독자 없음 (alert_email = null) 알람이 울려도 아무도 모른다
알람 범위 CPU ≥ 80%, StatusCheck만 생성. 메모리·디스크 알람은 비활성 호스트가 살아 있으면 장애로 보지 않는다
애플리케이션 지표 5xx 비율·p95·Hikari 알람 없음 사용자가 겪는 오류와 지연을 감지하지 못한다
Discord GitHub Actions가 CI 실패만 직접 전송 운영 장애를 받는 채널이 없다

2. 장애 판단 지점

요청이 지나가는 순서를 따라가며, 지점마다 **"여기가 깨지면 사용자가 무엇을 겪는가"**를 기준으로 판단 지점을 정했다.

flowchart LR
  USER["사용자"] --> ALB["① Prod ALB"]
  ALB --> TG["② 대상 그룹<br/>헬스체크"]
  TG --> TASK["③ ECS 서비스<br/>rest · websocket · fastapi"]
  TASK --> APPM["④ 애플리케이션<br/>5xx · 지연 · Hikari · JVM"]
  TASK --> REDIS["⑤ Redis Pub/Sub<br/>채팅 fan-out"]
  TASK --> RDS["RDS MySQL"]

  subgraph OBS["⑥ 관측 계층"]
    ALLOY["Alloy"] --> LOKI["Loki"]
    PROM["Prometheus"]
    GRAF["Grafana"]
  end
  TASK -. 로그 .-> ALLOY
  TASK -. 메트릭 .-> PROM
Loading
# 판단 지점 깨지면 생기는 일 신호 담당 경로
① 진입점 (Prod ALB) 대상이 없거나 응답하지 않아 ALB가 502·503·504를 직접 반환 HTTPCode_ELB_5XX_Count CloudWatch
② 대상 그룹 헬스체크에 실패한 태스크가 생겨 처리 용량이 줄어듦 UnHealthyHostCount CloudWatch
③ ECS 서비스 태스크가 desired 수만큼 뜨지 못함 (Spot 회수, 메모리 부족, 크래시 루프) Desired − Running (Container Insights) CloudWatch
④ 애플리케이션 5xx 응답, 느린 응답, DB 커넥션 대기, 힙 부족 Actuator · FastAPI 메트릭 Grafana
⑤ 메시지 브로커 (Redis) 채팅 fan-out 지연·유실 EngineCPU, 메모리, 대역폭 초과 CloudWatch
⑥ 관측 계층 자체 모니터링 호스트·Loki·Alloy가 죽어 ①~⑤를 보지 못함 하트비트, 데이터 디스크, Alloy 전송 실패 CloudWatch

ALB 자체 5xx(①)와 애플리케이션 5xx(④)는 다른 신호다. ①은 요청이 앱까지 가지 못한 경우이고, ④는 앱이 받았지만 실패한 경우다. 그래서 두 신호를 서로 다른 경로에서 본다.


3. 알림 경로를 두 개로 나눈 이유

Grafana Alerting은 모니터링 EC2 안에서 돈다. 모니터링 호스트가 죽으면 서비스 알림도 같이 멈추고, 알림이 오지 않는 상태가 정상처럼 보인다. 그래서 다음 두 가지는 모니터링 스택과 독립된 CloudWatch Alarm에 둔다.

  • 모니터링 호스트가 죽어도 알아야 하는 것 (⑥)
  • AWS가 이미 지표를 주는 것 (①②③⑤). Prometheus로 복제하지 않는다.

애플리케이션 내부 지표(④)는 Prometheus에만 있으므로 Grafana Alerting이 맡는다.

구분 경로 A: CloudWatch Alarm 경로 B: Grafana Alerting
실행 위치 AWS 관리형 (모니터링 호스트와 독립) 모니터링 EC2
판단 지점 ① ② ③ ⑤ ⑥ ④
데이터 AWS/ApplicationELB, ECS/ContainerInsights, AWS/ElastiCache, 하트비트 커스텀 지표 Prometheus
수신 이메일(SNS) + Discord #incident Discord #incident / #alerts
정의 위치 v2/alerting.tf, v2/broker.tf v2/modules/monitoring/templates/grafana/alerting.yaml.tftpl
켜는 변수 alerting_enabled, alert_discord_enabled monitoring_grafana_alerting_enabled
flowchart LR
  subgraph SRC["AWS 지표"]
    ALBM["ALB · 대상 그룹"]
    ECSM["ECS Container Insights"]
    ECM["ElastiCache"]
    HB["모니터링 하트비트<br/>sync.sh가 1분마다 전송"]
    ALF["Alloy 로그 지표 필터"]
  end

  subgraph A["경로 A · CloudWatch"]
    CWA["CloudWatch Alarm<br/>moyeota-v2-*"]
    SNS["SNS<br/>moyeota-v2-alerts"]
    EB["EventBridge 규칙<br/>ALARM · OK 상태 변경"]
    APID["API destination<br/>입력 변환기 → JSON"]
  end

  subgraph B["경로 B · 모니터링 EC2"]
    PROM["Prometheus"]
    GA["Grafana Alerting<br/>규칙 6개 · 1분 평가"]
    POL["알림 정책<br/>stage · severity 라우팅"]
  end

  APP["ECS 태스크<br/>:8090 /actuator/prometheus<br/>fastapi /metrics"]
  MAIL["이메일"]
  INC["Discord #incident"]
  ALT["Discord #alerts"]

  ALBM & ECSM & ECM & HB & ALF --> CWA
  CWA --> SNS --> MAIL
  CWA -->|상태 변경 이벤트| EB --> APID --> INC
  APP -->|scrape 30초| PROM --> GA --> POL
  POL -->|"stage=prod & severity=page"| INC
  POL -->|그 외| ALT
Loading

3.1 CloudWatch → Discord: Lambda 대신 EventBridge API destination

모니터링 설계 단계(설계 문서 6절)에서는 SNS → Lambda → Discord를 계획했지만, 구현하면서 바꿨다. SNS는 Discord 웹훅이 요구하는 {"content": "..."} 형식으로 보낼 수 없어 중간에 변환기가 필요하다. Lambda를 쓰면 코드·런타임·패키징을 따로 관리해야 한다. 반면 EventBridge 입력 변환기로 JSON을 만들고 API destination으로 웹훅에 바로 POST하면 실행 코드 없이 같은 일을 한다.

항목 설정 이유
이벤트 패턴 aws.cloudwatch · CloudWatch Alarm State Change · 알람 이름 접두어 moyeota-v2- · 상태 ALARM, OK 이름 규칙만 지키면 새로 추가한 알람도 자동으로 Discord에 간다
인증 의미 없는 API 키 헤더 X-Moyeota-Source API destination은 connection 인증이 필수다. Discord는 이 헤더를 무시한다
전송 제한 초당 1회 Discord 웹훅 rate limit
재시도 최대 10회, 이벤트 보존 1시간 응답이 없거나 429·5xx면 EventBridge가 다시 보낸다
OK 전송 ALARM과 OK 모두 전송 복구 시점이 채널에 남는다

Discord 메시지 형식은 다음과 같다. 알람 사유(reason) 문자열은 따옴표가 섞여 JSON이 깨질 수 있어서 넣지 않고, 콘솔 링크로 대신한다.

**[ALARM]** `moyeota-v2-prod-alb-elb-5xx` (이전 OK)
2026-10-15T05:00:00Z
https://ap-northeast-2.console.aws.amazon.com/cloudwatch/home?region=ap-northeast-2#alarmsV2:alarm/moyeota-v2-prod-alb-elb-5xx

3.2 감시자를 감시하는 하트비트

모니터링 호스트의 sync.sh는 1분마다 Prometheus·Loki·Grafana 상태(ComponentHealthy 0 또는 1)와 데이터 디스크·메모리 사용률을 CloudWatch에 보낸다. 하트비트 알람은 treat_missing_data = breaching으로 설정해서, 호스트가 통째로 죽어 지표가 끊겨도 ALARM이 된다.

Loki로 가는 로그 유실은 별도로 잡는다. Alloy가 재시도를 모두 실패하고 로그 배치를 버리면 자기 로그(awslogs)에 final error sending batch를 남긴다. 이 문구를 로그 지표 필터로 세어 알람을 건다. 이때 CloudWatch Logs에는 원본 로그가 남아 있으므로 사후 분석은 가능하다.


4. 알림 규칙

4.1 경로 A: CloudWatch 알람 (모두 이메일 + #incident)

알람 이름은 모두 moyeota-v2-로 시작한다. 아래 표에서는 이 접두어를 생략했다.

# 알람 조건 생성 대상
⑥ monitoring-{prometheus,loki,grafana}-down ComponentHealthy 최솟값 < 1, 3분 연속. 지표 누락도 ALARM 모니터링 호스트
⑥ monitoring-data-disk-high 데이터 디스크(Prometheus TSDB·Loki WAL) > 80% 모니터링 호스트
⑥ monitoring-host-status-check EC2 상태 검사 실패 2분 모니터링 호스트
⑥ <stage>-alloy-log-dropped Loki 전송 최종 실패 > 0건 (5분) 로그 수집 스테이지 중 alert_stages에 든 것
① prod-alb-elb-5xx ALB 자체 5xx 5분 합 > 10 (alert_alb_5xx_threshold) prod
② <stage>-<svc>-unhealthy-targets UnHealthyHostCount > 0, 3분 연속 alert_stages의 ALB 연결 서비스
③ <stage>-<svc>-tasks-below-desired Desired − Running > 0, 5분 연속 alert_stages 중 Container Insights를 켠 스테이지 (Alloy DAEMON 포함)
⑤ <stage>-redis-00N-engine-cpu EngineCPU 평균 > 70%, 3분 ElastiCache 노드별
⑤ <stage>-redis-00N-memory 메모리 사용률 > 80%, 3분 ElastiCache 노드별
⑤ <stage>-redis-00N-bandwidth-out-exceeded 대역폭 허용치 초과 > 0 ElastiCache 노드별

지속 시간은 배포 중 정상 동작을 오탐하지 않도록 정했다.

  • 대상 그룹 3분: 롤링 배포 중 새 태스크가 헬스체크를 통과하기 전의 짧은 UnHealthy 구간을 넘긴다.
  • 태스크 수 5분: 배포·Spot 교체로 Running이 잠깐 desired보다 적은 구간을 넘긴다.
  • Alloy DAEMON 태스크 수: DAEMON 서비스의 desired는 컨테이너 인스턴스 수와 같다. 메모리 여유가 없어 Alloy가 못 뜬 호스트가 있으면 이 알람이 울리고, 그 호스트의 로그는 Loki로 가지 않는다.

4.2 경로 B: Grafana 알림 규칙 (1분마다 평가)

# 규칙 조건 지속 severity
④ 수집 대상 응답 없음 up < 1 2분 page
④ Spring 5xx 비율 5xx / 전체 > 5% (/actuator 제외, 분당 6건 미만은 평가 제외) 5분 page
④ FastAPI 5xx 비율 5xx / 전체 > 5% (분당 6건 미만은 평가 제외) 5분 page
④ Spring p99 응답 시간 p99 > 2.5초 10분 warning
④ DB 커넥션 대기 hikaricp_connections_pending > 0 5분 warning
④ JVM 힙 사용률 힙 사용 / 최대 > 90% 10분 warning
  • 저트래픽 제외: 요청이 몇 건 없을 때는 1건만 실패해도 비율이 크게 튀어 오탐이 생긴다. 그래서 초당 0.1건(분당 6건) 이상일 때만 비율을 본다.
  • page와 warning의 기준: 사용자가 이미 실패를 겪고 있으면(응답 없음, 5xx) page로 둔다. 곧 문제가 될 징후(지연, 커넥션 대기, 힙)는 warning으로 둔다.
  • p99 2.5초: BE에 요청한 SLO bucket 경계(1s, 2.5s)와 맞췄다. 사용자 영향을 최종 판단하는 일은 SLO burn-rate 알림(7절)이 맡고, 이 규칙은 조기 경보 역할을 한다.
  • 각 규칙의 annotation에 1차 확인 항목을 적어 두었다. 예를 들어 5xx 비율 알림에는 "V2 서비스 상세 대시보드의 URI별 요청량과 에러 로그를 확인한다"가 함께 간다.

4.3 채널과 라우팅

채널 받는 알림 기대 행동
#incident CloudWatch 알람 전부(ALARM·OK), Grafana의 stage=prod & severity=page 즉시 확인하고 대응한다
#alerts 그 외 Grafana 알림 (prod warning, stg 알림) 업무 시간 안에 확인한다
Grafana 알림 정책 #alerts (기본) #incident
묶음 기준 alertname, stage, service 같음
첫 전송 대기 (group_wait) 30초 30초
같은 묶음 추가 전송 (group_interval) 5분 5분
미해결 시 재전송 (repeat_interval) 4시간 1시간

CloudWatch 경로에는 severity 구분이 없어서 모두 #incident로 간다. 이 경로에 둔 알람은 모두 관측 계층이 위험하거나 사용자 진입이 막힌 상황이라서 즉시 대응 대상으로 본다.

4.4 알림 대상 스테이지

alert_stages의 기본값은 ["prod"]이다. Grafana 규칙도 같은 변수를 stage 정규식으로 쓴다.

스테이지 알림 이유
prod 기본 대상
stg QA 기간에만 ["stg", "prod"]로 추가 QA 때만 올렸다가 내리는 환경이다. stg의 CloudWatch 알람도 #incident로 가므로 QA가 끝나면 다시 뺀다
dev 제외 09~24시에만 운영해서 내려갈 때마다 태스크 수·대상 그룹 알람이 울린다

예외로, 모니터링 하트비트 알람은 스테이지와 상관없이 만들고, Redis 알람은 ElastiCache가 있는 모든 스테이지에 만든다.


5. 켜는 순서와 검증

모든 기능은 변수 기본값이 꺼짐(false)이라서, 코드를 머지하는 것만으로는 리소스가 생기지 않는다. 아래 순서대로 켠다.

단계 변수·작업 확인
1 모니터링 호스트 정상 확인 docker compose ps, Prometheus targets, CloudWatch에 하트비트 지표가 들어오는지. 하트비트 알람은 지표가 없으면 바로 ALARM이 되므로 반드시 먼저 확인한다
2 alerting_enabled = true, alert_emails 각 메일함에서 SNS 구독 확인(Confirm subscription)
3 SSM /<config_path_prefix>/monitoring/DISCORD_WEBHOOK_INCIDENT 값 입력 → alert_discord_enabled = true 값이 CHANGE_ME면 apply가 실패한다
4 SSM DISCORD_WEBHOOK_ALERTS 값 입력 → monitoring_grafana_alerting_enabled = true Grafana Alerting에 규칙 6개가 보이는지, Contact points에서 Test 전송
5 (QA 기간) alert_stages = ["stg", "prod"] stg 규칙 생성 확인

경로 A 테스트:

# SNS 구독 상태
aws sns list-subscriptions-by-topic --topic-arn $(terraform output -raw alerts_sns_topic_arn)

# 알람을 강제로 ALARM으로 바꿔 이메일·Discord 수신 확인 → 다음 평가에서 OK가 오는지 확인
aws cloudwatch set-alarm-state \
  --alarm-name moyeota-v2-monitoring-data-disk-high \
  --state-value ALARM --state-reason "manual test"
검증 항목 방법 상태
Terraform 구성 tofu validate, tofu test (이메일 구독, 알람 키, 하트비트 breaching, Discord 대상 생성, 알림 파일 기본 미배포) 완료
쿼리 문법 PromQL 31개 promtool check rules, LogQL 5개 로컬 Loki 쿼리 완료
입력 변환기 템플릿 JSON 이스케이프(\n) 완료
경로 A 실환경 set-alarm-state → 이메일·#incident ALARM 수신, 이어서 OK 수신 미검증
경로 B 실환경 Grafana Contact point Test → #incident·#alerts 수신 미검증
실제 장애 감지 stg 장애 주입(FIS) 시 해당 규칙 발생 stg 장애 주입 테스트 때

6. 운영 시 주의점

항목 내용
웹훅 URL이 state에 남음 경로 A의 웹훅은 plan 시 SSM에서 읽어 API destination에 넣는다. 그래서 Terraform state에 저장되므로 state 버킷 접근을 제한한다. 웹훅을 바꾸면 다시 apply해야 한다
Grafana 웹훅 오류는 조용히 실패 경로 B의 웹훅은 .env 환경 변수로 주입된다. 값이 CHANGE_ME면 규칙은 정상으로 보이지만 전송만 실패한다. 켜기 전에 Contact point Test를 반드시 한다
메트릭이 없으면 알림도 없음 규칙의 noDataState가 OK다. BE가 /actuator/prometheus를 열지 않았거나 histogram을 켜지 않았으면 5xx·p99 규칙은 평가할 데이터가 없어 절대 울리지 않는다. BE 요청 이슈(issues/monitoring/be-request-monitoring.md)의 P0 항목이 반영됐는지 확인하는 것까지가 알림 체계 구축의 완료 조건이다
알람 이름 접두어 EventBridge가 moyeota-v2- 접두어로 매칭한다. 이 접두어가 없는 알람은 이메일로만 가고, 접두어를 쓰면 콘솔에서 손으로 만든 알람도 Discord로 간다
규칙 수정·삭제 프로비저닝된 규칙은 UI에서 수정할 수 없으므로 임계값은 코드로만 바꾼다. 파일에서 지운 규칙은 Grafana에 남으므로 deleteRules에 uid를 넣어 한 번 배포한다
알림 파일 변경 시 재시작 대시보드·알림 파일이 바뀌면 동기화 스크립트가 Grafana를 재생성해서 수 초간 중단된다

7. 남은 작업

항목 내용 시점
SLO burn-rate 알림 (issue-09, 미구현) 임계값 알림은 "지금 나쁜가"만 본다. SLO 알림은 에러 예산을 얼마나 빨리 쓰는가로 판단해 오탐과 누락을 줄인다. prod rest 가용성 99.9%, rolling 28일, page 14.4×(1h & 5m)·6×(6h & 30m), ticket 3×(1d & 2h)·1×(3d & 6h) BE SLO bucket 배포 후
RDS 알람 현재 RDS에는 알람이 없다. Prod RDS로 이관한 뒤 CPU, FreeStorageSpace, DatabaseConnections 알람을 경로 A에 추가하는 것을 검토한다 Prod DB 이관 후
BE 연동 P0 /actuator/prometheus 접근 허용, HTTP histogram, JSON 구조화 로그 stg 배포 전

참고 문헌

⚠️ **GitHub.com Fallback** ⚠️