Durcir les Dockerfiles : réduire la taille des images et les risques de sécurité sans casser les builds
Ce tutoriel explique comment réduire la taille des images Docker et diminuer la surface d’attaque (et donc les risques de sécurité) tout en préservant la reproductibilité et la stabilité des builds. L’objectif n’est pas de “faire petit à tout prix”, mais de construire des images prévisibles, minimales, scannables, et maintenables.
1) Pourquoi “durcir” un Dockerfile ?
Un Dockerfile “durci” vise généralement :
- Moins de dépendances : moins de paquets installés = moins de CVE potentielles.
- Moins de couches et de fichiers : images plus petites, pulls plus rapides, moins de cache invalide.
- Moins de privilèges : exécuter en non-root, permissions minimales.
- Meilleure traçabilité : versions figées, builds reproductibles.
- Moins de secrets : aucune clé/API token dans les layers.
En pratique, on cherche le meilleur compromis entre :
- Sécurité (surface d’attaque, privilèges, provenance),
- Performance (taille, vitesse de build),
- Fiabilité (reproductibilité, compatibilité runtime).
2) Diagnostiquer l’existant : taille, couches, contenu
Avant d’optimiser, mesurez.
2.1 Taille d’image et historique des couches
docker images
docker history --no-trunc monimage:latest
docker history permet de repérer les couches qui gonflent : apt-get install, npm install, pip install, copies de gros artefacts, etc.
2.2 Explorer le contenu d’une image
Pour inspecter rapidement :
docker run --rm -it --entrypoint sh monimage:latest
# ou bash si présent
Puis :
du -h -d 1 / | sort -h
du -h -d 1 /usr | sort -h
2.3 Scanner les vulnérabilités (exemples)
Avec Docker Scout (si disponible) :
docker scout quickview monimage:latest
docker scout cves monimage:latest
Ou avec Trivy :
trivy image monimage:latest
Ces outils ne “réparent” pas, mais ils vous indiquent où vous êtes exposé (OS packages, libs, etc.).
3) Choisir une base d’image adaptée (et la figer)
3.1 Distroless, Alpine, Debian slim : que choisir ?
- Debian/Ubuntu slim : bon compromis compatibilité / taille. Beaucoup d’outils disponibles, glibc standard.
- Alpine : très petit, mais utilise
musl(pasglibc) ; certaines libs/logiciels peuvent avoir des surprises (binaries précompilés, compatibilité). - Distroless : excellent pour réduire la surface d’attaque (pas de shell, pas de package manager), mais nécessite une discipline stricte (debug plus difficile, besoin d’un build multi-stage).
Recommandation pragmatique :
- Si vous avez besoin de compatibilité maximale :
debian:bookworm-slim. - Si vous publiez un binaire statique ou un runtime minimal maîtrisé : distroless.
- Évitez de changer de famille (Debian → Alpine) sans tests, car vous pouvez casser des dépendances natives.
3.2 Figer la base : tag + digest
Les tags bougent (:latest, :slim), ce qui nuit à la reproductibilité. Vous pouvez figer par digest :
FROM debian:bookworm-slim@sha256:xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Pour obtenir le digest :
docker pull debian:bookworm-slim
docker inspect --format='{{index .RepoDigests 0}}' debian:bookworm-slim
Avantage : build identique dans le temps (à dépendances identiques).
Inconvénient : il faut mettre à jour volontairement pour intégrer des patches de sécurité.
4) Réduire la taille : principes concrets qui marchent
4.1 Utiliser le multi-stage build
Le multi-stage permet de séparer :
- une étape “builder” (compilateurs, headers, outils),
- une étape “runtime” (seulement ce qui est nécessaire pour exécuter).
Exemple Node.js (build + runtime) :
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Points importants :
npm cigarantit une installation cohérente avecpackage-lock.json.- On installe les dépendances de prod dans l’étape runtime, sans toolchain.
- On exécute en
USER node(non-root).
4.2 Minimiser les fichiers copiés : .dockerignore
Un build lent et une image gonflée viennent souvent d’un contexte énorme (node_modules local, logs, build artifacts, .git).
Créez un .dockerignore :
.git
node_modules
dist
coverage
*.log
.env
Dockerfile
docker-compose.yml
Adaptez selon votre projet. L’objectif : ne pas envoyer au daemon Docker des milliers de fichiers inutiles.
4.3 Réduire le nombre de couches (sans perdre en lisibilité)
Chaque RUN crée une couche. Combinez les commandes liées, surtout pour apt.
Mauvais (cache et taille) :
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*
Mieux :
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
--no-install-recommends évite l’installation de paquets “recommandés” souvent inutiles.
4.4 Nettoyer les caches (au bon endroit)
Pour Debian/Ubuntu :
- supprimer
/var/lib/apt/lists/*après installation, - éviter
apt-get upgradedans l’image (souvent contre-productif en reproductibilité).
Pour Python/pip :
pip install --no-cache-dir ...
Pour npm :
npm cache clean --force(selon cas) et surtout éviter de copiernode_modulesdepuis l’hôte.
Pour Go :
- build statique et copie du binaire final uniquement.
5) Durcissement sécurité : utilisateurs, permissions, capabilities
5.1 Ne pas exécuter en root
Par défaut, Docker exécute en root. Créez un utilisateur dédié.
Exemple Debian slim :
RUN useradd -r -u 10001 -g nogroup appuser
USER 10001
Ou si vous voulez un home :
RUN useradd -m -u 10001 appuser
USER appuser
Ensuite, assurez-vous que les fichiers copiés appartiennent au bon user :
COPY --chown=10001:0 ./dist /app/dist
5.2 Répertoires en écriture : limiter
Beaucoup d’applications n’ont besoin d’écrire que dans /tmp ou un dossier précis.
- Créez un dossier dédié :
RUN mkdir -p /app/data && chown -R 10001:0 /app/data - Évitez de rendre
/appentier writable si ce n’est pas nécessaire.
5.3 Réduire les outils disponibles en runtime
Plus votre image contient d’outils (shell, curl, package manager), plus un attaquant a de “jouets” en cas d’exécution de code.
- Préférez une image runtime minimaliste.
- Évitez d’installer
bash,curl,vim“pour debug” dans l’image de prod. - Pour debug, utilisez plutôt une image dédiée ou des techniques d’observabilité.
5.4 Read-only filesystem et options d’exécution (côté runtime)
Même avec un Dockerfile propre, vous pouvez durcir au lancement :
docker run --rm \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,size=64m \
--cap-drop ALL \
--security-opt no-new-privileges \
-p 3000:3000 \
monimage:latest
Notes :
--cap-drop ALLretire les capabilities Linux (souvent inutile pour une app web).--read-onlyempêche l’écriture dans le FS (sauf volumes/tmpfs).no-new-privilegesempêche l’élévation de privilèges via setuid, etc.
Attention : certaines apps ont besoin d’écrire (logs, caches). Ajustez avec des volumes ou tmpfs.
6) Gestion des secrets : ne jamais les “baker” dans l’image
6.1 Anti-patterns fréquents
COPY .env /app/.envARG NPM_TOKEN=...puisRUN npm config set ...RUN echo "$SSH_KEY" > /root/.ssh/id_rsa
Tout ce qui est dans une layer peut être extrait via docker save / inspection.
6.2 Utiliser BuildKit secrets (build-time)
Avec BuildKit, vous pouvez monter un secret sans l’écrire dans l’image.
Exemple : token npm pendant npm ci.
Dockerfile :
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS build
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm ci
COPY . .
RUN npm run build
Build :
DOCKER_BUILDKIT=1 docker build \
--secret id=npmrc,src=$HOME/.npmrc \
-t monimage:build .
Le fichier .npmrc n’est pas conservé dans l’image finale.
6.3 Secrets runtime
Pour l’exécution, passez plutôt via :
- variables d’environnement (avec prudence),
- fichiers montés (Docker secrets, Kubernetes secrets),
- gestionnaire de secrets (Vault, AWS Secrets Manager, etc.).
7) Reproductibilité : versions figées, lockfiles, dépôts stables
7.1 OS packages : éviter l’aléatoire
Sur Debian/Ubuntu, apt-get install dépend de l’état des dépôts au moment du build. Pour des besoins forts de reproductibilité, vous pouvez :
- figer l’image de base par digest,
- éviter
apt-get upgrade, - éventuellement utiliser un snapshot repository (plus avancé).
Dans tous les cas, gardez une trace des versions :
dpkg -l | head
7.2 Langages : utilisez les lockfiles
- Node :
package-lock.json+npm ci - Python :
requirements.txtfigé (oupoetry.lock) - Rust :
Cargo.lock - Go :
go.sum
Exemple Python (avec venv dans builder) :
# syntax=docker/dockerfile:1.7
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
FROM python:3.12-slim AS runtime
WORKDIR /app
COPY --from=build /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY . .
RUN useradd -r -u 10001 -g nogroup appuser
USER 10001
CMD ["python", "app.py"]
Ici, on évite d’installer build-essential si vos wheels sont disponibles. Si vous compilez des dépendances natives, installez les toolchains uniquement dans build.
8) Build rapide et stable : cache BuildKit, ordre des instructions
8.1 L’ordre des COPY est crucial
Le cache Docker se base sur les instructions. Si vous faites :
COPY . .
RUN npm ci
Alors le moindre changement de code invalide le cache de npm ci, ce qui est coûteux.
Préférez :
COPY package*.json ./
RUN npm ci
COPY . .
Ainsi, tant que les manifestes ne changent pas, l’installation reste cachée.
8.2 Cache BuildKit pour les gestionnaires de paquets
Exemple pour apt :
RUN --mount=type=cache,target=/var/cache/apt \
apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Exemple pour pip :
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
Exemple pour npm :
RUN --mount=type=cache,target=/root/.npm \
npm ci
Ces caches accélèrent les builds répétés sans gonfler l’image finale (le cache est côté build).
9) Santé et robustesse : HEALTHCHECK, signaux, PID 1
9.1 HEALTHCHECK (optionnel mais utile)
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:3000/health || exit 1
Attention : ajouter wget/curl juste pour healthcheck peut augmenter la surface d’attaque. Alternatives :
- endpoint TCP check minimal,
- healthcheck côté orchestrateur (Kubernetes probes),
- binaire minimal dédié.
9.2 PID 1 et gestion des signaux
Certaines apps gèrent mal SIGTERM si elles sont PID 1. Pour des runtimes comme Node, Python, etc., considérez tini (init minimal) :
Debian :
RUN apt-get update && apt-get install -y --no-install-recommends tini \
&& rm -rf /var/lib/apt/lists/*
ENTRYPOINT ["tini", "--"]
CMD ["node", "dist/server.js"]
Mais encore une fois : installer tini ajoute un paquet. Dans certains environnements, l’orchestrateur gère déjà bien ce point, ou vous pouvez utiliser une image qui inclut déjà un init minimal.
10) Exemples complets : avant / après (cas réalistes)
10.1 Exemple “avant” (Node) : gros et risqué
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]
Problèmes :
node:20(non slim) plus gros.COPY . .avantnpm installinvalide le cache en permanence.npm install(non déterministe vsnpm ci).- dépendances dev probablement présentes en runtime.
- exécution en root.
- contexte potentiellement énorme sans
.dockerignore.
10.2 Exemple “après” : multi-stage, non-root, cache-friendly
# syntax=docker/dockerfile:1.7
FROM node:20-bookworm AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci
COPY . .
RUN npm run build
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json /app/package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev \
&& npm cache clean --force
# Utilisateur non-root présent dans l'image node officielle
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Gains typiques :
- image plus petite (slim + pas d’outils de build),
- moins de CVE (moins de paquets),
- builds plus rapides (cache npm),
- runtime non-root.
11) Distroless : durcissement maximal (avec contraintes)
Distroless supprime shell et package manager. Exemple pour une app Go statique.
11.1 Dockerfile Go statique + distroless
# syntax=docker/dockerfile:1.7
FROM golang:1.22-bookworm AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN --mount=type=cache,target=/go/pkg/mod go mod download
COPY . .
RUN --mount=type=cache,target=/root/.cache/go-build \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o /out/app ./cmd/app
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/app"]
Points clés :
CGO_ENABLED=0pour un binaire statique (sinon distroless static peut ne pas suffire).-trimpathet-ldflags "-s -w"réduisent la taille du binaire.:nonrootimpose un utilisateur non-root.- Pas de shell : pour debug, il faut des stratégies alternatives (logs, metrics, image debug séparée).
12) Bonnes pratiques supplémentaires (souvent négligées)
12.1 Labels OCI (traçabilité)
Ajoutez des labels utiles :
LABEL org.opencontainers.image.source="https://example.com/monrepo" \
org.opencontainers.image.revision="$VCS_REF" \
org.opencontainers.image.created="$BUILD_DATE"
Build :
docker build \
--build-arg VCS_REF="$(git rev-parse HEAD)" \
--build-arg BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
-t monimage:latest .
Et dans le Dockerfile :
ARG VCS_REF
ARG BUILD_DATE
LABEL org.opencontainers.image.revision=$VCS_REF \
org.opencontainers.image.created=$BUILD_DATE
12.2 COPY vs ADD
- Utilisez
COPYpar défaut. ADDa des comportements implicites (extraction d’archives, URLs) qui peuvent surprendre et nuire à la lisibilité/reproductibilité.
12.3 Éviter les “curl | sh” non vérifiés
Anti-pattern :
RUN curl -sSL https://example.com/install.sh | sh
Préférez :
- paquets officiels,
- vérification de checksum/signature,
- version pin.
12.4 Tests dans le pipeline
Un Dockerfile durci peut casser des hypothèses (écriture disque, user non-root). Ajoutez des tests :
docker build -t monimage:test .
docker run --rm -p 3000:3000 monimage:test
Testez aussi en mode durci :
docker run --rm --read-only --cap-drop ALL --security-opt no-new-privileges monimage:test
Si ça casse, corrigez l’app (répertoire writable explicite, gestion de /tmp, etc.) plutôt que de revenir à root.
13) Checklist “durcissement sans casser les builds”
Utilisez cette checklist comme garde-fou :
- Base
- Image de base adaptée (slim/distroless si possible)
- Tag stable, idéalement digest
- Contexte
-
.dockerignorestrict
-
- Cache et reproductibilité
-
COPYdes manifests avant le code - lockfiles et commandes déterministes (
npm ci, etc.) - BuildKit cache mounts si pertinent
-
- Réduction de surface
- Multi-stage (pas de toolchain en runtime)
-
--no-install-recommendset nettoyage apt - pas d’outils “debug” en prod
- Privilèges
-
USERnon-root - permissions minimales (dossiers writable explicitement)
-
- Secrets
- aucun secret dans le Dockerfile ou le contexte
- BuildKit secrets pour accès privé au build
- Runtime
- options
--read-only,--cap-drop ALL,no-new-privilegestestées - healthcheck/probes cohérents (sans gonfler l’image inutilement)
- options
14) Conclusion : une approche progressive
Durcir un Dockerfile est un processus itératif :
- Mesurez (taille, couches, CVE).
- Corrigez le plus rentable :
.dockerignore, ordre desCOPY, multi-stage. - Réduisez la surface : base slim, suppression des outils inutiles.
- Sécurisez l’exécution : non-root, FS read-only, capabilities minimales.
- Renforcez la reproductibilité : digests, lockfiles, secrets BuildKit.
Si vous me donnez un Dockerfile réel (et le langage/projet), je peux proposer une version “durcie” avec des compromis explicites (compatibilité vs minimalisme) et des commandes de test pour vérifier que le build et le runtime ne cassent pas.