왜 이미지 풀이 병목이 되는가
CI 파이프라인이나 대규모 노드 롤아웃에서 실제로 시간을 잡아먹는 구간은 빌드가 아니라 이미지 풀(pull)인 경우가 많다. 원인은 세 가지다. 첫째, 매 빌드마다 동일한 베이스 레이어를 원격 레지스트리에서 다시 내려받는다. 둘째, 수십 개 노드가 동시에 같은 이미지를 요청하면 레지스트리와 외부 대역폭에 부하가 몰린다. 셋째, Docker Hub 같은 퍼블릭 레지스트리는 익명·인증 사용자 모두에 풀 레이트 리밋을 걸어, 리밋에 걸리면 toomanyrequests 오류로 배포 전체가 멈춘다.
핵심 관점은 "레이어 단위 재사용"과 "네트워크 경로 단축"이다. 이미지는 콘텐츠 주소(digest) 기반 레이어의 집합이므로, 이미 가진 레이어를 다시 받지 않도록 만들고 풀 경로를 로컬로 당기는 것이 최적화의 전부다.
풀-스루 캐시 레지스트리 구축
가장 효과가 큰 조치는 조직 내부에 풀-스루(pull-through) 캐시를 두는 것이다. 노드는 외부 대신 내부 미러를 바라보고, 미러는 없는 레이어만 업스트림에서 받아 캐싱한다. 두 번째 풀부터는 LAN 속도로 끝나고 외부 레이트 리밋도 회피한다.
docker run -d --name registry-mirror -p 5000:5000 \
-v /data/registry:/var/lib/registry \
-e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io \
-e REGISTRY_PROXY_USERNAME=$DH_USER \
-e REGISTRY_PROXY_PASSWORD=$DH_TOKEN \
registry:2
노드의 컨테이너 런타임에 미러를 등록한다. containerd(예: k8s) 기준 설정은 다음과 같다.
# /etc/containerd/certs.d/docker.io/hosts.toml
server = "https://registry-1.docker.io"
[host."http://registry-mirror.internal:5000"]
capabilities = ["pull", "resolve"]
skip_verify = true
레이어 캐시를 살리는 빌드 순서
캐시 히트율은 Dockerfile 작성 방식에 직접 좌우된다. 자주 바뀌는 것(소스 코드)을 아래에, 거의 안 바뀌는 것(의존성)을 위에 두어야 한다. 의존성 설치 레이어가 재사용되면 빌드·풀 모두 짧아진다.
FROM python:3.12-slim
WORKDIR /app
# 의존성 정의만 먼저 복사 → requirements가 그대로면 이 레이어 캐시 재사용
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 소스는 마지막에 복사 → 코드만 바뀌면 위 레이어는 캐시 히트
COPY . .
CMD ["python", "app.py"]
CI에서는 docker buildx의 레지스트리 캐시 백엔드로 러너 간에도 레이어를 공유할 수 있다. --cache-from과 --cache-to type=registry를 함께 쓰면 새 러너도 이전 빌드 캐시를 재사용한다.
캐시 전략 비교
| 전략 | 효과 | 주의점 |
|---|---|---|
| 풀-스루 캐시 | 외부 대역폭·레이트 리밋 해소 | 미러 저장소 용량·GC 필요 |
| 노드 로컬 이미지 | 재풀 없음, 가장 빠름 | 노드 디스크 압박, 태그 신선도 관리 |
| 레지스트리 빌드 캐시 | 러너 간 레이어 공유 | 캐시 자체 push/pull 비용 |
레이트 리밋과 인증 다루기
레이트 리밋은 익명보다 인증 계정이, 인증 계정보다 미러가 유리하다. 그래도 리밋 근접을 감지하려면 응답 헤더를 확인해야 한다. 토큰 발급 후 ratelimit-remaining을 읽는다.
import requests
tok = requests.get(
"https://auth.docker.io/token",
params={"service": "registry.docker.io",
"scope": "repository:library/alpine:pull"}).json()["token"]
r = requests.head(
"https://registry-1.docker.io/v2/library/alpine/manifests/latest",
headers={"Authorization": f"Bearer {tok}",
"Accept": "application/vnd.oci.image.index.v1+json"})
print(r.headers.get("ratelimit-remaining")) # 예: 100;w=21600
태그 대신 digest로 고정하기
latest 같은 가변 태그는 캐시 무효화와 재현성 문제를 동시에 일으킨다. 배포에는 다이제스트 핀(image@sha256:...)을 쓰면 동일 다이제스트는 항상 캐시 히트이고, 노드는 이미 가진 레이어를 절대 다시 받지 않는다. 태그는 사람이 읽는 별칭으로만 두고 실제 배포 참조는 digest로 고정하는 것이 안전하다.
정리와 주의점
캐시는 무한하지 않다. 미러와 노드 모두 디스크가 계속 찬다. 미러 레지스트리는 registry garbage-collect를 주기적으로 돌리고, 노드는 kubelet의 이미지 GC 임계값(image-gc-high-threshold)을 설정해 오래된 레이어를 회수해야 한다. 또한 skip_verify=true는 내부 신뢰 네트워크 한정으로만 쓰고, 운영 환경에서는 내부 CA로 TLS를 붙이는 것이 원칙이다. 마지막으로 캐시가 정답을 반환하는지—즉 다이제스트가 업스트림과 일치하는지—를 신뢰하는 이유는 콘텐츠 주소 방식 덕분이므로, 태그 기반 미러보다 digest 기반 참조를 우선하면 캐시와 정합성을 동시에 얻는다.