Pattern FROM scratch : construire une image Docker tier Platine

Cet article documente le patron commun à toutes les images durcies du parc : la même architecture multi-étages, les mêmes principes, et surtout les mêmes pièges — parce que la plupart n'apparaissent qu'après coup, en production.

Pourquoi FROM scratch

Une image FROM scratch ne contient aucun système : pas de shell, pas de gestionnaire de paquets, pas d'utilitaires. Seuls les binaires strictement nécessaires au service sont présents.

Base Shell Outils Surface d'attaque
distribution complète oui gestionnaire de paquets, clients réseau large
Alpine oui gestionnaire de paquets moyenne
« distroless » non aucun réduite
FROM scratch non aucun minimale

Les bases dites « distroless » sont un recul par rapport à FROM scratch : elles embarquent des paquets supplémentaires, donc du code qui n'est pas celui du service.

FROM scratch ne veut pas dire zéro dépendance — la bibliothèque C, les bibliothèques d'exécution, les certificats racine et les fuseaux horaires sont bien copiés dans l'image. Cela veut dire zéro système : rien qui ne soit une dépendance directe d'un binaire présent.

Architecture multi-étages

flowchart TB
    subgraph s1["Etape 1 : compilation"]
        compile["Compilation depuis les sources<br/>avec drapeaux de durcissement"]
        strip["Reduction des binaires"]
    end
    subgraph s2["Etape 2 : binaire d entree"]
        gobuild["Compilation statique<br/>sans dependance C"]
    end
    subgraph s3["Etape 3 : preparation"]
        libs["Cloture resolue<br/>bibliotheques et liens"]
        tini["PID 1 statique"]
        user["Utilisateur non privilegie"]
        setcap["Capability sur le binaire"]
    end
    subgraph s4["Etape 4 : image finale"]
        copy["Copie des seuls artefacts"]
        final["Utilisateur, sonde, point d entree"]
    end
    s1 --> s3
    s2 --> s4
    s3 --> s4
    compile --> strip
    libs --> setcap
    copy --> final

Compilation durcie

CFLAGS  = -O2 -fstack-protector-strong -fstack-clash-protection
          -fPIE -D_FORTIFY_SOURCE=2 -Wformat -Werror=format-security
LDFLAGS = -Wl,-z,relro,-z,now,-z,noexecstack -pie
Drapeau Protection
RELRO complet table de liaison en lecture seule après édition de liens
PIE randomisation complète de l'espace d'adressage
protection de pile canaris sur les fonctions à risque
garde de pile pages de garde contre le débordement inter-piles
renforcement des fonctions détection de débordement à la compilation
pile non exécutable interdit l'exécution depuis la pile

Les binaires sont ensuite réduits — sans || true derrière, qui transformerait une réduction cassée en succès silencieux.

Les sources sont vérifiées avant compilation : signature quand l'amont en publie une, empreinte épinglée sinon. Et l'empreinte se vérifie sur un fichier déjà téléchargé, jamais au bout d'un tube : curl … | sha256sum renvoie le code de sortie de sha256sum, qui réussit sur un flux vide. L'empreinte de la chaîne vide, e3b0c442…855, est la signature reconnaissable de ce bug.

Un binaire d'entrée compilé statiquement

Un unique binaire remplace trois scripts shell : la création des répertoires au build, le contrôle de santé, et le point d'entrée. Il choisit son mode selon ses arguments.

Cette étape est déclarée sur la plateforme de construction : le langage compile en croisé depuis l'architecture de la machine, sans émulation. Seules les étapes en C sont émulées quand la cible diffère.

Préparation : résoudre la clôture

L'étape de préparation assemble le système de fichiers final. Pour chaque binaire, un outil liste le chargeur, les bibliothèques et leurs liens, et les recopie dans une arborescence qui ne contient qu'eux.

S'y ajoutent le PID 1 statique, l'utilisateur non privilégié avec un identifiant explicite, et le cas échéant la capability posée sur le binaire.

L'image finale

FROM scratch
COPY --link --from=prep /etc/passwd /etc/group /etc/
COPY --link --from=prep /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
# la cloture resolue : le chargeur, les bibliotheques et leurs liens,
# et rien d autre -- pas /lib ni /usr/lib en bloc
COPY --link --from=prep /rootfs/ /
COPY --link --from=prep /usr/sbin/named /usr/sbin/
COPY --link --from=prep /sbin/tini-static /sbin/tini
COPY --link --from=gobuilder /init /usr/local/bin/init
RUN ["/usr/local/bin/init", "--setup-dirs"]
USER 5300:5300
HEALTHCHECK CMD ["/usr/local/bin/init", "--healthcheck"]
ENTRYPOINT ["/sbin/tini", "--", "/usr/local/bin/init"]

Le RUN est en forme exec, entre crochets. Dans une image sans système il n'y a pas de shell : la forme habituelle échouerait, seul un binaire statique peut s'exécuter à ce stade.

Copier /lib en bloc est la facilité à ne pas s'autoriser

C'est ce que faisait ce patron à ses débuts, et c'est l'erreur qui annule tout le reste : elle fait entrer dans une image sans système des dizaines de bibliothèques qu'aucun binaire ne charge.

La clôture répond exactement à la bonne question — quelles bibliothèques ce binaire ouvre-t-il vraiment — et règle au passage le cas du chargeur, dont le nom dépend de l'architecture, sans avoir à le nommer.

L'autre moitié du contrat se vérifie après coup : un contrôle rejoue le chargement de chaque binaire de l'image publiée. Une bibliothèque oubliée ne se voit pas au build ; elle sort au premier démarrage, en production, ce qui est précisément le moment où on ne veut pas l'apprendre.

Un contrôle de santé qui parle le protocole

Le contrôle de santé parle le protocole du service plutôt que de constater un port ouvert : une vraie requête DNS pour le résolveur, un échange sur le socket de contrôle pour l'antivirus, une requête ICAP pour le pont, un ping applicatif écrit en Go pur pour le moteur PHP.

Un test de connexion se contenterait de dire que le processus est vivant. Il ne dit pas qu'il répond correctement — et c'est toute la différence entre une sonde et une décoration.

Dans le même esprit, chaque point d'entrée valide la configuration avant de lancer le service. Si la validation échoue, le conteneur ne démarre pas : pas de service en état dégradé. L'exception assumée est l'IPS, où une règle inconnue ne doit pas empêcher le démarrage — un IPS arrêté ne protège rien.

Identités et capabilities

Chaque image tourne sous un identifiant non privilégié explicite, choisi pour rester lisible dans la liste des processus de l'hôte.

Aucun service ne reçoit de capability par défaut. Deux images seulement en ont besoin, et elles sont posées sur le binaire à la construction plutôt que sur le conteneur entier : le résolveur, pour écouter sur un port privilégié, et l'IPS, pour manipuler les files du pare-feu.

Toutes les autres écoutent au-dessus du seuil privilégié et n'en demandent aucune.

Épingler les bases par empreinte

Un tag est mutable : republié, il ne casse aucun build — il en change silencieusement le résultat. Toutes les bases sont donc épinglées par empreinte.

FROM alpine:3.24@sha256:28bd5fe8b56d1bd048e5babf5b10710ebe0bae67db86916198a6eec434943f8b AS builder

Le piège est de vouloir factoriser la version. FROM alpine:${ALPINE_VERSION}@sha256:DIGEST se lit mieux, mais le jour où la variable passe à 3.25 sans re-résolution de l'empreinte, le Dockerfile annonce 3.25 et construit 3.24 — un mensonge que rien ne signale. Tag et empreinte sont donc écrits en dur tous les deux, et mis à jour dans le même commit.

Le durcissement ne s'arrête pas au build

Une image sans shell tourne quand même avec les capabilities par défaut du moteur d'exécution si personne ne les retire. Le socle est commun :

x-common-security: &common-security
  read_only: true
  security_opt:
    - no-new-privileges:true
  cap_drop:
    - ALL

Les capabilities sont ensuite réajoutées une par une, là où elles sont nécessaires, et les répertoires temporaires montés sans exécution ni périphériques. Le système de fichiers en lecture seule ne passe pas partout : certains services ont besoin d'écrire leur file d'attente ou leurs journaux.

Ce que le patron donne, mesuré

Un patron se juge sur ce qu'il produit. Sur les huit images du parc, quatre chiffres valent mieux qu'une déclaration d'intention.

  • Une seule bibliothèque est commune aux huit images : le chargeur. C'est la mesure la plus directe de ce que « compilé depuis les sources, sans base commune » veut dire.
  • Aucun bit setuid, aucun setgid, aucun fichier inscriptible par tous, sur les 403 fichiers nommés du parc. Trois modes seulement.
  • Deux capabilities en tout, sur deux binaires, posées à la construction et jamais accordées au conteneur.
  • Et un résultat contre-intuitif : dans chaque image, les fuseaux horaires et le magasin de certificats pèsent entre 60 et 95 % des chemins.

Ce dernier point est le meilleur argument pour un manifeste d'image : ce ne sont pas les binaires qui surprennent, c'est ce qui les accompagne sans que personne l'ait décidé.

Déploiement

Ces images tournent en conteneur sur le routeur, où le tag ne vit pas dans un fichier de configuration mais dans la configuration persistante : le mettre à jour demande une transaction, pas un téléchargement. Le playbook correspondant est décrit dans la mise à jour des images.


Liens

Articles par image

Dépôts

Articles connexes