# CI가 외부 레지스트리에 매달려 있을 때 - pull-through 캐시의 이득과 대가
CI 러너는 매번 새로 뜹니다. 그 말은 매 빌드마다 베이스 이미지를 받고, 의존성을 내려받고, 시스템 패키지를 설치한다는 뜻입니다. 평소에는 몇 분이면 끝나지만, 외부 레지스트리가 느려지거나 요청 수 제한에 걸리면 모든 빌드가 동시에 멈춥니다.
pull-through 캐시는 이 의존을 줄입니다. 처음 한 번만 외부에서 받고 그 뒤로는 내부에서 서빙합니다. 개념은 단순하지만, 붙이고 나면 새로운 실패 지점과 신선도 문제가 생깁니다. 이 글에서는 무엇을 캐시할 수 있는지, 각 층위의 제약이 무엇인지, 그리고 캐시가 만드는 새 문제를 정리합니다.
# 1. 캐시할 대상은 세 층위다
CI가 외부에서 받아 오는 것은 성격이 다른 셋입니다.
| 층위 | 예 | 도구 유형 | 실패 시 증상 |
|---|---|---|---|
| 컨테이너 이미지 | 베이스 이미지, 도구 이미지 | 레지스트리 미러 | ImagePullBackOff, 빌드 시작 실패 |
| 언어 패키지 | pip, npm, go module | 인덱스 프록시 | 의존성 설치 단계 실패 |
| OS 패키지 | apt, yum | 저장소 미러 | Dockerfile의 RUN apt-get 실패 |
세 층위는 도구가 다르므로 따로 붙여야 합니다. 레지스트리 미러를 세웠는데 여전히 빌드가 외부에 의존한다면, 대개 나머지 두 층위가 남아 있는 것입니다. 어디에서 시간이 가는지 먼저 재 보지 않으면 엉뚱한 층에 캐시를 붙이게 됩니다.
# 2. 레지스트리 미러의 제약
컨테이너 레지스트리는 pull-through 캐시 모드를 지원합니다. 동작은 단순합니다.
The first time you request an image from your local registry mirror, it pulls the image from the public Docker registry and stores it locally before handing it back to you. On subsequent requests, the local registry mirror is able to serve the image from its own storage.
설정도 짧습니다.
# registry 설정
proxy:
remoteurl: https://registry-1.docker.io
ttl: 168h
여기서 세 가지 제약이 따라옵니다.
하나의 업스트림만 미러할 수 있습니다.
It's currently possible to mirror only one upstream registry at a time.
Docker Hub, GHCR, quay.io, 각 클라우드 레지스트리를 모두 캐시하려면 인스턴스를 업스트림 수만큼 띄워야 합니다. 그러면 클라이언트 쪽에서 "어느 이미지를 어느 미러로 보낼지"를 정해야 하고, 이것이 컨테이너 런타임 설정의 문제가 됩니다.
# containerd 설정 - 레지스트리 호스트별로 미러를 지정한다
# /etc/containerd/certs.d/docker.io/hosts.toml
server = "https://registry-1.docker.io"
[host."http://mirror-dockerhub.internal:5000"]
capabilities = ["pull", "resolve"]
이 방식의 장점은 Dockerfile과 매니페스트를 고치지 않아도 된다는 것입니다. 이미지 이름은 그대로 두고 런타임이 알아서 미러를 먼저 찌릅니다. 반대로 FROM mirror.internal/library/python:3.12처럼 이름을 바꾸면 그 매니페스트는 미러 없이는 못 쓰게 되고, 로컬 개발 환경에서도 미러가 필요해집니다. 이름을 바꾸지 않는 쪽이 낫습니다.
푸시할 수 없습니다. 캐시 모드의 레지스트리는 읽기 전용입니다. 사내 이미지를 저장하는 레지스트리와 캐시를 같은 인스턴스로 겸용할 수 없다는 뜻입니다. 둘을 따로 운영해야 합니다.
자격 증명을 넣으면 노출 범위가 넓어집니다.
If you specify a username and password, it's very important to understand that private resources that this user has access to Docker Hub is made available on your mirror. You must secure your mirror by implementing authentication if you expect these resources to stay private!
미러에 업스트림 계정을 넣으면, 미러에 접근할 수 있는 사람은 그 계정이 볼 수 있는 프라이빗 이미지를 전부 볼 수 있습니다. 미러 자체에 인증을 걸지 않으면 접근 통제가 통째로 무력화됩니다. 요청 수 제한을 피하려고 계정을 넣는 경우가 많은데, 그 순간 미러가 인증 경계가 됩니다.
# 3. 언어 패키지 프록시가 푸는 문제와 못 푸는 문제
pip이나 npm 인덱스를 프록시하는 도구도 같은 원리로 동작합니다. 업스트림에서 받아 로컬에 저장하고, 다음부터는 로컬에서 줍니다.
여기서 흔한 기대 하나를 정리해야 합니다. 캐시는 재현성을 주지 않습니다. 같은 requirements.txt로 어제와 오늘 다른 버전이 설치되는 문제는 캐시가 아니라 락파일이 푸는 문제입니다. 캐시는 "같은 것을 더 빨리 받는" 도구이지 "같은 것을 받도록 보장하는" 도구가 아닙니다.
빌드 재현성이 어디서 깨지는지는 개발용 컨테이너 이미지가 어제와 다르게 빌드되는 이유 (opens new window)에 정리했습니다. 캐시를 붙이기 전에 락파일이 있는지부터 확인하는 편이 순서에 맞습니다.
그리고 캐시가 새로 만드는 위험이 하나 있습니다. 사내 패키지와 공개 패키지를 같은 인덱스에서 서빙하는 구성입니다. 사내에만 있던 이름이 공개 저장소에 같은 이름으로 등록되면, 프록시가 버전 비교 규칙에 따라 공개 쪽 패키지를 내려받을 수 있습니다. 의존성 혼동이라 불리는 경로입니다.
방어는 이름 공간을 겹치지 않게 만드는 것입니다.
- 사내 패키지에 고유 접두사를 붙인다 (
acme-등) - 사내 인덱스와 공개 프록시를 분리하고, 사내 이름은 사내 인덱스에서만 찾게 한다
- 락파일에 해시를 포함시켜 내용이 바뀌면 실패하게 한다
# 해시까지 고정하면 이름이 같아도 다른 내용은 거부된다
requests==2.32.3 \
--hash=sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6
# 4. 외부 의존을 내부 의존으로 바꾼 것이다
캐시가 주는 이득의 반대편에 이 사실이 있습니다. 캐시가 죽으면 모든 빌드가 멈춥니다. 외부 레지스트리 장애를 피하려고 만든 것이 새로운 단일 장애점이 됩니다.
게다가 외부 레지스트리보다 우리 캐시가 더 자주 죽습니다. 상대는 전담 팀이 24시간 운영하고, 우리 캐시는 파드 하나입니다.
그래서 설계에 폴백이 있어야 합니다. 런타임 미러 설정은 이 점에서 유리합니다. 미러가 응답하지 않으면 원본 레지스트리로 넘어갑니다. 이름을 바꾸지 않는 편이 낫다고 한 두 번째 이유입니다.
# 미러가 죽으면 server 로 폴백한다
server = "https://registry-1.docker.io"
[host."http://mirror-dockerhub.internal:5000"]
capabilities = ["pull", "resolve"]
언어 패키지 쪽도 마찬가지로 대체 인덱스를 지정해 둘 수 있습니다. 다만 폴백이 켜져 있으면 캐시가 조용히 죽어도 아무도 모릅니다. 빌드는 느려질 뿐 성공하기 때문입니다. 캐시 히트율을 지표로 내보내고 급락에 알림을 거는 것이 함께 필요합니다.
가용성을 올리려면 상태를 어디에 둘지도 정해야 합니다. 캐시 데이터는 다시 받을 수 있으므로 소실이 치명적이지 않지만, 여러 레플리카가 같은 저장소를 공유하려면 공유 볼륨이나 오브젝트 스토리지가 필요합니다. 파일 시스템을 여러 파드가 함께 쓰는 문제는 ReadWriteMany를 고르기 전에 (opens new window)에 정리했습니다.
# 5. 신선도 - 캐시가 옛 이미지를 계속 주는 문제
ttl 설정이 캐시 항목의 수명을 정합니다. 이 값이 만드는 문제가 태그의 성질과 맞물립니다.
가변 태그(:latest, :dev)를 캐시하면, 업스트림에서 그 태그가 다른 이미지를 가리키게 바뀌어도 TTL이 지나기 전까지 캐시는 옛것을 줍니다. 빌드가 예전 베이스 위에서 돌고, 그 사실이 어디에도 드러나지 않습니다.
근본 해법은 캐시 설정이 아니라 참조 방식입니다. 다이제스트로 고정하면 이 문제가 통째로 사라집니다. 다이제스트는 내용 주소이므로 캐시 항목과 요청이 일대일로 대응하고, 신선도라는 개념 자체가 없어집니다.
# 캐시가 잘못된 것을 줄 수 없는 형태
FROM python:3.12-slim@sha256:45b23dee08af5e43a7fea6c4cf9c25ccf269ee113168c19722f87876677c5cb2
태그가 왜 신뢰할 수 없는 참조인지는 이미지 태그는 포인터다 (opens new window)에 정리했습니다. 캐시를 도입하면 그 문제가 한 겹 더 가려진다는 점만 덧붙이면 됩니다.
저장 용량도 관리 대상입니다. 캐시는 요청받은 것을 계속 쌓으므로 손대지 않으면 무한히 자랍니다. TTL을 짧게 잡으면 히트율이 떨어지고, 길게 잡으면 디스크를 먹습니다. 자주 쓰는 것만 오래 남기는 것이 이상적이고, 실무에서는 TTL과 주기적 정리를 함께 씁니다.
# 6. 캐시가 답이 아닌 경우
빌드가 느린 이유가 항상 외부 다운로드는 아닙니다. 캐시를 붙이기 전에 확인할 것이 있습니다.
레이어 캐시가 없는 경우. 매번 의존성을 새로 설치하는 이유가 Dockerfile의 레이어 순서 때문일 수 있습니다. 소스를 먼저 복사하고 의존성을 설치하면 소스가 한 글자만 바뀌어도 의존성 설치가 다시 돕니다. 이건 pull-through 캐시로 못 고칩니다. 빌드 캐시 구성은 공유 베이스 이미지와 파생 이미지 CI (opens new window)에 정리했습니다.
받는 양 자체가 과한 경우. 베이스 이미지가 필요 이상으로 크면 캐시가 있어도 노드로 옮기는 시간이 듭니다. 이미지를 줄이는 편이 근본적입니다.
빌드 빈도가 낮은 경우. 하루 몇 번 도는 빌드라면 캐시 운영 비용이 이득보다 큽니다.
판단 기준을 하나 두면, "외부 다운로드가 빌드 시간의 몇 퍼센트인가"를 먼저 재는 것입니다. 그 값이 작으면 캐시는 답이 아닙니다.
# 7. 직접 확인하는 방법
먼저 어디에 시간이 가는지 잽니다.
# 빌드 단계별 소요 시간 - 외부 다운로드 비중을 본다
docker buildx build --progress=plain -t myapp . 2>&1 \
| grep -E '^#[0-9]+ DONE' | sort -t' ' -k3 -rn | head -10
미러가 실제로 쓰이는지 확인합니다.
# 노드의 런타임이 미러를 쓰는가 - 설정 확인
kubectl debug node/<node> -it --image=busybox -- \
cat /host/etc/containerd/certs.d/docker.io/hosts.toml
# 미러 쪽 액세스 로그에 요청이 들어오는지
kubectl -n ci logs deploy/registry-mirror --tail=50 | grep -c 'GET /v2/'
히트율은 업스트림으로 나간 요청과 들어온 요청의 비율로 잽니다.
# 캐시 히트율(Distribution 기준) - 급락하면 캐시가 죽었거나 참조가 바뀐 것
# misses: 업스트림에서 받아 온 요청, requests: 프록시로 들어온 blob/manifest 요청
1 - (
sum(rate(registry_proxy_misses_total{type=~"manifest|blob"}[10m]))
/
sum(rate(registry_proxy_requests_total{type=~"manifest|blob"}[10m]))
)
메트릭 이름은 구현마다 다르므로, 없다면 리버스 프록시 계층에서 세는 쪽이 확실합니다. 요점은 업스트림으로 나가는 요청 수를 지표로 두는 것입니다. 이 값이 0으로 떨어지면 캐시가 완전히 먹고 있는 것이고, 갑자기 치솟으면 캐시가 죽었거나 참조가 바뀐 것입니다.
폴백이 실제로 동작하는지도 미리 확인해 두어야 합니다.
# 미러를 잠시 내리고 빌드가 성공하는지 (느려지기만 해야 정상)
kubectl -n ci scale deploy/registry-mirror --replicas=0
# ... 테스트 빌드 실행 ...
kubectl -n ci scale deploy/registry-mirror --replicas=1
이 시험을 안 해 보면 폴백이 있다고 믿는 상태와 실제로 동작하는 상태를 구분할 수 없습니다.
# 8. 트러블슈팅
| 증상 | 원인 | 조치 |
|---|---|---|
| 미러를 세웠는데 여전히 느림 | 다른 층위(언어·OS 패키지)가 남음 | 단계별 소요 시간 측정 |
| 특정 레지스트리만 캐시 안 됨 | 인스턴스당 업스트림 하나 | 업스트림별 인스턴스 |
| 사내 이미지를 못 올림 | 캐시 모드는 읽기 전용 | 저장용 레지스트리 분리 |
| 프라이빗 이미지가 노출 | 미러에 업스트림 계정 + 인증 없음 | 미러에 인증 적용 |
| 옛 베이스 이미지로 빌드됨 | 가변 태그 + TTL | 다이제스트 고정 |
| 캐시가 죽자 빌드 전면 중단 | 폴백 미설정 | 원본으로 폴백, 사전 시험 |
| 캐시가 죽었는데 아무도 모름 | 폴백이 조용히 흡수 | 업스트림 요청 수 알림 |
| 디스크가 계속 참 | 정리 정책 없음 | TTL + 주기적 GC |
| 사내 패키지 대신 공개 것이 설치 | 이름 공간 충돌 | 접두사 분리, 해시 고정 |
# 9. 마무리
- 캐시할 대상은 컨테이너 이미지 · 언어 패키지 · OS 패키지 세 층위이고 도구가 다릅니다. 하나만 붙이고 효과가 없다고 판단하기 쉽습니다.
- 레지스트리 미러는 인스턴스당 업스트림 하나입니다. 그리고 푸시할 수 없으므로 사내 저장용과 겸용할 수 없습니다.
- 미러에 업스트림 계정을 넣는 순간 미러가 인증 경계가 됩니다. 미러 자체 인증이 없으면 프라이빗 이미지가 그대로 열립니다.
- 캐시는 속도를 주지 재현성을 주지 않습니다. 같은 것을 받도록 보장하는 것은 락파일과 다이제스트입니다.
- 외부 의존을 없애면 내부 의존이 생깁니다. 폴백을 두되, 폴백이 조용히 흡수하므로 업스트림 요청 수를 지표로 감시해야 합니다.
- 이미지 이름을 바꾸지 않고 런타임 미러 설정으로 처리하는 편이 낫습니다. 매니페스트가 미러에 묶이지 않고 폴백도 자연스럽게 동작합니다.
- 붙이기 전에 외부 다운로드가 빌드 시간의 몇 퍼센트인지 재 봅니다. 작으면 캐시는 답이 아닙니다.