문제는 속도가 아니라 한도다
CI 가 한 번 돌 때마다 FROM python:3.12-slim 이 Docker Hub 에서 내려온다. 노드가 열 대면 열 번, 파이프라인이 하루 백 번 돌면 백 번이다. Docker Hub 는 이것을 세고 있다.
Docker 문서가 밝히는 한도는 이렇다. 로그인하지 않은 클라이언트는 IPv4 주소 하나(또는 IPv6 /64 대역) 당 6시간에 100회, 개인 계정으로 로그인하면 200회, Pro·Team·Business 는 무제한이다. 회사 사무실이나 클러스터처럼 여러 대가 NAT 뒤에서 한 주소로 나가면, 그 100회를 모두가 나눠 쓴다.
숫자를 문서로만 믿지 않고 헤더를 읽어 봤다. Docker 가 한도 확인용으로 열어 둔 ratelimitpreview/test 저장소에 익명 토큰으로 매니페스트를 요청하면 응답 헤더에 남은 횟수가 실린다.
TOK=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq -r .token)
curl -s -I -H "Authorization: Bearer $TOK" \
https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest | grep -i ratelimit
ratelimit-limit: 100;w=3600
ratelimit-remaining: 85;w=3600
실험을 몇 번 하는 동안 remaining 은 91 → 88 → 85 로 줄었다. 헤더의 창(w=3600)은 초 단위로 3,600 이라고 적혀 있는데 문서는 6시간이라고 말한다. 어느 쪽이 맞든, 이 확인 요청 자체도 한 회로 세어진다는 점은 확실하다.
프록시 캐시는 이 숫자를 지키는 장치다. 클러스터의 모든 노드가 Docker Hub 대신 내 캐시에 물으면, 같은 이미지는 업스트림에서 한 번만 내려온다.
가장 작은 실험: registry:2 를 pull-through 캐시로
Docker 가 배포하는 registry:2 이미지는 환경 변수 하나로 읽기 전용 프록시가 된다.
docker run -d --name regmirror -p 5001:5000 \
-v $PWD/data:/var/lib/registry \
-e REGISTRY_PROXY_REMOTEURL=https://registry-1.docker.io \
registry:2
이 상태에서 localhost:5001/library/nginx:1.27-alpine 을 당기면, 캐시는 없는 레이어를 Docker Hub 에서 받아 디스크에 두고 클라이언트에 넘긴다. 로컬 데몬에 nginx 레이어가 하나도 없는 것을 확인한 뒤(docker images | grep nginx 가 0줄), 같은 이미지를 세 번 당기고 매번 로컬 사본을 지웠다.
| 시도 | 걸린 시간 | 캐시 디렉터리 | 비고 |
|---|---|---|---|
| 1번째 (캐시 경유) | 7.81초 | 22M (블롭 파일 15개) | 캐시가 비어 있어 업스트림에서 채움 |
| 2번째 (캐시 경유) | 3.95초 | 22M (변화 없음) | 디스크에서 바로 |
| 3번째 (캐시 경유) | 4.01초 | 22M (변화 없음) | 디스크에서 바로 |
| Docker Hub 직접 | 2.86초 | — | 비교용 |
docker images 가 보고하는 이미지 크기는 76.8MB 인데 캐시는 22M 이다. 캐시가 보관하는 것은 압축된 레이어 블롭이고, 데몬이 보고하는 것은 풀어 놓은 크기라서 그렇다.
실험이 말해 주는 것
첫 pull 은 느려진다. 업스트림에서 받아 디스크에 쓰고 다시 넘기는 두 단계라서 직접 당기는 것(2.86초)보다 세 배 가까이 걸렸다.
두 번째부터도 직접보다 빠르지 않았다. 집 회선에서 Docker Hub 를 직접 당기는 것이 2.86초, 캐시가 채워진 뒤 프록시를 거치는 것이 약 4초였다. 프록시 컨테이너가 같은 노트북에서 돌고 있어 hop 이 하나 더 있는 셈이다. 회선이 빠르고 클라이언트가 한 대뿐이라면 프록시 캐시는 속도를 위한 장치가 아니다.
얻는 것은 업스트림 요청 수다. 2·3번째 pull 에서 캐시 디렉터리가 1바이트도 늘지 않았다. 레이어는 업스트림에서 다시 받지 않았다는 뜻이다. 노드가 스무 대인 클러스터라면 Docker Hub 가 세는 횟수는 스물이 아니라 하나에 가까워진다. 이것이 프록시 캐시의 값이다.
한 가지 정직하게 적어 둘 것이 있다. registry:2 는 업스트림에 낸 요청을 info 로그에 남기지 않아서, pull 마다 매니페스트 확인을 몇 번 하는지는 세지 못했다. 디스크가 늘지 않았다는 사실로 레이어 재다운로드가 없었다는 것까지만 말할 수 있다.
Nexus: 프록시, 호스티드, 그리고 그룹
registry:2 프록시는 한 업스트림만 본다. 회사에서는 보통 Docker Hub 도 보고, 직접 만든 이미지도 올리고, 두 곳을 한 주소로 쓰고 싶다. Sonatype Nexus Repository 의 Docker 저장소는 세 종류다.
- proxy — 원격 저장소를 캐시한다. Docker Hub 라면 Remote storage 에
https://registry-1.docker.io를 적고, Docker Index 는 "Use Docker Hub" 를 고른다(검색은index.docker.io가 맡기 때문에 둘을 따로 적는다). - hosted — 내가 push 하는 곳이다.
- group — 여러 proxy·hosted 를 한 URL 로 묶는다. 문서는 그룹을 "사용자에게 읽기 권한으로 모든 저장소를 노출하는 권장 방법" 이라고 부르며, 멤버는 "원하는 순서로" 넣는다. 그룹에 새 저장소를 넣으면 클라이언트 설정을 바꾸지 않아도 바로 보인다.
클라이언트 쪽 설정은 Repository Connector 포트 하나(예: nexus.example.com:8082)를 데몬의 mirror 로 등록하는 것으로 끝난다. 그 뒤로 docker pull nginx 는 그룹 → 멤버 순서대로 찾고, hosted 에 없으면 proxy 가 Docker Hub 에서 가져와 캐시한다.
Nexus 가 ECR 을 업스트림으로 둘 때는 인증이 특이하다. ECR 의 토큰은 12시간짜리인데, Nexus 문서에 따르면 첫 pull 때 그 토큰을 캐시하고 여섯 시간마다 갱신한다. AWS 자격 증명을 Nexus 에 한 번 주면 나머지는 Nexus 가 돌린다.
ECR pull-through cache: AWS 안에서 캐시하기
클러스터가 AWS 에 있다면 캐시를 따로 운영할 필요 없이 ECR 이 그 일을 한다. 규칙(pull through cache rule)에 업스트림과 접두사를 적어 두면, <계정>.dkr.ecr.<리전>.amazonaws.com/<접두사>/library/nginx:1.27-alpine 을 당길 때 ECR 이 업스트림에서 받아 내 계정의 ECR 저장소에 보관한다.
문서에서 확인한 규칙은 이렇다.
- 인증 없이 되는 업스트림은 ECR Public, Kubernetes 컨테이너 이미지 레지스트리, Quay 세 곳이다. Docker Hub, Azure Container Registry, GitHub·GitLab Container Registry, Chainguard 는 Secrets Manager 비밀이 필수이고, 다른 계정의 ECR 은 IAM 역할로 인증한다.
- 그 비밀의 이름은
ecr-pullthroughcache/로 시작해야 하고, 규칙과 같은 계정·리전에 있어야 한다. Docker Hub 를 계정으로 당기게 되므로 익명 100회 한도가 아니라 그 계정의 한도를 쓴다. - 같은 태그를 다시 당길 때, 지난 24시간 안에 업스트림과 대조한 적이 있으면 업스트림에 묻지 않고 캐시를 그대로 준다. 창이 지났으면 새 버전을 확인해 갱신한다. 태그를 덮어써서 배포하는 습관이 있다면 하루까지 옛 이미지를 받을 수 있다.
- 업스트림 갱신에 실패해도 마지막 캐시본은 계속 내려온다. Docker Hub 가 죽어도 이미 당긴 이미지로는 배포가 된다는 뜻이다.
- 캐시 저장소에 태그 불변(immutability)을 켜면 같은 태그의 갱신이 막힌다.
- 멀티 아키텍처 이미지는 매니페스트 목록과 그 안의 아키텍처 전부를 가져온다. 하나만 원하면 그 아키텍처의 다이제스트로 당긴다.
- 첫 pull 은 ECR 이 업스트림에 나가야 하므로 인터넷 경로가 필요할 수 있다. PrivateLink 엔드포인트만 있는 VPC 는 첫 pull 이 실패할 수 있어 문서가 경로를 미리 두라고 권한다. 이후 pull 은 필요 없다.
- AWS Lambda 는 pull-through cache 규칙으로 만든 이미지를 지원하지 않는다.
어느 것을 고를까
| registry:2 proxy | Nexus (proxy + group) | ECR pull-through cache | |
|---|---|---|---|
| 운영 부담 | 컨테이너 하나 | 서버 하나 + 디스크 관리 | 없음 (관리형) |
| 업스트림 | 하나 | 여러 개를 그룹으로 | 규칙마다 하나 |
| 내 이미지 push | 불가 (읽기 전용) | hosted 에 | 일반 ECR 저장소에 |
| 갱신 확인 | 매 pull 마다 업스트림 매니페스트 확인 | 설정 가능 | 태그당 24시간에 한 번 |
| 맞는 곳 | 홈랩, 노드 몇 대 | 사내, 언어 패키지까지 한곳에 | AWS 안의 EKS·ECS |
우리 파이프라인에서는
LabHub 의 빌드는 Jenkins 가 kaniko 로 이미지를 만들어 사설 Harbor 에 올리고, ArgoCD 가 그 태그를 배포한다. 베이스 이미지는 Dockerfile 에 다이제스트까지 박아 둔다.
FROM python:3.12-slim@sha256:78387bc3…
프록시 캐시를 앞에 두어도 이 줄은 그대로다. 다이제스트는 내용의 해시라서 어느 캐시를 거쳐 오든 같은 바이트여야 통과한다. 캐시가 한도를 지켜 주고, 다이제스트가 내용을 지켜 준다. 두 장치는 서로 다른 일을 한다.
한 줄로
프록시 캐시는 pull 을 빠르게 하는 장치가 아니라 업스트림이 세는 횟수를 하나로 줄이는 장치다. 노드가 몇 대면 registry:2 로 충분하고, 저장소가 여럿이면 Nexus 그룹으로 한 주소를 만들고, AWS 안이라면 ECR 이 대신 캐시하게 두면 된다.
현재 단락 (1/54)
CI 가 한 번 돌 때마다 `FROM python:3.12-slim` 이 Docker Hub 에서 내려온다. 노드가 열 대면 열 번, 파이프라인이 하루 백 번 돌면 백 번이다. D...