AWS 실전 #5 모니터링: CloudWatch 알람과 X-Ray

8 분 소요

#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 단위로 쌓이면 비용이 커집니다.

30 일 보관
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가 한 키씩 쿼리할 수 있습니다.

FastAPI: JSON로깅
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 100

B) 응답 시간 분포 (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 20

D) request_id로 한 요청 추적 #

fields @timestamp, level, message, path, status, duration_ms
| filter request_id = "abc-123-xyz"
| sort @timestamp asc

E) Stacktrace가 찍힌 줄들 #

fields @timestamp, message
| filter @message like /Traceback|exception/
| sort @timestamp desc

F) 로그인 시도 (인증 실패) #

fields @timestamp, source_ip, username
| filter event = "auth_fail"
| stats count(*) by source_ip
| sort count(*) desc

G) 비용: 어떤 path가 가장 많이 호출 #

fields path
| stats count(*) by path
| sort count(*) desc
| limit 30

Saved Queries #

자주 쓰는 쿼리는 콘솔에서 저장합니다. 팀 전체가 공유합니다. CloudFormation / Terraform으로 IaC화 가능 (aws_cloudwatch_query_definition).

3) CloudWatch Metrics: 핵심 지표 #

ECS Container Insights #

기본 ECS 메트릭은 빈약합니다. Container Insights를 켜면 작업 / 서비스 단위 CPU / 메모리 / 네트워크 / 디스크 / 실행 중인 작업 수를 한 번에 볼 수 있습니다.

Container Insights 활성화
aws ecs update-cluster-settings \
  --cluster blog-cluster \
  --settings name=containerInsights,value=enabled

추가 비용 (작은 클러스터 ~$1~3/월)이 들지만 운영에선 필수.

모니터링 표: 무엇을 봐야 하는가 #

메트릭자원의미알람 임계 (예시)
HTTPCode_Target_5XX_CountALB백엔드 5xx5분 합계 ≥ 5
HTTPCode_ELB_5XX_CountALBALB 자체 5xx (대부분 healthy host 0)5분 합계 ≥ 1
TargetResponseTime (p99)ALB응답 시간 p995분 평균 ≥ 1.0s
UnHealthyHostCountTarget Group죽은 task 수5분 평균 ≥ 1
CPUUtilization (Service)ECS서비스 평균 CPU5분 평균 ≥ 80%
MemoryUtilization (Service)ECS메모리5분 평균 ≥ 85%
RunningTaskCountECS떠 있는 task 수desired와 다름
CPUUtilizationRDSDB CPU5분 평균 ≥ 80%
DatabaseConnectionsRDS연결 수max_connections의 80%
FreeStorageSpaceRDS남은 디스크< 5GB
ReadLatency / WriteLatencyRDS디스크 지연> 50ms

Custom Metrics #

앱에서 직접 찍는 메트릭. **로그에 EMF (Embedded Metric Format)**로 박으면 별도 호출 없이.

EMF로 비즈니스 메트릭
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) 알람: 임계값을 넘으면 사람을 부른다 #

ALB 5xx 알람
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 작업 실행 정상” → 진짜 백엔드 문제입니다.

Composite Alarm
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 / OpsGenie

SNS → Slack Lambda #

lambda_handler.py
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가 답합니다.

X-Ray Trace의 모양
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.28s

FastAPI/Django 통합 #

설치
pip install aws-xray-sdk
FastAPI
from 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로:

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% 만:

x-ray.json
{
  "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에 운영의 시그널을 한 화면에 모읍니다:

권장 위젯 9 개
[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 FreeStorage
대시보드 IaC (Terraform 발췌)
resource "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편을 하나의 흐름으로 묶어 정리합니다.

X