AWS 실전 #5 모니터링: CloudWatch 알람과 X-Ray
#1 ~ #4에서 인프라가 코드가 되고 배포가 자동이 됐습니다. 그런데 정작 이 시스템이 잘 돌고 있는지, 즉 5xx가 늘었는지, RDS CPU가 80%인지, 어떤 요청이 5초 걸렸는지를 한 화면에서 보지 못하고 있습니다.
이번 글은 그 상태를 한눈에 보이게 합니다.
- CloudWatch Logs + Logs Insights 운영 쿼리
- CloudWatch Metrics. ECS / RDS / ALB의 핵심 메트릭과 알람 임계값
- 알람 → SNS → 슬랙의 흐름
- X-Ray. 분산 추적으로 “어디가 느린가” 를 한 줄로
- 대시보드. 한 화면에서 시스템 상태를 확인
큰 그림: 모니터링의 4가지 구성 #
┌──────────────┬──────────────┬──────────────┬──────────────┐
│ Metrics │ Logs │ Traces │ Events │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ "얼마나" │ "무엇이" │ "어디서" │ "언제" │
│ 요청수, 5xx │ stacktrace │ DB가 5초 │ 배포, 스케일 │
│ CPU, 메모리 │ access log │ 외부 API 1초 │ 페일오버 │
├──────────────┼──────────────┼──────────────┼──────────────┤
│ CloudWatch │ CloudWatch │ X-Ray │ EventBridge │
│ Metrics │ Logs │ │ │
└──────────────┴──────────────┴──────────────┴──────────────┘이 글은 Metrics + Logs + Traces 세 영역입니다. Events는 고급 #5에서 다뤘습니다.
1) CloudWatch Logs: 이미 흐르고 있다 #
#1의 작업 정의에 awslogs가 포함되어 있어 모든 컨테이너 stdout/stderr가 자동으로 CloudWatch Logs로 갑니다.
Log Group: /ecs/blog-api
│
├── Log Stream: api/<task-id-1> ← 작업별로 stream 한 개
├── Log Stream: api/<task-id-2>
└── Log Stream: api/<task-id-3>Retention 설정: 비용 분리 #
기본값은 무한 보관입니다. 작은 트래픽에서도 한 달치 로그가 GB 단위로 쌓이면 비용이 커집니다.
aws logs put-retention-policy \
--log-group-name /ecs/blog-api \
--retention-in-days 30권장:
- 운영 access log: 30 ~ 90일
- 디버그 / verbose: 7일
- 감사 로그: 365일 (또는 S3 export 후 삭제)
구조화 로그가 핵심 #
print()는 검색이 어렵습니다. JSON으로 찍으면 Logs Insights가 한 키씩 쿼리할 수 있습니다.
import logging, json, sys
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"level": record.levelname,
"logger": record.name,
"message": record.getMessage(),
"ts": self.formatTime(record),
**getattr(record, "extra", {}),
})
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])@app.middleware("http")
async def access_log(request, call_next):
start = time.time()
response = await call_next(request)
logging.info("access", extra={"extra": {
"method": request.method,
"path": str(request.url.path),
"status": response.status_code,
"duration_ms": int((time.time() - start) * 1000),
"request_id": request.state.request_id,
}})
return response이렇게 찍으면 Logs Insights가 다음 같은 쿼리를 정확히 답합니다.
2) Logs Insights: 운영 쿼리 7개 #
자주 쓰는 쿼리 모음. 책갈피해 두세요.
A) 5xx만 추리기 #
fields @timestamp, status, path, request_id, message
| filter status >= 500
| sort @timestamp desc
| limit 100B) 응답 시간 분포 (p50/p90/p99) #
fields @timestamp, duration_ms
| filter ispresent(duration_ms)
| stats
count(*) as requests,
pct(duration_ms, 50) as p50,
pct(duration_ms, 90) as p90,
pct(duration_ms, 99) as p99
by bin(5m)C)가장 느린 path #
fields path, duration_ms
| filter duration_ms > 1000
| stats count(*), avg(duration_ms), max(duration_ms) by path
| sort avg(duration_ms) desc
| limit 20D) request_id로 한 요청 추적 #
fields @timestamp, level, message, path, status, duration_ms
| filter request_id = "abc-123-xyz"
| sort @timestamp ascE) Stacktrace가 찍힌 줄들 #
fields @timestamp, message
| filter @message like /Traceback|exception/
| sort @timestamp descF) 로그인 시도 (인증 실패) #
fields @timestamp, source_ip, username
| filter event = "auth_fail"
| stats count(*) by source_ip
| sort count(*) descG) 비용: 어떤 path가 가장 많이 호출 #
fields path
| stats count(*) by path
| sort count(*) desc
| limit 30Saved Queries #
자주 쓰는 쿼리는 콘솔에서 저장합니다. 팀 전체가 공유합니다. CloudFormation / Terraform으로 IaC화 가능 (aws_cloudwatch_query_definition).
3) CloudWatch Metrics: 핵심 지표 #
ECS Container Insights #
기본 ECS 메트릭은 빈약합니다. Container Insights를 켜면 작업 / 서비스 단위 CPU / 메모리 / 네트워크 / 디스크 / 실행 중인 작업 수를 한 번에 볼 수 있습니다.
aws ecs update-cluster-settings \
--cluster blog-cluster \
--settings name=containerInsights,value=enabled추가 비용 (작은 클러스터 ~$1~3/월)이 들지만 운영에선 필수.
모니터링 표: 무엇을 봐야 하는가 #
| 메트릭 | 자원 | 의미 | 알람 임계 (예시) |
|---|---|---|---|
HTTPCode_Target_5XX_Count | ALB | 백엔드 5xx | 5분 합계 ≥ 5 |
HTTPCode_ELB_5XX_Count | ALB | ALB 자체 5xx (대부분 healthy host 0) | 5분 합계 ≥ 1 |
TargetResponseTime (p99) | ALB | 응답 시간 p99 | 5분 평균 ≥ 1.0s |
UnHealthyHostCount | Target Group | 죽은 task 수 | 5분 평균 ≥ 1 |
CPUUtilization (Service) | ECS | 서비스 평균 CPU | 5분 평균 ≥ 80% |
MemoryUtilization (Service) | ECS | 메모리 | 5분 평균 ≥ 85% |
RunningTaskCount | ECS | 떠 있는 task 수 | desired와 다름 |
CPUUtilization | RDS | DB CPU | 5분 평균 ≥ 80% |
DatabaseConnections | RDS | 연결 수 | max_connections의 80% |
FreeStorageSpace | RDS | 남은 디스크 | < 5GB |
ReadLatency / WriteLatency | RDS | 디스크 지연 | > 50ms |
Custom Metrics #
앱에서 직접 찍는 메트릭. **로그에 EMF (Embedded Metric Format)**로 박으면 별도 호출 없이.
import json, time, logging
def emit_metric(metric_name, value, unit="Count", **dims):
payload = {
"_aws": {
"Timestamp": int(time.time() * 1000),
"CloudWatchMetrics": [{
"Namespace": "BlogApp",
"Dimensions": [list(dims.keys())],
"Metrics": [{"Name": metric_name, "Unit": unit}],
}],
},
metric_name: value,
**dims,
}
logging.info(json.dumps(payload))
emit_metric("PostCreated", 1, env="prod")
emit_metric("CommentCreated", 1, env="prod")
emit_metric("LoginFailed", 1, source_ip="...")CloudWatch가 로그를 파싱해 자동으로 BlogApp/PostCreated 메트릭을 만듭니다. 별도 PutMetricData API 호출 없음. 비용 / 지연 모두 절약됩니다.
4) 알람: 임계값을 넘으면 사람을 부른다 #
aws cloudwatch put-metric-alarm \
--alarm-name "blog-alb-5xx-burst" \
--metric-name HTTPCode_Target_5XX_Count \
--namespace AWS/ApplicationELB \
--statistic Sum \
--period 60 \
--evaluation-periods 5 \
--datapoints-to-alarm 3 \
--threshold 5 \
--comparison-operator GreaterThanOrEqualToThreshold \
--treat-missing-data notBreaching \
--dimensions Name=LoadBalancer,Value=app/blog-alb/abc123 \
--alarm-actions arn:aws:sns:ap-northeast-2:123456789012:ops-alerts핵심 옵션 정리:
| 옵션 | 의미 |
|---|---|
period | 데이터 포인트 단위 (60 = 1분) |
evaluation-periods | 몇 개 포인트를 평가 |
datapoints-to-alarm | 그 중 몇 개가 임계 넘으면 알람 |
treat-missing-data | 데이터 없을 때. notBreaching 권장 |
comparison-operator | >= / > / < / <= |
5/3 패턴 (“최근 5분 중 3분이 임계값 넘으면”)이 일시적 스파이크를 거르면서도 진짜 사고를 잡는 기준.
Composite Alarm #
여러 알람을 묶습니다. “ALB 5xx 알람 AND 작업 실행 정상” → 진짜 백엔드 문제입니다.
aws cloudwatch put-composite-alarm \
--alarm-name "blog-real-incident" \
--alarm-rule "ALARM('blog-alb-5xx-burst') AND OK('blog-running-tasks-low')"OK()는 해당 알람이 정상인데 다른 알람이 발생한 경우에만 반응합니다. 이렇게 하면 노이즈를 줄일 수 있습니다.
5) SNS → 슬랙: 사람에게 전달되는 부분 #
CloudWatch Alarm
│
▼
SNS Topic (ops-alerts)
│
├── Email subscription (운영진)
├── SMS subscription (oncall)
├── Lambda subscription ← Slack webhook으로 변환
└── PagerDuty / OpsGenieSNS → Slack Lambda #
import json, os, urllib.request
WEBHOOK = os.environ["SLACK_WEBHOOK"]
def handler(event, context):
for record in event["Records"]:
msg = json.loads(record["Sns"]["Message"])
text = (
f":rotating_light: *{msg['AlarmName']}*\n"
f"Region: {msg['Region']}\n"
f"State: {msg['NewStateValue']} (was {msg['OldStateValue']})\n"
f"Reason: {msg['NewStateReason']}\n"
)
req = urllib.request.Request(
WEBHOOK,
data=json.dumps({"text": text}).encode(),
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req)고급 #3 Lambda의 패턴. SNS subscription으로 SNS가 Lambda를 호출하면 끝.
알람 메시지 양식 #
좋은 알람 메시지에는:
- 무엇이 깨졌는가 (alarm name)
- 얼마나 깨졌는가 (threshold / actual value)
- 어디서 (region, service)
- 언제 (timestamp)
- 링크. 콘솔 / 대시보드 / Logs Insights로 바로
링크가 가장 중요합니다. 새벽 3시에 슬랙을 본 당직자가 클릭 한 번에 맥락으로 들어가야 합니다.
6) X-Ray: 분산 추적 #
“5xx가 늘었어” 까지는 Metrics가 알려줍니다. “왜 5xx가 늘었지?” 는 Logs가. “이 요청이 어디에서 5초를 보냈지?” 는 X-Ray가 답합니다.
Request: POST /posts 4.2s
│
├── ALB 0.01s
│
└── ECS api 4.15s
│
├── auth.verify_token 0.05s
│
├── db.posts.insert 3.80s ← 범인
│ └── RDS PostgreSQL 3.78s
│ └── (slow query)
│
└── notify.publish (SNS) 0.30s
└── SNS:Publish 0.28sFastAPI/Django 통합 #
pip install aws-xray-sdkfrom aws_xray_sdk.core import xray_recorder
from aws_xray_sdk.ext.fastapi.middleware import XRayMiddleware
from aws_xray_sdk.ext.sqlalchemy.query import XRayQuery
xray_recorder.configure(service="blog-api")
app = FastAPI()
app.add_middleware(XRayMiddleware, recorder=xray_recorder)
# SQLAlchemy 추적
from aws_xray_sdk.ext.sqlalchemy_core import unpatch
# (engine 생성 시 자동 patch)Sidecar: X-Ray Daemon #
ECS에서는 X-Ray Daemon 컨테이너를 같은 task definition 안에 sidecar로:
{
"containerDefinitions": [
{ "name": "api", ... },
{
"name": "xray-daemon",
"image": "public.ecr.aws/xray/aws-xray-daemon:latest",
"portMappings": [{ "containerPort": 2000, "protocol": "udp" }],
"essential": false
}
]
}앱은 127.0.0.1:2000으로 trace를 보내고, daemon이 X-Ray 서비스로 일괄 전송. 별도 IAM 액션 (xray:PutTraceSegments)이 task role에 필요합니다.
어디서 가치가 가장 큰가 #
| 상황 | X-Ray 가치 |
|---|---|
| 단일 컨테이너 + 단일 DB | 보통. Logs만으로도 충분 |
| 여러 마이크로서비스 호출 | 매우 큼. 어느 단계가 느린지 한 줄 |
| 외부 API의존 | 매우 큼. 외부가 진짜 느린지 검증 |
| Lambda + DynamoDB | 매우 큼. Lambda 콜드 스타트, 외부 호출 분리 |
샘플링 #
모든 요청을 추적하면 비용이 큽니다. 샘플링 룰로 5~10% 만:
{
"version": 2,
"rules": [{
"description": "Default",
"service_name": "*",
"http_method": "*",
"url_path": "*",
"fixed_target": 1,
"rate": 0.05
}],
"default": { "fixed_target": 1, "rate": 0.05 }
}/health 같은 헬스체크는 0% 로 빼버려야 trace가 잡음으로 가득 차지 않습니다.
7) 대시보드: 한 화면 #
CloudWatch Dashboard에 운영의 시그널을 한 화면에 모읍니다:
[1] Requests/s (ALB) [2] 5xx rate (ALB) [3] p99 latency
[4] ECS CPU (Service) [5] ECS Memory [6] Running tasks
[7] RDS CPU [8] RDS Connections [9] RDS FreeStorageresource "aws_cloudwatch_dashboard" "blog" {
dashboard_name = "blog-overview"
dashboard_body = jsonencode({
widgets = [
{
type = "metric",
x = 0, y = 0, width = 8, height = 6,
properties = {
metrics = [["AWS/ApplicationELB", "RequestCount", "LoadBalancer", "app/blog-alb/abc123"]],
period = 60, stat = "Sum", region = "ap-northeast-2",
title = "Requests/min"
}
}
# ... 8 개 더
]
})
}정기 리뷰 #
매주 한 번 oncall이 대시보드를 훑으면서 점진적으로 나빠지는 지표를 찾습니다. 알람은 즉시 사고만 잡고, 점진적 악화는 사람의 눈이 더 빠릅니다.
함정: 운영 모니터링의 함정 #
1) Alert Fatigue: 알람이 너무 많다 #
알람 30개 / 일 → 곧 모두가 알람을 무시. 권장:
| 알람 등급 | 빈도 | 통로 |
|---|---|---|
| Critical | 월 1~2회 | PagerDuty / SMS |
| Warning | 주 1~2회 | Slack #ops |
| Info | 자주 | Slack #ops-info (조용한 채널) |
진짜 사람을 깨우는 알람은 5개 미만으로 유지합니다.
2) Logs가 무한히 커짐 #
Retention 설정 누락 → 6개월 후 청구서 충격. 모든 log group에 retention 적용 (Terraform으로 한 번에).
3) Logs가 너무 작음 #
운영 사고 직후 “그때 로그 보자” → 7일 retention이라 이미 사라짐. 사고 발생 후 즉시 export는 너무 늦습니다. 핵심 그룹은 30일 이상.
4) X-Ray 100% 샘플링 #
비용이 폭주. 5~10% 샘플 + 에러 / 느린 요청만 100% (X-Ray의 sampling rule로 가능).
5) SLO 없이 알람만 #
알람의 임계값이 어디서 왔는지. “내가 80% 라고 했습니다”. SLO (예: p99 < 500ms 99% 시간)가 명시되지 않으면 임계값이 임의가 됩니다. SLO 정의 → 알람 임계값 도출.
6) Dashboard만 있고 보지 않음 #
만들어 두고 안 보는 대시보드는 없는 것과 같음. 주간 oncall 회의에 30분 대시보드 리뷰를 넣습니다.
7) 알람이 사람에게 닿지 않음 #
이메일만 → 받은 편지함 깊은 곳으로. SMS / PagerDuty / Slack의 mention 같은 자기를 부르는 통로.
정리 #
이번 글에서 잡은 것:
- 4가지 구성. Metrics / Logs / Traces / Events
- CloudWatch Logs. awslogs 자동, retention 설정, 구조화 JSON, 7개 운영 쿼리
- CloudWatch Metrics. Container Insights 활성, ECS / RDS / ALB의 핵심 메트릭과 임계값
- EMF. PutMetricData 없이 로그로 메트릭 발행
- 알람.
period × evaluation × datapoints패턴,treat-missing-data - Composite Alarm. 노이즈 줄이기
- SNS → Lambda → Slack. 알람을 사람에게 닿게
- X-Ray. 분산 추적, sidecar daemon, 샘플링으로 비용 통제
- 대시보드. 9개 위젯을 한 화면에, IaC화
- 함정. alert fatigue, retention, 샘플링, SLO 누락, 대시보드 미관찰, 알람 통로
다음: 비용과 트랙 마무리 #
이제 시스템이 잘 돌고, 사고가 나면 알람이 울리는 구조까지 왔습니다. 마지막으로, 얼마나 들고 있는가, 그리고 27편의 트랙을 한 번에 회고합니다.
#6 비용 최적화와 대시보드: 트랙 마무리에서는 Cost Explorer 분석, Savings Plans / Spot Fargate, 적정 크기 조정, 태깅 강제, 비용 대시보드, 그리고 AWS 트랙 27편을 하나의 흐름으로 묶어 정리합니다.