Skip to content
Published on

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

シェア
Authors

はじめに — イメージ名は明らかに合っているのに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 unknowncrane manifest IMAGE実際に存在するタグへ修正
プライベートレジストリの認証失敗401 Unauthorized, authentication requiredSecretをデコードした後に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 manifestdocker 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の診断は推論ではなく読解であり、読むべき文はすでにイベントにあります。