V2 서비스 장애 알림 체계 설계 - 100-hours-a-week/KTB4-3rd-CLOUD GitHub Wiki
V1에서는 장애를 사용자 CS 문의로 먼저 알았다. CloudWatch 알람은 있었지만 SNS 토픽에 구독자가 없어 아무에게도 전달되지 않았다. V2는 요청 경로에서 장애를 판단할 지점을 정하고, 지점마다 알림 규칙을 두어 Discord로 개발팀에 보낸다. 목표는 사용자보다 먼저 장애를 아는 것이다.
모니터링 도구 선정과 수집 구조는 V2 중앙 집중식 모니터링 시스템 설계를 따른다. 이 문서는 그 위의 알림 계층만 다룬다.
1. V1의 알림은 왜 동작하지 않았는가?
2. 장애는 어디에서 판단하는가?
3. 알림 경로를 왜 두 개로 나누는가?
4. 어떤 규칙이 어느 채널로 가는가?
5. 어떤 순서로 켜고 어떻게 검증하는가?
| 항목 | V1 상태 | 결과 |
|---|---|---|
| 알람 수신자 | SNS moyeota-alerts 토픽에 구독자 없음 (alert_email = null) |
알람이 울려도 아무도 모른다 |
| 알람 범위 | CPU ≥ 80%, StatusCheck만 생성. 메모리·디스크 알람은 비활성 | 호스트가 살아 있으면 장애로 보지 않는다 |
| 애플리케이션 지표 | 5xx 비율·p95·Hikari 알람 없음 | 사용자가 겪는 오류와 지연을 감지하지 못한다 |
| Discord | GitHub Actions가 CI 실패만 직접 전송 | 운영 장애를 받는 채널이 없다 |
요청이 지나가는 순서를 따라가며, 지점마다 **"여기가 깨지면 사용자가 무엇을 겪는가"**를 기준으로 판단 지점을 정했다.
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
| # | 판단 지점 | 깨지면 생기는 일 | 신호 | 담당 경로 |
|---|---|---|---|---|
| ① | 진입점 (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(④)는 다른 신호다. ①은 요청이 앱까지 가지 못한 경우이고, ④는 앱이 받았지만 실패한 경우다. 그래서 두 신호를 서로 다른 경로에서 본다.
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
모니터링 설계 단계(설계 문서 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
모니터링 호스트의 sync.sh는 1분마다 Prometheus·Loki·Grafana 상태(ComponentHealthy 0 또는 1)와 데이터 디스크·메모리 사용률을 CloudWatch에 보낸다. 하트비트 알람은 treat_missing_data = breaching으로 설정해서, 호스트가 통째로 죽어 지표가 끊겨도 ALARM이 된다.
Loki로 가는 로그 유실은 별도로 잡는다. Alloy가 재시도를 모두 실패하고 로그 배치를 버리면 자기 로그(awslogs)에 final error sending batch를 남긴다. 이 문구를 로그 지표 필터로 세어 알람을 건다. 이때 CloudWatch Logs에는 원본 로그가 남아 있으므로 사후 분석은 가능하다.
알람 이름은 모두 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로 가지 않는다.
| # | 규칙 | 조건 | 지속 | 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별 요청량과 에러 로그를 확인한다"가 함께 간다.
| 채널 | 받는 알림 | 기대 행동 |
|---|---|---|
#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로 간다. 이 경로에 둔 알람은 모두 관측 계층이 위험하거나 사용자 진입이 막힌 상황이라서 즉시 대응 대상으로 본다.
alert_stages의 기본값은 ["prod"]이다. Grafana 규칙도 같은 변수를 stage 정규식으로 쓴다.
| 스테이지 | 알림 | 이유 |
|---|---|---|
| prod | 기본 대상 | |
| stg | QA 기간에만 ["stg", "prod"]로 추가 |
QA 때만 올렸다가 내리는 환경이다. stg의 CloudWatch 알람도 #incident로 가므로 QA가 끝나면 다시 뺀다 |
| dev | 제외 | 09~24시에만 운영해서 내려갈 때마다 태스크 수·대상 그룹 알람이 울린다 |
예외로, 모니터링 하트비트 알람은 스테이지와 상관없이 만들고, Redis 알람은 ElastiCache가 있는 모든 스테이지에 만든다.
모든 기능은 변수 기본값이 꺼짐(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 장애 주입 테스트 때 |
| 항목 | 내용 |
|---|---|
| 웹훅 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를 재생성해서 수 초간 중단된다 |
| 항목 | 내용 | 시점 |
|---|---|---|
| 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 배포 전 |
- V2 중앙 집중식 모니터링 시스템 설계
- 레포
issues/monitoring/: issue-04·pr-04(CloudWatch 알람·Discord), issue-08·pr-08(Grafana 알림), issue-09(SLO), be-request-monitoring - Amazon EventBridge — API destinations
- Amazon CloudWatch — Alarm events and EventBridge
- Grafana — Use configuration files to provision alerting resources
- Google SRE Workbook — Alerting on SLOs