background success stories

Monter une image OCI comme volume dans Kubernetes

ImageVolume Kubernetes : monter une image OCI comme volume read-only sur K3S 1.33. Sanity, site statique, bundle de configs et limites.

Distribuer du contenu read-only à un pod Kubernetes reste un sujet où chaque équipe a sa recette. Par exemple un site statique, des poids ML, ou un bundle de configs trop gros pour un ConfigMap. On voit alors un initContainer qui télécharge, ou une image runtime gonflée d’un COPY, ou un PVC partagé, ou encore un sidecar git-sync. Aucune n’est mauvaise, mais toutes traînent leurs limites. Or, depuis Kubernetes 1.31, le projet a ajouté une voie de plus : ImageVolume sur Kubernetes. Cette feature permet de monter directement une image OCI comme volume read-only via volumes.image (KEP-4639, alpha en 1.31, beta en 1.33, beta on-by-default en 1.35, GA cible 1.36). Nous l’avons donc testée pendant un après-midi sur notre lab K3S 1.33.5+k3s1. Voici ce qui marche, ce qui surprend, et ce que nous gardons en tête pour la prod.

Pourquoi ImageVolume Kubernetes ?

Notre point de départ : un site statique servi par nginx dans un cluster K3S. Le runtime nginx ne change jamais. En revanche, le contenu HTML, lui, change tout le temps. Avec une approche classique on se retrouve à rebuilder une image qui contient nginx et le HTML à chaque mise à jour de contenu. Cela mélange deux choses qui n’ont aucune raison d’être couplées. L’alternative habituelle, c’est un initContainer qui pull le contenu depuis un bucket S3, ou un PVC, ou un sidecar git-sync. Or, à chaque fois on rajoute une dépendance externe (un bucket, un volume RWX, un repo Git accessible) et un point de panne.

ImageVolume change la donne sur ce cas précis. D’abord, le contenu HTML vit dans une image OCI minimale (FROM scratch + COPY). Ensuite, le contenu est versionné dans le même registry que les images runtime, signé, scanné, taggué. Enfin, le kubelet sait le pull et le présenter au container comme un répertoire normal. Aucun script de bootstrap, aucun rebuild applicatif. L’idée n’est pas neuve (CRI-O proposait déjà des choses similaires) mais l’avoir en standard Kubernetes change la portée. Ainsi, avant de plonger, nous avons relu la doc Kubernetes officielle et la KEP-4639 pour ne pas dire de bêtises sur les promesses du feature gate.

Activer ImageVolume Kubernetes sur K3S 1.33

Notre lab tourne sur un seul nœud Kubernetes; il s’agit de K3S. Le runtime est containerd 2.1.4-k3s1, embarqué par K3S. D’abord, avant de toucher quoi que ce soit, nous voulions vérifier deux choses : la version du cluster, et savoir si le feature gate ImageVolume est connu de l’apiserver. Pour ça on utilise les métriques Prometheus exposées par kubectl --raw /metrics. Cette commande affiche une ligne kubernetes_feature_enabled par feature gate avec son stage.

$ kubectl get nodes
$ kubectl get --raw /metrics | grep ImageVolume
Sortie de kubectl get nodes et de la métrique ImageVolume Kubernetes sur K3S 1.33.5+k3s1
Le cluster est en 1.33.5, et la feature ImageVolume est connue en stage BETA.

Attention au piège : le 1 en fin de ligne kubernetes_feature_enabled{name="ImageVolume",stage="BETA"} 1 signifie ici que la métrique est enregistrée, pas que la feature est activée. Pour savoir si elle est active, il faut regarder l’arg --feature-gates passé à apiserver et kubelet. Or, sur K3S, ces flags se passent via /etc/rancher/k3s/config.yaml. K3S consomme ce fichier au démarrage du service.

On édite donc config.yaml et on ajoute deux entrées kube-apiserver-arg et kubelet-arg. Le feature gate doit être actif des deux côtés. D’abord, l’apiserver doit accepter le champ volumes.image à l’admission. Ensuite, le kubelet doit savoir construire le mount au lancement du pod.

# /etc/rancher/k3s/config.yaml
disable:
  - traefik
kube-apiserver-arg:
  - "feature-gates=ImageVolume=true"
kubelet-arg:
  - "feature-gates=ImageVolume=true"

Ensuite, on redémarre K3S et on revérifie la métrique. Pas de drame côté pods existants. En effet, containerd ne s’arrête pas pendant le restart de K3S. Donc les conteneurs en cours continuent à tourner. Le control plane est indisponible le temps du restart, environ sept secondes au chrono manuel sur le lab. À prendre comme un ordre de grandeur, pas comme une mesure stable.

$ sudo systemctl restart k3s
$ kubectl get --raw /metrics | grep ImageVolume

Sanity test : ImageVolume Kubernetes avec nginx

Notre premier test ne sert qu’à une chose : prouver que le feature gate fonctionne. Plus précisément, on veut vérifier que le kubelet sait construire un mount à partir d’une image OCI quelconque. D’abord, on crée un namespace. Ensuite, on déploie un pod busybox qui monte l’image officielle nginx:1.27-alpine sur /mnt/oci. Le pod inspecte ce qu’il y trouve et retourne le résultat dans ses logs.

$ kubectl create namespace imagevolume-poc

YAML du pod sanity-test

Voici le YAML complet du pod. Le bloc qui change par rapport à un pod classique, c’est volumes. Au lieu d’un configMap, secret ou persistentVolumeClaim, on utilise image avec une reference (la même syntaxe que spec.containers[].image) et un pullPolicy. On laisse IfNotPresent ici. Ainsi le kubelet réutilisera l’image si elle est déjà dans le cache containerd.

# sanity-pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: sanity-test
  namespace: imagevolume-poc
spec:
  restartPolicy: Never
  containers:
  - name: shell
    image: busybox:1.36
    command:
    - sh
    - -c
    - |
      echo '== contents of mounted image =='
      ls /mnt/oci
      echo
      echo '== nginx.conf head =='
      head -8 /mnt/oci/etc/nginx/nginx.conf
      echo
      echo '== write test =='
      touch /mnt/oci/should-fail 2>&1 || echo 'RO: cannot write (expected)'
    volumeMounts:
    - name: oci-vol
      mountPath: /mnt/oci
      readOnly: true
  volumes:
  - name: oci-vol
    image:
      reference: nginx:1.27-alpine
      pullPolicy: IfNotPresent

Sortie attendue du pod

On applique le manifest, puis on récupère les logs. Le pod a un restartPolicy: Never. Ainsi, une fois l’inspection faite, il termine en Completed et ne redémarre pas.

$ kubectl apply -f sanity-pod.yaml
pod/sanity-test created

$ kubectl -n imagevolume-poc logs sanity-test
Logs du pod sanity-test ImageVolume Kubernetes : rootfs nginx visible, nginx.conf lisible, écriture refusée
Sortie de kubectl logs : le rootfs nginx est bien monté et l’écriture est refusée.

Trois choses sont à retenir dans cette sortie. D’abord, sous /mnt/oci nous avons bien le rootfs nginx complet (etc/, usr/, docker-entrypoint.sh). Le kubelet a déclenché le pull de nginx:1.27-alpine, containerd a extrait ses layers via son snapshotter, et le rootfs assemblé est exposé en mount. Ensuite, nginx.conf est lisible tel quel. Donc on peut récupérer un fichier de configuration depuis une image OCI sans avoir à instancier un container nginx. Enfin, touch échoue avec Read-only file system. Ce n’est pas une question de readOnly: true que nous avons ajouté côté volumeMounts. En effet, on aurait pu enlever ce flag, le résultat serait le même : le kubelet rend le mount read-only par défaut.

ImageVolume Kubernetes pour un site statique

Le sanity test fonctionne, on passe au scénario qui motivait ce POC. L’objectif est de découpler le runtime nginx (qui ne change jamais) du contenu HTML (qui change tout le temps). La recette tient en trois étapes. D’abord, on crée une image OCI qui ne contient que le contenu HTML. Ensuite, on la push sur un registry. Enfin, on la monte dans un Deployment nginx classique.

Build de l’image de contenu

On commence par un index.html minimal et un Dockerfile de deux lignes. FROM scratch garantit qu’on n’embarque aucun rootfs. Ainsi, le layer final ne contient que le fichier copié. C’est le pattern recommandé pour distribuer du contenu via image.

# Dockerfile
FROM scratch
COPY index.html /index.html

Ensuite, on build et push sur ttl.sh qui sert de registry éphémère pour le POC. Sur ce registry, les images expirent au bout d’une heure. Cela nous convient pour ne pas polluer notre registry avec des artefacts de tests.

$ docker build -t ttl.sh/imagevol-static-e470546e:1h .
$ docker push ttl.sh/imagevol-static-e470546e:1h
docker build et docker push d'une image OCI de contenu statique sur ttl.sh
Le build est instantané puisque l’image ne contient qu’un fichier.

Déployer et tester

Côté Kubernetes, le Deployment qui sert le site est un nginx stock. Ainsi, on n’a aucune modification d’image. Le seul ajout est le volume image monté sur /usr/share/nginx/html, le répertoire que nginx sert par défaut.

# usecase1-deploy.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: static-nginx
  namespace: imagevolume-poc
spec:
  replicas: 1
  selector:
    matchLabels: { app: static-nginx }
  template:
    metadata:
      labels: { app: static-nginx }
    spec:
      containers:
      - name: nginx
        image: nginx:1.27-alpine        # runtime stock, jamais rebuild
        ports: [{ containerPort: 80 }]
        volumeMounts:
        - name: site
          mountPath: /usr/share/nginx/html
          readOnly: true
      volumes:
      - name: site
        image:
          reference: ttl.sh/imagevol-static-e470546e:1h
          pullPolicy: Always

Ensuite, on apply, on expose en Service ClusterIP. Puis on teste depuis l’intérieur du cluster avec un pod curl jetable. Donc kubectl run avec --rm sert exactement à ça : un test ad-hoc qui se nettoie tout seul à la sortie.

$ kubectl apply -f usecase1-deploy.yaml
$ kubectl -n imagevolume-poc expose deploy static-nginx --port=80
$ kubectl -n imagevolume-poc get deploy,svc,pod -l app=static-nginx
NAME                           READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/static-nginx   1/1     1            1           14s

NAME                   TYPE        CLUSTER-IP      PORT(S)
service/static-nginx   ClusterIP   10.43.118.207   80/TCP

NAME                                READY   STATUS    RESTARTS   AGE
pod/static-nginx-7c9bfd5f8b-q7r2m   1/1     Running   0          14s

Le pod est Running. Le Service ClusterIP est attribué dans la foulée. Ensuite, on curl le contenu pour confirmer que ce que sert nginx vient bien de notre image OCI de contenu, et non d’un quelconque index.html par défaut.

$ kubectl -n imagevolume-poc run curl-test \
    --image=curlimages/curl:8.10.1 \
    --restart=Never --rm -i --quiet -- \
    curl -s http://static-nginx.imagevolume-poc.svc.cluster.local/index.html
Sortie de curl prouvant que nginx sert le HTML monté depuis l'image OCI
Le HTML servi est bien celui que nous avons mis dans notre image OCI, pas le default nginx.

Donc pour publier une nouvelle version du site, on rebuild l’image de contenu. Ensuite, on push avec un nouveau tag (ou on retag fixe + pullPolicy: Always). Enfin, on fait un kubectl set image sur le volume. L’image runtime nginx ne bouge pas. Aucun rolling update touchant à nginx, aucun rebuild applicatif. Ainsi, c’est exactement le découplage qu’on cherchait. Cependant, pour des workloads stateful sur Kubernetes nous avons d’autres mécaniques en tête. Nous avions documenté l’une d’elles dans PostgreSQL sur Kubernetes avec CloudNativePG.

Use case 2 : config bundle, image vs OCI artefact

Notre deuxième scénario, c’est de distribuer un bundle de configurations qui dépasse la limite d’un ConfigMap. Cette limite est de 1 MiB par défaut, et correspond à etcd request size. Disons un app.yaml, un rules.json de quelques centaines de Ko, et un MANIFEST.txt. On veut les versionner et les distribuer comme un binaire.

Tentative avec un artefact OCI pur

Notre premier réflexe a été de créer un artefact OCI pur via oras, pour voir comment ImageVolume Kubernetes traite ce format. L’idée était propre sur le papier : un mediaType custom, pas de rootfs tar, juste les fichiers tels quels dans le manifest. Ainsi, c’est typiquement ce que fait Helm pour pousser des charts en OCI, ou cosign pour pousser des signatures.

$ oras push ttl.sh/imagevol-config-0a3b11f4:1h \
    --artifact-type application/vnd.demo.config.bundle.v1+json \
    app.yaml:application/yaml \
    rules.json:application/json \
    MANIFEST.txt:text/plain

Ensuite, on déploie un pod consumer qui monte cet artefact via volumes.image. Le YAML reprend la structure du sanity-pod, avec trois changements : nom du pod, mountPath sur /etc/config, et la référence pointe sur l’artefact oras.

# artifact-consumer.yaml
apiVersion: v1
kind: Pod
metadata:
  name: artifact-consumer
  namespace: imagevolume-poc
spec:
  restartPolicy: Never
  containers:
  - name: shell
    image: busybox:1.36
    command:
    - sh
    - -c
    - |
      echo '== layers in mounted artifact =='
      ls -la /etc/config
      echo
      echo '== app.yaml =='
      cat /etc/config/app.yaml
    volumeMounts:
    - name: cfg
      mountPath: /etc/config
      readOnly: true
  volumes:
  - name: cfg
    image:
      reference: ttl.sh/imagevol-config-0a3b11f4:1h
      pullPolicy: Always

On apply, on lit les logs.

$ kubectl apply -f artifact-consumer.yaml
$ kubectl -n imagevolume-poc logs artifact-consumer
Pod qui monte un OCI artefact pur : pull réussi, mount vide
L’artefact OCI pur est bien pull mais ImageVolume n’en monte rien : répertoire vide.

Le pod démarre sans erreur. Cependant, la commande ls dans /etc/config retourne un répertoire vide. Constat empirique sur K3S 1.33 et containerd 2.1 : l’image est pull et présente dans le cache containerd, mais le mount ne contient rien. En effet, ImageVolume reconstruit un filesystem à partir de layers de mediaType application/vnd.oci.image.layer.v1.tar ou application/vnd.docker.image.rootfs.diff.tar.gzip. Or des layers de mediaType arbitraire (application/yaml, application/json) sont stockés dans le cache mais ne sont pas assemblés en rootfs. Ainsi, la doc upstream parle bien d’OCI object, mais en pratique sur cette version, seules les images au sens classique fonctionnent pour le mount. C’est cohérent avec le comportement de containerd qui n’a pas de logique d’assemblage générique pour des mediaTypes inconnus.

Workaround : image FROM scratch

Donc le pattern qui marche pour ImageVolume Kubernetes, c’est de pousser ces mêmes fichiers comme une image OCI traditionnelle. Avec FROM scratch + COPY, on obtient un layer tar standard. Et celui-ci est montable directement par ImageVolume.

# Dockerfile
FROM scratch
COPY app.yaml rules.json MANIFEST.txt /

$ docker build -t ttl.sh/imagevol-config-img-26eac4ca:1h .
$ docker push ttl.sh/imagevol-config-img-26eac4ca:1h

Ensuite, avec ce nouveau tag, on relance un pod consumer. La structure reprend celle d’artifact-consumer.yaml, avec un autre nom de pod et la référence qui pointe sur l’image FROM scratch qu’on vient de pousser.

# configbundle-consumer.yaml
apiVersion: v1
kind: Pod
metadata:
  name: configbundle-consumer
  namespace: imagevolume-poc
spec:
  restartPolicy: Never
  containers:
  - name: shell
    image: busybox:1.36
    command:
    - sh
    - -c
    - |
      echo '== layers in mounted artifact =='
      ls -la /etc/config
      echo
      echo '== app.yaml =='
      cat /etc/config/app.yaml
    volumeMounts:
    - name: cfg
      mountPath: /etc/config
      readOnly: true
  volumes:
  - name: cfg
    image:
      reference: ttl.sh/imagevol-config-img-26eac4ca:1h
      pullPolicy: Always
$ kubectl apply -f configbundle-consumer.yaml
$ kubectl -n imagevolume-poc logs configbundle-consumer
Pod ImageVolume Kubernetes qui monte un bundle de configs depuis une image FROM scratch : fichiers présents
Avec une vraie image (FROM scratch + COPY), le bundle est bien monté.

Cette fois, on récupère bien les fichiers : app.yaml, rules.json et MANIFEST.txt sont visibles dans /etc/config avec leur contenu intact. À retenir : si votre outillage vous pousse vers oras pour distribuer des configs, deux options se présentent. Soit un sidecar qui les pull et les écrit sur un emptyDir. Soit, plus simple, réemballer ces mêmes fichiers dans une image OCI classique. Ainsi, la deuxième solution évite tout code de glue et reste fidèle au pattern ImageVolume.

ImageVolume Kubernetes et subPath

Avec un volume ConfigMap ou Secret, nous avons l’habitude d’utiliser subPath. Cela permet de projeter un seul fichier dans un chemin précis. Par exemple monter app.yaml sur /etc/app/app.yaml sans exposer le répertoire entier. Or, avec ImageVolume, nous avons voulu tester la même mécanique. Cependant, ça ne marche pas comme attendu.

On part de l’image FROM scratch qui contient déjà app.yaml, et on déploie un pod subpath-consumer qui demande à monter uniquement ce fichier sur /etc/app/app.yaml via subPath.

# subpath-consumer.yaml
apiVersion: v1
kind: Pod
metadata:
  name: subpath-consumer
  namespace: imagevolume-poc
spec:
  restartPolicy: Never
  containers:
  - name: shell
    image: busybox:1.36
    command:
    - sh
    - -c
    - |
      echo '== cat /etc/app/app.yaml =='
      cat /etc/app/app.yaml 2>&1 || true
      echo
      echo '== ls -la /etc/app =='
      ls -la /etc/app
    volumeMounts:
    - name: cfg
      mountPath: /etc/app/app.yaml
      subPath: app.yaml          # attendu : mount fichier ; observe : mount repertoire (cf. capture)
      readOnly: true
  volumes:
  - name: cfg
    image:
      reference: ttl.sh/imagevol-config-img-26eac4ca:1h
      pullPolicy: IfNotPresent
$ kubectl apply -f subpath-consumer.yaml
$ kubectl -n imagevolume-poc logs subpath-consumer
Pod tentant subPath sur ImageVolume : le mountPath devient un répertoire au lieu d'un fichier
Avec ImageVolume + subPath, /etc/app/app.yaml est créé comme un répertoire.

Nous n’avons pas trouvé de mention explicite de ce comportement dans la doc Kubernetes au moment du test. Or, le comportement diffère clairement de celui de configMap ou secret avec subPath. On suppose que c’est lié à la façon dont containerd expose le rootfs de l’image. En effet, c’est un mount unique de répertoire, que Kubernetes traite différemment de l’extraction par fichier que fait l’objet ConfigMap. Donc le pattern à appliquer en attendant : monter le répertoire entier de l’image et laisser l’application piocher le fichier dont elle a besoin. Cependant, si la projection mono-fichier est non négociable, ConfigMap reste plus adapté.

Quand utiliser ImageVolume Kubernetes

Bons cas d’usage

  • Site statique servi par un runtime générique (nginx, caddy, httpd), pour découpler le runtime du contenu.
  • Modèles ML / weights distribués via le registry (versionnage, signature cosign, scan trivy, le tout déjà géré par la chaîne CI image).
  • Datasets read-only de référence (datasets de test, géo-données figées, fichiers de fixtures).
  • Bundles de configuration qui dépassent la limite ConfigMap (1 MiB) et qu’on veut versionner comme un binaire.
  • Plugins ou extensions compilés livrés à un runtime générique. Pour les pipelines de données temps réel à côté, nous avions aussi écrit sur Streamer les changements Oracle vers Kafka en temps réel.

Limites observées sur K3S 1.33

  • Les artefacts OCI purs (mediaType custom, pas de tar layer) ne donnent pas de rootfs montable. Workaround : image FROM scratch + COPY.
  • Le subPath produit un répertoire, pas un fichier ; ne pas l’utiliser pour la projection mono-fichier.
  • Le mount est strictement read-only par construction. Si on veut du writable éphémère par-dessus, on combine avec un emptyDir.
  • Le pullPolicy est respecté comme pour les images runtime ; Always = call registry à chaque démarrage de pod, à utiliser avec discernement sur les workloads à fort taux de redémarrage.
  • L’image n’est pas reflétée dans status.containerStatuses du pod ; pour auditer ce qu’un pod monte réellement, lire spec.volumes.

Conclusion

Sur K3S 1.33, ImageVolume sur Kubernetes tient ses promesses pour le cas le plus courant. Ainsi, on peut distribuer du contenu versionné via le même registry que les images runtime, sans rebuild applicatif. Le sanity test, le site statique et le bundle de configs se mettent en place en quelques minutes. Cependant, les rough edges constatés (artefacts purs non montables, subPath qui donne un répertoire) ne sont pas bloquants tant qu’on reste sur le pattern image OCI classique. Enfin, l’arrivée en GA cible Kubernetes 1.36 (toujours selon la KEP) enlèvera la barrière feature gate. En revanche, le comportement, lui, ne change pas.

Nos deux prochaines expérimentations sur ce lab. D’abord, comparer la latence de boot d’un pod avec ImageVolume (pullPolicy: Always, registry distant) à celle d’un pod montant un PVC RWX. Ensuite, tester la combinaison ImageVolume + cosign verification au niveau du kubelet, pour ne monter que des images signées. Si vous avez déjà testé l’un ou l’autre, nous sommes preneurs de retours.

Aller plus loin avec ImageVolume Kubernetes en production

Ce POC vous a donné envie de pousser ImageVolume Kubernetes au-delà du lab ? Axians est certifié Kubernetes et accompagne au quotidien des organisations qui exploitent la plateforme en production. Pas en démo, pas en POC : sur des clusters qui portent du trafic réel et leur lot d’incidents nocturnes. Notre offre Cloud Native s’articule autour de trois pôles : la formation, l’accompagnement, et le maintien en condition opérationnelle.

Nos formations Kubernetes

  • Introduction à Kubernetes et aux opérateurs : notre best-seller. Pour les équipes qui découvrent la plateforme, du pod jusqu’aux operators custom, et qui veulent une base solide en quelques jours.
  • Kubernetes pour la production : centrée sur Helm et ArgoCD, pour mettre en place une chaîne GitOps robuste et auditable.
  • CNPG Operator : pour exploiter PostgreSQL en cloud-native sans concession. À ce titre, Axians est référencé par EnterpriseDB comme support officiel de CNPG Operator.

Accompagnement et conseil

  • Transition de Docker vers Kubernetes : audit de l’existant, gap analysis, plan de migration applicatif et opérationnel, sans rupture pour vos équipes.
  • Gouvernance Kubernetes : organisation, modèles d’équipe, sécurité, FinOps, pour adopter la plateforme sereinement et garder le contrôle dans la durée.
  • Adaptation des pratiques Dev et Ops : pipelines CI/CD, observabilité, SRE, runbooks d’incident. Nous travaillons avec vos équipes existantes, pas à leur place.

MCO Kubernetes : trois niveaux d’engagement

  • Silver : un coup de pouce ponctuel quand un sujet vous coince. Vous gardez la main sur votre cluster, nous intervenons à la demande.
  • Gold : supervision en heures ouvrées (9h – 18h), alerting temps réel, notre équipe est notifiée dès qu’un incident intervient.
  • Platinium : 24 / 7. Astreinte, SLA stricts, gestion d’incident continue. Pour les workloads critiques qui ne peuvent pas attendre le lendemain matin.

Si l’un de ces sujets résonne avec votre contexte, écrivez-nous. Une heure d’échange suffit en général à clarifier ce dont vous avez vraiment besoin, et à voir si nous sommes le bon partenaire pour vous. Vous pouvez nous joindre via le formulaire de contact d’axiansdb.com, ou directement à votre interlocuteur Axians habituel.