
ECR pull rate limit에 한 번이라도 당해봤다면, 이 글이 도움이 될 거다. Spegel은 클러스터 안에서 노드끼리 이미지를 공유하게 해주는 stateless P2P OCI registry mirror다. 어느 노드 하나가 이미지를 받아두면, 같은 클러스터의 다른 노드들은 그 노드에서 끌어다 쓴다. 외부 레지스트리 호출이 확 줄어든다.
최근 3월에 CoreWeave가 자기네 매니지드 쿠버네티스(CKS)에서 Spegel 튜토리얼을 공식 문서로 내놓을 정도로 P2P 이미지 분배는 더 이상 실험 단계가 아니다. K3s, RKE2는 아예 임베디드로 들어가 있고, AKS/EKS에서도 Helm으로 깔아서 잘 굴러간다. 우리 팀에서도 노드 80대 EKS 클러스터에 두 달째 돌리고 있는데, 한번 정리해두면 좋겠다 싶어서 가이드로 쓴다.
왜 필요한가
쿠버네티스 클러스터를 좀 키워보면 비슷한 패턴을 겪는다. 노드가 20대, 30대 넘어가면서 새 배포 한번 할 때마다 ECR이나 Docker Hub로 동일한 이미지 풀 요청이 수십 번씩 나간다. 보통은 문제가 안 되는데, 다음 같은 상황에서 터진다.
- AZ 장애 복구 중 Karpenter가 노드 30대를 한꺼번에 띄움. 같은 이미지를 30번 풀.
- Docker Hub 무료 tier에서 anonymous pull limit (시간당 100회) 초과.
- ECR의 GetAuthorizationToken throttle, pull throttle.
- 네트워크 비용 (NAT Gateway 통한 외부 호출이 누적되면 의외로 큰 청구서).
Spegel을 깔면 첫 번째 노드만 외부에서 풀하고, 나머지는 클러스터 내부 네트워크로 끌어다 쓴다. 우리 팀 기준으로는 deploy 시 P95 image pull duration이 18초 → 4초 정도로 줄었다. 외부 ECR 호출 횟수는 80% 가량 감소했고.
어떻게 동작하나
각 노드에 DaemonSet으로 떠서 두 가지 역할을 한다. 첫째, containerd가 이미지를 풀할 때 그 노드를 mirror로 먼저 찌르도록 라우팅한다. 둘째, 이 노드가 이미 받아놓은 layer를 P2P로 다른 노드에게 서빙한다. 노드 간 디스커버리는 libp2p 위의 Kademlia DHT다. 별도 컨트롤 플레인이 없고, 외부 DB도 안 쓴다. Stateless다.
핵심은 containerd의 로컬 image layer 캐시를 그대로 활용한다는 점이다. Spegel 자체가 이미지를 저장하지 않는다. 그래서 디스크 부담이 없다. 단점은 containerd 전용이라는 것. Docker shim 쓰는 노드에서는 못 쓴다.
사전 작업: containerd 설정
이 부분이 함정이다. Helm으로 깔기 전에 containerd가 registry mirror를 지원하도록 설정돼 있어야 한다. 안 되어 있으면 Spegel pod가 그냥 죽는다.
/etc/containerd/config.toml에서 두 가지를 확인한다.
[plugins."io.containerd.grpc.v1.cri".registry]
config_path = "/etc/containerd/certs.d"
[plugins."io.containerd.grpc.v1.cri".containerd]
discard_unpacked_layers = false
config_path가 비어 있는 게 기본값인 배포판이 많다. 이걸 안 해두면 containerd가 mirror 설정 파일을 읽지를 않는다. discard_unpacked_layers = false도 중요한데, 이 옵션이 true면 풀이 끝나고 layer를 지워버려서 P2P로 서빙할 게 없어진다.
EKS의 경우 AMI를 launch template + userdata로 커스터마이즈하면 된다. Bottlerocket은 Spegel 공식 문서에 별도 가이드가 있다. AKS는 좀 더 까다로워서 노드 풀 생성 시 옵션을 줘야 하니 공식 문서 확인 권장.
설정 변경 후 containerd 재시작은 필요. 노드를 rolling restart 하든, Karpenter면 node template 바꾸고 자연 교체되게 두든.
Helm 설치
설정이 끝났으면 Helm 한 줄이다.
helm upgrade --install spegel \
oci://ghcr.io/spegel-org/helm-charts/spegel \
--namespace spegel \
--create-namespace \
--version v0.x.x
values는 거의 손댈 게 없지만 두어 개는 신경 쓰는 게 좋다.
spegel:
# 어떤 registry를 mirror할지. 와일드카드도 됨
registries:
- https://123456789012.dkr.ecr.ap-northeast-2.amazonaws.com
- https://registry-1.docker.io
- https://quay.io
- https://ghcr.io
# 메트릭 노출 (Prometheus가 자동으로 긁어가게)
serviceMonitor:
enabled: true
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
registries 리스트에 적은 것만 mirror 대상이 된다. 우리는 처음에 ECR만 넣고 시작했다가, 점차 docker.io, ghcr.io까지 늘렸다. 한꺼번에 다 넣지 말고 단계적으로 가는 게 안전하다. 문제 생기면 롤백 영향 범위가 작으니까.
동작 확인
DaemonSet이 뜨면 노드마다 80번 포트(기본값)에 mirror가 올라온다. 노드에서 직접 찔러보면 된다.
# 노드 안에서
curl -s http://127.0.0.1:30021/v2/ | head
진짜 동작하는지는 메트릭으로 본다. Prometheus에 다음 쿼리 던져본다.
sum(rate(spegel_mirror_requests_total{cache="hit"}[5m])) /
sum(rate(spegel_mirror_requests_total[5m]))
hit ratio가 0.6 이상 나오면 잘 도는 거다. 우리 팀은 평균 0.75 정도다. 새 이미지 첫 배포는 어차피 miss이므로 100%는 안 나온다.
Grafana 대시보드는 ID 18089가 공식이다. 그대로 import해서 쓰면 된다.
운영하면서 만난 잔돌멩이들
도입은 쉬웠는데 운영하면서 몇 가지 알게 된 게 있다.
노드 부팅 직후 race. 노드가 새로 떴을 때 Spegel pod이 뜨기 전에 다른 pod이 이미지를 풀려고 하면 그냥 외부에서 풀해버린다. 큰 문제는 아니지만 P2P 효과가 살짝 깎인다. priorityClassName을 system-node-critical로 주고, Spegel을 빠르게 띄우게 했더니 좀 나아졌다.
같은 이미지인데 hit이 안 됨. 이미지 digest가 다르면 다른 이미지로 본다. 당연한 얘긴데, multi-arch manifest를 풀할 때 노드 아키텍처가 섞여 있으면 hit rate가 떨어진다. arm64/amd64 노드가 섞인 클러스터라면 각 아키별로 mirror가 분리된다고 생각하면 된다.
메트릭 cardinality. 기본 메트릭에 registry, repository label이 다 붙는다. 모노레포에서 이미지 종류가 수백 개면 Prometheus 시리즈 수가 확 늘 수 있다. 우리는 registry label만 남기고 repository는 relabel로 drop했다.
마무리
P2P 레지스트리 미러 같은 솔루션은 작은 클러스터에서는 오버킬이지만, 노드 30대 넘어가는 순간부터는 이득이 명확하다. 무엇보다 외부 레지스트리 의존도가 줄어드는 게 운영자 입장에서 마음이 편하다. ECR이 잠깐 5xx 뱉어도 in-cluster mirror로 deploy가 계속 굴러간다.
설치 자체는 한 시간이면 끝나는데, containerd 설정 검증과 메트릭 대시보드 붙이는 데 반나절 정도는 잡으면 적당하다. 다음 글에서는 Spegel 도입 후 ECR 비용 변화를 좀 더 구체적으로 정리해볼까 한다.
'IT > 컨테이너' 카테고리의 다른 글
| Kaniko가 archived된 뒤, 우리는 어떻게 컨테이너 빌드 도구를 골랐나 (0) | 2026.05.24 |
|---|---|
| containerd image pull 흐름 — snapshotter와 unpack 단계 파헤치기 (0) | 2026.05.08 |
| Alpine 베이스 이미지를 Wolfi로 갈아치우면서 삽질한 이야기 (0) | 2026.05.02 |
| BuildKit cache mount, 이거 모르는 분 꽤 많더라 (0) | 2026.04.25 |