Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Par mis à jour le

Améliorer cette page

Logo Docker

Tester le contrat d'une image, c'est la lancer comme le ferait un utilisateur et lui poser des questions sans jamais la modifier : démarre-t-elle, répond-elle, accepte-t-elle sa configuration, tourne-t-elle sans privilège, s'arrête-t-elle sur docker stop, dit-elle la vérité sur son état ? Cette leçon écrit un script de test, tester-contrat.sh, qui rend le code 0 pour une image conforme et le code 1, avec un message qui nomme le contrôle, pour une image inutilisable.

Un build qui réussit ne prouve pas que l'image se laisse utiliser. Vous allez faire passer le même script à une image correcte, puis à six images volontairement cassées, et lire chaque refus. Toutes les mesures viennent de Docker Engine 29.9.2 et de Buildx 0.38.1, sur une VM Debian 13, avec l'image de base python:3.15.2-slim.

La leçon part du script de vérification de Séparer construction et exécution (set -e, une affectation puis un test) et le généralise en protocole. Vous avez déjà vu, dans Démarrage, disponibilité et santé, les trois états de santé, les réglages d'une sonde et le rôle de depends_on côté Compose. Ici, la sonde est déclarée par l'image elle-même, et c'est le script de test qui la juge.

  • Dresser la liste de ce qu'un utilisateur de l'image peut attendre, c'est-à-dire son contrat
  • Écrire un script de test qui borne chaque attente au lieu de dormir un temps fixe
  • Déclarer un HEALTHCHECK dans l'image et lire .Config.Healthcheck et .State.Health
  • Contrôler la configuration, l'uid, le mode --read-only et l'arrêt sur SIGTERM
  • Faire refuser six images cassées par le même script
  • Écrire les durées d'un HEALTHCHECK dans une unité que hadolint accepte

Le contrat d'une image est la liste de ce qu'une personne qui l'utilise peut attendre sans lire son Dockerfile. Elle n'a pas à savoir comment l'image est construite : elle lance docker run, puis docker stop, et elle suppose que tout se passe bien. Chaque attente devient un contrôle que le script exécute de l'extérieur.

Attente de l'utilisateurContrôle du scriptCe que le script observe
L'image démarredemarragela route /pret répond avant une limite de 15 s
Elle fait son travailfonctionnement/pret et /travail rendent la réponse prévue
Elle se configureconfigurationsans variable obligatoire, arrêt avec un code non nul et un message ; une variable optionnelle est prise en compte
Elle n'exige aucun privilègepermissionsuid différent de 0 ; service qui tourne sous --read-only
Elle s'arrête quand on le demandearretcode de sortie 0 ou 143, en moins de 8 s
Elle dit son étatsanteun HEALTHCHECK existe et suit le travail réel

Les noms de la colonne centrale servent d'étiquettes : chaque message du script commence par l'un d'eux, entre crochets, pour que le refus dise tout de suite de quoi il s'agit.

Un test extérieur évite un piège que les tests du code ne voient pas. Le test unitaire de l'application passe même si l'image oublie un fichier, tourne en root ou ignore SIGTERM. Seul le lancement de l'image finale expose ces défauts, comme la section sur verifier-image.sh l'a montré pour le chargeur, les fuseaux et les certificats. Des outils spécialisés existent pour décrire la structure d'une image, comme Container Structure Test ; ici, le script reste du shell, pour que chaque contrôle soit lisible.

Le service releve-contrat est un petit programme Python qui joue le rôle de l'application à livrer. Il écoute sur le port 8080, répond sur deux routes et sait s'arrêter proprement. Créez un répertoire de travail, puis le fichier releve.py.

releve.py
import os, sys, time, signal, threading
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
canal = os.environ.get("CANAL")
if not canal:
print("erreur: la variable CANAL est obligatoire", file=sys.stderr)
sys.exit(78)
cadence = float(os.environ.get("CADENCE", "1"))
data = os.environ.get("DATA_DIR", "/data")
source = os.path.join(data, "source")
journal = os.environ.get("JOURNAL")
if journal:
with open(journal, "a") as f:
f.write("demarrage\n")
open(source, "a").close()
etat = {"dernier": 0.0}
arret = threading.Event()
def travailler():
while not arret.is_set():
if os.path.exists(source):
with open(os.path.join(data, "dernier"), "w") as f:
f.write(str(time.time()))
etat["dernier"] = time.time()
arret.wait(cadence)
class H(BaseHTTPRequestHandler):
def reponse(self, code, texte):
corps = (texte + "\n").encode()
self.send_response(code)
self.send_header("Content-Length", str(len(corps)))
self.end_headers()
self.wfile.write(corps)
def do_GET(self):
if self.path == "/pret":
self.reponse(200, f"pret canal={canal} cadence={cadence:g}")
elif self.path == "/travail":
age = time.time() - etat["dernier"]
if age <= 5 * cadence:
self.reponse(200, f"travail ok age={age:.1f}")
else:
self.reponse(503, "travail trop ancien ou source absente")
else:
self.reponse(404, "inconnu")
def log_message(self, *a):
pass
srv = ThreadingHTTPServer(("0.0.0.0", 8080), H)
def sigterm(signum, frame):
arret.set()
threading.Thread(target=srv.shutdown).start()
signal.signal(signal.SIGTERM, sigterm)
threading.Thread(target=travailler, daemon=True).start()
print("service pret", flush=True)
srv.serve_forever()
print("arret propre", flush=True)
sys.exit(0)

Le programme tient en trois comportements, qui sont autant de choses à tester.

  • Il refuse de démarrer sans la variable CANAL : message sur la sortie d'erreur, code de sortie 78 (la valeur que la convention sysexits réserve aux erreurs de configuration). La variable CADENCE, elle, est optionnelle.
  • Il travaille en continu : une boucle réécrit le fichier /data/dernier tant que le fichier /data/source existe. /pret répond 200 dès que le serveur écoute. /travail répond 200 si le travail date de moins de cinq cadences, et 503 sinon, c'est-à-dire quand /data/source a disparu.
  • Il traite SIGTERM : le gestionnaire arrête le serveur, le programme affiche arret propre et sort avec le code 0.

Une dernière variable, JOURNAL, est facultative : si elle est définie, le programme y écrit une ligne au démarrage. L'image correcte ne la définit pas ; une des images cassées s'en sert.

La différence entre /pret et /travail est le cœur de la leçon : /pret dit que le processus répond, /travail dit qu'il fait ce pour quoi il existe.

Une image déclare sa santé avec l'instruction HEALTHCHECK du Dockerfile : quiconque lance l'image, avec docker run ou dans Compose, obtient la sonde sans rien écrire. Les états et les réglages sont ceux de la leçon sur la santé des services Compose : starting tant qu'aucune sonde n'a réussi, healthy après une sonde réussie, unhealthy après retries échecs de suite. Seul l'endroit où on les déclare change. L'option --start-interval, disponible depuis Docker Engine 25.0 d'après la documentation du Dockerfile, règle la fréquence des sondes pendant le délai de grâce. Voici le Dockerfile de l'image correcte.

Dockerfile
FROM python:3.15.2-slim@sha256:8c13a3f37a6a5b71498228e902c4b9f51c1f7900d49fabf71f9a650c6120e0d0
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY releve.py /app/releve.py
RUN mkdir /data && chown 10001:10001 /data
EXPOSE 8080
USER 10001
HEALTHCHECK --interval=2s --timeout=1s --start-period=3s --start-interval=0.5s --retries=2 CMD ["python", "-c", "import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8080/travail\", timeout=1)"]
CMD ["python", "/app/releve.py"]

Trois choix méritent d'être lus. /data appartient à l'uid 10001 et USER 10001 s'applique ensuite, donc le programme tourne sans privilège et écrit là où il en a le droit. La forme exec de CMD, avec des crochets, fait du programme Python le PID 1 du conteneur ; c'est ce qui lui permet de recevoir SIGTERM. Enfin, la sonde appelle /travail, pas /pret, avec Python, déjà présent dans l'image : une sonde ne doit pas exiger un outil que l'image n'installe pas.

La sonde est ici en forme exec, un tableau JSON après CMD. La forme shell, HEALTHCHECK CMD commande, est aussi permise : Docker l'exécute alors avec /bin/sh -c, ce qui suppose un shell dans l'image, et .Config.Healthcheck.Test affiche CMD-SHELL au lieu de CMD. Dans une image sans shell, seule la forme exec fonctionne.

Construisez l'image avec une version explicite, jamais latest, puis lisez ce que Docker a enregistré.

Fenêtre de terminal
docker build -t releve-contrat:1.0 .
docker inspect --format '{{json .Config.Healthcheck}}' releve-contrat:1.0
{"Test":["CMD","python","-c","import urllib.request; urllib.request.urlopen(\"http://127.0.0.1:8080/travail\", timeout=1)"],"Interval":2000000000,"Timeout":1000000000,"StartPeriod":3000000000,"StartInterval":500000000,"Retries":2}

Docker range les durées en nanosecondes : Interval vaut 2000000000, donc 2 s, et StartInterval 500000000, donc 0,5 s. Les options de l'instruction portent les noms des réglages Compose, avec deux tirets au lieu d'un tiret bas (--start-period pour start_period).

OptionValeur iciRôle
--interval2 sdélai entre deux sondes
--timeout1 sdurée maximale d'une sonde
--retries2échecs consécutifs avant l'état unhealthy
--start-period3 sdélai de grâce au démarrage
--start-interval0,5 sdélai entre deux sondes pendant ce délai de grâce

Tant qu'aucune sonde n'a réussi, l'état est starting. Lancez l'image avec une période de grâce longue et une première sonde à 10 s, puis relevez l'état tout de suite.

Fenêtre de terminal
docker run -d --name m-st --health-start-period 30s --health-start-interval 10s -e CANAL=x releve-contrat:1.0
docker inspect --format '{{json .State.Health}}' m-st
docker ps --filter name=m-st --format '{{.Status}}'
sleep 12
docker inspect --format '{{json .State.Health.Status}}' m-st
docker rm -f m-st
{"Status":"starting","FailingStreak":0,"Log":[]}
Up Less than a second (health: starting)
"healthy"

Les deux relevés diffèrent ainsi : juste après le lancement, aucune sonde n'a encore tourné (Log est vide) et l'état est starting. Douze secondes après le lancement, la première sonde a réussi et l'état est healthy, sans attendre la fin des 30 s de grâce : une sonde réussie pendant cette période suffit.

Quatre bornes règlent la sonde, et chacune répond à un problème précis. Le moteur lance une sonde, attend qu'elle se termine, puis compte l'intervalle à partir de cette fin avant d'en lancer une autre : deux sondes ne se chevauchent jamais. Pour le vérifier, on construit une image dont la sonde dort 5 secondes, puis on la lance avec --health-interval 2s et --health-timeout 10s. Travaillez dans un répertoire à part, pour ne pas écraser le Dockerfile de la leçon. Le script de la sonde est le suivant.

sonde.sh
#!/bin/sh
sleep 5
exit 0

Le Dockerfile copie ce script dans l'image et la laisse tourner 300 secondes, le temps des mesures.

Dockerfile
FROM alpine:3.24.3@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
COPY sonde.sh /sonde.sh
RUN chmod +x /sonde.sh
CMD ["sleep", "300"]

Construisez l'image, puis lancez le premier cas. Le journal de santé donne le début et la fin de chaque sonde.

Fenêtre de terminal
docker build -t sonde-lente:1.0 .
docker run -d --name hc-long --health-cmd /sonde.sh --health-interval 2s --health-timeout 10s --health-retries 10 --health-start-period 0s sonde-lente:1.0
sleep 22
docker inspect --format '{{range .State.Health.Log}}{{.Start}} {{.End}} rc={{.ExitCode}} out={{printf "%q" .Output}}{{"\n"}}{{end}}' hc-long
2026-11-03 14:41:01.154901397 +0000 UTC 2026-11-03 14:41:06.193151098 +0000 UTC rc=0 out=""
2026-11-03 14:41:08.193598519 +0000 UTC 2026-11-03 14:41:13.221457609 +0000 UTC rc=0 out=""
2026-11-03 14:41:15.222217271 +0000 UTC 2026-11-03 14:41:20.251444286 +0000 UTC rc=0 out=""

Comparez chaque Start au End de la ligne précédente : 14:41:06.19 puis 14:41:08.19, soit 2 secondes pile. La sonde suivante attend donc la fin de la précédente, même si celle-ci dure plus que l'intervalle, et le rythme réel est la durée de la sonde plus l'intervalle.

Supprimez ce conteneur, puis relancez avec --health-timeout 3s, toujours avec la sonde de 5 secondes. Le moteur tue l'essai à 3 secondes, et cet essai compte comme un échec.

Fenêtre de terminal
docker rm -f hc-long
docker run -d --name hc-court --health-cmd /sonde.sh --health-interval 2s --health-timeout 3s --health-retries 10 --health-start-period 0s sonde-lente:1.0
sleep 20
docker inspect --format '{{range .State.Health.Log}}{{.Start}} {{.End}} rc={{.ExitCode}} out={{printf "%q" .Output}}{{"\n"}}{{end}}' hc-court
docker rm -f hc-court
2026-11-03 14:41:23.601239318 +0000 UTC 2026-11-03 14:41:26.647349491 +0000 UTC rc=-1 out="Health check exceeded timeout (3s)"
2026-11-03 14:41:28.64799245 +0000 UTC 2026-11-03 14:41:31.675077976 +0000 UTC rc=-1 out="Health check exceeded timeout (3s)"
2026-11-03 14:41:33.675922544 +0000 UTC 2026-11-03 14:41:36.706129849 +0000 UTC rc=-1 out="Health check exceeded timeout (3s)"

Le code -1 et le message Health check exceeded timeout (3s) montrent ce que fait --timeout : une sonde bloquée ne dure pas indéfiniment, elle est interrompue et bascule du côté des échecs. Les trois autres bornes se lisent ainsi.

  • --retries : avec 2 au minimum, un échec isolé (un hoquet de la machine) ne déclare pas le service malade.
  • --start-period : un service lent à démarrer n'est pas jugé trop tôt, les échecs de cette période ne comptent pas, jusqu'à la première sonde réussie.
  • --interval : plus il est court, plus l'état réagit vite, et plus la sonde consomme.

Pour choisir, calculez le délai au bout duquel le conteneur est déclaré unhealthy. La leçon sur Compose le donne en ordre de grandeur : interval fois retries, plus la durée de chaque sonde. Voici la même formule prise au pire : après la période de démarrage, il faut retries échecs, et chaque échec dure au plus timeout, puisque l'intervalle se compte après la fin de l'essai. Cela donne retries × (timeout + interval). Avec le Dockerfile de la leçon (--interval=2s, --timeout=1s, --retries=2), cela fait 2 × (1 + 2) = 6 secondes, sans compter les 3 s de période de grâce. Le --timeout entre donc dans ce délai : choisissez-le juste au-dessus de la durée normale de la sonde.

Deux mesures vérifient la formule, et elles se lisent sans être rejouées. La première porte sur la sonde seule : l'image sonde-lente:1.0 a été lancée avec --health-cmd 'sleep 10' --health-interval 2s --health-timeout 1s --health-retries 2 --health-start-period 0s, de sorte que chaque essai est interrompu au bout d'une seconde. L'état relevé toutes les 3 s environ est starting à 3,27 s et à 6,29 s, puis unhealthy à 9,31 s. Le journal donne le moment exact du changement : le deuxième échec se termine 4,06 s après le début du premier essai, qui suit de 2 s le lancement, soit vers 6,1 s, au plus près des 6 s calculés. Le relevé de 6,29 s dit encore starting parce que le conteneur existe depuis un peu moins de 6,29 s à ce moment-là. Enfin, les fins de deux échecs consécutifs sont séparées de 3,03 s, soit 1 s de délai plus 2 s d'intervalle.

La formule ne compte pas le délai propre au service. Ici, /travail ne rend 503 que lorsque la dernière écriture date de plus de cinq cadences : entre 4 et 5 s après la coupure de /data/source (4,1 à 4,3 s sur quatre mesures). Ce délai s'ajoute à celui de la sonde.

La seconde mesure porte sur l'exemple de la leçon : après la coupure de /data/source, le premier 503 arrive environ 4,2 s après la coupure, puis l'état passe à unhealthy au bout de 2,5 à 4,4 s supplémentaires, selon le moment où tombe la sonde. Au total, il se passe de 6,6 à 8,6 s entre la coupure et le changement d'état (quatre mesures).

Qu'affiche .State.Health d'un conteneur en marche ?

Section intitulée « Qu'affiche .State.Health d'un conteneur en marche ? »

Le conteneur porte un état de santé, que docker ps résume entre parenthèses et que docker inspect détaille. Pour le voir, il faut deux conteneurs : un avec sonde et un sans. Le second utilise releve-contrat:1.0-sans-healthcheck, la même image que la correcte sans la ligne HEALTHCHECK, que vous fabriquez d'abord. Revenez dans le répertoire de la leçon, celui du Dockerfile de l'image correcte, copiez ce Dockerfile dans Dockerfile.1.0-sans-healthcheck, supprimez-y la ligne HEALTHCHECK, puis construisez.

Fenêtre de terminal
docker build -f Dockerfile.1.0-sans-healthcheck -t releve-contrat:1.0-sans-healthcheck .

Lancez ensuite les deux conteneurs, attendez une seconde, puis listez-les.

Fenêtre de terminal
docker run -d --name m-a -e CANAL=x releve-contrat:1.0
docker run -d --name m-f -e CANAL=x releve-contrat:1.0-sans-healthcheck
sleep 1
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
NAMES IMAGE STATUS
m-f releve-contrat:1.0-sans-healthcheck Up 1 second
m-a releve-contrat:1.0 Up 1 second (healthy)

Le conteneur sans sonde n'affiche que Up : rien ne dit s'il travaille. Celui qui en a une affiche (healthy). Lisez maintenant le détail, six secondes après.

Fenêtre de terminal
sleep 5
docker inspect --format '{{json .State.Health}}' m-a
docker inspect --format '{{json .State.Health}}' m-f
{"Status":"healthy","FailingStreak":0,"Log":[{"Start":"2026-11-03T13:37:38.253516787Z","End":"2026-11-03T13:37:38.459444892Z","ExitCode":0,"Output":""},{"Start":"2026-11-03T13:37:40.460098575Z","End":"2026-11-03T13:37:40.679210969Z","ExitCode":0,"Output":""},{"Start":"2026-11-03T13:37:42.680100657Z","End":"2026-11-03T13:37:42.905873651Z","ExitCode":0,"Output":""}]}
null

Trois champs sont à lire. Status donne l'état. FailingStreak compte les échecs consécutifs, ici 0. Log garde les derniers résultats de la sonde (cinq entrées au plus), avec leur code de sortie (ExitCode) et leur sortie. Les trois sondes réussies démarrent à un peu plus de 2 s d'écart, parce que l'intervalle se compte à partir de la fin de la sonde précédente. Pour l'image sans sonde, .State.Health vaut null : c'est ce que le script teste pour refuser une image sans HEALTHCHECK.

Supprimez le fichier /data/source dans le conteneur m-a, ce qui arrête le travail, puis relisez l'état douze secondes après.

Fenêtre de terminal
docker exec m-a python -c "import os; os.remove('/data/source')"
sleep 12
docker inspect --format '{{json .State.Health}}' m-a
docker ps --format 'table {{.Names}}\t{{.Status}}'
docker inspect --format 'running={{.State.Running}} restarts={{.RestartCount}} pid={{.State.Pid}}' m-a

La sortie de .State.Health est abrégée ici : le journal garde les deux dernières sondes réussies puis les trois sondes en échec, avec la trace Python de chacune.

{"Status":"unhealthy","FailingStreak":3,"Log":[{"Start":"2026-11-03T13:37:44.907568353Z","End":"2026-11-03T13:37:45.120684474Z","ExitCode":0,"Output":""},{"Start":"2026-11-03T13:37:47.121482637Z","End":"2026-11-03T13:37:47.338941123Z","ExitCode":0,"Output":""},{"Start":"2026-11-03T13:37:49.340973058Z","End":"2026-11-03T13:37:49.605037839Z","ExitCode":1,"Output":"Traceback (most recent call last):\n …\nurllib.error.HTTPError: HTTP Error 503: Service Unavailable\n"}, …]}
NAMES STATUS
m-f Up 18 seconds
m-a Up 18 seconds (unhealthy)
running=true restarts=0 pid=484411

Lisez trois choses. Le journal explique pourquoi : la sonde a rendu le code 1, et sa sortie finit par HTTP Error 503: Service Unavailable, la réponse de /travail. L'état passe à unhealthy et FailingStreak vaut 3 au moment de la lecture. Surtout, le conteneur reste en marche : running=true, restarts=0. Docker signale le problème, mais il ne redémarre pas le conteneur. Ce sont l'orchestrateur ou le script de test qui lisent cet état et décident.

Le schéma suivant résume ces états et ce qui les fait changer. Suivez les flèches : une sonde qui réussit mène à healthy, retries échecs de suite mènent à unhealthy, et la flèche en pointillé rappelle qu'aucun redémarrage n'en découle. À l'écart, l'image sans sonde n'a aucun état : .State.Health vaut null.

Trois états de santé reliés par des flèches. L'état starting devient healthy dès qu'une sonde réussit, ou unhealthy après retries échecs une fois le délai de grâce passé. L'état healthy devient unhealthy quand la sonde échoue retries fois de suite, et unhealthy redevient healthy quand la sonde réussit de nouveau. De l'état unhealthy part un lien en pointillé vers une note : le conteneur reste en marche, sans redémarrage. À part, un élément grisé : une image sans HEALTHCHECK n'a pas d'état de santé, .State.Health vaut null.une sonde réussitretries échecsaprès le délai de grâcela sonde échoueretries fois de suitela sonde réussitde nouveauImage sans HEALTHCHECK.State.Health vaut nullstartinghealthyunhealthyLe conteneur reste en marcheaucun redémarrage
Les trois états de santé mesurés, et ce qu'un état unhealthy ne déclenche pas. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

Comment surcharger ou désactiver la sonde d'une image ?

Section intitulée « Comment surcharger ou désactiver la sonde d'une image ? »

Au lancement, deux options remplacent la sonde de l'image : --health-cmd pour en fournir une autre, --no-healthcheck pour la supprimer. Elles servent à tester une image sans la reconstruire. Voici la surcharge qui échoue toujours, avec un intervalle d'une seconde et un seul échec toléré.

Fenêtre de terminal
docker run -d --name m-s --health-cmd 'exit 1' --health-interval 1s --health-retries 1 -e CANAL=x releve-contrat:1.0
sleep 4
docker inspect --format '{{json .Config.Healthcheck}} {{.State.Health.Status}}' m-s
{"Test":["CMD-SHELL","exit 1"],"Interval":1000000000,"Timeout":1000000000,"StartPeriod":3000000000,"StartInterval":500000000,"Retries":1} unhealthy

La commande et les options données remplacent celles de l'image (CMD-SHELL, Interval, Retries), mais les champs non redonnés, Timeout, StartPeriod et StartInterval, gardent la valeur du Dockerfile. Avec --no-healthcheck, le test devient ["NONE"] et l'état de santé disparaît.

Fenêtre de terminal
docker run -d --name m-n --no-healthcheck -e CANAL=x releve-contrat:1.0
docker inspect --format '{{json .Config.Healthcheck}} {{json .State.Health}}' m-n
{"Test":["NONE"],"Interval":2000000000,"Timeout":1000000000,"StartPeriod":3000000000,"StartInterval":500000000,"Retries":2} null

Une dernière précision sur la sonde de l'image : elle est copiée dans le conteneur à sa création. Après avoir modifié le HEALTHCHECK et reconstruit l'image, un conteneur déjà créé garde l'ancienne sonde ; il faut le supprimer et le recréer avec docker run pour que la nouvelle s'applique. Avec Compose, docker compose up recrée de lui-même le service dont l'image a changé, sans qu'il faille ajouter --force-recreate.

Retirez les conteneurs de cette section avant de continuer.

Fenêtre de terminal
docker rm -f m-a m-f m-s m-n

Comment le script lance-t-il l'image et attend-il sans durée fixe ?

Section intitulée « Comment le script lance-t-il l'image et attend-il sans durée fixe ? »

Un test qui dort deux secondes puis interroge le service échoue sur une machine lente et fait perdre du temps sur une machine rapide. Le script remplace chaque pause fixe par une boucle bornée : elle interroge, sort dès que la condition est vraie, et s'arrête en échec à une limite. Le fichier commence par le protocole commun, puis deux fonctions d'attente.

tester-contrat.sh
#!/usr/bin/env bash
# Usage : tester-contrat.sh <image>
set -euo pipefail
image="${1:?usage: $0 <image>}"
pref="tc-$$"
refuser() { echo "REFUSE [$1] $2"; exit 1; }
ok() { echo "ok [$1] $2"; }
nettoyer() { docker rm -f "$pref-a" "$pref-b" "$pref-c" "$pref-d" >/dev/null 2>&1 || true; }
trap nettoyer EXIT
# --- Parties propres au service releve-contrat : à adapter à votre image ---
donnees=/data # dossier de données inscriptible
fichier_travail="$donnees/source" # fichier dont dépend le travail du service
# L'image doit aussi fournir `id` et `python`, utilisés avec `docker exec` plus bas.
# --- Fin des parties propres au service ---
port_de() {
local p
p=$(docker port "$1" 8080/tcp | head -n 1)
echo "${p##*:}"
}
# attendre_pret <conteneur> <limite en s> : affiche la durée, échoue sinon
attendre_pret() {
local nom="$1" limite="$2" debut fin etat port
debut=$(date +%s.%N)
port=$(port_de "$nom")
while true; do
if curl -fs --max-time 1 "http://127.0.0.1:$port/pret" >/dev/null 2>&1; then
fin=$(date +%s.%N)
awk -v a="$debut" -v b="$fin" 'BEGIN{printf "%.2f", b-a}'
return 0
fi
etat=$(docker inspect --format '{{.State.Running}}' "$nom")
[ "$etat" = "true" ] || return 2
fin=$(date +%s.%N)
awk -v a="$debut" -v b="$fin" -v l="$limite" 'BEGIN{exit !(b-a>l)}' && return 1
sleep 0.2
done
}
# attendre_sante <conteneur> <attendu> <limite> : sonde l'état, sans durée fixe
attendre_sante() {
local nom="$1" attendu="$2" limite="$3" debut fin etat
debut=$(date +%s.%N)
while true; do
etat=$(docker inspect --format '{{.State.Health.Status}}' "$nom")
[ "$etat" = "$attendu" ] && return 0
fin=$(date +%s.%N)
awk -v a="$debut" -v b="$fin" -v l="$limite" 'BEGIN{exit !(b-a>l)}' && return 1
sleep 0.5
done
}
# --- sante (déclaration) ---
hc=$(docker inspect --format '{{json .Config.Healthcheck}}' "$image")
[ "$hc" != "null" ] || refuser sante "aucun HEALTHCHECK déclaré dans l'image"
sonde=$(docker inspect --format '{{json .Config.Healthcheck.Test}}' "$image")
[ "$sonde" != '["NONE"]' ] || refuser sante "HEALTHCHECK NONE : la sonde de l'image est désactivée"
ok sante "HEALTHCHECK déclaré"

Enregistrez ce bloc dans tester-contrat.sh et lancez-le dès maintenant sur l'image correcte : le fichier ne contient encore que la déclaration de la sonde, donc une seule ligne s'affiche.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0
ok [sante] HEALTHCHECK déclaré

Sur une image construite sans la ligne HEALTHCHECK, le même fichier écrit REFUSE [sante] aucun HEALTHCHECK déclaré dans l'image. Sur une image dont le Dockerfile porte HEALTHCHECK NONE à la place, il écrit REFUSE [sante] HEALTHCHECK NONE : la sonde de l'image est désactivée, et il rend le code 1 dans les deux cas.

Le début du fichier fixe le protocole. refuser écrit REFUSE [contrôle] message puis sort avec le code 1, ok écrit une ligne de succès, et l'instruction trap nettoyer EXIT supprime les conteneurs de test quel que soit le résultat, y compris après un refus. Les conteneurs portent le préfixe tc- suivi du PID du script ($$), pour que deux exécutions simultanées ne se heurtent pas.

Les conteneurs sont lancés avec -p 127.0.0.1::8080 : Docker choisit lui-même un port libre de l'hôte, et port_de le relit avec docker port. Aucun port n'est écrit en dur, donc aucune collision avec un autre service de la machine.

attendre_pret rend trois résultats distincts : 0 si /pret répond (et elle affiche alors la durée), 1 si la limite est atteinte, 2 si le conteneur s'est arrêté pendant l'attente. Le script appelle ces fonctions avec || rc=$? ou || refuser : l'affectation puis le test, le geste de la leçon sur le build multi-étapes, empêche set -e de couper le script avant que le message de refus soit écrit. attendre_sante fait la même chose pour l'état de santé, qu'elle lit avec docker inspect --format '{{.State.Health.Status}}'.

Les dernières lignes du bloc reprennent la section sur la santé : une image dont .Config.Healthcheck vaut null est refusée avant même d'être lancée, et une image dont le test vaut ["NONE"] (HEALTHCHECK NONE dans le Dockerfile, qui désactive la sonde) l'est aussi, avec un message qui le dit.

Le bloc de variables placé après le trap regroupe ce qui est propre au service de démonstration : le dossier de données, le fichier dont dépend le travail, et la présence de id et de python dans l'image. Tout le reste du script vaut pour n'importe quelle image qui expose /pret sur le port 8080. Le fichier tester-contrat.sh s'obtient en collant, dans l'ordre, les blocs de code des sections de configuration, de démarrage, de permissions, de santé et d'arrêt à la suite de celui-ci : cinq blocs à coller, un par section.

Un conteneur dont la configuration est mal fournie doit s'arrêter vite et dire laquelle. Le script teste les deux cas : la variable obligatoire absente, puis une variable optionnelle donnée. Ajoutez ces deux contrôles à la suite du fichier.

Fenêtre de terminal
# --- configuration : variable obligatoire absente ---
docker run -d --name "$pref-c" "$image" >/dev/null
code=$(timeout 20 docker wait "$pref-c" || echo timeout)
[ "$code" != "0" ] || refuser configuration "sans CANAL, le conteneur s'est arrêté avec le code 0 (ou n'a pas vérifié)"
[ "$code" != "timeout" ] || refuser configuration "sans CANAL, le conteneur tourne toujours : la variable obligatoire n'est pas vérifiée"
journal=$(docker logs "$pref-c" 2>&1 | head -n 5)
case "$journal" in *CANAL*) ;; *) refuser configuration "code $code mais aucun message ne nomme CANAL" ;; esac
ok configuration "CANAL absente : arrêt avec le code $code et un message clair"
# --- configuration : variable optionnelle ---
docker run -d --name "$pref-d" -e CANAL=essai -e CADENCE=2 -p 127.0.0.1::8080 "$image" >/dev/null
d=$(attendre_pret "$pref-d" 15) || refuser configuration "le conteneur avec CADENCE=2 ne répond pas"
rep=$(curl -s --max-time 2 "http://127.0.0.1:$(port_de "$pref-d")/pret")
case "$rep" in *cadence=2*) ;; *) refuser configuration "CADENCE=2 ignorée (réponse : $rep)" ;; esac
ok configuration "CADENCE=2 prise en compte"

Le premier contrôle lance l'image sans CANAL et attend sa fin avec docker wait, bornée à 20 s par timeout. Trois défauts sont distingués : un code 0 (le conteneur s'est arrêté comme si tout allait bien), un dépassement des 20 s (il tourne alors qu'il n'a pas ce qu'il lui faut), et un arrêt en erreur dont les journaux ne nomment pas la variable. Le script accepte n'importe quel code non nul, pas seulement 78 : le contrat dit « échouer », pas « échouer avec ce chiffre ».

Le second contrôle donne CADENCE=2 et vérifie que /pret répond cadence=2. Une variable que le programme ignore se verrait ainsi, alors qu'elle ne fait échouer aucun démarrage.

Comment tester le démarrage et le fonctionnement ?

Section intitulée « Comment tester le démarrage et le fonctionnement ? »

Le démarrage se mesure du lancement à la première réponse de /pret, avec une limite de 15 s. Le fonctionnement vérifie ensuite que les deux routes répondent ce qui est prévu, et pas seulement qu'elles répondent.

Fenêtre de terminal
# --- demarrage ---
docker run -d --name "$pref-a" -e CANAL=essai -p 127.0.0.1::8080 "$image" >/dev/null
rc=0
duree=$(attendre_pret "$pref-a" 15) || rc=$?
[ "$rc" -eq 0 ] || refuser demarrage "le service ne répond pas dans les 15 s (code $rc)"
ok demarrage "répond en ${duree} s"
# --- fonctionnement ---
port=$(port_de "$pref-a")
rep=$(curl -s --max-time 2 "http://127.0.0.1:$port/pret")
case "$rep" in "pret canal=essai"*) ;; *) refuser fonctionnement "réponse inattendue sur /pret : $rep" ;; esac
trav=$(curl -s -o /dev/null -w '%{http_code}' --max-time 2 "http://127.0.0.1:$port/travail")
[ "$trav" = "200" ] || refuser fonctionnement "/travail rend $trav au lieu de 200"
ok fonctionnement "/pret et /travail répondent comme prévu"

Le conteneur tc-…-a lancé ici sert aux contrôles suivants : permissions, santé et arrêt. Le code de retour de attendre_pret est conservé dans rc et affiché dans le message, ce qui distingue une limite dépassée (1) d'un conteneur qui s'est arrêté (2). Pour l'image correcte, le script affiche une durée de l'ordre de 0,3 s, qui dépend de la machine : elle sert à repérer une dérive, pas à fixer un seuil.

Le contrôle de fonctionnement compare le contenu de la réponse. /pret doit annoncer canal=essai, preuve que la variable donnée au lancement est bien arrivée dans le programme. curl -w '%{http_code}' lit le code HTTP de /travail sans afficher le corps.

Deux contrôles couvrent les droits : le processus n'est pas root, et l'image continue de fonctionner quand son système de fichiers est en lecture seule.

Fenêtre de terminal
# --- permissions ---
uid=$(docker exec "$pref-a" id -u)
[ "$uid" != "0" ] || refuser permissions "le processus tourne avec l'uid 0 (root)"
ok permissions "uid $uid, non root"
docker run -d --name "$pref-b" --read-only --tmpfs "$donnees:uid=$uid,gid=$uid" -e CANAL=essai -p 127.0.0.1::8080 "$image" >/dev/null
rc=0
attendre_pret "$pref-b" 15 >/dev/null || rc=$?
[ "$rc" -eq 0 ] || refuser permissions "le service ne tourne pas sous --read-only avec un tmpfs sur /data (code $rc)"
if docker exec "$pref-b" python -c "open('/tmp/intrus','w')" >/dev/null 2>&1; then
refuser permissions "écriture possible dans /tmp sous --read-only : le système de fichiers n'est pas en lecture seule"
fi
ok permissions "--read-only : le service tourne, l'écriture hors /data est refusée"

Avec les blocs de configuration, de démarrage et de permissions collés à la suite du fichier, le script donne ceci sur l'image correcte. Les durées varient d'une exécution à l'autre.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0
ok [sante] HEALTHCHECK déclaré
ok [configuration] CANAL absente : arrêt avec le code 78 et un message clair
ok [configuration] CADENCE=2 prise en compte
ok [demarrage] répond en 0.27 s
ok [fonctionnement] /pret et /travail répondent comme prévu
ok [permissions] uid 10001, non root
ok [permissions] --read-only : le service tourne, l'écriture hors /data est refusée

Le premier contrôle lance id -u avec docker exec : il lit l'uid de l'utilisateur configuré pour un nouveau processus du conteneur (ici celui de USER), et non celui du PID 1. Il refuse l'uid 0. Le script teste « différent de 0 » plutôt que « égal à 10001 » : le contrat est de ne pas être root, pas de porter un numéro précis. Pour comprendre pourquoi USER doit passer après les instructions qui écrivent, lisez la leçon sur USER.

Le second contrôle relance l'image avec --read-only, qui rend tout le système de fichiers du conteneur non inscriptible, sauf /data, monté en tmpfs (un dossier gardé en mémoire) au nom de l'uid relevé par le premier contrôle (10001 ici). L'image doit démarrer quand même : elle n'écrit donc que dans le dossier qu'elle déclare. Le script tente ensuite d'écrire dans /tmp/intrus, un chemin que l'uid 10001 peut écrire quand --read-only est absent et qui n'est pas sous /data ; sous --read-only, le noyau répond Errno 30 (système de fichiers en lecture seule) et l'écriture échoue. Écrire dans /app ne prouverait rien : ce dossier appartient à root, donc l'écriture est déjà refusée à l'uid 10001 sans --read-only (Errno 13, permission refusée).

Ce contrôle évite une panne d'exploitation : une application qui écrit dans son propre dossier ne démarre plus dès que l'exploitant monte le conteneur en lecture seule. Un refus de ce contrôle suivi de (code 2) veut dire que le conteneur s'est arrêté pendant l'attente.

Déclarer un HEALTHCHECK ne suffit pas : la sonde doit suivre le travail. Le script le vérifie par un cycle complet, sain, malade, sain, en agissant sur le conteneur de l'extérieur.

Fenêtre de terminal
# --- sante (comportement) ---
attendre_sante "$pref-a" healthy 20 || refuser sante "l'image n'est pas devenue healthy en 20 s"
ok sante "healthy"
docker exec "$pref-a" python -c "import os; os.remove('$fichier_travail')"
attendre_sante "$pref-a" unhealthy 20 || refuser sante "le travail a cessé mais l'image reste healthy après 20 s (la sonde ne mesure pas le travail)"
ok sante "unhealthy quand le travail cesse"
docker exec "$pref-a" python -c "open('$fichier_travail','a').close()"
attendre_sante "$pref-a" healthy 20 || refuser sante "le travail a repris mais l'image n'est pas redevenue healthy"
ok sante "retour à healthy"

Une fois ce bloc collé à son tour, trois lignes s'ajoutent, une par étape du cycle.

[...]
ok [permissions] --read-only : le service tourne, l'écriture hors /data est refusée
ok [sante] healthy
ok [sante] unhealthy quand le travail cesse
ok [sante] retour à healthy

Le script attend d'abord healthy, puis supprime le fichier de travail (/data/source ici), le geste qui arrête le travail, et attend unhealthy pendant 20 s au plus. Il recrée ensuite le fichier et attend le retour à healthy. Une image dont la sonde regarde seulement si le processus répond ne passera jamais à unhealthy : le script la refuse alors avec le message qui dit que la sonde ne mesure pas le travail.

La limite de 20 s laisse une marge sur les valeurs de l'image (intervalle de 2 s, 2 échecs, cinq cadences d'une seconde pour que le travail soit jugé trop ancien). Si vous allongez l'intervalle de la sonde, allongez cette limite avec lui.

L'arrêt se teste avec la commande que l'utilisateur tapera, docker stop, en mesurant le temps qu'elle prend et le code de sortie qui en résulte. Ajoutez le dernier contrôle, qui conclut le script.

Fenêtre de terminal
# --- arret ---
t0=$(date +%s.%N)
docker stop "$pref-a" >/dev/null
t1=$(date +%s.%N)
duree=$(awk -v a="$t0" -v b="$t1" 'BEGIN{printf "%.2f", b-a}')
sortie=$(docker inspect --format '{{.State.ExitCode}}' "$pref-a")
case "$sortie" in 0|143) ;; *) refuser arret "code de sortie $sortie après docker stop en ${duree} s (SIGTERM non traité ?)" ;; esac
awk -v d="$duree" 'BEGIN{exit !(d<8)}' || refuser arret "arrêt en ${duree} s, trop proche du délai de grâce de 10 s"
ok arret "code $sortie en ${duree} s"
echo "ACCEPTE $image"

docker stop envoie SIGTERM, attend dix secondes par défaut, puis envoie SIGKILL. Deux valeurs de code sont acceptées : 0, quand le programme traite le signal et sort proprement, et 143, soit 128 + 15, quand le programme n'est pas PID 1, par exemple derrière docker run --init, et qu'il est terminé par SIGTERM faute de gestionnaire. En forme exec et sans gestionnaire, c'est au contraire le PID 1 qui ignore SIGTERM : Docker le tue à la fin du délai et le conteneur finit en 137, soit 128 + 9 (SIGKILL). Les mécanismes sont expliqués dans Cycle de vie d'un conteneur, PID 1 et arrêt propre.

Le second test porte sur la durée : un arrêt qui dure presque dix secondes, même avec un code 0, signale une image qui attend le délai de grâce. Le seuil de 8 s est un choix du script, pas une règle de Docker ; ajustez-le à votre service. La dernière ligne, ACCEPTE, n'est atteinte que si tous les contrôles ont réussi.

Lancez le script sur l'image releve-contrat:1.0, puis lisez le code de sortie.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0
echo "code=$?"
ok [sante] HEALTHCHECK déclaré
ok [configuration] CANAL absente : arrêt avec le code 78 et un message clair
ok [configuration] CADENCE=2 prise en compte
ok [demarrage] répond en 0.27 s
ok [fonctionnement] /pret et /travail répondent comme prévu
ok [permissions] uid 10001, non root
ok [permissions] --read-only : le service tourne, l'écriture hors /data est refusée
ok [sante] healthy
ok [sante] unhealthy quand le travail cesse
ok [sante] retour à healthy
ok [arret] code 0 en 0.52 s
ACCEPTE releve-contrat:1.0
code=0

Onze contrôles passent et le code de sortie est 0. Le passage complet a pris 14,4 s de bout en bout sur la machine de la leçon, une durée qui dépend de la machine. Les valeurs à regarder sont 78, le code que le programme rend sans CANAL, et 0 à l'arrêt : l'image traite SIGTERM en 0,52 s.

Comment fabriquer des images cassées, et que refuse le script ?

Section intitulée « Comment fabriquer des images cassées, et que refuse le script ? »

Un test qui n'a jamais refusé aucune image ne prouve rien : on ne sait pas s'il voit les défauts ou s'il dit toujours oui. Six images ne diffèrent de la bonne que par un écart minime chacune. Pour chaque variante, copiez le Dockerfile de l'image correcte dans Dockerfile.<étiquette> et appliquez l'écart du tableau, puis construisez avec docker build -f Dockerfile.<étiquette> -t releve-contrat:<étiquette> ..

ÉtiquetteÉcart avec le Dockerfile correctDéfaut du contrat
1.0-rootla ligne USER 10001 est suppriméele programme tourne en root
1.0-sigtermCMD en forme shell : CMD python /app/releve.py; exit $?SIGTERM n'atteint pas le programme
1.0-sante-processusla sonde appelle /pret au lieu de /travaill'état de santé ne dépend pas du travail
1.0-ecrit-horsRUN touch /app/journal.log && chown 10001 /app/journal.log avant USER, et ENV JOURNAL=/app/journal.log aprèsle programme écrit hors de /data
1.0-sans-controleENV CANAL=defaut ajouté après USERla variable obligatoire a une valeur par défaut cachée
1.0-sans-healthcheckla ligne HEALTHCHECK est suppriméeaucune sonde

L'ordre de 1.0-ecrit-hors compte : le RUN touch s'exécute en root, avant USER, sinon le build échoue (voir le dépannage). Lancez maintenant le même script sur chaque image et lisez le premier refus.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0-root
ok [sante] HEALTHCHECK déclaré
ok [configuration] CANAL absente : arrêt avec le code 78 et un message clair
ok [configuration] CADENCE=2 prise en compte
ok [demarrage] répond en 0.28 s
ok [fonctionnement] /pret et /travail répondent comme prévu
REFUSE [permissions] le processus tourne avec l'uid 0 (root)

Cinq contrôles passent et le sixième refuse. Cette image démarre, répond et se configure : un test du code applicatif la dirait bonne. Seul le contrat sur l'uid la bloque. Le script a rendu le code 1 après 2,1 s.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0-sigterm
ok [sante] HEALTHCHECK déclaré
ok [configuration] CANAL absente : arrêt avec le code 78 et un message clair
ok [configuration] CADENCE=2 prise en compte
ok [demarrage] répond en 0.27 s
ok [fonctionnement] /pret et /travail répondent comme prévu
ok [permissions] uid 10001, non root
ok [permissions] --read-only : le service tourne, l'écriture hors /data est refusée
ok [sante] healthy
ok [sante] unhealthy quand le travail cesse
ok [sante] retour à healthy
REFUSE [arret] code de sortie 137 après docker stop en 10.29 s (SIGTERM non traité ?)

Tous les contrôles passent sauf le dernier, qui est le seul à pouvoir voir ce défaut. Avec une commande suivie de ; exit $?, la forme shell de CMD laisse un shell comme PID 1, et ce shell ne transmet pas SIGTERM au programme Python : Docker attend les dix secondes du délai de grâce avant de tuer le conteneur. Le script a mesuré 137 après 10,29 s, et il a rendu le code 1 après 22,1 s en tout. Ces durées sont une mesure sur la machine de la leçon (les mesures de l'arrêt forcé vont de 10,21 à 10,30 s) : retenez l'ordre de grandeur, le délai de grâce de dix secondes.

L'image n'a pas ps, mais le noyau expose la ligne de commande du PID 1 dans /proc. Lisez-la dans un conteneur lancé avec cette image.

Fenêtre de terminal
docker run -d --name m-sig -e CANAL=x releve-contrat:1.0-sigterm >/dev/null
docker exec m-sig cat /proc/1/cmdline | tr '\0' ' '; echo
docker rm -f m-sig >/dev/null
/bin/sh -c python /app/releve.py; exit $?

Le PID 1 doit apparaître comme /bin/sh -c python /app/releve.py; exit $? : c'est le shell, et non python, qui reçoit SIGTERM. Avec l'image correcte, la même commande affiche python /app/releve.py.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0-sante-processus
ok [sante] HEALTHCHECK déclaré
ok [configuration] CANAL absente : arrêt avec le code 78 et un message clair
ok [configuration] CADENCE=2 prise en compte
ok [demarrage] répond en 0.27 s
ok [fonctionnement] /pret et /travail répondent comme prévu
ok [permissions] uid 10001, non root
ok [permissions] --read-only : le service tourne, l'écriture hors /data est refusée
ok [sante] healthy
REFUSE [sante] le travail a cessé mais l'image reste healthy après 20 s (la sonde ne mesure pas le travail)

Cette image a bien un HEALTHCHECK, et le contrôle de déclaration l'a laissée passer. Sa sonde interroge /pret, qui répond tant que le processus tourne, donc elle reste healthy alors que le travail a cessé. Ce défaut est sournois : l'état de santé est vert et faux. Seul le cycle complet du contrôle de santé le démasque, en 23,2 s.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0-ecrit-hors
ok [sante] HEALTHCHECK déclaré
ok [configuration] CANAL absente : arrêt avec le code 78 et un message clair
ok [configuration] CADENCE=2 prise en compte
ok [demarrage] répond en 0.28 s
ok [fonctionnement] /pret et /travail répondent comme prévu
ok [permissions] uid 10001, non root
REFUSE [permissions] le service ne tourne pas sous --read-only avec un tmpfs sur /data (code 2)

Dans l'utilisation normale, cette image fonctionne : le fichier /app/journal.log appartient à l'uid 10001 et le programme y écrit. Sous --read-only, l'écriture échoue, le conteneur s'arrête, et attendre_pret rend le code 2. Le refus apparaît en 2,8 s. C'est la panne que l'exploitant découvre le jour où il durcit l'image, et que ce contrôle rend visible avant la livraison.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0-sans-controle
ok [sante] HEALTHCHECK déclaré
REFUSE [configuration] sans CANAL, le conteneur tourne toujours : la variable obligatoire n'est pas vérifiée

Avec ENV CANAL=defaut, la variable obligatoire n'est plus jamais absente : le programme démarre sans que l'utilisateur ait choisi un canal, et le conteneur ne s'arrête pas. Le script attend 20 s la fin du conteneur, puis refuse (20,5 s au total). Une valeur par défaut cachée dans l'image laisse la configuration sans contrôle, ce que le contrat interdit pour une variable déclarée obligatoire.

Fenêtre de terminal
bash tester-contrat.sh releve-contrat:1.0-sans-healthcheck
REFUSE [sante] aucun HEALTHCHECK déclaré dans l'image

Le refus est immédiat : le script lit .Config.Healthcheck, trouve null et s'arrête avant de lancer le moindre conteneur, en moins de 0,1 s à la mesure.

Le protocole est le même pour les sept images : une commande, un code de sortie, une ligne qui nomme le contrôle. Ajouter un contrôle, c'est ajouter un bloc au script, sans rien changer à la manière de l'appeler.

Pourquoi hadolint refuse-t-il une durée en millisecondes ?

Section intitulée « Pourquoi hadolint refuse-t-il une durée en millisecondes ? »

hadolint, l'analyseur statique de Dockerfile, n'accepte que les unités s, m et h dans les durées d'un HEALTHCHECK, alors que Docker Engine accepte aussi ms. Un Dockerfile écrit avec --start-interval=500ms se construit donc sans erreur, puis échoue à l'analyse avec missing whitespace. La parade est d'écrire la durée en secondes : 0.5s, comme dans le Dockerfile de la leçon.

Revenez dans le répertoire de la leçon, celui qui contient le Dockerfile de l'image correcte : la commande sed plus bas le lit par son nom. La commande ci-dessous épingle la version de hadolint par son digest et coupe le réseau : le résultat ne dépend ni d'une mise à jour ni d'un accès extérieur. Elle lit le Dockerfile de l'image correcte, qui porte 0.5s.

Fenêtre de terminal
docker run --rm -i --network none hadolint/hadolint:v2.15.1@sha256:32dac94127fd60b7b7e3fbfc65e1383b9b5e25c9bfd7b8536de7a539fe68a12d hadolint - < Dockerfile
echo "rc=$?"
rc=0

Le code 0 signifie que l'analyse passe. Pour voir le refus, donnez à hadolint le même Dockerfile avec la durée en millisecondes, dans un fichier temporaire.

Fenêtre de terminal
sed 's/--start-interval=0.5s/--start-interval=500ms/' Dockerfile > Dockerfile.ms
docker run --rm -i --network none hadolint/hadolint:v2.15.1@sha256:32dac94127fd60b7b7e3fbfc65e1383b9b5e25c9bfd7b8536de7a539fe68a12d hadolint - < Dockerfile.ms
echo "rc=$?"
rm Dockerfile.ms
/dev/stdin:8:79 missing whitespace
rc=1

Regardez la colonne 79 : elle désigne la fin de 500ms. L'analyseur lit 500m comme 500 minutes, puis le s restant ne correspond à aucun champ, d'où « missing whitespace ». C'est une erreur d'analyse, pas une règle de style que --ignore pourrait désactiver. Les versions 2.14.0 (image hadolint/hadolint:v2.14.0@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e) et 2.15.1 de hadolint refusent toutes deux 500ms, avec le même message et les mêmes colonnes.

Durée testéeOptionCode de sortie
500ms--start-interval1 (missing whitespace)
1s--start-interval0
0.5s--start-interval0
2s--start-interval0
500ms--interval1 (missing whitespace, colonne 28)

Le tableau montre que --start-interval est reconnu : seule l'unité ms pose problème, y compris sur --interval, qui n'a rien de récent. Écrivez donc toutes les durées de HEALTHCHECK en s, m ou h. Docker enregistre 0.5s comme "StartInterval":500000000, la même valeur que 500ms. La fiche hadolint décrit les règles et l'intégration en CI.

Que faire quand un contrôle refuse ou que le script échoue ?

Section intitulée « Que faire quand un contrôle refuse ou que le script échoue ? »

Chaque ligne du tableau reprend un message que Docker, hadolint ou le script affichent réellement.

SymptômeCauseSolution
error: no such object: releve-contrat:1.0-ecrit-horsl'image n'existe pas : le build a échoué ou l'étiquette est mal écritedocker image ls releve-contrat ; relancer docker build et lire son erreur
RUN touch … did not complete successfully: exit code: 1 pendant le buildle RUN est placé après USER 10001 : un utilisateur sans privilège ne peut pas créer un fichier dans /appplacer le RUN qui écrit avant USER, puis redonner le fichier à l'uid avec chown
REFUSE [configuration] … le conteneur s'est arrêté avec le code 0le programme refuse de démarrer, mais un wrapper masque son code de sortie (par exemple un ; true avant exit $? en forme shell)rendre le code du programme tel quel, en forme exec
REFUSE [permissions] … (code 2)le conteneur s'arrête sous --read-only : le programme écrit hors du dossier prévulire docker logs, déplacer l'écriture dans /data ou monter un tmpfs au bon endroit
REFUSE [sante] … reste healthy après 20 sla sonde interroge une route qui ne dépend pas du travailpointer la sonde sur /travail, ou sur la vérification qui juge le résultat réel
Up … (unhealthy) et le conteneur ne bouge pasDocker ne redémarre pas un conteneur unhealthylire .State.Health.Log, corriger la cause, ou confier la décision à un orchestrateur
REFUSE [arret] code de sortie 137 … en 10.29 sSIGTERM n'atteint pas le programmeforme exec pour CMD, ou un gestionnaire de signal dans le programme
/dev/stdin:8:79 missing whitespace avec hadolintune durée du HEALTHCHECK est écrite en millisecondes (500ms), unité que hadolint n'accepte pasécrire la durée en secondes (0.5s), voir la section sur les millisecondes

Quand un refus ne suffit pas, relancez le conteneur en cause à la main, car le trap supprime les conteneurs de test à la sortie du script, puis lisez docker logs. La page Inspecter un conteneur en marche donne le détail des champs à regarder.

Le script de test supprime déjà ses conteneurs avec trap. Il reste les images construites, l'image de hadolint et le cache de build, où BuildKit garde l'image de base python:3.15.2-slim : elle n'apparaît pas dans docker image ls, donc docker image rm ne peut pas la retirer. Retirez les images par leur nom exact, videz le cache avec docker builder prune --all --force (il efface le cache de tous vos builds, pas seulement celui de cette leçon), et supprimez les fichiers de la leçon par leur nom, sans suppression récursive.

Fenêtre de terminal
docker rm -f m-a m-f m-s m-n hc-long hc-court 2>/dev/null
docker image rm sonde-lente:1.0 releve-contrat:1.0 releve-contrat:1.0-root releve-contrat:1.0-sigterm \
releve-contrat:1.0-sante-processus releve-contrat:1.0-ecrit-hors \
releve-contrat:1.0-sans-controle releve-contrat:1.0-sans-healthcheck
docker builder prune --all --force
docker image rm hadolint/hadolint:v2.15.1@sha256:32dac94127fd60b7b7e3fbfc65e1383b9b5e25c9bfd7b8536de7a539fe68a12d
docker ps -a

Pour les fichiers, rm releve.py Dockerfile tester-contrat.sh et chaque Dockerfile.<étiquette> que vous avez créé, puis rmdir sur le répertoire de travail. Dans le répertoire de la sonde lente, rm sonde.sh Dockerfile, puis rmdir. Vérifiez que docker ps -a ne liste plus aucun conteneur de la leçon.

Le lab fournit un Dockerfile et un service à construire : le conteneur obtenu affiche toujours « Up », même quand son travail est à l'arrêt. Vous y ajoutez une sonde de santé qui juge le travail réel plutôt que la présence du processus, puis vous provoquez les trois états : sain au départ, malade quand le travail cesse, sain de nouveau quand il reprend. Seul l'état du système est contrôlé, avec dsoxlab check, jamais les commandes tapées : la validation lit la sonde déclarée dans l'image et ses bornes de temps, puis observe l'état de santé du conteneur à chaque étape.

  1. Le contrat d'une image est ce qu'un utilisateur peut attendre sans lire son Dockerfile : démarrer, répondre, se configurer, tourner sans privilège, s'arrêter, dire son état. Chaque attente devient un contrôle, lancé de l'extérieur sur l'image finale.

  2. Un script de test borne chaque attente : une boucle qui sort dès que la condition est vraie et échoue à une limite, jamais une pause fixe. Chaque refus écrit REFUSE [contrôle] message et rend le code 1.

  3. Pour une variable obligatoire absente, l'image doit s'arrêter avec un code non nul et un message qui nomme la variable. Une valeur par défaut cachée dans l'image le rend sans effet.

  4. Contrôlez l'uid avec docker exec … id -u et relancez l'image avec --read-only et un tmpfs sur le dossier de données : une image qui écrit ailleurs s'arrête sous --read-only.

  5. docker stop envoie SIGTERM puis SIGKILL après dix secondes. Un code 137, rendu à la fin du délai de grâce (10 s par défaut ; 10,29 s mesurées ici), signale une image qui ignore SIGTERM ; 0 ou 143 est acceptable.

  6. HEALTHCHECK s'écrit dans le Dockerfile et se lit dans .Config.Healthcheck, en nanosecondes, puis .State.Health et son Log. Un conteneur unhealthy reste en marche ; --health-cmd surcharge la sonde et --no-healthcheck la retire.

  7. Une sonde doit juger le travail, pas le processus. Le test qui coupe le travail et attend unhealthy, puis healthy au retour, est le seul à démasquer une sonde toujours verte.

  8. hadolint n'accepte que les unités s, m et h dans les durées d'un HEALTHCHECK, alors que Docker 29.9.2 accepte aussi ms : écrivez 0.5s plutôt que 500ms pour éviter missing whitespace.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

8 questions
8 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn