En corto

En la máquina donde construyes:

1
2
3
docker build -t test-app:v1.0.0 .
docker save -o test-app-v1.0.0.tar test-app:v1.0.0
rsync -v test-app-v1.0.0.tar node1:/tmp/

En cada nodo de k3s donde pueda caer el pod:

1
2
sudo k3s ctr images import /tmp/test-app-v1.0.0.tar
sudo k3s crictl images | grep test-app        # docker.io/library/test-app  v1.0.0

Y en el Deployment, que kubelet no intente descargarla:

1
2
3
4
5
spec:
  containers:
  - name: test-app
    image: test-app:v1.0.0
    imagePullPolicy: Never

Por qué

k3s corre sobre containerd, no sobre Docker. Una imagen que existe en el Docker de la misma máquina es invisible para k3s, y la primera vez que un pod la pide kubelet intenta bajarla de Docker Hub y falla con ErrImagePull. Las salidas son tres: un registry (lo correcto a partir de cierto tamaño), importar el tar en cada nodo, o dejar que k3s la importe sola desde un directorio.

k3s ctr es el ctr de containerd ya apuntado al socket de k3s y al namespace k8s.io, que es donde miran kubelet y crictl. Si usas el binario ctr a pelo necesitas --address /run/k3s/containerd/containerd.sock -n k8s.io.

Alternativas para más de un nodo

Directorio de imágenes. Todo .tar, .tar.gz o .tar.zst que dejes en /var/lib/rancher/k3s/agent/images/ se importa cada vez que arranca k3s (es el mecanismo documentado para instalaciones sin Internet):

1
2
3
sudo mkdir -p /var/lib/rancher/k3s/agent/images/
sudo cp test-app-v1.0.0.tar /var/lib/rancher/k3s/agent/images/
sudo systemctl restart k3s        # o k3s-agent

Ventaja sobre el import a mano: si el recolector de imágenes de kubelet la borra, vuelve en el siguiente arranque.

Registry embebido (Spegel). Desde k3s 1.29 los servidores pueden arrancar con --embedded-registry (o embedded-registry: true en /etc/rancher/k3s/config.yaml); cada nodo publica en la red P2P (puerto 5001) las imágenes que ya tiene y los demás las bajan de él en vez de Internet. Requiere listar los registries a espejar en /etc/rancher/k3s/registries.yaml:

1
2
3
mirrors:
  docker.io:
  "*":

Con eso, importas la imagen en un nodo y el resto la consigue solo, siempre que los nodos se vean entre sí por 5001 y 6443.

Registry propio. Un registry:2 o Harbor en el clúster, y docker push. Es el camino cuando hay CI de por medio.

Listar y limpiar

1
2
3
4
sudo k3s crictl images                    # lo que hay en el nodo
sudo k3s crictl rmi --prune               # borra las que ningún contenedor usa
sudo k3s crictl rmi docker.io/library/test-app:v1.0.0
sudo k3s crictl rmi --all                 # todo; kubelet volverá a bajar lo que necesite

kubelet también recolecta solo: cuando el disco de /var/lib/rancher/k3s/agent/containerd supera el 85 % borra imágenes sin uso hasta bajar del 80 %. Los umbrales se cambian en el servicio:

1
2
3
4
# /etc/rancher/k3s/config.yaml
kubelet-arg:
  - image-gc-high-threshold=85
  - image-gc-low-threshold=80

Si ese disco se queda corto, mover el data-dir es otro post.

Trampas

  • Cada nodo. Un pod programado en un nodo sin la imagen da ErrImageNeverPull. O importas en todos, o etiquetas el nodo y usas nodeSelector, o pasas al registry embebido.
  • Arquitectura. Para nodos ARM (Raspberry Pi) la imagen tiene que ser linux/arm64: docker buildx build --platform linux/arm64.
  • latest tira de la red. Con tag latest, la política por defecto es Always y kubelet intenta bajarla aunque exista. Usa tags explícitos y Never o IfNotPresent.
  • El nombre completo de una imagen importada es docker.io/library/test-app:v1.0.0; en el manifiesto test-app:v1.0.0 resuelve a lo mismo.
  • podman: podman save --format docker-archive -o app.tar app:v1, el formato que ctr importa sin sorpresas.
  • Un crictl rmi --prune borra también lo que importaste si en ese momento ningún contenedor lo usa. Para lo que quieres que sobreviva, el directorio agent/images/.

Referencias: Air-Gap Install y Embedded Registry Mirror en la documentación de k3s.