Loading Local Images Into kind Without a Registry
You built api:dev, ran kind load docker-image api:dev, and the pod still shows ErrImagePull: failed to pull and unpack image "docker.io/library/api:dev": pull access denied — or it starts fine but keeps running yesterday's code even after a fresh load. kind load is the simplest way to get a locally built image into a kind cluster, with no registry to run, but it interacts with pull policies and tags in ways that are not obvious. This page covers the three failures that make it seem broken, as part of local Kubernetes development.
For small images and single-node clusters, loading directly is often the better choice than running a registry: nothing extra to start, nothing to wire into containerd, and no network hop.
Diagnostic
Compare what is loaded into the node with what the pod asks for and how it asks:
#!/usr/bin/env bash
set -euo pipefail
node="shop-control-plane"
docker exec "$node" crictl images | grep -E 'IMAGE|api' || true
kubectl --context kind-shop get deploy api -o jsonpath='{.spec.template.spec.containers[0].image}{" pullPolicy="}{.spec.template.spec.containers[0].imagePullPolicy}{"\n"}'
kubectl --context kind-shop get pods -l app=api -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.status.containerStatuses[0].imageID}{"\n"}{end}'
docker image inspect api:dev --format 'local id={{.Id}} arch={{.Architecture}}'
Expected bad output for the pull-policy case:
IMAGE TAG IMAGE ID SIZE
docker.io/library/api dev 4b1e9c7a2d11 61.2MB
api:dev pullPolicy=Always
api-5d8f7c9b4-k2m9x
local id=sha256:4b1e9c7a2d11... arch=arm64
The image is present on the node, but imagePullPolicy: Always makes the kubelet contact Docker Hub for docker.io/library/api:dev, which does not exist, so the pod never uses the loaded copy.
Root cause
kind load docker-image exports the image from the host's Docker store and imports it into each node's containerd store. The kubelet then decides whether to pull based on imagePullPolicy: with Always, it contacts the registry every time a container starts and fails when the image exists only locally; with IfNotPresent, it uses the local copy. If the tag is latest or omitted, Kubernetes defaults the policy to Always, which is why api:latest fails where api:dev works. The stale-code case comes from Kubernetes' change detection: pods are only replaced when the pod template changes. Loading a new image under the same tag changes nothing in the Deployment, so existing pods keep running the old image, and new pods may even start from the old cached layer if the load raced with scheduling. The architecture case is simpler: an image built for linux/amd64 on an Intel CI runner or with --platform cannot run on an arm64 kind node, and the container exits immediately with exec format error.
Each cause has a precise fix, and none requires a registry.
Resolution
- Use a specific tag and
IfNotPresent. Never uselatestfor locally loaded images:
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: shop/api:dev
imagePullPolicy: IfNotPresent
ports:
- containerPort: 8080
- Give every build a unique tag and update the Deployment to it, so Kubernetes sees a change and rolls out:
#!/usr/bin/env bash
set -euo pipefail
tag="dev-$(git rev-parse --short HEAD)-$(date +%s)"
docker build -t "shop/api:$tag" ./api
kind load docker-image "shop/api:$tag" --name shop
kubectl --context kind-shop set image deployment/api api="shop/api:$tag"
kubectl --context kind-shop rollout status deployment/api --timeout=90s
If you must keep a fixed tag — for example because a Helm values file references it — force a rollout after the load with kubectl rollout restart deployment/api, which changes a template annotation.
- Build for the node's architecture. Check the node and build to match:
#!/usr/bin/env bash
set -euo pipefail
node_arch=$(kubectl --context kind-shop get nodes -o jsonpath='{.items[0].status.nodeInfo.architecture}')
docker buildx build --platform "linux/$node_arch" --load -t shop/api:dev ./api
docker image inspect shop/api:dev --format '{{.Architecture}}'
- Clean up old loaded images occasionally. Every unique tag stays in the node's containerd store until removed:
#!/usr/bin/env bash
set -euo pipefail
for node in $(kind get nodes --name shop); do
docker exec "$node" crictl rmi --prune
done
crictl rmi --prune removes images not used by any running container, which is safe for a development cluster.
Expected output
$ kubectl --context kind-shop rollout status deployment/api --timeout=90s
deployment "api" successfully rolled out
$ kubectl --context kind-shop get pods -l app=api -o jsonpath='{.items[0].spec.containers[0].image}{"\n"}'
shop/api:dev-3f2a91c-1726651200
$ kubectl --context kind-shop logs deploy/api | head -1
server listening on :8080 (build 3f2a91c)
The pod runs the freshly loaded image, identified by its unique tag, and logs the commit it was built from.
Prevention
Log the build commit at startup. Passing
--build-arg GIT_SHA=$(git rev-parse --short HEAD)into the image and printing it on boot makes "is this pod running my code?" a one-line check.Lint manifests for
latest. Fail CI when a local values file uses:latestor omits the tag; the policy default it triggers is the most common reason loaded images are ignored.Switch to a registry when loads get slow. Loading copies the whole image into every node each time. Past roughly 500 MB or two nodes, a local registry is faster because only changed layers move.
Platform caveats
Apple Silicon (ARM64): kind nodes are arm64. Images pulled from CI artifacts built for amd64 fail with
exec format error; rebuild locally or use multi-arch images from buildx.
macOS (Docker Desktop):
kind loadneeds the image in the classic image store. With the containerd image store enabled in Docker Desktop, multi-platform images may fail to export; build with--loadfor a single platform.
Podman: use
kind load image-archivewith a tarball frompodman save, sincekind load docker-imageexpects the Docker CLI's image store.
Rollback
Point the Deployment back at a known-good tag, which is still in the node's store unless pruned:
#!/usr/bin/env bash
set -euo pipefail
kubectl --context kind-shop rollout undo deployment/api
kubectl --context kind-shop rollout status deployment/api
Frequently Asked Questions
Why does kind try to pull an image I already loaded?
The container's imagePullPolicy is Always, explicitly or because the tag is latest. Set a specific tag and imagePullPolicy: IfNotPresent so the kubelet uses the copy already in the node's containerd store.
Why are my pods still running old code after kind load?
Kubernetes replaces pods only when the pod template changes. Loading a new image under the same tag changes nothing, so use a unique tag per build and kubectl set image, or run kubectl rollout restart after loading.
Does kind load work with multi-node clusters?
Yes, it loads into every node by default, which is also why it gets slow: the full image is copied once per node. Use --nodes to target specific nodes, or a local registry for large images.
How do I see which images are loaded into a node?
Run docker exec <node-name> crictl images. kind nodes are containers, so docker exec gives direct access to containerd's CLI on each node.