지난 편에서는 파드가 떴다 죽기를 반복하는 CrashLoopBackOff를 다뤘습니다. 이번에는 파드가 아예 뜨지도 못하는 경우입니다. kubectl get pods를 쳤더니 STATUS 칸에 ImagePullBackOff가 떠 있고, READY는 0/1에서 더는 올라가지 않습니다. 컨테이너가 시작조차 못 한 상태라 로그를 보려 해도 볼 컨테이너가 없습니다. 잠시 뒤 다시 보면 ErrImagePull이었다가 또 ImagePullBackOff로 바뀌어 있습니다. 이미지를 받아 오지 못하는 이유는 몇 가지로 정해져 있고, 각각 kubectl describe pod에 서로 다른 단서를 남깁니다. 그 단서부터 읽으면 원인을 빠르게 좁힐 수 있습니다.
저는 이 상태를 새 클러스터로 옮기거나 프라이빗 레지스트리를 처음 붙일 때 가장 자주 만났습니다. 처음 겪었을 때 제일 헷갈렸던 건, 화면에는 똑같이 ImagePullBackOff만 떠 있는데 실제 원인은 전혀 다른 곳에 있었다는 점입니다. 그래서 이번 편은 "어떤 단서를 보면 어느 원인인지" 짚는 데 집중하겠습니다.
이름만 보면 특정 오류 같지만, 쿠버네티스(Kubernetes)에서 이 상태는 "이미지를 받아 오지 못해서 재시도 간격(Back-Off)을 늘리며 다시 시도하는 중"이라는 뜻입니다. 파드가 어느 노드에 배치되면 그 노드의 kubelet이 컨테이너를 띄우기 전에 레지스트리에서 이미지를 내려받습니다. 이 풀(pull) 과정이 실패하면 kubelet은 곧장 포기하지 않고 잠시 기다렸다 다시 시도합니다. 쿠버네티스 공식 문서도 ImagePullBackOff를 이미지를 받아 오지 못해 컨테이너를 시작하지 못한 상태로 설명하고, 뒤에 붙은 BackOff는 재시도 간격을 점점 늘려 간다는 뜻이라고 밝히고 있습니다.
순서를 보면 이해가 빠릅니다. 처음 이미지 받기에 실패하면 상태가 ErrImagePull로 잡힙니다. 곧바로 다시 시도했는데 또 실패하면, 쿠버네티스는 재시도 간격을 점점 늘리며 ImagePullBackOff로 바꿔 둡니다. 공식 문서 기준으로 이 간격은 최대 5분(300초)까지 벌어집니다. 그러니 ErrImagePull과 ImagePullBackOff는 서로 다른 문제가 아니라, 같은 이미지 풀 실패가 진행되는 과정에서 나타나는 상태입니다. 처음 한 번 실패한 직후라면 ErrImagePull, 몇 번 실패해 백오프에 들어갔다면 ImagePullBackOff로 보면 됩니다.
진짜 원인은 따로 있습니다. 실무에서 자주 만나는 원인은 크게 네 가지로 나눠 볼 수 있습니다.

이 네 가지는 거의 항상 kubectl describe pod의 Events에 서로 다른 메시지를 남깁니다. 그래서 진단의 첫걸음은 로그도 재배포도 아니고, 이벤트 한 줄을 읽는 것입니다.
kubectl describe pod <파드이름>출력 맨 아래 Events에서 Failed to pull image 다음에 붙는 문구에 따라 원인이 달라집니다.
표만 봐도 어느 정도 원인을 좁힐 수 있지만, 하나씩 살펴보겠습니다.
가장 흔하고 가장 빨리 풀리는 경우입니다. 레지스트리는 잘 찾아갔는데 거기에 그런 이미지나 태그가 없는 경우입니다. 여기서도 한 단계 더 좁힐 수 있습니다. 이름 자체가 레지스트리에 없으면 repository does not exist나 not found가, 이름은 맞는데 그 태그만 없으면 manifest unknown이 보입니다. myapp:v1.2.3이라고 적었는데 실제로는 v1.2.30이었거나, 레지스트리 경로 한 글자가 틀린 식입니다. 이런 메시지가 보이면 코드를 뒤질 게 아니라 매니페스트의 image: 한 줄부터 다시 봅니다.

레지스트리도 찾았고 이미지도 있는데, "너는 이걸 받을 권한이 없다"고 거절당하는 경우입니다. 이벤트에 pull access denied, unauthorized, 401이 보이면 이 경우입니다. 대부분 imagePullSecrets가 빠졌거나, 자격 증명이 만료됐거나, 잘못된 시크릿을 가리키고 있습니다. 공식 문서도 비공개 레지스트리에서 imagePullSecret 없이 받아 오려 할 때 ImagePullBackOff가 날 수 있다고 안내합니다.

그보다 앞 단계에서 막힌 경우입니다. 권한 문제가 아니라 레지스트리 서버까지 연결 자체가 안 됩니다. 사내 사설 레지스트리를 쓸 때 자주 나오고, 이벤트에 dial tcp ... i/o timeout이나 no such host가 찍힙니다. 주소 오타, 방화벽·보안그룹, 사내 DNS 미설정 중 하나인 경우가 많습니다.

인증과 헷갈리기 쉬운 경우입니다. 권한이 없는 게 아니라, 정해진 시간 안에 너무 많이 요청해서 잠시 막힌 상태입니다. Docker Hub의 익명·무료 풀 한도가 대표적입니다. 노드가 여러 대인데 다 같은 퍼블릭 이미지를 동시에 당겨 오면 이벤트에 toomanyrequests가 뜹니다.

원인을 구분하는 게 왜 중요한지는, 제가 실제로 시간을 버렸던 오판 케이스를 보면 분명해집니다.
가장 자주 빠지는 함정은 인증 실패를 이미지 없음으로 착각하는 것입니다. not found라는 단어 때문에 "이미지가 없나" 싶어 태그를 한참 확인하지만, 정작 이벤트를 끝까지 읽으면 pull access denied가 같이 찍혀 있을 때가 있습니다. 일부 레지스트리는 권한이 없는 사용자에게 "이미지가 있는지조차 알려 주지 않으려고" 없는 것처럼 응답하기 때문입니다. 그래서 이벤트는 첫 단어만 보지 말고 한 줄 전체를 읽는 편이 안전합니다.
두 번째는 imagePullSecrets의 네임스페이스 불일치입니다. 시크릿(Secret)은 분명히 만들었는데도 계속 401이 나서 한참을 들여다본 적이 있습니다. 원인은 단순했습니다. 시크릿은 default 네임스페이스에 만들었는데, 파드는 다른 네임스페이스에서 떠 있었습니다. 시크릿은 네임스페이스 안에서만 보이기 때문에, 파드와 같은 네임스페이스에 시크릿이 있는지부터 확인하는 게 빠릅니다. "분명히 만들었는데 왜 안 먹지"라는 생각이 들면 십중팔구 이 경우입니다.
세 번째는 :latest 태그와 캐싱 혼동입니다. 어제는 잘 뜨던 파드가 오늘 새 노드에서만 ImagePullBackOff가 나는 경우입니다. 멀쩡하던 노드에는 이미지가 이미 캐시돼 있어서, imagePullPolicy가 Always가 아니면 받아 오지 않고 그냥 씁니다. 그런데 태그를 생략하거나 :latest로 두면 기본값이 Always라, 컨테이너를 시작할 때마다 레지스트리에서 이미지를 다시 확인합니다. 새로 스케줄된 노드는 캐시가 없으면 이미지를 직접 받아야 하므로, 그 과정에서 인증이나 rate limit에 걸려 실패할 수 있습니다. 반대로 특정 버전 태그를 박아 두면 정책 기본값이 IfNotPresent라 캐시를 우선 쓰므로 이런 변동이 줄어듭니다. "왜 어떤 노드만 실패하지"라는 의문이 들면 노드별 이미지 캐시 차이를 의심해 봅니다.
원인을 구분하는 방법을 알아도, 실제 진단에는 한 가지 어려움이 더 있습니다. 단서가 금방 사라진다는 점입니다.
오래된 이벤트는 영구히 보관되지 않습니다. 재시도가 반복되면 처음 실패 메시지가 뒤로 밀리거나 일정 시간이 지나 정리될 수 있습니다. 그래서 처음 실패했을 때 어떤 메시지였는지를 놓치면, 정작 제일 중요한 첫 단서를 다시 보기 어렵습니다. 인증 실패였는지 rate limit이었는지는 그 첫 줄에 다 들어 있는데 말입니다.
어느 노드에서 풀이 실패했는지, 그때 레지스트리가 무엇을 응답했는지도 마찬가지입니다. 노드가 여러 대고 파드가 이리저리 스케줄되면, 사람이 일일이 따라다니며 그 순간을 붙잡기는 어렵습니다. rate limit처럼 시간에 따라 풀렸다 다시 막혔다 하는 문제는 특히 그렇습니다. 한 번 지나간 응답 추이는 그 자리에서 보지 못하면 남지 않습니다.
그래서 지난 상태를 시간 순으로 보존하는 모니터링이 있으면 추적이 한결 수월해집니다. 언제 처음 풀에 실패했는지, 어느 노드에서 어떤 메시지가 떴는지를 나중에도 되짚어 볼 수 있기 때문입니다. ImagePullBackOff처럼 단서가 빨리 휘발하는 문제일수록, 이벤트와 상태 변화를 타임라인으로 모아 두는 쪽이 도움이 됩니다.

ImagePullBackOff와 ErrImagePull은 무엇이 다른가요?
다른 문제가 아니라 같은 실패의 처음과 그다음입니다. 처음 풀에 실패하면 ErrImagePull로 잡히고, 곧바로 다시 시도해도 실패하면 재시도 간격을 늘리며 ImagePullBackOff로 바뀝니다.
ImagePullBackOff인데 로그를 볼 수 없습니다. 원인을 어떻게 찾나요?
컨테이너가 시작조차 못 한 상태라 볼 로그가 없습니다. kubectl describe pod <파드이름>을 실행해 맨 아래 Events에서 Failed to pull image 다음 한 줄을 읽으면 원인이 좁혀집니다.
이미지 이름이 맞는데도 not found가 뜹니다.
일부 레지스트리는 권한 없는 사용자에게 이미지 존재 여부조차 숨기려고 없는 것처럼 응답합니다. 이벤트를 끝까지 읽어 pull access denied나 401이 함께 찍혀 있는지 확인해 보세요. 있으면 이름이 아니라 인증 문제입니다.
시크릿을 분명히 만들었는데 계속 401이 납니다.
imagePullSecrets의 네임스페이스 불일치일 가능성이 큽니다. 시크릿은 네임스페이스 안에서만 보이므로, 파드와 같은 네임스페이스에 시크릿이 있는지부터 확인하는 게 빠릅니다.
어제는 잘 뜨던 파드가 새 노드에서만 ImagePullBackOff가 납니다.
노드별 이미지 캐시 차이입니다. 태그를 생략하거나 :latest로 두면 정책 기본값이 Always라 노드마다 레지스트리에 다시 물어보고, 캐시가 없는 새 노드에서 인증이나 rate limit에 걸릴 수 있습니다. 특정 버전 태그를 박으면 기본값이 IfNotPresent라 이런 변동이 줄어듭니다.
ImagePullBackOff를 만났을 때 제일 먼저 할 일은 kubectl describe pod로 Events의 Failed to pull image 다음 한 줄을 읽는 것입니다. not found나 manifest unknown이면 이미지 이름·태그, pull access denied나 401이면 인증, dial tcp timeout이나 no such host면 네트워크, toomanyrequests면 rate limit입니다. 단어 하나로 원인만 잡아도 절반은 푼 셈입니다.
남는 어려움은 결국 그 첫 단서가 금방 사라진다는 데 있습니다. 만약 파드별로 이미지 풀 실패 이벤트가 처음 발생한 시점과 노드, 레지스트리 응답까지 시간 순으로 남아 있다면, 이벤트가 밀려 사라진 뒤에도 처음 그 순간으로 돌아가 원인을 짚을 수 있습니다. 요즘은 모니터링 화면에서 "왜 이 파드가 ImagePullBackOff야?"처럼 자연어로 물으면 쌓인 이벤트를 근거로 원인을 정리해 주는 방식도 나와 있어서, 단서를 직접 뒤지는 수고를 한결 덜 수 있습니다.
와탭 Kubernetes 모니터링을 15일 무료로 사용하면서 남아 있는 기록으로 직접 원인을 추적해 보는 것도 방법입니다.