Docker Hub: toomanyrequests — pull rate limit에 도달함(429)
안녕하세요, BlueByte입니다. 몇 달째 매일 밤 잘 돌던 배포가 새벽 2시에 toomanyrequests: You have reached your pull rate limit로 실패하는데, 코드는 아무것도 바뀐 게 없습니다. 이쪽에서 고장 난 것도 아닙니다. Docker Hub는 이미지 pull을 계정별·출발지 IP별로 세는데, 같은 주소 뒤의 누군가가 공용 예산을 다 써 버린 것입니다. 오늘은 CLI·Kubernetes·BuildKit에서 이 메시지가 어떻게 보이는지, 어떤 한도에 걸렸고 언제 풀리는지 알려 주는 헤더 읽는 법, 원인별 해결(pull이 실제로 일어나는 곳에서 로그인하기, pull-through 캐시, pull 줄이기), 그리고 정말로 더 큰 한도 아래 들어갔는지 확인하는 법까지 하나씩 짚어보겠습니다.
메시지와 그것이 나타나는 자리
Docker Hub는 manifest 요청에 HTTP 429와 아래 본문으로 답하고, 데몬은 레지스트리의 toomanyrequests 코드를 앞에 붙여 그대로 전달합니다:
Error response from daemon: toomanyrequests: You have reached your pull rate limit. You may increase the limit by authenticating and upgrading: https://www.docker.com/increase-rate-limitsKubernetes에서는 같은 문구가 Pod 이벤트 안에 들어가고, Pod는 ImagePullBackOff를 반복합니다:
Warning Failed kubelet Failed to pull image "nginx:1.27": ... 429 Too Many Requests - Server message: toomanyrequests: You have reached your pull rate limit ...docker build는 FROM 줄에서 failed to resolve source metadata for docker.io/library/...: 429 Too Many Requests로 멈춥니다. 세 군데에서 보이지만 원인은 하나입니다. manifest를 요청한 주체에게 남은 pull이 없었습니다.
예산을 세는 방식, 그리고 사무실 전체가 한꺼번에 막히는 이유
Docker Hub 문서가 숫자를 정합니다. 비인증 pull은 IPv4 주소당(IPv6는 /64당) 100회, 인증한 Personal 계정은 200회이며 둘 다 "6시간 기준으로 계산"됩니다. Pro·Team·Business는 무제한입니다. 중요한 단위는 출발지 주소입니다. NAT 게이트웨이, 사무실 공용 회선, CI 러너 풀은 Docker Hub에 IPv4 주소 하나로 보이므로, 그 뒤의 모든 머신에서 나가는 익명 pull이 같은 100회를 나눠 씁니다. 내 노트북이 로그인돼 있어도 노드의 kubelet이나 파이프라인의 러너에게는 아무 영향이 없습니다. 각자는 자기 신분으로 pull하고, 기본값은 익명입니다.
어떤 한도에 걸렸고 언제 풀리는지 헤더로 읽기
추측하지 말고 레지스트리에 물어보세요. ratelimitpreview/test 이미지가 이 용도로 있고, HEAD 요청은 pull을 소모하지 않고 카운터를 읽습니다:
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq -r .token)
curl -sI -H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest \
| grep -iE '^(HTTP|ratelimit|docker-ratelimit)'HTTP/2 200
docker-ratelimit-source: 203.0.113.10
ratelimit-limit: 100;w=3600
ratelimit-remaining: 100;w=3600ratelimit-limit이 한도, w=가 초 단위 윈도, ratelimit-remaining이 남은 횟수, docker-ratelimit-source가 지금 세고 있는 주소입니다. 이 주소가 내 머신의 공인 IP가 아니라면 NAT이고, 누군가와 나눠 쓰고 있는 것입니다. 이 확인은 실패한 호스트(노드, 러너)에서 하십시오. 그곳의 주소가 문제이기 때문입니다. 솔직히 덧붙이면, 문서는 w=21600(6시간)을 보여 주지만 제가 클라우드 호스트에서 직접 실행했을 때는 헤더가 w=3600을 돌려줬습니다. 기억하는 숫자보다 자기 헤더의 윈도를 믿으세요. 인증된 시점을 보려면 curl -s --user "$USER:$PAT" ...로 토큰을 받으면 됩니다. ratelimit 헤더가 아예 없다면 유료 플랜이라 한도가 적용되지 않는 경우일 수 있습니다.
해결 1: pull이 실제로 일어나는 곳에서 로그인하기
비밀번호 대신 personal access token을 쓰고, 셸 히스토리에 남지 않게 파이프로 넘기세요:
echo "$DOCKER_PAT" | docker login --username your-user --password-stdinLogin Succeeded자격 증명은 그 호스트의 그 사용자 $HOME/.docker/config.json에 저장됩니다. CI에서는 토큰을 secret으로 두고 같은 명령을 파이프라인 단계로 넣고, 빌드 서버에서는 빌드를 실제로 돌리는 계정으로 실행하세요. 그런 다음 --user를 붙여 헤더 확인을 다시 하면 한도가 200으로 올라가 있거나, Pro·Team 계정이라면 헤더가 사라져 있어야 합니다.
해결 2: imagePullSecrets로 kubelet에 자격 증명 주기
Kubernetes 노드는 내 노트북의 로그인을 절대 보지 못합니다. Docker Hub 서버 이름으로 레지스트리 secret을 만들어 Pod에서 참조하거나, 네임스페이스의 service account에 붙여 모든 Pod가 물려받게 하세요:
kubectl create secret docker-registry regcred \
--docker-server=https://index.docker.io/v1/ \
--docker-username=your-user --docker-password="$DOCKER_PAT" \
--docker-email=you@example.com
kubectl patch serviceaccount default \
-p '{"imagePullSecrets": [{"name": "regcred"}]}'spec:
imagePullSecrets:
- name: regcred
containers:
- name: web
image: nginx:1.27
imagePullPolicy: IfNotPresent이미 docker login을 했다면 kubectl create secret generic regcred --from-file=.dockerconfigjson=$HOME/.docker/config.json --type=kubernetes.io/dockerconfigjson으로 그 파일을 재사용할 수 있습니다. secret이 생긴 뒤 멈춘 Pod를 지우면 새 Pod가 인증된 상태로 pull합니다.
해결 3: Docker Hub에 같은 질문을 반복하지 않기 — pull-through 캐시
여러 호스트가 같은 이미지를 받는다면 미러가 한 번만 받아 나머지에 로컬로 나눠 줍니다. registry 이미지를 Docker Hub의 프록시로 띄우면 됩니다:
# /etc/docker/registry/config.yml (on the mirror host)
proxy:
remoteurl: https://registry-1.docker.io
username: your-user
password: your-pat그리고 모든 클라이언트의 /etc/docker/daemon.json이 미러를 가리키게 합니다:
{ "registry-mirrors": ["https://mirror.internal:5000"] }데몬을 재시작한 뒤 docker info의 Registry Mirrors: 항목에 미러가 나오는지 확인하세요. 문서가 주는 주의 두 가지입니다. 이 방식으로 미러링할 수 있는 건 Docker Hub뿐이고, 미러에 자격 증명을 넣으면 그 계정이 볼 수 있는 모든 비공개 이미지가 미러에 접근하는 누구에게나 읽히므로 앞단에 인증을 세워야 합니다. containerd 노드에서는 같은 설정이 containerd의 registry host 설정에 있습니다.
실제 사례: NAT 게이트웨이 한도에 걸린 새벽 2시 롤아웃
12노드 클러스터가 매일 밤 새 릴리스를 롤아웃했습니다. 어느 날 밤 모든 Pod가 ImagePullBackOff에 멈췄고 kubectl describe pod에 위의 429가 찍혔습니다. 노드에서 헤더를 확인하니 ratelimit-remaining: 0이었고, docker-ratelimit-source는 VPC의 NAT 게이트웨이 주소였습니다. 같은 서브넷의 kubelet 12개와 CI 러너 하나가 주소 하나의 100회를 익명으로 나눠 쓰고 있었던 것입니다. 각 네임스페이스의 default service account에 regcred를 붙이고 워크로드에 imagePullPolicy: IfNotPresent를 준 것이 해결이었고, 미러는 일주일 뒤에 들어갔습니다. 같은 노드에서 다시 확인하니 ratelimit-limit: 200이 나왔고 롤아웃은 깨끗하게 이미지를 받았습니다.
확인하고, 카운터가 다시 차오르지 않게 하기
어떤 해결이든 노트북이 아니라 실패했던 호스트에서 증명하세요:
TOKEN=$(curl -s --user "$DOCKER_USER:$DOCKER_PAT" "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq -r .token)
curl -sI -H "Authorization: Bearer $TOKEN" https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest | grep -i ratelimit한도가 200이면 Personal 로그인이 쓰이고 있는 것이고, 헤더가 없으면 유료 플랜입니다. 한도 아래에 머물려면 latest 대신 태그를 고정하고 imagePullPolicy: IfNotPresent로 재시작 때 노드 캐시를 재사용하게 하고, 모든 파이프라인에 빌드 단계로 로그인을 넣고, 클러스터와 러너 풀 앞에 미러를 두고, 노드에서 ratelimit-remaining을 주기적으로 지켜봐서 롤아웃이 먼저 알아채기 전에 예산이 줄어드는 걸 보십시오.
ImagePullBackOff·abuse 한도와 어떻게 다른지
ImagePullBackOff는 원인이 아니라 상태입니다. 잘못된 태그, 비공개 저장소의 pull secret 누락, 그리고 이 rate limit이 모두 거기서 끝나며, 이벤트 문구만이 셋을 가릅니다. manifest unknown, unauthorized, toomanyrequests입니다. Docker Hub에는 별도의 abuse rate limit도 있어서 웹 페이지와 API를 포함한 모든 요청에 pull 한도 본문 없이 429 Too Many Requests로 답합니다. 이쪽은 한 주소에서 요청이 몰릴 때 발생하며, 답은 로그인이 아니라 속도를 늦추는 것입니다. 다음에 pull이 429로 죽으면 실패한 호스트에서 먼저 헤더를 확인해 보세요. 주소·한도·시계를 한 줄로 알려 줍니다.
관련 질문
노트북에서는 로그인돼 있는데 왜 CI는 계속 rate limit에 걸리나요?
자격 증명은 한 호스트의 한 사용자 $HOME/.docker/config.json에만 있고, 러너는 자기 신분으로 pull합니다. access token을 secret으로 두고 docker login --password-stdin을 파이프라인 단계로 넣은 뒤, 러너에서 헤더 확인을 돌려 한도가 200으로 읽히거나(유료 플랜이면) 헤더가 사라지는지 확인하세요.
한도는 언제 풀리나요?
ratelimit-limit 헤더의 w= 값이 초 단위 윈도입니다. 문서는 6시간 기준과 w=21600을 보여 주지만 자기 호스트의 헤더가 기준입니다. 제가 클라우드 호스트에서 확인했을 때는 w=3600이 나왔으니, 기억하는 숫자보다 레지스트리가 알려 주는 값을 믿으세요.
이미지가 이미 있는 Pod를 재시작해도 pull로 세나요?
imagePullPolicy: IfNotPresent에 고정 태그라면 kubelet은 노드의 이미지를 쓰고 레지스트리에 접속하지 않습니다. latest 태그의 기본값인 Always라면 시작할 때마다 Docker Hub에 다시 가고, 바쁜 클러스터에서 공용 예산을 갉아먹는 것이 바로 이 동작입니다.
무료 Docker 로그인이면 충분한가요, 유료 플랜이 필요한가요?
Personal 로그인은 계정당 윈도마다 200회로 예산을 올려 주고, 문서는 Pro·Team·Business를 무제한으로 표시합니다. 같은 이미지를 받는 플릿이라면 미러가 모두를 대신해 한 번만 받으므로, 플랜 변경보다 pull-through 캐시가 먼저 문제를 없애는 경우가 많습니다.
pull rate limit 문구 없이 429 Too Many Requests만 받았습니다.
Docker Hub의 별도 abuse rate limit입니다. 한 주소에서 요청이 몰리면 웹 페이지와 API를 포함한 모든 요청에 적용됩니다. 속도를 늦춰 다시 시도하세요. 로그인으로는 바뀌지 않습니다.
참고 자료
Haneul Seo
Infrastructure engineer · 10+ years running Linux fleets
같은 카테고리 다른 글
Git: fatal: detected dubious ownership in repository
Git 2.35.2의 CVE-2022-24765 수정 이후, Git은 작업 트리나 .git 디렉터리의 소유자가 명령을 실행한 사용자와 다르면 저장소를 읽지 않습니다. 컨테이너, CI 작업, sudo 세션, 공유 드라이브에서 주로 나타납니다. 저장소가 내 것이어야 한다면 소유권을 바로잡고, 아니라면 global 설정의 safe.directory에 정확한 경로를 추가하세요 — Git은 이 설정을 저장소 자신의 설정에서는 무시합니다.
Kubernetes: Internal error occurred: failed calling webhook
admission webhook이 쓰기 요청 앞에 서 있는데 API server가 응답을 받지 못했고, 기본값인 failurePolicy: Fail이 그 침묵을 거부로 바꾼 것입니다. 메시지 끝부분이 곧 진단입니다 — context deadline exceeded는 호출이 도달하지 못한 것, no endpoints available은 떠 있는 게 없는 것, x509 줄은 API server가 webhook 인증서를 신뢰하지 않는 것입니다. 원인마다 해결이 다르고, 어느 것도 매니페스트 문제가 아닙니다.
MySQL: ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
다른 트랜잭션이 쥐고 있는 행 잠금을 innodb_lock_wait_timeout 만큼 기다리다 포기한 것입니다. sys.innodb_lock_waits 가 막고 있는 세션을 지목하고 KILL 문까지 만들어 주며, blocking_query 가 NULL 이면 블로커가 열린 트랜잭션 위에서 놀고 있다는 뜻입니다. 재시도 로직이 가장 자주 틀리는 지점은 따로 있습니다. 기본값에서는 타임아웃된 문장 하나만 롤백되므로, 트랜잭션은 그대로 열린 채 앞서 잡은 잠금을 전부 쥐고 있습니다.
Redis: MISCONF Redis is configured to save RDB snapshots, but it's currently unable to persist to disk
읽기는 되는데 모든 쓰기가 거부됩니다. 마지막 백그라운드 저장이 실패했고 stop-writes-on-bgsave-error 기본값이 yes 이기 때문입니다. 진짜 원인은 로그에 적혀 있습니다 — 디스크 공간 부족, redis 사용자가 쓸 수 없는 dir, rename 시점의 read-only 마운트, 또는 fork 가 Cannot allocate memory 로 실패하는 경우입니다. 원인을 고치고 BGSAVE 한 번만 돌리면 재시작 없이 쓰기가 돌아옵니다. rdb_last_bgsave_status 가 err 에서 ok 로 바뀝니다. stop-writes-on-bgsave-error no 는 쓰기를 즉시 되살리지만 스냅샷은 여전히 실패한 상태로 두므로, 해결이 아니라 의도한 맞교환으로 다뤄야 합니다.
Node.js: FATAL ERROR: Reached heap limit — JavaScript heap out of memory (종료 코드 134)
V8 힙에는 시스템 메모리와 Node 릴리스에서 유도되는 자기만의 천장이 있고, 가진 RAM 보다 훨씬 낮은 경우가 흔합니다. 빌드나 서버가 거기 닿으면 V8 은 FATAL ERROR: Reached heap limit 과 종료 코드 134 로 스스로 중단합니다. v8.getHeapStatistics().heap_size_limit 으로 실제 한도를 읽은 뒤, 큰 작업은 --max-old-space-size(MiB)나 NODE_OPTIONS 로 올리고, 컨테이너 안에서는 cgroup 한도보다 낮게 잡고, 오래 도는 프로세스의 누수는 --heapsnapshot-near-heap-limit 으로 잡으세요. FATAL ERROR 줄 없이 137 로 끝나면 컨테이너 kill 이지 이 오류가 아닙니다.
Docker: 컨테이너 시작 직후 "exec format error" — 플랫폼이 다른 이미지, 에뮬레이터 없음, 셔뱅 없는 스크립트
컨테이너가 첫 명령에서 exec format error 로 끝납니다. 커널의 ENOEXEC, 즉 파일은 있지만 여기서는 실행할 수 없다는 뜻입니다. 실제로는 한 CPU 아키텍처에서 빌드한 이미지(Apple 실리콘 Mac은 linux/arm64 를 만듭니다)를 binfmt_misc 에 QEMU 핸들러가 없는 다른 아키텍처(x86_64 서버)에서 돌리거나, 엔트리포인트 스크립트의 첫 줄이 셔뱅이 아닌 경우입니다. uname -m, docker image inspect, ls /proc/sys/fs/binfmt_misc 로 원인을 가르고, docker buildx build --platform 을 명시(또는 두 플랫폼 매니페스트 목록 발행)하거나, 에뮬레이션이 목적이면 QEMU 등록과 --platform, 스크립트라면 #!/bin/sh 한 줄로 해결합니다.