Aller au contenu
Conteneurs & Orchestration medium

Construire une image Podman : multi-stage, cache, secrets, push

29 min de lecture

logo podman

Ce guide vous apprend à construire une image reproductible, petite, rapide à builder, et à la publier proprement. Vous comprendrez le raisonnement derrière chaque décision de build, pas juste les options.

À la fin, vous saurez :

  • Optimiser le cache pour des builds en quelques secondes
  • Utiliser multi-stage pour diviser la taille par 10
  • Passer des secrets sans les exposer dans l'image
  • Publier vers un registry avec un digest vérifiable

Les quatre raccourcis ci-dessous mènent directement aux sections qui changent le plus vos builds. Si vous cherchez un gain immédiat, commencez par le cache : c'est l'optimisation qui demande le moins de travail pour le plus d'effet.

Avant d'optimiser quoi que ce soit, il faut savoir qui fait le travail et sur quoi il travaille. Deux notions suffisent : le moteur de build réellement invoqué par podman build, et le contexte de build, c'est-à-dire l'ensemble des fichiers qui lui sont transmis. La quasi-totalité des builds lents ou cassés s'explique par l'une de ces deux notions mal comprise.

Quand vous exécutez podman build, Podman appelle Buildah pour construire l'image. Cela a des implications importantes :

AspectCe que ça signifie
Stockage partagépodman et buildah voient les mêmes images
CompatibilitéDockerfile et Containerfile sont équivalents
Options avancéesCertaines options viennent de Buildah (--secret, --ssh)
Sans démonPas de daemon à maintenir, builds rootless possibles

Flux de build : Containerfile vers Image OCI via Buildah

Le contexte est le dossier envoyé au builder. Tout ce qui est dedans peut être copié dans l'image avec COPY.

Fenêtre de terminal
# Le "." à la fin = contexte = dossier courant
podman build -t mon-image:1.0 .

Problème fréquent : un dossier node_modules/ de 500 MB dans le contexte ralentit tous les builds.

Solution : le fichier .containerignore (voir section cache).

Un build reproductible signifie : même Containerfile, même image, demain comme dans 6 mois. La principale source de dérive n'est pas votre code mais l'image de base : un tag comme python:3.12 pointe vers un contenu différent à chaque correctif publié en amont. Le premier réflexe consiste donc à figer cette référence, puis à écrire un Containerfile minimal que l'on mesurera avant de l'optimiser.

Un tag est une étiquette mobile : le mainteneur peut la faire pointer ailleurs à tout moment. Plus le tag est précis, moins il bouge, et seul le digest est totalement immuable.

# ❌ Mauvais : "latest" peut changer demain
FROM python:3.14.6
# ❌ Mauvais encore : "3.12" peut passer de 3.12.1 à 3.12.2
FROM python:3.12-alpine
# ✅ Bon : version exacte + digest pour la reproductibilité
FROM python:3.12.8-alpine3.21

Cette première version est volontairement naïve : elle marche, mais elle ignore le cache et embarque tout le contexte. Elle sert de point de comparaison chiffré pour les optimisations qui suivent. L'arborescence du projet tient en quatre fichiers.

  • Répertoiremon-api/
    • Containerfile
    • requirements.txt
    • Répertoireapp/
      • init .py
      • main.py
Containerfile
FROM python:3.12.8-alpine3.21
LABEL org.opencontainers.image.title="Mon API"
LABEL org.opencontainers.image.version="1.0.0"
WORKDIR /app
# Copier tout et installer
COPY . .
RUN pip install --no-cache-dir -r requirements.txt
# Utilisateur non-root
RUN adduser -D appuser
USER appuser
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0"]

Sans mesure de départ, impossible de démontrer un gain. Relevez systématiquement deux chiffres, la taille finale et le temps de build, avant toute modification du Containerfile.

  1. Construire l'image

    Fenêtre de terminal
    time podman build -t mon-api:v1-naive .

    Notez le temps de build et la taille.

  2. Mesurer la taille

    Fenêtre de terminal
    podman images | grep mon-api
    Résultat
    localhost/mon-api v1-naive abc123 30 seconds ago 287 MB
  3. Voir les layers

    Fenêtre de terminal
    podman image history mon-api:v1-naive
  4. Tester

    Fenêtre de terminal
    podman run --rm -p 8000:8000 mon-api:v1-naive
    # Dans un autre terminal: curl http://localhost:8000

Constat : 287 MB et 45 secondes de build. On peut faire beaucoup mieux.

Le cache est l'optimisation la plus importante et elle ne coûte qu'un réordonnancement d'instructions. Un build bien caché passe de 2 min à 10 sec, sans rien changer au contenu de l'image produite. Tout repose sur un principe unique : Podman réutilise le résultat d'une instruction tant que ni cette instruction, ni les fichiers qu'elle copie, ni aucune instruction précédente n'ont changé.

Podman met en cache chaque instruction (chaque RUN, COPY, etc. crée un "layer"). Si une instruction n'a pas changé, le layer en cache est réutilisé.

Mais : dès qu'un layer change, tous les layers suivants sont invalidés.

Invalidation du cache Docker : un changement invalide tous les layers suivants

Placer COPY . . avant l'installation des dépendances suffit à annuler tout le bénéfice du cache : la moindre virgule modifiée dans le code change le layer copié, donc invalide l'installation qui suit.

# ❌ Cache invalidé à chaque modification de code
FROM python:3.12.8-alpine3.21
WORKDIR /app
COPY . . # ← Change à chaque commit
RUN pip install -r requirements.txt # ← Rebuild 100% du temps !

Le remède consiste à copier d'abord le seul fichier dont dépend l'installation, ici requirements.txt, puis le code applicatif. L'installation reste alors en cache tant que la liste des dépendances ne bouge pas.

# ✅ Dépendances installées une seule fois
FROM python:3.12.8-alpine3.21@sha256:ba13ef990f6e5d13014e9e8d04c02a8fdb0fe53d6dccf6e19147f316e6cc3a84
WORKDIR /app
# 1. Copier UNIQUEMENT les fichiers de dépendances
COPY requirements.txt .
# 2. Installer (en cache tant que requirements.txt ne change pas)
RUN pip install --no-cache-dir -r requirements.txt
# 3. Copier le code (change souvent, mais pip est déjà fait)
COPY . .
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0"]

La démonstration tient en trois commandes : un build sans cache, une modification de code, puis un rebuild. L'option --no-cache sert ici à obtenir une mesure de référence honnête, sans réutilisation de layers antérieurs.

Fenêtre de terminal
# Premier build (cold cache)
time podman build --no-cache -t mon-api:v2-cache .
# → 45 secondes
# Modifier un fichier Python
echo "# comment" >> app/main.py
# Deuxième build (warm cache)
time podman build -t mon-api:v2-cache .
# → 3 secondes ! (pip install est en cache)

Excluez du contexte tout ce qui n'est pas nécessaire au build :

.containerignore
# Contrôle de version
.git/
.gitignore
# Fichiers de développement
*.md
README*
docs/
tests/
# Environnements virtuels (installés dans l'image)
.venv/
venv/
__pycache__/
*.pyc
# Secrets et config locale
.env
.env.*
secrets/
*.key
*.pem
# Fichiers de build
Containerfile*
.containerignore
.dockerignore

Mesurer l'impact :

Fenêtre de terminal
# Avant .containerignore
du -sh mon-api/
# 500 MB (avec .venv et .git)
# Après .containerignore (effectif dans le build)
tar -cvf - --exclude-from=.containerignore . | wc -c
# 50 KB

--no-cache est un outil de diagnostic, pas un mode normal.

SituationUtilisez
Build de développementCache normal
CI/CD répétitifCache normal
"Mon build semble buggé"--no-cache pour debug
Image finale avant release--no-cache (optionnel)

Le multi-stage build sépare l'environnement de compilation de l'environnement d'exécution. C'est le pattern standard pour les applications compilées.

Le problème : les outils de compilation restent dans l'image

Section intitulée « Le problème : les outils de compilation restent dans l'image »

Installer gcc ou les en-têtes de développement d'une base de données est souvent indispensable pour compiler une dépendance native. Le souci, c'est que ces paquets restent dans l'image finale alors qu'aucun processus ne s'en sert au démarrage.

# Image de développement = tout installé
FROM python:3.12-slim@sha256:57cd7c3a7a273101a6485ba99423ee568157882804b1124b4dd04266317710de
RUN apt-get update && apt-get install -y \
gcc libpq-dev # Nécessaires au build seulement
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["python", "app.py"]

Taille : 400 MB (gcc et libpq-dev inclus, inutiles au runtime).

Un Containerfile peut enchaîner plusieurs FROM. Chaque FROM ouvre un stage, et seul le dernier produit l'image publiée. L'instruction COPY --from=builder récupère uniquement les artefacts utiles du stage précédent, ce qui laisse les compilateurs derrière.

Containerfile
# Stage 1: Builder (avec outils de compilation)
FROM python:3.12.8-alpine3.21 AS builder
RUN apk add --no-cache gcc musl-dev libffi-dev
WORKDIR /install
COPY requirements.txt .
# Installer dans un prefix isolé
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
# Stage 2: Runtime (minimal)
FROM python:3.12.8-alpine3.21 AS runtime
# Copier UNIQUEMENT les packages installés
COPY --from=builder /install /usr/local
WORKDIR /app
COPY app/ ./app/
# Utilisateur non-root
RUN adduser -D appuser
USER appuser
EXPOSE 8000
CMD ["python", "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0"]

Les deux images cohabitent dans le stockage local, ce qui permet de lire l'écart directement dans podman images. La colonne de droite donne la taille décompressée sur disque, pas la taille transférée au registry, qui est plus faible car compressée.

Fenêtre de terminal
# Build multi-stage
podman build -t mon-api:v3-multistage .
# Comparer les tailles
podman images | grep mon-api
Résultat
localhost/mon-api v3-multistage def456 10 seconds ago 52 MB
localhost/mon-api v1-naive abc123 30 seconds ago 287 MB

Gain : 82% de réduction (287 MB → 52 MB).

Parfois vous voulez construire uniquement le stage builder (pour debug ou tests) :

Fenêtre de terminal
# Construire uniquement le builder
podman build --target builder -t mon-api:builder .
# Le stage builder contient gcc, parfait pour les tests
podman run --rm mon-api:builder python -m pytest

Go pousse le principe à son extrême : avec CGO_ENABLED=0, le compilateur produit un binaire statique qui n'a besoin d'aucune bibliothèque système. Le stage d'exécution peut alors partir de scratch, une base vide qui ne contient strictement aucun fichier.

Containerfile
# Builder
FROM golang:1.24-alpine AS builder
WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app
# Runtime (FROM scratch = 0 MB)
FROM scratch
COPY --from=builder /app /app
ENTRYPOINT ["/app"]

Taille finale : 8 MB (juste le binaire statique).

Certains builds nécessitent des secrets : token npm, clé SSH pour un repo privé, credentials pip. Ne les mettez jamais en ARG ou ENV, ils seraient visibles dans l'historique de l'image.

Le problème : un secret passé en ARG reste consultable

Section intitulée « Le problème : un secret passé en ARG reste consultable »

Un ARG est enregistré dans les métadonnées de l'image, et le fichier écrit par le RUN persiste dans le layer correspondant. Supprimer le fichier plus loin ne change rien : le layer précédent reste dans l'image et n'importe qui capable de la télécharger peut le lire.

# ❌ DANGEREUX : le token est dans l'image finale
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" >> ~/.npmrc
RUN npm install
Fenêtre de terminal
# N'importe qui peut extraire le token
podman history mon-image --no-trunc | grep NPM_TOKEN

L'option --secret monte un fichier secret temporairement pendant le build, sans le persister dans l'image.

Containerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
# Le secret est disponible UNIQUEMENT pendant ce RUN
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install
COPY . .
CMD ["node", "index.js"]
Fenêtre de terminal
# Créer le fichier secret
echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > /tmp/npmrc
# Build avec le secret
podman build --secret id=npmrc,src=/tmp/npmrc -t mon-app:secure .

Vérification : le secret n'est pas dans l'image.

Fenêtre de terminal
podman history mon-app:secure --no-trunc | grep -i token
# (aucun résultat)

Le mécanisme est le même dans les trois cas : le fichier est monté à l'emplacement attendu par l'outil, le temps d'un seul RUN. Notez la différence du dernier onglet : type=ssh ne monte pas un fichier mais expose la socket de l'agent SSH, ce qui évite de faire entrer la clé privée dans le build.

RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install
Fenêtre de terminal
podman build --secret id=npmrc,src=$HOME/.npmrc .

Podman produit des images au format OCI (Open Container Initiative) par défaut. C'est le format standard, supporté par tous les registries modernes. Le format historique de Docker reste accepté par Podman pour les cas de compatibilité, mais il n'apporte aucun avantage fonctionnel.

FormatDefault PodmanCompatible
OCITous registries modernes
DockerNonLegacy, Docker Hub

Le format se choisit au moment du build avec --format. Le cas qui justifie de repasser en format Docker est un registry ancien ou un outil interne qui refuse le manifeste OCI.

Fenêtre de terminal
# Format OCI (défaut)
podman build --format oci -t mon-image:oci .
# Format Docker (compatibilité legacy)
podman build --format docker -t mon-image:docker .

Règle : gardez OCI sauf si vous avez un problème de compatibilité spécifique.

L'objectif : l'image est poussée vers un registry et vous avez conservé son digest, l'empreinte cryptographique qui identifie ce contenu exact et rien d'autre. Un tag se réécrit, un digest non : c'est lui qui permettra plus tard de prouver quelle image tournait en production, de la signer ou de lui rattacher un SBOM (Software Bill of Materials, l'inventaire des composants d'un logiciel).

Les six étapes suivantes forment le trajet minimal entre une image locale et une release traçable. Les trois premières sont locales et réversibles ; le push ne l'est pas toujours, car beaucoup de registries interdisent d'écraser un tag déjà publié.

  1. Tag sémantique

    Fenêtre de terminal
    # Tag de développement
    podman tag mon-api:v3-multistage mon-api:1.0.0
    podman tag mon-api:v3-multistage mon-api:1.0
    podman tag mon-api:v3-multistage mon-api:1
    podman tag mon-api:v3-multistage mon-api:latest
  2. Tag pour le registry

    Fenêtre de terminal
    podman tag mon-api:1.0.0 registry.example.com/team/mon-api:1.0.0
  3. Authentification

    Fenêtre de terminal
    podman login registry.example.com
  4. Push

    Fenêtre de terminal
    podman push registry.example.com/team/mon-api:1.0.0
  5. Récupérer le digest

    Fenêtre de terminal
    podman inspect registry.example.com/team/mon-api:1.0.0 \
    --format '{{.Digest}}'
    Résultat
    sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
  6. Documenter

    Conservez le digest dans votre documentation de release :

    Release 1.0.0
    Image: registry.example.com/team/mon-api:1.0.0
    Digest: sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855

Les deux identifiants ne servent pas au même usage et ne se remplacent pas. Le tableau ci-dessous résume la seule propriété qui compte vraiment en production : la mutabilité.

TagsDigests
MutableOui (:latest peut changer)Non (identifiant crypto)
LisibleOui (:1.0.0)Non (sha256:abc...)
TraçableNonOui (SBOM, signatures)

Règle : utilisez les tags pour la lisibilité, conservez les digests pour la traçabilité.

La séquence ne change pas d'un registry à l'autre : authentification, tag complet incluant le nom d'hôte, puis push. Seule l'authentification varie, et l'onglet GitHub illustre la bonne pratique : --password-stdin évite que le jeton apparaisse dans l'historique du shell ou dans la liste des processus.

Fenêtre de terminal
podman login docker.io
podman tag mon-api:1.0.0 docker.io/monuser/mon-api:1.0.0
podman push docker.io/monuser/mon-api:1.0.0

Ces options sont utiles dans des cas spécifiques. Ne les utilisez pas par défaut.

--squash fusionne tous les layers en un seul.

AvantageInconvénient
Image plus petite (un seul layer)Perd l'historique des layers
Pas d'artefacts de build visiblesCache inutilisable pour builds incrémentaux
Incompatible avec SBOM par layer
Fenêtre de terminal
# Squash tout
podman build --squash -t mon-image:squashed .
# Squash uniquement les layers ajoutés (garde le base layer)
podman build --squash-all -t mon-image:all-squashed .

Quand utiliser : si la traçabilité des layers n'est pas importante et que vous voulez une image minimale.

Cette option décide si chaque instruction produit son propre layer intermédiaire. Passer à --layers=false réduit le nombre de couches mais prive les builds suivants de tout point de reprise, donc de cache.

Fenêtre de terminal
# Créer un layer par instruction (défaut)
podman build --layers -t mon-image:layered .
# Tout dans un seul layer (comme --squash mais différent internement)
podman build --layers=false -t mon-image:single .

--pull : contrôler le téléchargement de l'image de base

Section intitulée « --pull : contrôler le téléchargement de l'image de base »

Cette option décide si Podman va rechercher une version plus récente de l'image de base avant de construire. Elle n'a plus d'effet pratique si vous référencez la base par digest, puisque le digest désigne déjà un contenu unique.

Fenêtre de terminal
# Pull uniquement si l'image n'existe pas localement (défaut)
podman build --pull=missing -t mon-image .
# Toujours pull (pour avoir la dernière version)
podman build --pull=always -t mon-image .
# Jamais pull (utiliser le cache local)
podman build --pull=never -t mon-image .

La grande majorité des échecs de build se ramène à une poignée de causes, presque toujours liées au contexte ou à l'ordre des instructions. Le tableau ci-dessous sert de premier tri ; les commandes qui suivent permettent d'aller chercher la cause exacte quand le symptôme ne suffit pas.

Lisez ce tableau du symptôme vers la solution : la colonne du milieu explique pourquoi le symptôme apparaît, ce qui évite d'appliquer un correctif au hasard.

SymptômeCauseSolution
COPY failed: file not foundFichier hors contexte ou dans .containerignoreVérifier le chemin et .containerignore
Build très lentContexte trop grosAjouter .containerignore
Cache jamais utiliséOrdre des instructionsMettre les deps avant le code
Image trop grossePas de multi-stageUtiliser multi-stage
Secret visible dans historyARG/ENV pour secretsUtiliser --secret
Push échouePas authentifiépodman login

Ces commandes répondent chacune à une question précise : ce que fait le build, ce que pèse chaque layer, ce qui part réellement dans le contexte, et ce que contient l'image produite. La dernière est la vérification à faire avant toute publication d'une image construite avec un secret.

Fenêtre de terminal
# Voir les logs détaillés
podman build --log-level debug -t mon-image .
# Voir la taille de chaque layer
podman image history mon-image:1.0 --no-trunc
# Vérifier le contexte envoyé
tar -cvf - . 2>&1 | head -20
# Analyser le contenu de l'image
podman run --rm -it mon-image:1.0 sh
# Vérifier qu'un secret n'est pas dans l'image
podman history mon-image:1.0 --no-trunc | grep -i secret

Symptôme : chaque build refait tout.

Diagnostic :

Fenêtre de terminal
# Voir quel layer invalide le cache
podman build -t test . 2>&1 | grep -E "(STEP|Using cache|COMMIT)"

Causes fréquentes :

  1. COPY . . trop tôt
  2. Fichiers qui changent toujours dans le contexte (.git/, logs)
  3. --no-cache utilisé par erreur

Objectif : construire une API FastAPI optimisée et la publier. L'exercice reprend les trois leviers du guide dans l'ordre où ils se mesurent : une version naïve pour établir la référence, une version multi-stage avec le cache correctement ordonné, puis un rebuild pour constater le gain. Comptez une quinzaine de minutes, connexion au registry comprise.

  1. Créer le projet

    Fenêtre de terminal
    mkdir -p ~/lab-build/app && cd ~/lab-build
    app/main.py
    from fastapi import FastAPI
    app = FastAPI()
    @app.get("/")
    def read_root():
    return {"message": "Hello from optimized image!"}
    requirements.txt
    fastapi==0.115.0
    uvicorn[standard]==0.32.0
  2. Build naïf (baseline)

    Containerfile.naive
    FROM python:3.12-slim
    WORKDIR /app
    COPY . .
    RUN pip install -r requirements.txt
    CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
    Fenêtre de terminal
    time podman build -f Containerfile.naive -t api:naive .
    podman images | grep api

    Notez : taille et temps de build.

  3. Build optimisé (multi-stage + cache)

    Containerfile
    # Builder
    FROM python:3.12.8-alpine3.21 AS builder
    WORKDIR /install
    COPY requirements.txt .
    RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
    # Runtime
    FROM python:3.12.8-alpine3.21
    COPY --from=builder /install /usr/local
    WORKDIR /app
    COPY app/ ./app/
    RUN adduser -D appuser && chown -R appuser /app
    USER appuser
    EXPOSE 8000
    CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0"]
    Fenêtre de terminal
    time podman build -t api:optimized .
    podman images | grep api
  4. Mesurer le gain cache

    Fenêtre de terminal
    # Modifier le code
    echo "# comment" >> app/main.py
    # Rebuild (devrait être rapide)
    time podman build -t api:optimized .

    Le rebuild devrait prendre < 5 secondes (pip en cache).

  5. Publier (optionnel)

    Fenêtre de terminal
    # Tag
    podman tag api:optimized registry.example.com/demo/api:1.0.0
    # Push
    podman login registry.example.com
    podman push registry.example.com/demo/api:1.0.0
    # Digest
    podman inspect registry.example.com/demo/api:1.0.0 --format '{{.Digest}}'

Résultats attendus :

MétriqueNaïfOptimiséGain
Taille~300 MB~50 MB-83%
Build initial45 sec30 sec-33%
Rebuild (code change)45 sec3 sec-93%
  1. podman build utilise Buildah en interne, stockage partagé, sans démon
  2. Pinner les versions : python:3.12.8-alpine3.21 pas python:latest
  3. Cache = ordre des instructions : dépendances avant code
  4. .containerignore : exclure .git/, .venv/, node_modules/
  5. Multi-stage : séparer builder (avec gcc) et runtime (sans)
  6. Secrets : utiliser --secret, jamais ARG/ENV
  7. Publier : tag + push + conserver le digest
  8. Squash : cas particulier, pas une best practice universelle

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn