TL;DR
On the machine where you build:
| |
On every k3s node where the pod may land:
| |
And in the Deployment, so kubelet does not try to pull it:
| |
Why
k3s runs on containerd, not Docker. An image that exists in the Docker on the same machine is invisible to k3s, and the first time a pod asks for it kubelet tries to pull it from Docker Hub and fails with ErrImagePull. There are three ways out: a registry (the right one past a certain size), importing the tar on every node, or letting k3s import it by itself from a directory.
k3s ctr is containerd’s ctr already pointed at the k3s socket and the k8s.io namespace, which is where kubelet and crictl look. If you use the bare ctr binary you need --address /run/k3s/containerd/containerd.sock -n k8s.io.
Alternatives for more than one node
Images directory. Every .tar, .tar.gz, or .tar.zst you drop into /var/lib/rancher/k3s/agent/images/ is imported each time k3s starts (it is the documented mechanism for installs without Internet):
| |
Advantage over the manual import: if kubelet’s image garbage collector removes it, it comes back on the next start.
Embedded registry (Spegel). Since k3s 1.29 servers can start with --embedded-registry (or embedded-registry: true in /etc/rancher/k3s/config.yaml); each node publishes on the P2P network (port 5001) the images it already has and the others pull them from it instead of the Internet. It requires listing the registries to mirror in /etc/rancher/k3s/registries.yaml:
| |
With that, you import the image on one node and the rest get it on their own, as long as the nodes can reach each other on 5001 and 6443.
Your own registry. A registry:2 or Harbor in the cluster, and docker push. It is the path once CI is involved.
Listing and cleaning
| |
kubelet also collects on its own: when the disk holding /var/lib/rancher/k3s/agent/containerd goes over 85 % it deletes unused images until it drops below 80 %. The thresholds are changed on the service:
| |
If that disk runs short, moving the data-dir is another post.
Traps
- Every node. A pod scheduled on a node without the image gives
ErrImageNeverPull. Either import on all of them, or label the node and usenodeSelector, or move to the embedded registry. - Architecture. For ARM nodes (Raspberry Pi) the image must be
linux/arm64:docker buildx build --platform linux/arm64. latesthits the network. With thelatesttag the default policy isAlwaysand kubelet tries to pull it even if it exists. Use explicit tags andNeverorIfNotPresent.- The full name of an imported image is
docker.io/library/test-app:v1.0.0; in the manifesttest-app:v1.0.0resolves to the same thing. - podman:
podman save --format docker-archive -o app.tar app:v1, the formatctrimports with no surprises. - A
crictl rmi --prunealso deletes what you imported if no container is using it at that moment. For what must survive, theagent/images/directory.
References: Air-Gap Install and Embedded Registry Mirror in the k3s documentation.