- Published on
Kubernetes ImagePullBackOffとErrImagePull完全解剖 — 原因文字列ひとつで終わらせる
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — イメージ名は明らかに合っているのにPodが起動しません
デプロイするとPodがこの状態で止まります。
kubectl get pod -n analytics
NAME READY STATUS RESTARTS AGE
ingest-6b8c94f7d5-4tzqr 0/1 ImagePullBackOff 0 3m12s
ingest-6b8c94f7d5-9wdhm 0/1 ErrImagePull 0 3m12s
同じDeploymentのPodなのに、一方はImagePullBackOff、もう一方はErrImagePullです。イメージ名を何度も見直しましたがタイプミスはありません。この時点で多くの人はレジストリのUIを開いてタグを目視で確認し始めますが、その必要はありません。kubeletは失敗した理由を文字列そのままイベントに残しています。
ErrImagePullとImagePullBackOffは同じ事象の2つの段階です
2つの状態の関係は単純です。
- kubeletがイメージをダウンロードしようと試みる
- 失敗するとコンテナ状態のReasonがErrImagePullになり、失敗理由を含むFailedイベントが記録される
- kubeletはしばらくして再試行する。この待機区間の間、ReasonはImagePullBackOffに変わる
- 再試行がまた失敗すると待機時間が2倍に伸びる。10秒から始まり最大5分で固定される
つまりErrImagePullが失敗の瞬間であり、ImagePullBackOffはその合間の待機です。同じDeploymentのPodが互いに異なる状態に見える理由もここにあります。照会した時点が各Podの再試行サイクルのどのあたりだったか、という違いにすぎません。
ここから実務的に重要な結論が出ます。レジストリ側を修正した後は最大5分待つ必要はなく、Podを削除するほうが速いです。新しいPodはバックオフ履歴なしに即座に最初のプルを試みます。
kubectl rollout restart deployment/ingest -n analytics
Eventsの最後の1行に原因がそのまま書かれています
診断はdescribe1回で始まり、事実上そこで終わります。
kubectl describe pod ingest-6b8c94f7d5-4tzqr -n analytics | tail -12
Events:
Type Reason Age From Message
---- ------ ---- ---- -------
Normal Scheduled 3m24s default-scheduler Successfully assigned analytics/ingest-6b8c94f7d5-4tzqr to ip-10-0-2-77
Normal Pulling 2m1s (x4 over 3m23s) kubelet Pulling image "ghcr.io/example/ingest:2.7.0"
Warning Failed 2m1s (x4 over 3m22s) kubelet Failed to pull image "ghcr.io/example/ingest:2.7.0": rpc error: code = NotFound desc = failed to pull and unpack image "ghcr.io/example/ingest:2.7.0": failed to resolve reference "ghcr.io/example/ingest:2.7.0": ghcr.io/example/ingest:2.7.0: not found
Warning Failed 2m1s (x4 over 3m22s) kubelet Error: ErrImagePull
Normal BackOff 97s (x6 over 3m22s) kubelet Back-off pulling image "ghcr.io/example/ingest:2.7.0"
Warning Failed 97s (x6 over 3m22s) kubelet Error: ImagePullBackOff
Warning Failedの行のMessageがすべてです。この文字列だけを正確に読めば原因が確定します。
複数のネームスペースを一度に洗うときは、イベントを直接フィルタリングするほうが速いです。
kubectl get events -A --field-selector reason=Failed --sort-by=.lastTimestamp | tail -5
NAMESPACE LAST SEEN TYPE REASON OBJECT MESSAGE
analytics 41s Warning Failed pod/ingest-6b8c94f7d5-4tzqr Failed to pull image "ghcr.io/example/ingest:2.7.0": ... not found
payments 2m8s Warning Failed pod/checkout-5d7f8b9c4-x2klm Failed to pull image "registry.example.com/checkout:1.14.2": ... 401 Unauthorized
media 6m11s Warning Failed pod/transcode-79c5d6f8b-vn4pq Failed to pull image "redis:7.2": ... toomanyrequests: You have reached your pull rate limit.
よくある誤答: Eventsを読まずにイメージ名から確認し直すこと。名前のタイプミスは8つの分岐のうちの1つにすぎず、残りの7つは名前をいくら見ても見えてきません。
原因の分岐と診断
| 原因 | Eventsの原因文字列 | 診断コマンド | 解決 |
|---|---|---|---|
| 存在しないタグ・名前 | not found, manifest unknown | crane manifest IMAGE | 実際に存在するタグへ修正 |
| プライベートレジストリの認証失敗 | 401 Unauthorized, authentication required | Secretをデコードした後にPodスペックを確認 | Secret作成後にServiceAccountへ付与し、Podを再作成 |
| Secretが別のネームスペース | 401 Unauthorized(認証情報がまったく渡らない) | kubectl get secret -n 対象ネームスペース | 同じネームスペースにSecretを作成 |
| Docker Hubのレートリミット | toomanyrequests, pull rate limit | レートリミットヘッダーの照会 | 認証プルへ切り替え、レジストリミラー |
| ネットワーク・プロキシ・エアギャップ | i/o timeout, no such host, connection refused | ノードから直接リクエスト | プロキシ環境変数、ミラー、内部レジストリ |
| プライベートCAが未信頼 | x509 certificate signed by unknown authority | ノードのCAバンドルを確認 | ノードへCAを配布、containerd設定 |
| アーキテクチャの不一致 | no match for platform in manifest | docker buildx imagetools inspect | マルチアーキテクチャビルドまたはノード選択 |
| ノードのディスク不足 | no space left on device | ノードのDiskPressureコンディション | イメージGCしきい値の調整、ディスク増設 |
存在しないタグ
最も単純ですが最も頻繁に出ます。CIがイメージをプッシュする前にマニフェストを先に適用したか、タグの規則が変わったか、タグが整理ポリシーによって削除された場合です。クラスターに入る前に確認できます。
crane ls ghcr.io/example/ingest | tail -5
2.6.4
2.6.5
2.7.0-rc1
2.7.1
crane manifest ghcr.io/example/ingest:2.7.0
Error: fetching manifest ghcr.io/example/ingest:2.7.0: GET https://ghcr.io/v2/example/ingest/manifests/2.7.0: MANIFEST_UNKNOWN
2.7.0はなく、2.7.0-rc1と2.7.1があります。リリースパイプラインがrcのサフィックスを外せなかったのです。
ノードのディスク不足
意外と頻繁に見落とす分岐です。ノードのディスクが埋まるとkubeletのイメージGCが回りますが、それより大きなイメージを受け取ろうとするとプルが失敗します。
kubectl describe node ip-10-0-2-77 | grep -A8 "Conditions:"
Conditions:
Type Status LastTransitionTime Reason Message
---- ------ ------------------ ------ -------
MemoryPressure False Sat, 25 Jul 2026 22:10:44 +0900 KubeletHasSufficientMemory kubelet has sufficient memory available
DiskPressure True Sun, 26 Jul 2026 08:52:03 +0900 KubeletHasDiskPressure kubelet has disk pressure
PIDPressure False Sat, 25 Jul 2026 22:10:44 +0900 KubeletHasSufficientPID kubelet has sufficient PID available
Ready True Sat, 25 Jul 2026 22:10:54 +0900 KubeletReady kubelet is posting ready status
DiskPressureがTrueのノードは新しいPodのスケジューリングも拒否します。イメージGCのしきい値はkubelet設定のimageGCHighThresholdPercentとimageGCLowThresholdPercentで調整します。デフォルト値はそれぞれ85と80です。
プライベートレジストリ認証 — 作ることより付いたかを確認するほうが難しいです
401が出たら順番に確認します。
まずSecretを作ります。--docker-serverの値がイメージ参照のレジストリホストと正確に一致しなければならない点が罠です。
kubectl create secret docker-registry regcred \
--docker-server=registry.example.com \
--docker-username=deploy-bot \
--docker-password="$(cat ~/.registry-token)" \
--namespace=payments
Docker Hubは例外です。イメージ参照がnginx:1.25のように短くても実際のホストはdocker.ioに正規化され、認証サーバーの値はhttps://index.docker.io/v1/を使わなければなりません。ここにdocker.ioを入れるとSecretは作られますが認証が付きません。
作ったら中身をデコードして目視で確認します。
kubectl get secret regcred -n payments \
-o jsonpath='{.data.\.dockerconfigjson}' | base64 -d | jq .
{
"auths": {
"registry.example.com": {
"username": "deploy-bot",
"password": "glpat-xxxxxxxxxxxx",
"auth": "ZGVwbG95LWJvdDpnbHBhdC14eHh4eHh4eHh4eHg="
}
}
}
次が本当の罠です。Secretを作っても、Podがそれを使えという指示を受けなければ何も起こりません。Podスペックに直接入れる方法とServiceAccountに付ける方法がありますが、HelmチャートがimagePullSecretsの値を公開していない場合が多く、実務では後者が有用です。
kubectl patch serviceaccount default -n payments \
-p '{"imagePullSecrets": [{"name": "regcred"}]}'
serviceaccount/default patched
ここで大半の人が詰まります。ServiceAccountのimagePullSecretsは、Podが作成される瞬間にPodスペックへコピーされます。すでに起動していたPodには遡って適用されません。パッチの後にPodを作り直す必要があります。
kubectl rollout restart deployment/checkout -n payments
そして実際にPodスペックへ入ったかを確認します。この1行が「設定したのになぜ動かないのか」の大半を解決します。
kubectl get pod -n payments -l app=checkout \
-o jsonpath='{.items[0].spec.imagePullSecrets}'
[{"name":"regcred"}]
チェックリストに整理すると4つです。
- SecretがPodと同じネームスペースにあるか。Secretはネームスペースを越えません
--docker-serverの値がイメージ参照のホストと文字列として一致しているか- Podスペックまたは、Podが使用するServiceAccountにimagePullSecretsが実際に付いているか
- PodがそのServiceAccountを使っているか。デフォルト値はdefaultですが、チャートが専用のServiceAccountを作っている可能性があります
kubectl get pod -n payments -l app=checkout \
-o jsonpath='{.items[0].spec.serviceAccountName}'
checkout-sa
defaultにだけパッチしていた場合、ここでずれます。
最後にクラウドレジストリのトークン期限を覚えておく必要があります。AWS ECRの認証トークンは12時間後に期限切れになるため、kubectl create secretで作った静的なSecretは必ずいつか401を出します。ノードのIAMロールに基づく認証や更新コントローラーを使うのが正解であり、静的なSecretは一時的な診断用にだけ使います。
よくある誤答: ノードにSSHで入ってdocker loginすること。ほとんどのクラスターはcontainerdをランタイムとして使うためDockerの認証情報がまったく参照されず、通ったとしてもノードが入れ替わる瞬間に消えます。
ローカルでは動くのにクラスターで動かない2つ
アーキテクチャの不一致
Apple SiliconのMacでdocker buildをすると、デフォルトの成果物はlinux/arm64です。これをamd64ノードで構成されたクラスターに載せると2つの異なる症状が出ます。どちらなのかを区別することが重要です。
イメージがマニフェストリストなのにノードのプラットフォームに合う項目がなければプル段階で失敗します。
kubectl describe pod ingest-6b8c94f7d5-4tzqr -n analytics | grep "Failed to pull"
Warning Failed 8s kubelet Failed to pull image "ghcr.io/example/ingest:2.7.1": no match for platform in manifest: not found
一方、単一アーキテクチャのイメージがプラットフォーム検査を通過してしまうと、プルは成功して実行段階で落ちます。このときはImagePullBackOffではなくCrashLoopBackOffとして現れます。
kubectl logs ingest-6b8c94f7d5-4tzqr -n analytics --previous
exec /usr/local/bin/ingest: exec format error
exec format errorは事実上アーキテクチャ不一致の指紋です。このメッセージを見てアプリケーションコードを漁るのは時間の無駄です。
確認はイメージ側とノード側の両方向で行います。
docker buildx imagetools inspect ghcr.io/example/ingest:2.7.1
Name: ghcr.io/example/ingest:2.7.1
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:2b1e7f43c9d8a0b6e2f14c7d9a3b58e0c1f26d4a8b7e930f5c2a1d6b4e83f97c
Manifests:
Name: ghcr.io/example/ingest:2.7.1@sha256:8f3c1d...
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
kubectl get nodes -o custom-columns=NAME:.metadata.name,ARCH:.status.nodeInfo.architecture
NAME ARCH
ip-10-0-1-14 amd64
ip-10-0-2-77 amd64
ip-10-0-3-91 amd64
イメージはarm64ひとつだけで、ノードはすべてamd64です。解決はマルチアーキテクチャビルドです。
docker buildx create --use --name multiarch
docker buildx build \
--platform linux/amd64,linux/arm64 \
-t ghcr.io/example/ingest:2.7.1 \
--push .
混在アーキテクチャのクラスターなら、Podが合うノードにだけ行くよう制約をかける方法もあります。
spec:
nodeSelector:
kubernetes.io/arch: amd64
Docker Hubのレートリミット
パブリックイメージを使っていて特定の時間帯にだけ失敗するなら、匿名プルの上限です。上限はIP単位で集計されるため、NATゲートウェイひとつを共有するクラスターではノード数が増えるほど早く消尽します。
kubectl describe pod transcode-79c5d6f8b-vn4pq -n media | grep "Failed to pull"
Warning Failed 15s kubelet Failed to pull image "redis:7.2": failed to pull and unpack image "docker.io/library/redis:7.2": failed to resolve reference "docker.io/library/redis:7.2": unexpected status from HEAD request to https://registry-1.docker.io/v2/library/redis/manifests/7.2: 429 Too Many Requests
現在残っている上限は直接照会できます。
TOKEN=$(curl -s "https://auth.docker.io/token?service=registry.docker.io&scope=repository:ratelimitpreview/test:pull" | jq -r .token)
curl -s --head -H "Authorization: Bearer $TOKEN" \
https://registry-1.docker.io/v2/ratelimitpreview/test/manifests/latest | grep -i ratelimit
ratelimit-limit: 100;w=21600
ratelimit-remaining: 7;w=21600
6時間の窓で100回、残りは7回です。解決策は3つあり、順番に検討します。認証されたアカウントでプルするようimagePullSecretsを付けるのが最も速く、社内レジストリミラーを置くのが最も根本的であり、よく使うベースイメージを社内レジストリに複製しておくのが最も安全です。
containerdのミラー設定はノードでこのように入れます。
# /etc/containerd/certs.d/docker.io/hosts.toml
server = "https://registry-1.docker.io"
[host."https://registry-mirror.example.com/v2"]
capabilities = ["pull", "resolve"]
skip_verify = false
再発防止 — imagePullPolicyの整理とダイジェスト固定
imagePullPolicyのデフォルト値が生む罠
明示しなければKubernetesがタグを見て決めます。
- タグがlatestであるか、タグがまったくなければAlways
- それ以外のすべてのタグはIfNotPresent
- ダイジェストで指定するとIfNotPresent
2つ目の規則が事故を生みます。可変タグを上書きするパイプラインでは、そのタグをすでにキャッシュしたノードは新しいイメージを受け取りません。結果として同じDeploymentのPodたちが互いに異なるコードを実行することになり、再現しないバグになります。ノードごとに実際のイメージIDを比較すると露わになります。
kubectl get pods -n analytics -l app=ingest \
-o custom-columns=POD:.metadata.name,NODE:.spec.nodeName,IMAGEID:.status.containerStatuses[0].imageID
POD NODE IMAGEID
ingest-6b8c94f7d5-4tzqr ip-10-0-1-14 ghcr.io/example/ingest@sha256:8f3c1d...
ingest-6b8c94f7d5-9wdhm ip-10-0-2-77 ghcr.io/example/ingest@sha256:2b1e7f...
同じタグなのにダイジェストが違います。
よくある誤答: この問題をimagePullPolicyをAlwaysに変えて解決すること。症状は消えますが、ノードがPodを起動するたびにレジストリへマニフェストを照会するため、レートリミットとレジストリ障害にそのまま晒されます。根本的な解決はタグを不変にしてダイジェストで固定することです。
spec:
containers:
- name: ingest
image: ghcr.io/example/ingest@sha256:8f3c1d5b2e7a94c0f13d68b5a2e9c47f0d81b3a65e2c9f7048d1b6a35c8e29f4
imagePullPolicy: IfNotPresent
ダイジェストで固定すれば、タグが上書きされてもすべてのノードが同一のバイトを実行します。ロールバックも正確になります。
デプロイ前にイメージの存在を検証します
マニフェストを適用する前に、イメージが実際に存在し認証が通るかを確認するステップをCIに入れると、ImagePullBackOffの半分が消えます。
skopeo inspect \
--creds "deploy-bot:${REGISTRY_TOKEN}" \
docker://registry.example.com/checkout:1.14.2 \
--format '{{.Digest}} {{.Architecture}}'
sha256:9c1f0f4d2a8b73e5c16f9d20a4b8e7c35f1d69a20c8b4e73f5a1d92c6b8e40f7 amd64
プル失敗をアラートで捕捉します
apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
name: image-pull-alerts
namespace: monitoring
spec:
groups:
- name: image-pull
rules:
- alert: ImagePullFailing
expr: kube_pod_container_status_waiting_reason{reason=~"ImagePullBackOff|ErrImagePull"} == 1
for: 5m
labels:
severity: critical
annotations:
summary: "{{ $labels.namespace }}/{{ $labels.pod }} cannot pull its image"
Podがまったく起動できない状態なので、アプリケーションの指標では絶対に捕捉できません。必ずkube-state-metrics側にルールを掛ける必要があります。
おわりに — 原因文字列を読めば推測は必要ありません
ImagePullBackOffはステータス列で最も頻繁に見えますが、最も速く終わらせられる問題でもあります。describeのWarning Failedの行に、not foundなのか、401なのか、toomanyrequestsなのか、no match for platformなのか、no space left on deviceなのかがそのまま書かれており、この5つの文字列が原因分岐の大半を覆います。
覚えておくべき一文はこれです。ImagePullBackOffの診断は推論ではなく読解であり、読むべき文はすでにイベントにあります。