배포 롤아웃이 멈춰 있고 파드가 Running으로 넘어가질 않는다. 상태를 보면 ErrImagePull이 잠깐 떴다가 ImagePullBackOff로 굳어 있다. 신규 서비스를 올릴 때도, 프라이빗 레지스트리로 옮길 때도, 며칠 잘 돌던 ECR 기반 클러스터에서도 심심찮게 만나는 상태다. 막상 돌려보면 원인은 몇 가지로 정해져 있는데, kubectl get pod 결과만 멍하니 보고 있으면 뭘 고쳐야 할지 안 보인다.
결론부터 말하자면, 이 증상은 kubelet이 이미지를 못 받아온 것뿐이고 원인은 거의 kubectl describe pod의 Events 한 섹션에 그대로 찍힌다. 태그 오타, 프라이빗 레지스트리 자격증명 누락, ECR 토큰 만료, Docker Hub rate limit 이 네 가지가 자주 마주치는 원인이다. describe로 에러 문구를 먼저 읽고, 문구에 맞는 수정 한 가지를 적용하면 대개 끝난다.
1. ImagePullBackOff와 ErrImagePull이 뭔가
둘 다 kubelet이 컨테이너 이미지를 레지스트리에서 받아오는 데 실패했다는 뜻이다. ErrImagePull은 pull을 시도했다가 바로 실패한 직후 상태이고, ImagePullBackOff는 그 재시도를 지수 백오프(점점 간격을 늘리며 다시 시도)로 미뤄 둔 대기 상태다. 즉 같은 실패의 다른 단계라고 보면 된다. 처음엔 ErrImagePull로 보이다가 몇 초 뒤 ImagePullBackOff로 바뀌는 건 정상이고, 백오프 간격은 재시도가 거듭될수록 최대 5분까지 벌어진다.
중요한 건 이게 스케줄링 실패가 아니라는 점이다. 파드는 이미 특정 노드에 배치됐고, 그 노드의 kubelet이 이미지를 못 가져오고 있는 것이다. 그래서 노드 자원이나 Pending과는 원인 계통이 다르다.
2. describe로 5분 안에 원인 특정하기
진단은 한 줄이면 된다. 파드 이름을 넣어 describe를 찍고 맨 아래 Events만 본다.
$ kubectl get pod NAME READY STATUS RESTARTS AGE web-7c9f8b6d4f-2xq7p 0/1 ImagePullBackOff 0 2m $ kubectl describe pod web-7c9f8b6d4f-2xq7p
출력 맨 아래가 이렇게 나온다.
Events: Type Reason Age From Message ---- ------ ---- ---- ------- Normal Scheduled 3m default-scheduler Successfully assigned default/web-7c9f8b6d4f-2xq7p to ip-10-0-1-23 Normal Pulling 2m (x4 over 3m) kubelet Pulling image "myregistry.example.com/web:v1.4.2" Warning Failed 2m (x4 over 3m) kubelet Failed to pull image "myregistry.example.com/web:v1.4.2": ... Warning Failed 2m (x4 over 3m) kubelet Error: ErrImagePull Normal BackOff 30s (x6 over 3m) kubelet Back-off pulling image "myregistry.example.com/web:v1.4.2" Warning Failed 30s (x6 over 3m) kubelet Error: ImagePullBackOff
볼 곳은 Failed to pull image 뒤에 붙는 상세 문구다. 이 문구가 원인을 거의 그대로 알려준다. 아래 세 섹션에서 자주 나오는 문구별로 차례로 고쳐 본다. Events가 짧게 잘려 안 보이면 kubelet 쪽 원문을 이렇게 뽑는다.
$ kubectl get events --field-selector involvedObject.name=web-7c9f8b6d4f-2xq7p --sort-by=.lastTimestamp
3. 태그 오타와 존재하지 않는 이미지
자주 마주치는 원인 중 하나이면서 가장 허무한 쪽이다. describe에 이런 문구가 보이면 이름이나 태그가 틀린 것이다.
Failed to pull image "myregistry.example.com/web:v1.4.2": ... manifest for myregistry.example.com/web:v1.4.2 not found: manifest unknown
manifest unknown이나 not found는 레지스트리에 그 태그가 없다는 뜻이다. 오타(v1.4.2를 1.4.2로), 밀어 넣지 않은 태그, 리포지터리 경로 실수가 대부분이다. 실제로 그 태그가 레지스트리에 있는지 먼저 확인한다.
# Docker Hub, 일반 레지스트리
$ docker manifest inspect myregistry.example.com/web:v1.4.2
# ECR
$ aws ecr describe-images --repository-name web \
--image-ids imageTag=v1.4.2 --region ap-northeast-2
태그만 고치면 되면 매니페스트를 다시 쓰는 대신 이렇게 바로 이미지를 바꿔도 롤아웃이 다시 돈다.
$ kubectl set image deployment/web web=myregistry.example.com/web:v1.4.3
태그 동작과 스테일 배포 주의
여기서 imagePullPolicy 기본값을 짚고 넘어간다. 태그를 생략하거나 :latest를 쓰면 기본값은 Always가 되어 컨테이너를 띄울 때마다 새로 pull한다. 반대로 v1.4.2 같은 고정 태그를 쓰면 기본값은 IfNotPresent라서 노드에 이미 같은 태그 이미지가 있으면 받아오지 않고 캐시된 것을 쓴다.
그래서 스테일(오래된) 배포 사고는 고정 태그 쪽에서 난다. 같은 v1.4.2 태그에 이미지를 새로 덮어쓰고 재배포하면, 이미 그 태그를 받아 둔 노드는 IfNotPresent 기본값 때문에 캐시된 구버전을 그대로 띄울 수 있다. 같은 태그를 재사용하지 말고 버전마다 새 태그를 쓰는 게 안전하다. 꼭 같은 태그를 덮어써야 한다면 매니페스트에 imagePullPolicy: Always를 명시한다.
4. 프라이빗 레지스트리 자격증명 누락
describe에 인증 거부 문구가 보이면 자격증명 문제다.
Failed to pull image "myregistry.example.com/web:v1.4.2": ... pull access denied, repository does not exist or may require authorization: ... authorization failed: no basic auth credentials
no basic auth credentials나 unauthorized는 kubelet이 익명으로 pull을 시도했다는 뜻이다. 프라이빗 레지스트리인데 imagePullSecrets가 파드에 안 붙어 있는 경우다. 먼저 docker-registry 타입 시크릿을 만든다.
$ kubectl create secret docker-registry regcred \
--docker-server=myregistry.example.com \
--docker-username=deploy \
--docker-password='********' \
--docker-email=ops@example.com
그리고 파드 스펙에 참조를 건다. 컨테이너가 아니라 파드 레벨에 쓴다는 점이 함정이다.
spec:
containers:
- name: web
image: myregistry.example.com/web:v1.4.2
imagePullSecrets:
- name: regcred
여기서 한 번 더 걸리는 지점이 있다. 시크릿은 파드와 같은 네임스페이스에 있어야 한다. regcred를 default에 만들어 놓고 파드는 prod에 띄우면 똑같이 인증 실패가 난다. 네임스페이스를 맞춰서 다시 만들거나, 그 네임스페이스의 default 서비스어카운트에 붙여 두면 매번 적는 수고를 던다.
$ kubectl patch serviceaccount default -n prod \
-p '{"imagePullSecrets": [{"name": "regcred"}]}'
시크릿 내용이 의심되면 디코드해서 서버 주소와 계정이 맞는지 확인한다.
$ kubectl get secret regcred -n prod \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d
5. ECR 토큰 만료
AWS ECR에서만 나타나는 패턴이다. describe 문구는 4절과 비슷한 인증 거부지만, 서버 주소가 ECR 엔드포인트고 처음엔 잘 되다가 일정 시간 뒤 실패한다는 점이 다르다.
Failed to pull image "123456789012.dkr.ecr.ap-northeast-2.amazonaws.com/web:v1.4.2": ... 401 Unauthorized ... authorization token has expired
ECR의 docker login 토큰은 발급 후 12시간 뒤 만료된다. 그래서 수동으로 get-login-password로 만든 regcred 시크릿을 쓰면 반나절 뒤 전부 ImagePullBackOff로 돌아선다. 급하게 되살릴 때는 토큰을 다시 받아 시크릿을 갱신한다.
$ kubectl create secret docker-registry regcred \
--docker-server=123456789012.dkr.ecr.ap-northeast-2.amazonaws.com \
--docker-username=AWS \
--docker-password="$(aws ecr get-login-password --region ap-northeast-2)" \
--dry-run=client -o yaml | kubectl apply -f -
다만 이건 임시 방편이고, 12시간마다 사람이 돌릴 수는 없다. EKS에서는 노드의 IAM 역할에 ECR pull 권한(AmazonEC2ContainerRegistryReadOnly 수준)을 주면 kubelet의 ECR 자격증명 공급자가 토큰을 알아서 받아오므로 imagePullSecrets 자체가 필요 없다. 자체 관리 클러스터라면 ecr-credential-provider를 kubelet에 설정하거나, 주기적으로 시크릿을 갱신하는 크론잡을 둔다. 노드 역할에 붙일 정책의 최소 범위는 이렇다.
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"ecr:GetAuthorizationToken",
"ecr:BatchGetImage",
"ecr:GetDownloadUrlForLayer"
],
"Resource": "*"
}]
}
6. Docker Hub rate limit
퍼블릭 이미지를 Docker Hub에서 바로 당겨 쓰는 클러스터에서 간헐적으로 나는 문구다.
Failed to pull image "nginx:1.27": ... toomanyrequests: You have reached your pull rate limit. ... https://www.docker.com/increase-rate-limit
toomanyrequests와 rate limit이 핵심 단어다. Docker Hub는 익명 pull에 IP 기준 요청 한도를 둔다. 노드 여러 대가 같은 NAT 공인 IP로 나가는 환경에서는 그 IP 한 개 몫의 한도를 전부가 나눠 쓰게 되어 쉽게 걸린다.
당장 푸는 방법은 익명 대신 인증된 pull로 바꾸는 것이다. Docker Hub 계정으로 regcred 시크릿을 만들어 붙이면 한도가 계정 기준으로 올라간다. 서버 주소는 https://index.docker.io/v1/를 쓴다.
$ kubectl create secret docker-registry dockerhub \
--docker-server=https://index.docker.io/v1/ \
--docker-username=myaccount \
--docker-password='********'
근본적으로는 자주 쓰는 베이스 이미지를 사내 레지스트리나 ECR에 미러링해 두고 거기서 받는 쪽으로 돌리는 게 낫다. 외부 레지스트리 한도에 배포 안정성이 묶이는 상황을 끊을 수 있다.
7. 정리
진단 순서는 간단하다. kubectl describe pod의 Events에서 Failed to pull image 뒤 문구를 읽고, manifest unknown이면 태그, no basic auth credentials면 imagePullSecrets, ECR에서 token has expired면 토큰 갱신이나 노드 IAM, toomanyrequests면 Docker Hub 인증이나 미러링으로 간다. 문구를 건너뛰고 추측으로 매니페스트를 뒤지는 게 제일 오래 걸린다. Events 한 줄을 먼저 읽는 습관이 결국 제일 빠르다.