
지난주 목요일 새벽 2시 47분. 알림 톡이 울려서 핸드폰을 봤더니 ArgoCD에서 동시에 11개 애플리케이션의 sync가 Degraded로 떨어졌다는 메시지였다. 처음엔 또 누군가가 이미지 태그를 잘못 박았겠지 싶었다. 새벽에 배포 멘션이 와있던 팀이 두 팀이었고, 자동 sync wave가 한꺼번에 돌고 있었으니까.
그런데 콘솔을 열어보니 좀 이상했다. Pod 이벤트에 ImagePullBackOff가 줄줄이 떠있는데, 에러 메시지가 익숙하지 않았다.
Failed to pull image "<account>.dkr.ecr.ap-northeast-2.amazonaws.com/svc-foo:v1.42.0":
rpc error: code = Unknown desc = failed to pull and unpack image:
failed to copy: httpReadSeeker: failed open: unexpected status code 429 Too Many Requests
ECR에서 429를 처음 받아봤다. 솔직히 그 시점까지 나는 ECR이 429를 내리는 일이 실무에서 일어난다는 걸 인지하고 있지 않았다.
처음에 헛다리 짚은 것들
이런 새벽에는 평소 잘 안 보는 것부터 의심하게 된다. 나는 순서대로 이걸 의심했다.
첫째, IAM 권한이 갑자기 깨졌나? 누가 어제 IRSA 어노테이션을 손댄 PR이 있었나? kubectl describe로 보니 Pod identity 토큰은 정상적으로 발급되고 있었다. 401이 아니라 429이니까 인증은 통과한 거다.
둘째, ECR endpoint VPC endpoint가 죽었나? AWS Health Dashboard 켜봤는데 서울 리전 ECR은 멀쩡했다. VPC endpoint 상태도 available. 이거 아니다.
셋째, 누가 ECR repository를 지웠나? 콘솔에서 repo 들어가보니 이미지 정상. 풀 자체는 가끔씩 성공한다. 일관되게 실패하는 게 아니라 들쭉날쭉 실패한다.
여기서 한 5분 정도 멍해졌다. 새벽 3시쯤 되니까 머리가 안 돌아간다. 평소엔 짚었을 단서들이 머릿속에서 잘 이어지지 않는다.
CloudTrail 보다가 발견한 것
다시 정신 차리고 CloudTrail에서 ECR 호출을 시간순으로 봤다. BatchGetImage, GetDownloadUrlForLayer 호출이 새벽 2시 30분부터 분당 수백 건씩 폭증해 있었다. 평소엔 deploy 한 번에 분당 50~80건 정도였다.
원인 파악은 의외로 단순했다. 새벽 2시 30분에 Karpenter의 consolidation이 한 번 크게 돌면서 노드가 14대 교체됐고, 동시에 ArgoCD가 image updater 한 사이클 돌면서 6개 앱이 새 이미지로 sync를 시작했다. 거기에 우리 팀의 야간 cronjob 24개도 그 시간대에 몰려있었다. 합쳐서 새 컨테이너가 한꺼번에 200개 가까이 뜨려고 했던 거다.
ECR 자체 풀 API는 분당 호출 수 제한이 있고, 우리는 그 한계를 모르고 살고 있었던 거다. 평소엔 부딪힐 일이 없으니까. 거기에 더해 일부 이미지의 base가 Docker Hub의 node:20-alpine이었고, 우리는 NAT gateway 1개로 outbound가 묶여있다. 단일 IP에서 Docker Hub 익명 풀이 100회/6시간을 넘기면 그것도 throttle. 즉 두 쪽에서 동시에 429를 두들겨 맞고 있었다.
일단 응급조치
새벽이라 영구 해결책을 짤 시간이 없다. 일단 피를 막아야 한다. 했던 것들:
# 1) 진행 중인 sync 중단
argocd app sync svc-foo --prune --timeout 5s || true
for app in $(argocd app list -o name | head -20); do
argocd app set $app --sync-policy=none
done
# 2) Karpenter consolidation 일시 중단
kubectl patch nodepool default --type=merge -p \
'{"spec":{"disruption":{"consolidateAfter":"Never"}}}'
# 3) cronjob 일괄 suspend
kubectl get cronjob -A -o name | xargs -I{} kubectl patch {} -p '{"spec":{"suspend":true}}'
이렇게 새 풀 요청을 끊어두니 한 7분쯤 지나서 ECR 측 throttle가 풀리기 시작했다. ImagePullBackOff에 있던 Pod들이 차근차근 살아났다. 완전히 정상화된 게 새벽 3시 23분. 그 시점에 이미 결제 API의 P99는 평소의 4배까지 튀어있었고, 어떤 RDS 쿼리는 timeout이 났다.
영구 조치를 짜면서 깨달은 것들
다음날 회고에서 우리가 정리한 것 중에 글로 남길만한 것 몇 개.
Docker Hub 의존을 정말 다 없앴는지 다시 봐야 한다
우리는 "사내 base image를 표준화했다"고 믿고 있었다. 근데 실제 Dockerfile들을 grep으로 훑어보니 8개 서비스가 여전히 FROM node:20-alpine 또는 FROM python:3.12-slim을 직접 쓰고 있었다. 신규 서비스 템플릿엔 ECR base가 박혀있는데, 오래된 서비스들이 그대로 남아있던 거다. 사람의 약속만으로는 안 지켜진다. CI에서 Dockerfile을 파싱해서 FROM docker.io/... 또는 FROM <hub-image>이면 fail하도록 막아야 한다.
지금은 PR 검증 step에 이걸 박았다:
- name: Block direct docker hub base
run: |
if grep -rE '^FROM ([a-z0-9_.-]+)(:|$)' --include='Dockerfile*' . \
| grep -vE '<account>\.dkr\.ecr\.[a-z0-9-]+\.amazonaws\.com'; then
echo "::error::Docker Hub base image detected. Use ECR pull-through cache."
exit 1
fi
완벽하진 않다. multi-stage에서 FROM builder처럼 stage alias 쓰는 케이스도 있고, 일부 ARG-driven base는 잡기 어렵다. 그래도 80% 케이스는 막힌다.
ECR pull-through cache는 진작 깔았어야 했다
부끄러운 얘긴데, 우리는 pull-through cache 기능 자체를 인지하고만 있었지 도입해본 적이 없었다. "어차피 사내 base 쓰니까 필요 없다"는 안일함이 있었다. 사고 다음 주에 docker.io와 quay.io, ghcr.io에 대해 pull-through 규칙을 다 만들었다.
aws ecr create-pull-through-cache-rule \
--ecr-repository-prefix docker-hub \
--upstream-registry-url registry-1.docker.io \
--credential-arn arn:aws:secretsmanager:ap-northeast-2:<acct>:secret:ecr-pullthrough/dockerhub
aws ecr create-pull-through-cache-rule \
--ecr-repository-prefix ghcr \
--upstream-registry-url ghcr.io
containerd 쪽 mirror 설정으로 docker.io를 자동으로 ECR pull-through 경로로 돌리는 방법도 있는데, 우리는 일부러 Dockerfile에서 명시적으로 <account>.dkr.ecr.../docker-hub/library/node:20-alpine 형태로 박았다. 이유는 트레이싱이 명확해서. mirror로 숨겨버리면 누가 어떤 외부 이미지에 의존하고 있는지 한눈에 안 보인다. 우리 팀은 명시성을 골랐는데, 이건 팀마다 다를 거다.
Karpenter consolidation 폭주는 사실 별개 문제
복기하면서 보니, 새벽 2시 30분의 노드 교체 14대는 사실 우리 의도가 아니었다. 전날 저녁에 한 엔지니어가 consolidationPolicy를 WhenEmptyOrUnderutilized로 바꾼 PR이 머지됐는데, 그 영향이 다음 사이클에 한꺼번에 터진 거다. 컨테이너 풀이 한꺼번에 몰린 표면적 원인은 ECR throttle이지만, 진짜 트리거는 이 정책 변경이었다.
이건 ECR과 별개의 교훈이다. 클러스터 스케줄링 정책을 바꾸는 PR은 작아도 영향 반경이 크다. 우리는 이런 변경에 대해 "다음날 아침 점검 시간에 적용"하는 라벨을 도입했다. PR에 disruption: review-required 라벨이 붙으면 ArgoCD가 자동 sync를 멈추고, 사람이 morning standup 후에 손으로 sync한다.
Pod의 image pull retry는 우리가 통제할 수 없다
이게 좀 답답한 부분인데, kubelet의 image pull backoff는 exponential하고 max가 5분이다. 한 번 backoff에 들어간 Pod는 다음 시도까지 최대 5분을 기다린다. 즉 ECR throttle가 30초 후에 풀려도, 운 나쁘게 5분 backoff 구간에 들어간 Pod는 5분을 더 기다린다. 이걸 짧게 조절하는 옵션은 GA되지 않았다 (kubelet --image-pull-backoff 플래그가 일부 디스트로에서 노출되어 있긴 한데, EKS 매니지드에서는 손대기 어렵다).
다시 말해, 풀이 막히는 사고가 한 번 터지면 그 영향은 "throttle 지속 시간"이 아니라 "throttle 지속 시간 + 최대 5분 정도의 꼬리"다. 이게 결제 API P99가 그 시점까지 튀어있었던 이유 중 하나다.
그래서 결론은
우리 팀에선 이 사고 이후 이런 합의가 생겼다. ECR을 "내 것"이라고 믿지 말 것. ECR도 호출 한도를 가진 AWS API이고, 단지 평소에 그 한도를 안 부딪힐 뿐이다. NAT 한 개로 묶인 클러스터에서 동시 풀 요청이 어떤 패턴으로 발생하는지 한 번쯤은 계산해봐야 한다. 노드 교체와 deploy와 cronjob이 시간상 겹치지 않게 분산되어 있는지도.
근데 솔직히, 이 모든 게 다 ECR throttle limit이 명시적으로 문서화되어 있고 모니터링 메트릭이 친절하게 노출돼 있었다면 진작 막았을 거다. CloudWatch에서 ECR API throttle 메트릭은 ThrottledCount로 보이긴 하는데, 알람으로 걸어둔 팀은 거의 못 봤다. 우리도 이번에야 알람을 박았다. 누가 다음에 또 이걸 모르고 당하는 게 싫어서 이 글을 쓴다.
혹시 비슷한 시점에 비슷한 증상으로 새벽에 깨신 분 있으면 댓글로 한번 이야기 나눠봅시다. 우리 팀만 이런 거 겪은 게 아닐 거라 믿고 있다.
'IT > AWS' 카테고리의 다른 글
| EKS CoreDNS, 노드 늘리기 전에 이거 먼저 보자 (0) | 2026.05.26 |
|---|---|
| NAT Gateway 청구서, VPC Endpoint로 줄이는 법 (0) | 2026.05.23 |
| EKS Auto Mode 도입 가이드 (0) | 2026.05.20 |
| AWS NLB로 gRPC 라우팅, ALPN 정책 한 줄을 안 넣으면 어떻게 깨지나 (0) | 2026.05.15 |
| NAT Gateway 청구서가 갑자기 3.2배로 뛴 날 (0) | 2026.05.13 |