Dev 모니터링과 알림 - 100-hours-a-week/KTB4-2nd-Cloud GitHub Wiki

모니터링과 알림

App Host 지표와 Nginx, Backend, AI 로그를 CloudWatch 한 곳에 모으고, 장애 의미가 분명한 항목에만 Alarm을 걸어 Discord로 받습니다.

로그에는 서비스 사이를 따라갈 수 있는 request_id, trip_id, job_id를 넣어서, 사용자 요청 하나가 Nginx에서 AI까지 어떻게 흘렀는지 한 번에 조회할 수 있게 했습니다.


수집 구조

flowchart LR
  subgraph APP["App EC2"]
    NG["Nginx<br/>Access, Error Log"] --> AG["CloudWatch Agent"]
    HOST["메모리, 디스크, 프로세스"] --> AG
    BE["Backend 컨테이너<br/>stdout JSON"]
  end
  subgraph WK["Worker EC2"]
    AI["AI 컨테이너<br/>stdout JSON"]
  end
  EC2M["EC2 기본 지표<br/>CPU, 네트워크, 상태 검사"]
 
  AG -- "로그" --> CWL[("CloudWatch Logs")]
  AG -- "지표" --> CWM[("CloudWatch Metrics")]
  BE -- "awslogs 드라이버" --> CWL
  AI -- "awslogs 드라이버" --> CWL
  EC2M --> CWM
 
  CWL --> D["Dashboard"]
  CWM --> D
  CWL --> A["Alarm"]
  CWM --> A
  A --> SNS["SNS"] --> L["Lambda"] --> DC["Discord"]
Loading
로그 Log Group 보존
Nginx Access /yeodam/v1/nginx/access 30일
Nginx Error /yeodam/v1/nginx/error 30일
Backend /yeodam/v1/backend 30일
AI /yeodam/v1/ai 30일

Log Group과 보존 기간은 Terraform이 만들고, EC2 Role에는 자기 Log Group에 쓰는 권한만 줬습니다.


Host 지표

EC2 기본 지표로는 메모리, 디스크, 프로세스 상태를 볼 수 없어서 App에 CloudWatch Agent를 설치했습니다. 60초 간격으로 보냅니다.

구분 지표
CPU 대기 cpu_usage_iowait
메모리 mem_used_percent, swap_used_percent
디스크 Root와 /var/lib/mysql의 사용률, inode
프로세스 전체, 실행, blocked 개수
TCP established, time_wait
주요 프로세스 nginx, mysqld, dockerd의 CPU, 메모리, PID 개수

EC2 기본 지표와 겹치는 CPU 사용률, 네트워크, 디스크 I/O는 다시 수집하지 않았습니다.

디스크 지표에서는 장치 이름을 빼고 마운트 경로로만 구분했습니다. 재부팅이나 재연결 뒤 NVMe 장치 이름이 바뀌면 같은 디스크가 다른 지표로 쌓이기 때문입니다.


Agent 설정이 사라지는 문제

처음에는 Agent 설정 원본을 Agent 기본 경로에 두었습니다. 설정 적용과 Agent 시작은 성공했는데, 스크립트 마지막의 SHA256 확인이 실패했고 롤백이 Agent를 멈춰버렸습니다.

확인해보니 fetch-config가 원본 파일을 내부 설정으로 가져가면서 원래 위치에서 지우고 있었습니다.

원본은 /etc/yeodam/cloudwatch-agent/app.json에 따로 두고, Agent 내부 경로는 Agent가 관리하도록 나눴습니다. 지금은 배포 스크립트가 Nginx 검증 뒤 이 설정을 자동으로 적용합니다.


Nginx 로그

Nginx는 요청마다 한 줄 JSON을 남깁니다.

{
  "timestamp": "2026-09-29T12:31:00+00:00",
  "request_id": "d0e3be9a2c5ac44f813799e56595bf76",
  "method": "GET",
  "normalized_route": "/api/trips/map",
  "status": 401,
  "request_time": 0.001,
  "upstream_response_time": "0.001",
  "upstream_status": "401",
  "release": "7eb0bceb5680ae3cce9c4690cf49f98b57bb5d2b"
}
  • request_id는 Nginx가 만들어 X-Request-ID 헤더로 Backend에 넘기고, Backend가 AI 호출에도 이어서 넘깁니다.
  • 경로는 /api/trips/:tripId처럼 ID를 지운 형태로 남깁니다. 사용자 식별자를 남기지 않고, 경로별 집계도 가능합니다.
  • 쿼리 스트링, 쿠키, Authorization 헤더, 요청 본문은 남기지 않습니다.
  • release에는 배포한 Cloud 커밋을 넣습니다.

request_time과 upstream_response_time을 둘 다 남긴 건 사진 업로드에서 전송이 느린 건지 Backend 처리가 느린 건지 나눠 보기 위해서입니다.


Backend, AI 로그

Backend와 AI는 컨테이너 stdout에 JSON을 한 줄씩 쓰고, Docker의 awslogs 드라이버가 CloudWatch로 바로 보냅니다. 파일로 남겼다가 Agent가 읽는 과정이 없어서 필드가 그대로 조회됩니다.

공통 필드는 event, result, request_id, trip_id, job_id, duration_ms, failure_stage, error_code, release입니다. release는 배포 스크립트가 이미지 태그의 커밋 SHA를 꺼내 넣습니다.

실제 여행 생성으로 확인했을 때, 마지막 업로드 요청의 request_id가 Nginx, Backend, AI 로그에 똑같이 남았고 같은 trip_id, job_id로 AI 처리 단계까지 이어졌습니다.


Dashboard

yeodam-v1-operations 하나에 App과 Worker 상태, Nginx 요청, Backend와 AI 결과를 모았습니다.

영역 내용
Host CPU, 메모리, Swap, I/O 대기, 디스크, 주요 프로세스 개수
Nginx 경로와 상태 코드별 요청 수, p95
Backend 이벤트별 성공, 실패 건수와 처리 시간, 최근 실패 30건
여행 생성 시작부터 종료까지 전체 시간, 종료 이벤트가 없는 작업
AI 작업 결과, 단계별 처리 시간, 최근 실패 30건

사진 업로드 경로는 상태 코드별로 나눠서 봅니다. 중간 Batch는 204, AI 처리까지 기다리는 마지막 Batch는 200이라, 둘을 섞으면 p95가 의미 없어집니다. 150장 여행에서 중간 Batch는 40초대, 마지막 Batch는 4분대였습니다.


Alarm

부하 테스트 전이라 CPU나 메모리의 정상 범위를 모르는 상태여서, 장애가 확실한 항목만 먼저 걸었습니다.

Alarm 조건
App EC2 상태 검사 2분 연속 실패
Worker EC2 상태 검사 실행 중 2분 연속 실패
App 디스크 Root 또는 MySQL 5분 연속 80% 이상
주요 프로세스 Nginx, MySQL, Docker PID 3분 연속 0개
Nginx 5xx 5분 동안 5건 이상

Worker는 평소에 꺼져 있어서, 데이터가 없는 상태를 정상으로 처리했습니다.

프로세스 Alarm은 처음 만들었을 때 계속 INSUFFICIENT_DATA에 머물렀습니다. 프로세스 Alarm이 Metric을 찾지 못함


Discord 알림

Alarm은 SNS로 가고, SNS가 Lambda를 호출해 Discord Webhook으로 보냅니다. Webhook URL은 Parameter Store SecureString에 두고 Lambda는 이 값 하나만 읽을 수 있습니다.

SNS에 직접 메시지를 보내는 것과, 실제 Nginx 5xx Alarm을 강제로 ALARM으로 바꾸는 것 두 가지로 확인했습니다. 장애 메시지와 정상 복귀 메시지가 모두 Discord에 들어왔습니다.

운영 중에는 이 Alarm으로 실제 장애를 감지했습니다.

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