Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

Chapitre 920 minIntermédiaire

Construire et publier une image Docker dans la CI

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • écrire un Dockerfile multi-étapes adapté à la production ;
  • construire, tester et publier une image avec GitHub Actions ;
  • choisir des étiquettes d'image fixes et traçables ;
  • construire une image dans GitLab CI avec Docker-in-Docker.

4 exercices corrigés

Construire une image Docker dans la CI, c'est confier au pipeline la fabrication de l'image à partir du Dockerfile : il la construit sur une machine neuve, l'étiquette avec le commit ou le numéro de version, la teste, l'analyse et la pousse dans un registre. Cette image unique est ensuite déployée en recette puis en production. Ce chapitre écrit un Dockerfile adapté à la CI, construit et teste l'image dans GitHub Actions, la publie dans le registre de GitHub avec les actions officielles de Docker, puis fait de même avec GitLab CI. Les bases de Docker (images, Dockerfile, Compose) sont dans le cours Conteneurs et Docker, qui aborde aussi Docker dans la CI/CD côté infrastructure.

Pourquoi livrer une image

Une image Docker contient l'application et tout ce dont elle a besoin pour tourner : la bonne version de Node.js, les bibliothèques système, les dépendances installées (les bases de Docker). Livrer une image plutôt que des fichiers règle trois problèmes d'un coup :

  • la même chose partout : l'image testée dans la CI est celle qui tourne en recette et en production, octet pour octet ;
  • un déploiement simple : sur le serveur, plus besoin d'installer Node.js ni de lancer npm ci ; on récupère l'image et on la démarre ;
  • un retour arrière immédiat : l'image de la version précédente est toujours dans le registre, il suffit de la redémarrer (chapitre 12).

Un Dockerfile pour la CI

dockerfile
# syntax=docker/dockerfile:1

# Étape 1 : installer les dépendances de production
FROM node:24-slim AS dependances
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev

# Étape 2 : l'image finale, sans outils de développement
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
COPY --from=dependances /app/node_modules ./node_modules
COPY package.json ./
COPY src ./src
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
CMD ["node", "src/server.js"]
ChoixPourquoi
node:24-slim plutôt que node:24Une image de base plus petite (Debian réduite) : moins de paquets, donc moins de failles et un téléchargement plus rapide
Construction en deux étapes (multi-stage)L'image finale ne contient que ce qui sert à l'exécution
COPY package.json package-lock.json avant COPY srcDocker met en cache chaque instruction : tant que les dépendances ne changent pas, npm ci n'est pas rejoué
npm ci --omit=devPas de Jest, d'ESLint ni de Prettier en production
USER nodeLe processus ne tourne pas en root : une faille dans l'application donne moins de pouvoir à l'attaquant
HEALTHCHECKDocker vérifie que l'API répond ; l'image slim n'a pas curl, d'où l'appel fetch de Node.js
CMD ["node", "src/server.js"]La forme tableau lance Node.js directement : il reçoit les signaux d'arrêt de Docker et s'arrête proprement

Le fichier .dockerignore, à côté du Dockerfile, exclut ce qui ne doit pas partir dans le contexte de construction :

text
node_modules
.git
.github
.env
.env.*
coverage
reports
*.md

Construire et tester l'image dans la CI

Docker est préinstallé sur les runners Ubuntu de GitHub. Avant de publier quoi que ce soit, on peut construire l'image et vérifier qu'elle démarre :

yaml
jobs:
  tester-image:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - name: Construire l'image
        run: docker build -t sorbier-frais:test .
      - name: Démarrer le conteneur
        run: docker run -d --name api -p 3000:3000 -e DB_HOST=exemple.invalid sorbier-frais:test
      - name: Test de fumée
        run: |
          curl --fail --silent --show-error --retry 10 --retry-delay 2 --retry-all-errors \
            http://127.0.0.1:3000/health
      - name: Journaux du conteneur en cas d'échec
        if: failure()
        run: docker logs api

Ce job vérifie ce que les tests unitaires ne voient pas : le Dockerfile construit, l'application démarre dans l'image, toutes ses dépendances sont présentes. La route /health de sorbier-frais répond même sans base de données ; une route qui vérifie aussi la base (/health/ready) se teste avec un service MySQL.

Choisir un registre

Un registre stocke les images et les distribue aux serveurs.

RegistreAdresseAuthentification dans la CI
GitHub Container Registryghcr.io/proprietaire/imageGITHUB_TOKEN avec packages: write, aucun secret à créer
Docker Hubdocker.io/compte/imageJeton d'accès Docker Hub enregistré comme secret
Registre de GitLabregistry.gitlab.com/groupe/projetCI_REGISTRY_USER et CI_REGISTRY_PASSWORD, fournis automatiquement
Registres des cloudsAWS ECR, Azure ACR, Google Artifact RegistryOIDC, de préférence (chapitre 8)
HarborAuto-hébergé dans l'entrepriseCompte robot

Pour un projet sur GitHub, ghcr.io est le plus simple : le jeton du workflow suffit. Une image publiée est privée par défaut ; le serveur qui la récupère doit s'authentifier, ou on la rend publique dans les réglages du paquet.

Étiqueter les images

Une étiquette (tag) est un nom donné à une image : ghcr.io/sorbier/sorbier-frais:2.4.0. Une même image peut porter plusieurs étiquettes.

ÉtiquetteExempleMobile ou fixeUsage
Empreinte du commitsha-3f9c2e1FixeSavoir exactement quel code tourne, déployer en recette
Version sémantique complète2.4.0Fixe (par convention)Déployer une version publiée en production
Version partielle2.4, 2Mobile : suit le dernier correctifPour ceux qui acceptent les mises à jour mineures
BranchemainMobileLa dernière construction de main
latestlatestMobileCommodité pour essayer ; jamais pour déployer
Empreinte du contenu (digest)@sha256:9b2f…Immuable, garanti par le registreLa référence la plus sûre pour la production

Le workflow complet : construire, étiqueter, publier

yaml
name: Image Docker

on:
  push:
    branches: [main]
    tags: ['v*']
  pull_request:

permissions:
  contents: read

env:
  IMAGE: ghcr.io/sorbier/sorbier-frais

jobs:
  image:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
    steps:
      - uses: actions/checkout@v7

      - name: Préparer Buildx
        uses: docker/setup-buildx-action@v4

      - name: Se connecter au registre
        if: github.event_name != 'pull_request'
        uses: docker/login-action@v4
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Calculer les étiquettes
        id: meta
        uses: docker/metadata-action@v6
        with:
          images: ${{ env.IMAGE }}
          tags: |
            type=sha
            type=ref,event=branch
            type=ref,event=pr
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}

      - name: Construire et publier
        uses: docker/build-push-action@v7
        with:
          context: .
          push: ${{ github.event_name != 'pull_request' }}
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
ÉtapeRôle
permissions: packages: writeAutorise le jeton à pousser dans ghcr.io, pour ce job seulement
docker/setup-buildx-actionPrépare Buildx, le moteur de construction moderne de Docker (cache, plusieurs architectures)
docker/login-actionSe connecte au registre ; ignorée pour les pull requests, qui ne publient rien
docker/metadata-actionCalcule les étiquettes selon l'événement et ajoute des étiquettes OCI (source, commit, date)
docker/build-push-actionConstruit, et pousse si push vaut true
cache-from et cache-to: type=ghaGarde les couches construites dans le cache de GitHub Actions : une construction sans changement de dépendances prend quelques secondes

Les étiquettes produites selon l'événement :

ÉvénementÉtiquettes
Pull request n° 42pr-42 et sha-… (construites, pas poussées)
push sur main au commit 3f9c2e1main, sha-3f9c2e1
Tag v2.4.02.4.0, 2.4, latest, sha-…

Pour un tag de version, docker/metadata-action ajoute aussi latest par défaut (réglage flavor: latest=auto). Ce n'est pas gênant tant qu'on ne déploie jamais latest ; pour ne pas la produire du tout, ajoute flavor: latest=false dans le with: de l'étape.

L'adresse de l'image est écrite en minuscules dans env.IMAGE : un registre refuse les majuscules, et le nom d'un compte GitHub peut en contenir.

Analyser l'image avant de la déployer

Après la construction, un scanner d'images (Trivy, Grype, Docker Scout) compare les paquets de l'image aux bases de vulnérabilités (analyse des images). On le règle pour faire échouer le pipeline sur les failles critiques corrigeables, et on publie le rapport comme artefact. Si tu utilises une action tierce pour cela, épingle-la sur une empreinte de commit : c'est justement un scanner de ce type dont les étiquettes ont été détournées en 2026.

Avec GitLab CI

GitLab construit l'image dans un conteneur Docker qui parle à un service Docker-in-Docker (docker:dind) :

yaml
image-docker:
  stage: build
  image: docker:29-cli
  services:
    - docker:29-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_SHORT_SHA"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

image-version:
  stage: build
  image: docker:29-cli
  services:
    - docker:29-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  before_script:
    - echo "$CI_REGISTRY_PASSWORD" | docker login -u "$CI_REGISTRY_USER" --password-stdin "$CI_REGISTRY"
  script:
    - docker build -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" .
    - docker push "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG"
  rules:
    - if: $CI_COMMIT_TAG

DOCKER_TLS_CERTDIR active la connexion chiffrée entre le client et le service Docker. Le mot de passe passe par --password-stdin pour ne pas apparaître dans la liste des processus. Docker-in-Docker demande un runner en mode privilégié : c'est le cas des runners partagés de GitLab.com, mais l'équipe qui gère un runner interne peut préférer un outil de construction sans démon Docker, comme Buildah.

Les pièges classiques

SymptômeCauseCorrection
La construction envoie « 850 Mo de contexte » et dure longtempsnode_modules et .git partent dans le contexteUn .dockerignore
npm ci est rejoué à chaque constructionCOPY . . avant npm ci : le moindre changement de code invalide le cacheCopier package*.json, npm ci, puis le code
exec format error au démarrage sur le serveurImage construite sur un Mac à processeur ARM, serveur en x86-64Construire dans la CI (runners x86-64), ou --platform linux/amd64, ou une image multi-architecture
denied: permission_denied: write_package au pushLe job n'a pas packages: writeAjouter la permission au job
Une image différente en recette et en productionL'image est reconstruite pour la productionConstruire une fois, promouvoir la même image (chapitre 11)
Un scanner signale 200 faillesImage de base complète et ancienneImage slim, reconstruction régulière, Dependabot sur le Dockerfile

Exercices

Exercice 1 sur 4 Corriger un Dockerfile Réussi À revoir

Trouve les cinq problèmes de ce Dockerfile destiné à la production.

dockerfile
FROM node:latest
WORKDIR /app
COPY . .
ENV DB_PASSWORD=Sorbier2026!
RUN npm install
EXPOSE 3000
CMD npm start
Voir la réponseMasquer la réponse de l'exercice 1
  1. node:latest : la version change sans prévenir et l'image de base est complète. Utiliser une version fixe et réduite : node:24-slim.
  2. COPY . . avant l'installation : chaque modification du code rejoue l'installation des dépendances, et sans .dockerignore, node_modules, .git et .env partent dans l'image. Copier d'abord package.json et package-lock.json, installer, puis copier le code, avec un .dockerignore.
  3. ENV DB_PASSWORD=… : un secret inscrit dans l'image, lisible par quiconque la récupère. Le fournir au démarrage du conteneur, et changer ce mot de passe.
  4. npm install : versions non garanties et dépendances de développement incluses. Utiliser npm ci --omit=dev.
  5. Le conteneur tourne en root : ajouter USER node.

Bonus : CMD npm start (forme texte) lance un shell, puis npm, puis Node.js, qui reçoit mal le signal d'arrêt ; préférer CMD ["node", "src/server.js"].

Tu avais trouvé ?

Exercice 2 sur 4 Quelles étiquettes ? Réussi À revoir

Avec le workflow complet de ce chapitre, quelles étiquettes sont poussées dans le registre dans chaque cas ?

  1. Hugo ouvre la pull request n° 57.
  2. La pull request est fusionnée ; le commit de fusion commence par a41d0c9.
  3. Lina pousse le tag v2.5.0 sur ce même commit.
Voir la réponseMasquer la réponse de l'exercice 2
  1. Aucune : l'image est construite (on vérifie que le Dockerfile fonctionne), mais push vaut false pour une pull request. Les étiquettes calculées, pr-57 et sha-…, ne sont pas poussées.
  2. main et sha-a41d0c9.
  3. 2.5.0, 2.5, latest et sha-a41d0c9 : pour un tag de version, l'action ajoute latest par défaut (flavor: latest=auto). C'est le même commit : l'étiquette sha-a41d0c9 désigne la même image.

Tu avais trouvé ?

Exercice 3 sur 4 Lire un échec de publication Réussi À revoir

Le job image échoue sur l'étape Construire et publier :

text
#12 exporting to image
#12 pushing layers 0.4s done
#12 ERROR: failed to push ghcr.io/sorbier/sorbier-frais:main: denied: permission_denied: write_package
ERROR: failed to build: failed to solve: failed to push ghcr.io/sorbier/sorbier-frais:main: denied: permission_denied: write_package
Error: buildx failed with: ERROR: failed to build: failed to solve: failed to push ghcr.io/sorbier/sorbier-frais:main: denied: permission_denied: write_package

Le workflow commence par permissions: contents: read et le job n'a pas de bloc permissions. Explique et corrige.

Voir la réponseMasquer la réponse de l'exercice 3

La construction a réussi ; c'est l'envoi au registre qui est refusé : write_package signifie que le jeton n'a pas le droit d'écrire un paquet. Le bloc permissions du workflow ne donne que contents: read, donc packages vaut none pour tous les jobs. On ajoute au job :

yaml
    permissions:
      contents: read
      packages: write

Si l'erreur persiste, vérifier dans les réglages du paquet sur GitHub que le dépôt a bien accès au paquet (Manage Actions access), ce qui arrive quand le paquet a été créé à la main ou par un autre dépôt.

Tu avais trouvé ?

Exercice 4 sur 4 « Ça marche sur mon Mac » Réussi À revoir

Hugo construit l'image sur son MacBook à puce Apple, la pousse à la main, et la démarre sur le serveur de recette (processeur x86-64). Le conteneur s'arrête aussitôt ; docker logs affiche exec /usr/local/bin/docker-entrypoint.sh: exec format error. Explique, et dis pourquoi le pipeline évite ce problème.

Voir la réponseMasquer la réponse de l'exercice 4

L'image a été construite pour l'architecture du Mac (linux/arm64) ; le serveur en linux/amd64 ne peut pas exécuter ses binaires, d'où exec format error. Construite par le pipeline sur un runner x86-64, l'image a l'architecture du serveur. De plus, aucune image ne doit être poussée à la main : seules les images construites et testées par le pipeline vont dans le registre, avec une étiquette qui renvoie au commit. Si l'équipe a des serveurs des deux architectures, Buildx construit une image multi-architecture (platforms: linux/amd64,linux/arm64).

Tu avais trouvé ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Environnements : développement, recette, productionRevoir le sommaire du cours