[인턴] ALB Target Group cutover에 묻혀 있던 잠재 결함 — SRE Dashboard 데이터 누락 디버깅 회고

TL;DR

  • 토요일 새벽 알람이 떴고 SRE Dashboard의 Summary 카드(서비스 성능/안정성/트래픽)가 -/0으로 보였다. 두 증상은 사실 별개 문제였다.
  • 알람의 원인은 한 fetcher의 일회성 502 — 5분간 자동 복구되었고 데이터 누락은 없었다.
  • 카드의 원인은 ECS Fargate cutover로 트래픽이 새 TG(ecs-tg)로 이동했는데, SRE collector는 여전히 옛 TG(legacy-ec2-tg) suffix를 CloudWatch dimension으로 들고 있었다.
  • 핵심 통찰: ALB의 Target 단위 메트릭(TargetResponseTime, HTTPCode_Target_*)은 LoadBalancer 단독 dimension으로는 발행조차 되지 않는다. 반드시 [LoadBalancer + TargetGroup] 조합 발행. 따라서 TG suffix가 옛 값이면 메트릭 4개가 통째로 0/empty가 된다.
  • README에 “ECS production 모듈이 활성화되면 자동 주입된다”고 적혀 있었지만 Pulumi caller에서 wire-up이 빠져 있었다. fix는 5파일짜리 prop drilling.

1. 첫 신호 — 두 가지 섞인 증상

“AWS CloudWatch 지표를 토요일부터 못 가져오는 것 같다. 에러 알람이 왔다.”

동시에 SRE Dashboard 화면을 보니 Summary 4개 카드 중 3개가 -/0으로 떠 있고 인프라 자원(CPU) 카드만 정상이었다. 처음에는 같은 원인이라고 가정했지만 실제로는 두 증상이 별개였다.

2. Lambda 로그 분석 — 알람의 진짜 원인

/aws/lambda/sre-dashboard-collector 로그 검색.

2026-04-25T06:05:08Z urllib.error.HTTPError: HTTP Error 502: Bad Gateway
  File "request_***_fetcher.py", line 116
2026-04-25T06:05:08Z [ERROR] RuntimeError: collector failed for sources ['request_***']

CloudWatch가 아니라 한 비즈니스 fetcher가 GraphQL 본 쿼리에서 502를 받은 것. CloudWatch Alarm history도 확인:

2026-04-25T15:06:56 KST  OK → ALARM
2026-04-25T15:11:56 KST  ALARM → OK   (5분간)

알람은 1번만 발생, 자동 복구. 모든 시간대의 manifest가 status: ok로 정상 적재 → 데이터 누락 없음.

handler 코드를 읽어보니, 5개 소스 중 단 하나라도 실패하면 전체를 RuntimeError로 던지는 구조였다. 한 소스의 일회성 5xx가 곧장 알람으로 직결되는 구조. 이것은 별도 이슈로 정리.

3. 진짜 문제 — Summary 카드 3개의 -/0

S3 data/last_week.json을 까보니 카드 값은 정상이었다.

{ "id": "alb_p99_max",    "value": 157.625, "unit": "초",  "status": "red"   }
{ "id": "alb_error_rate", "value": 0.003,   "unit": "%",   "status": "green" }
{ "id": "alb_rps_avg",    "value": 3.344,   "unit": "req/s" }
{ "id": "ecs_cpu_avg",    "value": 20.733,  "unit": "%",   "status": "green" }

그런데 current.jsonthis_week.json은:

alb_p99_max     value=null
alb_error_rate  value=null
alb_rps_avg     value=0.0
ecs_cpu_avg     value=23.479   ← 정상

ALB 관련 3개만 비어있고 ECS CPU만 정상. 처음 가설은 “트래픽이 적은 새벽 시간대라 ALB latency 메트릭이 발행 안 됨”이었지만 사용자가 부정: “새벽에도 트래픽이 원래 있었음.”

4. CloudWatch CLI로 가설 검증

같은 시간 같은 ALB를 직접 호출.

aws cloudwatch get-metric-statistics \
  --namespace AWS/ApplicationELB \
  --metric-name RequestCount \
  --dimensions Name=LoadBalancer,Value=app/<ALB-NAME>/<LB-SUFFIX> \
  --start-time 2026-04-26T23:00:00Z --end-time 2026-04-27T00:00:00Z \
  --period 300 --statistics Sum

결과: 5분당 수천 건 요청. 정상이다. 그런데 collector가 같은 시간에 받은 ALB payload는 60 points 모두 0.0.

차이는 dimension 한 줄이었다.

# CLI 호출 (성공)
Dimensions=[{"Name": "LoadBalancer", "Value": "..."}]

# collector 호출 (0/empty) — metrics.py:31-34
alb_dimensions = [
    {"Name": "TargetGroup",  "Value": settings.target_group_suffix},
    {"Name": "LoadBalancer", "Value": settings.load_balancer_suffix},
]

5. 핵심 통찰 — CloudWatch ALB 메트릭 발행 차원 규칙

AWS/ApplicationELB 네임스페이스의 메트릭은 차원 조합 별로 따로 발행된다.

메트릭 LoadBalancer 단독 발행 LoadBalancer + TargetGroup 발행
RequestCount ✅ ALB 전체 요청 ✅ 그 TG로 forward된 요청
HTTPCode_ELB_* ✅ ALB 자체 4xx/5xx
HTTPCode_Target_5XX_Count ✅ Target 메트릭
TargetResponseTime (p95/p99) ✅ Target 메트릭
UnHealthyHostCount ✅ TG 단위

요점은 TargetResponseTime, HTTPCode_Target_*, UnHealthyHostCount는 LoadBalancer 단독 dimension으로는 발행조차 되지 않는다는 것. Target 단위 메트릭이라 항상 [LB + TG] 조합으로만 잡힌다. RequestCount는 둘 다 발행되지만 의미가 다르다.

collector는 [LB + TG=legacy-ec2-tg] 조합으로 호출 중. 옛 TG로 트래픽이 0이면 4개 메트릭이 통째로 0/empty가 된다.

6. ALB Listener Rule 분석

ALB <ALB-NAME> (변경 없음)
└─ Listener :443 (HTTPS)
   ├─ rule prio 10000~10650 (마지막은 0.0.0.0/0)
   │   └─ forward → ecs-tg (<ECS-TG-SUFFIX>)
   └─ default
       └─ forward → legacy-ec2-tg  ← 도달 안 함

priority 10650에 0.0.0.0/0 rule이 있어 모든 트래픽이 새 TG(ecs-tg)로 forward된다. ALB는 그대로지만 트래픽 목적지가 바뀐 것.

분 단위 트래픽 추이로 cutover 시점을 좁히면:

시각 (KST) legacy-ec2-tg ecs-tg
4/24 14:00 1,232 44,629
4/24 22:00 3,304 57,266
4/24 22:30 3,684 63,745
4/24 23:00 0 61,749
이후 계속 0 50k~70k 유지

4/24 22:30 → 23:00 KST 사이에 cutover 100% 완료. 사용자가 인지한 “토요일부터” 와 정확히 일치한다.

7. “전엔 잘 나왔다”는 인지의 실체

git log로 추적해보니, collector는 4/21 SRE dashboard 프로토타입 시점부터 줄곧 legacy-ec2-tg를 가리키도록 작성되어 있었다 — 변경 없음. 그럼 4/21~4/24 동안 어떻게 데이터가 들어와 있었나? 토요일 직전 시간대의 raw payload를 까보면:

시각 (UTC) RPS sum/시간 p99 max
4/22 06시 5,536 5,793 ms
4/22 12시 5,534 157,625 ms
4/23 12시 6,762 157,100 ms
4/24 06시 3,907 6,751 ms
4/24 12시 10,319 17,474 ms
4/24 18시 0 EMPTY

ALB 전체(시간당 약 350,000건)의 1~3% 잔류 트래픽만 옛 TG에 흘러 들어오고 있었다. 카드는 0이 아니므로 정상으로 보였지만 실제로는 production의 일부만 보여주는 부분 집계였다. last_week.json의 빨간색 status p99=157초도 잔류 트래픽 한두 건의 슬로우 요청에서 그어진 값.

즉 “전엔 잘 나왔다”는 인지는 절반만 맞았다. 처음부터 잘못된 TG를 보고 있었지만 잔류 트래픽 덕에 0이 아니었을 뿐. ECS cutover 100% 완료가 잠재 결함을 표면화시킨 것.

8. 코드 수정 — wire-up이 빠져있던 5 파일

이미 README에 의도가 명시되어 있었다.

ECS production 모듈이 활성화되어 있으면 SRE collector는 ecsProduction.targetGroupArnSuffix를 사용해 ALB 지표를 조회한다. ECS 전환 후 옛 EC2 TG가 아닌 current ECS TG를 보게 하기 위함이다.

그러나 caller wire-up이 빠진 채 fallback이 옛 TG로 떨어진 상태였다. fix는 cloudWatchTargetGroupSuffix prop을 5단계 하향 전달:

// infra/src/index.ts
const sreDashboard = createSreDashboard({
  alarmActions: notification.alarmActions ?? [],
+ cloudWatchTargetGroupSuffix: ecsProduction?.targetGroupArnSuffix,
});
// infra/src/sre-dashboard/types.ts
export interface SreDashboardParams {
  alarmActions: pulumi.Input<string>[];
+ cloudWatchTargetGroupSuffix?: pulumi.Input<string>;
}
// infra/src/sre-dashboard/index.ts
const collector = createSreDashboardCollectorLambda({
  rawBucket,
  lambdaRole,
+ cloudWatchTargetGroupSuffix: params.cloudWatchTargetGroupSuffix,
});
// infra/src/sre-dashboard/collector/index.ts
interface CollectorArgs {
    rawBucket: aws.s3.Bucket;
    lambdaRole: aws.iam.Role;
+   cloudWatchTargetGroupSuffix?: pulumi.Input<string>;
}
// ...
buildCollectorEnvironment({
    rawBucket: args.rawBucket,
+   cloudWatchTargetGroupSuffix: args.cloudWatchTargetGroupSuffix,
})
// infra/src/sre-dashboard/collector/environment.ts
- ALB_TARGET_GROUP_SUFFIX: settings.sreDashboard.targetGroupSuffix,
+ ALB_TARGET_GROUP_SUFFIX:
+     args.cloudWatchTargetGroupSuffix ??
+     settings.sreDashboard.targetGroupSuffix,

pulumi up 후 환경변수 검증.

aws lambda get-function-configuration --function-name sre-dashboard-collector \
  --query 'Environment.Variables.ALB_TARGET_GROUP_SUFFIX' --output text
# → targetgroup/ecs-tg/<ECS-TG-SUFFIX>

9. 백필 — 50시간 매시간 분할 호출

window.pyevent.start/event.end를 함께 받으면 그 윈도우로 직접 처리한다. cloudwatch만 다시 수집하도록 event.sources=["cloudwatch"] 명시하고, S3 키가 hh 단위라 1시간씩 분할해 호출.

python3 -c "
from datetime import datetime, timedelta, timezone
s = datetime(2026, 4, 24, 23, tzinfo=timezone.utc)
e = datetime(2026, 4, 27,  1, tzinfo=timezone.utc)
cur = s
while cur < e:
    nxt = cur + timedelta(hours=1)
    print(cur.strftime('%Y-%m-%dT%H:%M:%SZ'), nxt.strftime('%Y-%m-%dT%H:%M:%SZ'))
    cur = nxt
" | while read ws we; do
  printf '→ %s ~ %s  ' "$ws" "$we"
  aws lambda invoke \
    --function-name sre-dashboard-collector \
    --payload "{\"start\":\"$ws\",\"end\":\"$we\",\"sources\":[\"cloudwatch\"]}" \
    --cli-binary-format raw-in-base64-out \
    /tmp/collector-out.json >/dev/null \
    && jq -r '.sources.cloudwatch.status' /tmp/collector-out.json
done

# builder 1회 트리거하여 last_week.json 즉시 재생성
aws lambda invoke \
  --function-name sre-dashboard-builder \
  --payload '{}' --cli-binary-format raw-in-base64-out \
  /tmp/builder-out.json

10. 회고 — 무엇을 배웠나

1. CloudWatch dimension의 발행 규칙은 메트릭마다 다르다. RequestCount만 LB 단독 발행이 가능하고, 나머지 Target 메트릭은 LB+TG 조합 필수. CLI로 dimension 한 줄만 빼고 호출하면 결과가 수천 RPS인지 0인지 갈린다.

2. 점진적 cutover는 잠재 결함을 가린다. 잔류 트래픽 1~3%만 흘러도 카드는 0이 아니므로 정상으로 보인다. 100% cutover가 완료되어야 결함이 표면화된다. 카드가 “보이느냐”가 아니라 “값의 절대 크기가 production 규모인가”를 봐야 한다.

3. 알람 이름 ≠ 진짜 원인. sre-dashboard-collector-errors 알람이 떴다고 collector 전체가 망가진 게 아니다. 한 소스 실패가 RuntimeError로 던져져 알람을 트리거하는 구조 때문. handler가 부분 실패와 전체 실패를 구분하지 않으면 알람의 신호 대 잡음 비가 떨어진다.

4. 설계 의도와 wire-up은 별개의 문제다. README에 “활성화되면 자동 주입”이라고 적혀 있어도, caller에서 인자를 전달하지 않으면 fallback으로 떨어진다. 문서의 약속과 실제 코드 wire-up은 별도로 검증해야 한다.

5. “잘 나오던 게 갑자기 안 나온다”의 함정. 처음부터 잘못된 것이지만 환경적 우연으로 가려져 있다가 환경 변화로 표면화될 수 있다. “이전엔 정상이었다”는 인지는 디버깅의 출발점일 뿐, 결론으로 가져가면 안 된다.

Categories:

Updated:

Leave a comment