Aller au contenu
Conteneurs & Orchestration medium

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

23 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

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.

# ❌ Mauvais : "latest" peut changer demain
FROM python:latest
# ❌ 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
  • 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"]
  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. Un build bien caché passe de 2 min à 10 sec.

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

# ❌ 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 !
# ✅ Dépendances installées une seule fois
FROM python:3.12.8-alpine3.21
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"]
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.

# Image de développement = tout installé
FROM python:3.12-slim
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).

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"]
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
Containerfile
# Builder
FROM golang:1.22-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.

# ❌ 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)
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 par défaut. C’est le format standard, supporté par tous les registries modernes.

FormatDefault PodmanCompatible
OCITous registries modernes
DockerNonLegacy, Docker Hub
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 (identifiant immuable).

  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
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é.

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.

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 »
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 .
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
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.

  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 tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.