GitHub Actions pour builder et déployer des images Docker : guide complet
Comment fonctionnent les GitHub Actions, buildx et BuildKit, les stratégies de déploiement, la gestion des secrets, les registres Docker, le cache de layers et l'optimisation des builds multi-stage.
Comment fonctionnent les GitHub Actions
Le modèle événementiel
GitHub Actions est une plateforme CI/CD construite sur une architecture événementielle. La chaîne fondamentale est :
Événement → Workflow → Job(s) → Step(s) → Action ou commande shell
Un événement est une activité spécifique dans un repository qui déclenche un workflow :
- Événements du repo :
push,pull_request,issues,release,create,delete, etc. Beaucoup supportent un filtrage partypes(ex.pull_request: types: [opened, synchronize, reopened]). - Planifiés : syntaxe cron POSIX via
on.schedule(intervalle minimum : 5 minutes). - Manuels :
workflow_dispatch(avec des inputs typés : choice, boolean, number, string, environment) et via l’API REST. - Appels de workflows réutilisables :
workflow_callpour composer des workflows. - Chaînage :
workflow_runse déclenche quand un autre workflow se termine.
Les événements peuvent être filtrés par :
- Branches/tags :
branches,branches-ignore,tags,tags-ignore(avec des patterns glob). - Chemins :
paths,paths-ignore(ex.paths: ['**.js']). Limité aux 300 premiers fichiers modifiés et 1 000 commits dans le diff.
Un workflow est un processus automatisé configurable défini dans un fichier YAML dans .github/workflows/. Un repo peut avoir plusieurs workflows.
Un job est un ensemble de steps qui s’exécutent sur le même runner. Par défaut les jobs tournent en parallèle. Les dépendances entre jobs se déclarent avec le mot-clé needs.
Un step est une unité de travail individuelle au sein d’un job. Les steps s’exécutent séquentiellement sur le même runner. Chaque step est soit une commande shell (via run), soit une action réutilisable (via uses). Les steps partagent le filesystem du runner.
Une action est une unité de code pré-définie et réutilisable (ex. actions/checkout clone le repo, actions/setup-node installe Node.js). Elles viennent du GitHub Marketplace, du même repo, d’autres repos publics, ou d’images Docker.
L’environnement d’exécution : les runners
Un runner est un serveur (VM ou conteneur) qui exécute les jobs. Chaque job tourne sur un runner fraîchement provisionné — un environnement propre à chaque fois.
GitHub-hosted vs self-hosted
| Aspect | GitHub-hosted | Self-hosted |
|---|---|---|
| Gestion | Entièrement géré par GitHub | Vous gérez la machine |
| Provisionnement | VM neuve par job | Machine persistante |
| OS disponibles | Ubuntu, Windows, macOS | N’importe quel OS avec le runner installé |
| Limite de temps par job | 6 heures | 5 jours |
| Coût | Minutes incluses par plan (gratuit pour les repos publics) | Gratuit à utiliser, vous payez le hardware |
Specs des runners standard (repos publics)
| Runner | CPU | RAM | SSD |
|---|---|---|---|
| Linux (standard) | 4 cores | 16 GB | 14 GB |
| Linux arm64 | 4 cores | 16 GB | 14 GB |
| Windows | 4 cores | 16 GB | 14 GB |
| macOS (Intel) | 4 cores | 14 GB | 14 GB |
| macOS (Apple Silicon) | 3 cores (M1) | 7 GB | 14 GB |
Pour les repos privés, les specs Linux/Windows x64 sont réduites : 2 cores, 8 GB RAM.
Les runners GitHub-hosted incluent des logiciels pré-installés (Git, Docker sur Linux, runtimes de langages, outils de build). La liste complète est dans le repo actions/runner-images.
Les larger runners (plans Team et Enterprise Cloud) offrent plus de CPU, des GPU, des IPs statiques et des images custom.
À quoi un runner a-t-il accès ?
Workspace
GITHUB_WORKSPACE: répertoire de travail par défaut oùactions/checkoutclone le repo.- Chaque job a un workspace frais. Les steps au sein du même job partagent le filesystem.
RUNNER_TEMP: répertoire temporaire nettoyé avant et après chaque job.
GITHUB_TOKEN
Un token généré automatiquement, de courte durée, disponible dans chaque workflow :
- Accessible via
${{ secrets.GITHUB_TOKEN }}ou${{ github.token }}. - Scopé au repo qui exécute le workflow.
- Permissions configurables au niveau workflow et job via la clé
permissions. - Scopes disponibles :
actions,checks,contents,deployments,id-token,issues,packages,pages,pull-requests,security-events,statuses— chacun àread,write, ounone. - Rate limit API : 1 000 requêtes/heure/repo.
Secrets
- Stockés chiffrés (Libsodium sealed box) au niveau organisation, repo, ou environnement.
- Accessibles via
${{ secrets.SECRET_NAME }}. - Jamais affichés dans les logs (automatiquement masqués).
- Disponibles pour le runner uniquement pendant l’exécution.
Variables d’environnement
Variables custom à trois scopes : workflow (env:), job (jobs.<id>.env:), step (steps[*].env:). Précédence : Step > Job > Workflow.
Variables par défaut clés :
| Variable | Description |
|---|---|
GITHUB_WORKSPACE | Répertoire de checkout |
GITHUB_SHA | SHA du commit déclencheur |
GITHUB_REF | Ref de branche ou tag |
GITHUB_REPOSITORY | owner/repo |
GITHUB_RUN_ID | ID unique du run |
RUNNER_OS | Linux, Windows, ou macOS |
RUNNER_ARCH | X86, X64, ou ARM64 |
Artifacts
Fichiers persistés après la fin d’un workflow (outputs de build, résultats de tests, logs). Upload via actions/upload-artifact, download via actions/download-artifact. Partageables entre jobs via needs:.
Limites de stockage par plan (par mois) :
| Plan | Stockage |
|---|---|
| Free | 500 MB |
| Pro | 1 GB |
| Team | 2 GB |
| Enterprise | 50 GB |
Cache
Cache des dépendances entre workflow runs via actions/cache ou le caching intégré des actions setup-*.
- Clé de cache : jusqu’à 512 caractères, avec
restore-keyspour le matching partiel. - Taille limite : 10 GB par repo par défaut.
- Éviction : entrées non accédées depuis 7 jours supprimées ; quand le repo atteint sa limite, les entrées les plus anciennes sont évincées.
- Scope : un workflow peut restaurer des caches de la branche courante, de la branche par défaut (
main), et (pour les PRs) de la branche de base. Il ne peut pas accéder aux caches des branches enfants ou sœurs.
Réseau
- Accès internet sortant complet (HTTP/HTTPS, SSH, etc.).
- Infrastructure Azure.
- ICMP bloqué (
pingettraceroutene fonctionnent pas). /etc/hostsbloque les pools de minage de crypto et les sites malveillants connus.
Relations entre jobs
Parallélisme (par défaut)
Les jobs tournent en parallèle par défaut. Trois jobs sans dépendances démarrent simultanément.
Dépendances (needs)
jobs:
build:
runs-on: ubuntu-latest
steps: [...]
test:
needs: build # attend que 'build' réussisse
runs-on: ubuntu-latest
steps: [...]
deploy:
needs: [build, test] # attend les deux
runs-on: ubuntu-latest
steps: [...]
Un job avec needs attend que tous les jobs listés réussissent. Pour exécuter même en cas d’échec, utiliser if: always().
Les outputs d’un job peuvent être passés aux jobs dépendants via jobs.<id>.outputs.
Stratégie matrix
Exécute le même job avec plusieurs combinaisons de variables :
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
node: [18, 20, 22]
fail-fast: true # annule les restants si un échoue (défaut : true)
max-parallel: 4 # limite les jobs simultanés
Ceci crée 6 jobs (2 OS × 3 Node). Maximum : 256 jobs matrix par workflow.
Limites de jobs concurrents par plan
| Plan | Total concurrent | Limite macOS |
|---|---|---|
| Free | 20 | 5 |
| Pro | 40 | 5 |
| Team | 60 | 5 |
| Enterprise | 500 | 50 |
Contrôle de concurrence
concurrency:
group: deploy-${{ github.ref }}
cancel-in-progress: true
Garantit qu’un seul workflow/job avec le même groupe de concurrence tourne à la fois.
Structure YAML d’un workflow
# ─── Clés de premier niveau ─────────────────────────────
name: CI Pipeline # Nom affiché dans l'onglet Actions
run-name: Deploy by @${{ github.actor }} # Nom custom pour chaque run
on: # Déclencheur(s)
push:
branches: [main, "release/**"]
paths: ["src/**"]
pull_request:
branches: [main]
types: [opened, synchronize]
schedule:
- cron: "30 5 * * 1-5" # Jours de semaine à 05:30 UTC
workflow_dispatch:
inputs:
environment:
type: choice
options: [staging, production]
permissions: # Permissions GITHUB_TOKEN (global workflow)
contents: read
pull-requests: write
env: # Variables d'environnement globales
NODE_ENV: production
defaults: # Shell/working-directory par défaut
run:
shell: bash
working-directory: ./app
concurrency: # Empêcher les runs en double
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
# ─── Jobs ────────────────────────────────────────────────
jobs:
build:
name: Build Application
runs-on: ubuntu-latest # Choix du runner
timeout-minutes: 30 # Timeout du job
permissions: # Permissions token au niveau job
contents: read
environment: staging # Environnement de déploiement
env: # Variables d'env du job
BUILD_TYPE: release
outputs: # Passer des données aux jobs dépendants
version: ${{ steps.ver.outputs.version }}
strategy: # Stratégie matrix
matrix:
node: [18, 20]
os: [ubuntu-latest, windows-latest]
container: # Exécuter les steps dans un conteneur Docker
image: node:18
ports:
- 8080:80
services: # Conteneurs sidecar (bases de données, etc.)
postgres:
image: postgres:15
env:
POSTGRES_PASSWORD: test
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4 # Utiliser une action publiée
- name: Setup Node.js
uses: actions/setup-node@v4
with: # Paramètres d'entrée pour l'action
node-version: ${{ matrix.node }}
cache: npm
- name: Install dependencies
run: npm ci # Commande shell
- name: Get version
id: ver # ID du step pour référencer les outputs
run: echo "version=$(node -p 'require("./package.json").version')" >> "$GITHUB_OUTPUT"
- name: Run tests
run: npm test
env: # Variables d'env du step
CI: true
continue-on-error: true # Ne pas faire échouer le job si ce step échoue
timeout-minutes: 10
- name: Conditional step
if: github.ref == 'refs/heads/main'
run: echo "On main branch"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 5
deploy:
name: Deploy
needs: build # Attend que 'build' réussisse
runs-on: ubuntu-latest
if: github.event_name == 'push'
steps:
- name: Download artifact
uses: actions/download-artifact@v4
with:
name: build-output
- name: Use output from build
run: echo "Deploying version ${{ needs.build.outputs.version }}"
Expressions : ${{ <expression> }} avec opérateurs ==, !=, &&, ||, !.
Contextes disponibles : github, env, vars, secrets, inputs, job, jobs, steps, matrix, needs, strategy, runner.
Fonctions intégrées : contains(), startsWith(), endsWith(), format(), hashFiles(), toJSON(), fromJSON(), always(), success(), failure(), cancelled().
Références d’actions (uses) :
- Action publiée :
actions/checkout@v4 - SHA spécifique :
actions/checkout@a81bbbf... - Image Docker :
docker://alpine:3.18 - Action locale :
./path/to/action
Workflows réutilisables : appelés via jobs.<id>.uses: owner/repo/.github/workflows/file.yml@ref. Nesting jusqu’à 4 niveaux. Passer des inputs via with: et des secrets via secrets: (ou secrets: inherit).
Limites clés résumées
| Ressource | Limite |
|---|---|
| Temps d’exécution d’un job (GitHub-hosted) | 6 heures |
| Temps d’exécution d’un job (self-hosted) | 5 jours |
| Durée d’un workflow run | 35 jours |
| Temps d’attente en queue avant annulation | 24 heures |
| Jobs matrix par workflow | 256 |
| Cache par repo | 10 GB |
| Éviction du cache | Après 7 jours sans accès |
| Rate limit API GITHUB_TOKEN | 1 000 req/h/repo |
Buildx et BuildKit
Qu’est-ce que BuildKit ?
BuildKit est le moteur de build d’images conteneurs de nouvelle génération, développé par le projet Moby (le projet open-source derrière Docker). Il remplace entièrement l’ancien backend de docker build. BuildKit est le builder par défaut depuis Docker Desktop et Docker Engine v23.0+.
À la base, BuildKit utilise un format LLB (Low-Level Build) — un graphe de dépendances content-addressable qui sert de représentation binaire intermédiaire. Un composant frontend (typiquement le frontend Dockerfile, distribué comme image conteneur) convertit les Dockerfiles en instructions LLB.
Qu’est-ce que buildx ?
Buildx est un plugin CLI Docker (docker buildx) qui expose toutes les fonctionnalités de BuildKit via une interface familière type docker build. La relation clé :
Buildx utilise toujours BuildKit. Buildx est la couche CLI, BuildKit est le moteur d’exécution.
| Aspect | Legacy docker build | docker buildx build |
|---|---|---|
| Backend | Builder legacy (séquentiel) | BuildKit (graphe concurrent) |
| Multi-plateforme | Non supporté | Support complet (QEMU, cross-compilation, nœuds multiples) |
| Export/import de cache | Non supporté | Registry, local, S3, GHA, inline, Azure |
| Drivers de build | Docker daemon seulement | docker, docker-container, kubernetes, remote |
| Instances de builder | Un seul par défaut | Multiples builders isolés |
| Parallélisme | Exécution séquentielle des layers | Étapes indépendantes totalement concurrentes |
| Stages non utilisés | Construits quand même | Automatiquement ignorés |
| Tracking du cache | Heuristiques sur timestamps | Checksums content-addressable |
Fonctionnalités clés de buildx
Builds multi-plateforme
Les images multi-plateforme utilisent une manifest list pointant vers plusieurs manifests spécifiques à une architecture. Quand on pull, le registre sélectionne automatiquement la bonne variante pour l’hôte.
docker buildx build --platform linux/amd64,linux/arm64 -t myimage:latest --push .
Trois stratégies :
-
Émulation QEMU — Le plus simple à mettre en place. Aucun changement au Dockerfile requis. Attention : peut être significativement plus lent pour les tâches intensives en calcul. Docker Desktop embarque QEMU ; sur Linux :
docker run --privileged --rm tonistiigi/binfmt --install all -
Nœuds natifs multiples — Ajouter des nœuds hardware réels à un builder avec
--append. Chaque nœud build nativement pour son architecture. Meilleure performance, plus d’infrastructure à gérer. -
Cross-compilation — Utilise les build args prédéfinis (
BUILDPLATFORM,TARGETPLATFORM,TARGETOS,TARGETARCH). Pin les images de base avecFROM --platform=$BUILDPLATFORM. Évite totalement l’émulation. Spécifique au langage (ex.GOOS/GOARCHen Go).
Backends de cache
BuildKit supporte six backends de stockage de cache :
| Backend | Description | Idéal pour |
|---|---|---|
| Inline | Embarque les métadonnées de cache dans l’image de sortie | Workflows simples single-image |
| Registry | Stocke le cache comme image séparée dans un registre | CI/CD multi-branches avec cache partagé |
| Local | Écrit le cache sur le filesystem local | Développement, builds single-machine |
GitHub Actions (gha) | Utilise l’API cache native de GHA | Pipelines GitHub Actions |
| S3 | Stockage S3 AWS | Déploiements enterprise cloud-native |
| Azure Blob Storage | Stockage Azure | Déploiements enterprise Azure |
docker buildx build --push -t registry/image:tag \
--cache-from type=registry,ref=registry/cache-image \
--cache-to type=registry,ref=registry/cache-image,mode=max .
Le mode min (défaut) stocke uniquement le cache des layers finaux. Le mode max stocke le cache de tous les layers intermédiaires — plus gros mais indispensable pour les builds multi-stage.
Drivers de build
| Driver | Fonctionnement | Quand l’utiliser |
|---|---|---|
| docker | Utilise la bibliothèque BuildKit du daemon Docker | Défaut ; builds simples single-plateforme |
| docker-container | Lance un conteneur BuildKit isolé via Docker | Builds multi-plateforme, export de cache, isolation CI/CD |
| kubernetes | Crée des pods BuildKit dans un cluster K8s | Builds distribués à l’échelle enterprise |
| remote | Se connecte à un daemon BuildKit externe | BuildKit opéré indépendamment (infra de build partagée) |
docker buildx create --name mybuilder --driver docker-container --use
docker buildx ls # lister les builders (* marque l'actif)
docker buildx inspect
Alternatives à buildx
| Outil | Sans Docker Daemon ? | Sans Dockerfile ? | Idéal pour |
|---|---|---|---|
| Buildx/BuildKit | Besoin du daemon (ou driver K8s) | Non (utilise les Dockerfiles) | Usage général, multi-plateforme |
| Kaniko (Google) | Oui | Non | Legacy (archivé juin 2025) |
| Podman/Buildah (Red Hat) | Oui (daemonless) | Optionnel (Buildah) | Environnements rootless/daemonless |
| Jib (Google) | Oui | Oui | Projets Java/JVM |
| ko (Google) | Oui | Oui | Projets Go |
| Nix | Oui | Oui | Builds reproductibles bit-for-bit |
| pack (Cloud Native Buildpacks) | Besoin de Docker | Oui | Équipes plateforme, polyglotte |
| Melange + apko (Chainguard) | Oui | Oui | Sécurité supply-chain |
Kaniko : construit des images dans des conteneurs/pods K8s sans daemon Docker. Archivé par Google depuis juin 2025. Pour les nouveaux projets, BuildKit avec le driver Kubernetes est le remplacement recommandé.
Podman/Buildah : moteur de conteneur daemonless et rootless par design. Buildah offre un contrôle plus fin sur les layers — peut committer plusieurs changements en un seul layer.
Jib : construit des images optimisées pour Java directement depuis Maven ou Gradle, sans Docker installé. Sépare intelligemment dépendances, ressources et classes en layers distincts.
ko : builder d’images OCI natif pour Go. Pas de Dockerfile. Produit des images minimales (base distroless + binaire Go). Idéal pour Go + Kubernetes.
Nix : traite les builds comme des fonctions pures de leurs entrées. Images conteneurs reproductibles bit-for-bit.
pack : transforme le code source en images OCI automatiquement via des buildpacks. Auto-détecte le langage et le framework. Projet CNCF, origine Heroku.
Buildx dans GitHub Actions
Docker fournit une suite d’actions officielles qui fonctionnent ensemble :
jobs:
build:
runs-on: ubuntu-latest
steps:
# 1. (Optionnel) Checkout — uniquement si contexte local
- uses: actions/checkout@v4
# 2. (Optionnel) QEMU pour l'émulation cross-plateforme
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
# 3. Configurer buildx
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# 4. Se connecter au registre
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 5. Build et push
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
platforms: linux/amd64,linux/arm64
tags: user/app:latest
cache-from: type=gha
cache-to: type=gha,mode=max
docker/setup-buildx-action : crée et boot une instance de builder BuildKit. Utilise le driver docker-container par défaut, ce qui permet les builds multi-plateforme et l’export de cache.
docker/build-push-action : exécute le docker buildx build en utilisant le builder configuré par setup-buildx-action. Inputs clés : context, file, push, load, tags, platforms, build-args, secrets, cache-from, cache-to.
Comment elles fonctionnent ensemble :
setup-buildx-actioncrée un builder driverdocker-containeret le rend disponible.build-push-actiondétecte le builder actif et l’utilise avec toutes les capacités BuildKit.setup-qemu-action(si inclus) enregistre les handlers QEMU pour émuler les architectures non-natives.login-actionstocke les credentials du registre pour le push.
Le backend de cache GHA (type=gha) s’intègre directement avec l’infrastructure de cache native de GitHub Actions, évitant le besoin d’un stockage externe.
Déployer : du code mergé à la production
Jusqu’ici on a vu comment GitHub Actions construit des images Docker. Mais construire une image ne sert à rien si elle reste dans un registre. La deuxième moitié du pipeline — le déploiement — pose des questions fondamentalement différentes : comment mettre à jour une application en production sans interrompre le service ? Comment s’assurer qu’on peut revenir en arrière si quelque chose se passe mal ? Comment empêcher un déploiement accidentel en production un vendredi soir ?
Cette section part de la vue d’ensemble (comment structurer un pipeline complet) et descend progressivement dans les détails : les stratégies de déploiement, les outils de GitHub pour contrôler qui déploie quoi et quand, et les différentes approches selon l’infrastructure cible.
Anatomie d’un pipeline CI/CD
Avant de parler de stratégies de déploiement, il faut comprendre la structure générale d’un pipeline. Un pipeline CI/CD se découpe en deux phases distinctes :
- CI (Continuous Integration) : vérifier que le code est correct. C’est la partie lint, tests, build de l’image.
- CD (Continuous Deployment/Delivery) : amener le code vérifié en production. C’est la partie déploiement.
La séparation est importante parce que les deux phases n’ont pas les mêmes contraintes. Le CI doit être rapide et donner du feedback au développeur. Le CD doit être fiable et contrôlé — on ne veut pas la même permissivité pour “lancer les tests sur une PR” et “déployer en production”.
Un pipeline typique enchaîne ces étapes :
lint → test → build image → push au registre → deploy staging → smoke tests → deploy production
Chaque flèche est une gate : si l’étape échoue, le pipeline s’arrête. On ne construit pas d’image si les tests échouent. On ne déploie pas en production si les smoke tests sur staging ne passent pas.
Smoke test ? Le terme vient de l’électronique : quand on branche un circuit pour la première fois, si ça fume, c’est qu’il y a un problème fondamental. Un smoke test est un test rapide et superficiel qui vérifie que les fonctionnalités essentielles marchent après un déploiement : l’app répond en HTTP 200, la page de login s’affiche, l’API retourne une réponse valide sur un endpoint clé, la connexion à la base de données fonctionne. On ne teste pas tout en détail — on détecte les pannes grossières (l’app ne démarre pas, le serveur renvoie des 500, une variable d’environnement manque) avant d’aller plus loin.
Quelques principes structurants :
Build once, deploy many. L’image construite à l’étape “build” est la même qui sera déployée en staging, puis en production. On ne rebuild jamais entre environnements. Cela garantit qu’on teste exactement ce qu’on va déployer — pas une version recompilée qui pourrait différer subtilement.
Fail fast. Les étapes les plus rapides et les plus susceptibles d’échouer passent en premier. Le linting prend quelques secondes et attrape les erreurs évidentes ; les tests unitaires prennent une minute ; le build de l’image prend plusieurs minutes. Inutile de construire une image de 2 GB si un import est cassé.
Pipeline PR vs pipeline deploy. Sur une pull request, on exécute lint + test seulement — le but est de donner du feedback rapide au développeur. Le pipeline complet (build + push + deploy) ne se déclenche qu’au merge sur main.
Voici ce que ça donne dans un workflow GitHub Actions :
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm run lint
test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- run: npm test
# Tout ce qui suit ne tourne que sur main, pas sur les PRs
build-and-push:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
needs: test
outputs:
image-tag: ${{ github.sha }}
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
tags: |
ghcr.io/org/myapp:${{ github.sha }}
ghcr.io/org/myapp:latest
cache-from: type=gha
cache-to: type=gha,mode=max
deploy-staging:
needs: build-and-push
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.example.com
steps:
- name: Deploy
run: ./deploy.sh staging ${{ needs.build-and-push.outputs.image-tag }}
smoke-tests:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- run: ./smoke-test.sh https://staging.example.com
deploy-production:
needs: smoke-tests
runs-on: ubuntu-latest
environment:
name: production
url: https://example.com
steps:
- name: Deploy
run: ./deploy.sh production ${{ needs.build-and-push.outputs.image-tag }}
Points à noter : le tag d’image (github.sha) est passé entre les jobs via outputs et needs — on ne le recalcule pas, on ne rebuild pas. Et le if sur build-and-push fait que les PRs ne déclenchent que lint et test.
Les environments GitHub : contrôler qui déploie quoi
Dans le workflow ci-dessus, les jobs deploy-staging et deploy-production déclarent un environment. Ce n’est pas cosmétique — les environments sont un mécanisme de GitHub pour ajouter des gardes-fous autour des déploiements.
Un environment se configure dans Settings > Environments du repository. On y définit :
Des règles de protection qui conditionnent l’exécution du job :
- Reviewers requis — Jusqu’à 6 personnes ou équipes doivent approuver avant que le job puisse s’exécuter. Concrètement, quand le pipeline atteint le job de déploiement, il se met en pause et envoie une notification aux reviewers. Le pipeline ne reprend qu’après approbation. L’option “prevent self-review” empêche la personne qui a poussé le code de s’auto-approuver.
- Timer d’attente — Un délai (en minutes) avant que le job puisse s’exécuter, même après approbation. Utile pour laisser le temps de réagir en cas de doute.
- Restriction par branches — Seules certaines branches peuvent déclencher un déploiement vers cet environment. Par exemple, seul
mainpeut déployer en production, mais n’importe quelle branchefeature/*peut déployer en staging.
Des secrets et variables scopés à l’environnement. Staging et production ont typiquement des URLs de base de données, des clés API et des credentials différents. Plutôt que de gérer ça avec des conditionnels dans le workflow (if env == production then ...), on définit un secret DATABASE_URL dans chaque environment avec des valeurs différentes. Le workflow reste identique — c’est l’environment qui injecte les bonnes valeurs. Et ces secrets ne sont accessibles qu’une fois les règles de protection passées : un job en staging ne verra jamais les secrets de production.
En pratique, la configuration minimale recommandée :
- Un environment staging sans reviewers requis (déploiement automatique au merge sur main).
- Un environment production avec au moins un reviewer requis et une restriction sur la branche
main.
Comment remplacer l’ancienne version par la nouvelle
Maintenant qu’on sait quand et qui déclenche un déploiement, reste la question du comment. Quand on déploie une nouvelle version d’une application, il faut bien basculer de l’ancienne à la nouvelle. Cette transition est le moment critique : c’est là que les choses peuvent casser. Il existe quatre grandes stratégies, chacune avec un compromis différent entre simplicité, vitesse et sécurité.
Recreate : le plus simple
On arrête toutes les instances de l’ancienne version, puis on démarre les nouvelles. Pendant la transition, l’application est indisponible.
C’est la stratégie à utiliser quand le downtime n’est pas un problème : outils internes, environnements de dev/test, jobs de traitement batch qui ne servent pas de trafic utilisateur. Son avantage est sa simplicité absolue : pas de souci de compatibilité entre versions, pas de routage de trafic à gérer, un état propre à chaque déploiement.
Rolling update : le défaut raisonnable
Plutôt que de tout arrêter d’un coup, les instances sont remplacées progressivement — quelques-unes à la fois. Si on a 10 instances, on en arrête 2, on démarre 2 nouvelles, on vérifie qu’elles sont saines (via un health check — une URL que l’application expose pour dire “je suis prête”), puis on passe aux 2 suivantes, et ainsi de suite.
C’est la stratégie par défaut dans Kubernetes (le type RollingUpdate des Deployments) et dans la plupart des orchestrateurs de conteneurs (AWS ECS, etc.). Elle ne nécessite aucune infrastructure supplémentaire et offre un déploiement sans interruption de service.
La contrepartie : pendant le déploiement, l’ancienne et la nouvelle version coexistent et servent du trafic simultanément. Si la nouvelle version change un format d’API ou un schéma de base de données de manière incompatible, ça peut poser problème. Il faut donc maintenir une compatibilité ascendante entre versions successives — par exemple, ajouter un champ avant de l’utiliser, plutôt que de le renommer d’un coup.
L’autre inconvénient : le rollback (retour à la version précédente) n’est pas instantané. Il faut relancer un rolling update en sens inverse, ce qui prend le même temps que le déploiement initial.
Blue-green : le rollback instantané
On maintient deux environnements identiques, appelés par convention “blue” et “green”. À tout moment, l’un sert le trafic réel (disons blue), l’autre est inactif (green). Pour déployer :
- On déploie la nouvelle version sur l’environnement inactif (green).
- On teste green complètement — tests d’intégration, smoke tests, vérification manuelle si besoin.
- On bascule le trafic de blue vers green (typiquement en changeant un DNS, un load balancer, ou une règle de routage).
- Si quelque chose ne va pas, on rebascule sur blue en quelques secondes.
L’avantage majeur est le rollback instantané : blue est toujours là, intacte, prête à reprendre le trafic. C’est la stratégie de choix pour les applications critiques où chaque minute de panne coûte cher.
L’inconvénient est le coût : on maintient en permanence le double de l’infrastructure. Et les migrations de base de données demandent une coordination particulière, puisque les deux environnements partagent souvent la même base de données — il faut que le schéma soit compatible avec les deux versions pendant la transition.
Canary : le test en conditions réelles
Le déploiement canary (du nom des canaris dans les mines de charbon, qui servaient de sentinelles) expose la nouvelle version à un petit sous-ensemble du trafic réel — par exemple 5% des requêtes — avant de la généraliser.
Le processus est progressif :
- On déploie la nouvelle version sur une fraction des instances (ou on configure un routage pondéré).
- On observe les métriques : taux d’erreur, latence, utilisation mémoire.
- Si tout est stable, on augmente progressivement le pourcentage : 5% → 25% → 50% → 100%.
- Si les métriques dégradent, on redirige tout le trafic vers l’ancienne version.
Cette stratégie offre la validation la plus fiable puisqu’on teste avec du vrai trafic. Mais elle a un coût d’entrée : il faut un mécanisme de routage de trafic pondéré (un service mesh comme Istio ou Linkerd, ou un ingress controller configuré pour le split de trafic) et une infrastructure de monitoring suffisamment mature pour détecter rapidement les anomalies. Sans métriques fiables, un canary est inutile — on ne saurait pas quand augmenter le pourcentage ni quand faire marche arrière.
Quelle stratégie choisir ?
| Critère | Recreate | Rolling | Blue-Green | Canary |
|---|---|---|---|---|
| Downtime | Oui | Non | Non | Non |
| Coût infrastructure | x1 | x1 | x2 | x1 + routage |
| Vitesse de rollback | Lente | Lente | Instantanée | Rapide |
| Complexité | Minimale | Faible | Moyenne | Élevée |
| Prérequis | Aucun | Health checks | Load balancer configurable | Service mesh + monitoring |
En pratique : rolling update par défaut — c’est le choix raisonnable qui convient à la majorité des cas. Blue-green quand le rollback instantané est critique (paiement, auth). Canary quand on a une infrastructure d’observabilité mature et un trafic suffisant pour que les métriques aient du sens.
Où déployer : les différentes cibles
La stratégie de déploiement (rolling, blue-green, etc.) est un concept abstrait. Concrètement, le “comment” dépend de l’infrastructure sur laquelle tourne l’application. Voici les cas les plus courants et comment GitHub Actions s’y intègre.
Kubernetes (EKS, GKE, AKS, self-hosted)
Kubernetes est un orchestrateur de conteneurs : il gère le cycle de vie des conteneurs sur un cluster de machines. Quand on dit “déployer sur Kubernetes”, on met à jour un objet Deployment pour pointer vers la nouvelle image, et Kubernetes se charge d’appliquer la stratégie choisie (rolling update par défaut).
- name: Configure kubectl
uses: azure/setup-kubectl@v3
- name: Set context
uses: azure/k8s-set-context@v3
with:
kubeconfig: ${{ secrets.KUBECONFIG }}
- name: Deploy
run: |
kubectl set image deployment/myapp \
myapp=ghcr.io/org/myapp:${{ github.sha }}
kubectl rollout status deployment/myapp --timeout=300s
La commande kubectl set image change le tag de l’image dans le Deployment. kubectl rollout status attend que Kubernetes ait fini de remplacer les anciens pods par les nouveaux, et échoue si le timeout est dépassé (utile pour faire échouer le pipeline en cas de problème).
Pour l’authentification, on utilise OIDC (décrit dans la section Secrets) via aws-actions/configure-aws-credentials pour Amazon EKS ou google-github-actions/auth pour Google GKE, plutôt que de stocker un kubeconfig long-lived dans les secrets.
Les services managés les plus courants : Amazon EKS, Google GKE, Azure AKS. Ils fournissent le control plane Kubernetes ; vous gérez les workloads.
AWS ECS (Elastic Container Service)
ECS est l’orchestrateur de conteneurs propre à AWS — une alternative à Kubernetes, plus simple mais spécifique à l’écosystème AWS. On définit une task definition (qui décrit les conteneurs à lancer) et un service (qui maintient N instances de cette task).
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/deploy-role
aws-region: us-east-1
- name: Deploy to ECS
uses: aws-actions/amazon-ecs-deploy-task-definition@v1
with:
task-definition: task-def.json
service: my-service
cluster: my-cluster
wait-for-service-stability: true
wait-for-service-stability: true fait que le step attend que le service ait fini sa mise à jour (rolling update par défaut dans ECS) avant de rendre la main.
GCP Cloud Run
Cloud Run est un service serverless de Google Cloud : on lui donne une image Docker et il gère tout le reste (scaling, load balancing, TLS). Pas de cluster à gérer, pas de Kubernetes à comprendre. On paie à l’utilisation.
- name: Authenticate to GCP
uses: google-github-actions/auth@v2
with:
workload_identity_provider: ${{ secrets.WIF_PROVIDER }}
service_account: ${{ secrets.SA_EMAIL }}
- name: Deploy to Cloud Run
uses: google-github-actions/deploy-cloudrun@v2
with:
service: my-service
region: us-central1
image: gcr.io/${{ secrets.GCP_PROJECT }}/myapp:${{ github.sha }}
Cloud Run gère automatiquement le rolling update : les nouvelles instances reçoivent du trafic seulement quand elles sont prêtes, les anciennes sont drainées progressivement. Il supporte aussi nativement le traffic splitting (canary) via la CLI gcloud.
VPS via SSH
Pour les cas plus simples — un serveur unique ou une petite infra sans orchestrateur — on peut déployer en SSH. Le pattern classique : le CI construit et pousse l’image au registre, puis se connecte au serveur pour puller et relancer les conteneurs.
- name: Deploy via SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SSH_HOST }}
username: ${{ secrets.SSH_USER }}
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
cd /opt/myapp
docker compose pull
docker compose up -d --remove-orphans
L’important est de toujours puller des images pré-construites plutôt que cloner le repo et builder sur le serveur. Builder en production, c’est consommer des ressources qui devraient servir le trafic, et ça introduit un risque d’échec de build qui laisse l’application dans un état intermédiaire.
L’approche GitOps : séparer le CI du CD
Les exemples ci-dessus montrent un pattern “push” : le CI construit l’image, puis pousse activement le déploiement vers l’infrastructure cible. Ça fonctionne, mais il existe une autre philosophie appelée GitOps, particulièrement répandue dans l’écosystème Kubernetes.
L’idée centrale de GitOps : on ne déploie jamais directement depuis le CI. À la place, l’état désiré de l’infrastructure est déclaré dans un repository Git dédié (le “repo GitOps”), sous forme de manifests Kubernetes (YAML). Un opérateur installé dans le cluster surveille ce repo en permanence et réconcilie l’état réel du cluster avec ce qui est décrit dans Git.
Le flux devient :
Push sur main
→ CI : lint, test, build, push image au registre
→ CI : met à jour le tag d'image dans le repo GitOps (un commit automatique)
→ L'opérateur détecte le nouveau commit dans le repo GitOps
→ L'opérateur applique les changements au cluster
L’avantage est que Git devient la source de vérité unique : on sait exactement ce qui tourne en production en regardant le repo GitOps. Les rollbacks sont un simple git revert. L’audit est intégré (l’historique Git). Et le CI n’a jamais besoin d’accès direct au cluster — il ne fait que modifier un fichier YAML dans un repo.
Les deux outils dominants :
ArgoCD fournit un dashboard web riche, la gestion multi-cluster, un système de RBAC enterprise, et de la SSO. Le CI fait un commit dans le repo GitOps, ArgoCD détecte le changement et synchronise le cluster :
# Dernière étape du CI — après le push de l'image
- name: Update image tag in GitOps repo
run: |
git clone https://x-access-token:${{ secrets.GITOPS_TOKEN }}@github.com/org/gitops-repo.git
cd gitops-repo/apps/myapp/overlays/staging
kustomize edit set image myapp=ghcr.io/org/myapp:${{ github.sha }}
git add . && git commit -m "Update myapp to ${{ github.sha }}"
git push
FluxCD est plus léger : pas de dashboard séparé, configuration entièrement via des CRDs Kubernetes (des objets YAML natifs K8s). Il peut même surveiller directement le registre d’images et détecter automatiquement les nouveaux tags sans que le CI ait besoin de committer dans le repo GitOps.
Le choix entre les deux dépend du contexte : ArgoCD quand on a besoin de visibilité (dashboards, multi-cluster, RBAC), FluxCD quand on préfère une approche légère et automation-first. Dans les deux cas, la bonne pratique est de séparer le repo de code source du repo de manifests GitOps :
gitops-repo/
apps/
myapp/
base/ # Manifests K8s de base (Kustomize)
deployment.yaml
service.yaml
kustomization.yaml
overlays/
staging/ # Surcharges pour staging
kustomization.yaml
production/ # Surcharges pour production
kustomization.yaml
infrastructure/
cert-manager/
ingress-nginx/
monitoring/
Identifier ses images : pourquoi :latest est un piège
Quel que soit le mode de déploiement, il y a une question transversale : comment identifier de manière fiable l’image qu’on déploie ? La tentation est d’utiliser le tag :latest — après tout, c’est “la dernière version”. Mais :latest est un tag mutable : il est écrasé à chaque push. Cela cause des problèmes concrets :
- Déploiements non-déterministes — Sur Kubernetes, si un pod redémarre et pull
:latest, il peut obtenir une image différente de celle des autres pods du même Deployment. L’application se retrouve avec des instances hétérogènes. - Rollback impossible — Si
:latestest le seul tag, l’image précédente est perdue dès qu’on en pousse une nouvelle. Impossible de revenir en arrière. - Ambiguïté de cache — Le runtime du conteneur peut utiliser un
:latestlocal qui ne correspond plus au:latestdu registre, selon la politique de pull configurée (imagePullPolicy). - Pas de traçabilité — En cas d’incident, impossible de déterminer quel commit exact tourne en production.
La pratique recommandée est le dual-tag : chaque image reçoit un tag immutable lié au commit (le SHA Git) et, pour les releases, un tag sémantique lisible par un humain :
tags: |
ghcr.io/org/myapp:${{ github.sha }}
ghcr.io/org/myapp:v1.4.2
| Type de tag | Exemple | Rôle |
|---|---|---|
| Git SHA | a1b2c3d | Immutable, unique, traçable au commit exact |
| Version sémantique | v1.4.2 | Lisible, utile pour les changelogs et la communication |
| Combiné | 1.4.2-ga1b2c3d | Le meilleur des deux : version + traçabilité |
latest | latest | Acceptable en dev local pour la commodité, à bannir partout ailleurs |
L’action docker/metadata-action automatise la génération de ces tags à partir du contexte Git :
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/org/myapp
tags: |
type=sha,prefix=
type=semver,pattern={{version}}
type=ref,event=branch
- name: Build and push
uses: docker/build-push-action@v6
with:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
Certains registres (AWS ECR, Harbor) permettent d’activer l’immutabilité des tags : une fois un tag poussé, il ne peut plus être écrasé. C’est une garantie au niveau infrastructure que v1.4.2 référencera toujours exactement la même image.
Gestion des secrets
Les secrets GitHub Actions
Les secrets GitHub Actions sont chiffrés au repos (Libsodium sealed box) et ne sont déchiffrés qu’au démarrage d’un workflow run. Ils sont automatiquement masqués dans les logs — si la valeur d’un secret apparaît dans l’output, GitHub la remplace par ***.
Trois niveaux de secrets
| Niveau | Scope | Qui peut créer | Notes |
|---|---|---|---|
| Repository | Tous les workflows du repo | Utilisateurs avec accès write | Le plus courant |
| Environment | Scopé à un environnement spécifique (ex. production, staging) | Accès admin requis | Peut exiger des reviewers avant l’exécution du job |
| Organization | Partagé entre plusieurs repos avec politiques d’accès configurables | Admins de l’organisation | Pas dispo pour les repos privés sur GitHub Free |
Référencer les secrets
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: En input d'action
uses: some-action@v1
with:
api-key: ${{ secrets.API_KEY }}
- name: En variable d'environnement
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
run: ./deploy.sh
Points de sécurité importants
- Les secrets des repos forkés ne sont PAS passés aux workflows (sauf
GITHUB_TOKEN). - Les workflows réutilisables n’héritent pas automatiquement des secrets — il faut les passer explicitement ou utiliser
secrets: inherit. - Éviter de passer des secrets via les arguments de ligne de commande (visibles via
/procou le monitoring de processus). Utiliser des variables d’environnement ou stdin. - Taille max d’un secret : 48 KB. Pour des payloads plus gros, utiliser le chiffrement GPG ou l’encodage Base64 avec un passphrase stocké comme secret séparé.
Passer des secrets aux builds Docker
Construire une image Docker nécessite souvent des secrets : un token NPM pour accéder à un registre privé, des credentials AWS pour télécharger un asset depuis S3, une clé SSH pour cloner un repo Git privé. Le réflexe naturel est de passer ces secrets en build arguments (ARG). C’est une erreur de sécurité.
Pourquoi --build-arg est dangereux
Les build arguments (ARG) et variables d’environnement (ENV) persistent dans l’image finale. Chaque instruction Dockerfile crée un layer, et les layers sont empilés pour former l’image. Un ARG utilisé dans un RUN est enregistré dans les métadonnées de ce layer. Un attaquant qui a accès à l’image peut les récupérer trivialement :
docker history --no-trunc <votre-image>
Même si on supprime un fichier contenant un secret dans une instruction ultérieure (RUN rm /tmp/credentials), ça ne change rien : le fichier existe toujours dans le layer précédent, qui fait partie de l’image. L’architecture en layers de Docker est append-only — on ne peut pas effacer rétroactivement le contenu d’un layer antérieur.
# NE FAITES PAS CECI — le secret finit dans l'image
ARG AWS_SECRET_ACCESS_KEY
RUN aws s3 cp s3://my-bucket/data /app/data
Docker intègre d’ailleurs un check de build (SecretsUsedInArgOrEnv) qui avertit quand un ARG ou ENV contient un nom qui ressemble à un secret.
Les secret mounts BuildKit
BuildKit résout ce problème avec les secret mounts : un mécanisme qui rend un secret disponible uniquement en mémoire pendant l’exécution d’une instruction RUN spécifique. Le secret n’est jamais écrit dans un layer, n’apparaît pas dans docker history, et disparaît dès que l’instruction RUN se termine.
Le principe repose sur deux côtés :
- Côté CLI (celui qui lance le build) : on déclare les secrets à rendre disponibles, avec un identifiant.
- Côté Dockerfile (celui qui consomme les secrets) : on monte le secret dans l’instruction
RUNqui en a besoin, via--mount=type=secret.
Le secret transite de la CLI au Dockerfile via un canal sécurisé de BuildKit, sans jamais toucher le filesystem de l’image.
Consommer un secret comme fichier
Par défaut, un secret monté est accessible comme fichier dans /run/secrets/<id>. C’est le mode le plus courant :
# syntax=docker/dockerfile:1
FROM python:3.12
COPY requirements.txt .
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
pip install -r requirements.txt
Ici, le fichier pip.conf (qui contient par exemple l’URL d’un index privé avec credentials) est monté à /etc/pip.conf le temps du pip install. pip le lit normalement, installe les dépendances, et le fichier disparaît. L’image finale contient les paquets installés mais aucune trace du pip.conf.
Si on ne précise pas target, le secret est monté à /run/secrets/<id> :
RUN --mount=type=secret,id=aws_creds \
AWS_SHARED_CREDENTIALS_FILE=/run/secrets/aws_creds \
aws s3 cp s3://my-bucket/model.bin /app/model.bin
Consommer un secret comme variable d’environnement
Certains outils attendent un secret dans une variable d’environnement plutôt que dans un fichier. Le paramètre env monte le contenu du secret directement dans une variable, pour la durée du RUN :
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
npm ci
La variable NPM_TOKEN existe le temps du npm ci puis disparaît. Elle n’est pas dans l’image.
Monter une clé SSH
Pour cloner un repo Git privé pendant le build, BuildKit propose un type de mount dédié pour SSH. Plutôt que copier une clé privée dans l’image (et risquer de la fuiter), on monte l’agent SSH :
# syntax=docker/dockerfile:1
FROM node:20-alpine
RUN --mount=type=ssh \
git clone git@github.com:org/private-repo.git /src/
Côté CLI, on passe --ssh default pour rendre l’agent SSH local disponible au build.
Comment ça se passe dans GitHub Actions
Dans un workflow GitHub Actions, les secrets sont stockés côté GitHub (chiffrés, masqués dans les logs — voir la section précédente). Il faut les injecter dans le build Docker via l’action docker/build-push-action, qui expose un input secrets prévu à cet effet.
Le mécanisme est le même que la CLI docker build --secret, mais adapté au contexte GitHub Actions : on passe les secrets sous la forme id=valeur, et l’action les transmet à BuildKit.
Voici plusieurs scénarios concrets, chacun avec le Dockerfile et le workflow associé.
Exemple 1 : installer des paquets NPM depuis un registre privé
Beaucoup de projets Node.js utilisent un registre NPM privé (GitHub Packages, Artifactory, Verdaccio) pour des paquets internes. npm ci a besoin d’un token d’authentification pour y accéder.
Le Dockerfile :
# syntax=docker/dockerfile:1
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json .npmrc ./
# Le token est monté comme variable d'env le temps du npm ci
# .npmrc référence ${NPM_TOKEN} — npm le résout automatiquement
RUN --mount=type=secret,id=npm_token,env=NPM_TOKEN \
npm ci
COPY . .
RUN npm run build
# --- Image finale : pas de token, pas de .npmrc, pas de devDependencies ---
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY --from=build /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
Le .npmrc associé (commité dans le repo, sans secret) :
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
@myorg:registry=https://npm.pkg.github.com
Le workflow :
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
secrets: |
npm_token=${{ secrets.NPM_TOKEN }}
La ligne secrets: | passe le secret au build. L’id (npm_token) correspond à celui déclaré dans le --mount=type=secret,id=npm_token du Dockerfile.
Exemple 2 : télécharger un asset privé depuis S3 pendant le build
Certains builds ont besoin de fichiers stockés dans S3 — un modèle de machine learning, un binaire propriétaire, des données de configuration. Il faut des credentials AWS pour y accéder, mais elles ne doivent surtout pas finir dans l’image.
Le Dockerfile :
# syntax=docker/dockerfile:1
FROM python:3.12-slim AS build
WORKDIR /app
RUN pip install awscli
# Les deux secrets sont montés comme variables d'env le temps du RUN
RUN --mount=type=secret,id=aws_access_key,env=AWS_ACCESS_KEY_ID \
--mount=type=secret,id=aws_secret_key,env=AWS_SECRET_ACCESS_KEY \
aws s3 cp s3://my-bucket/models/v3.bin /app/model.bin
COPY . .
RUN pip install -r requirements.txt
# --- Image finale : le modèle est là, les credentials AWS non ---
FROM python:3.12-slim
WORKDIR /app
COPY --from=build /app /app
CMD ["python", "serve.py"]
Le workflow :
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
secrets: |
aws_access_key=${{ secrets.AWS_ACCESS_KEY_ID }}
aws_secret_key=${{ secrets.AWS_SECRET_ACCESS_KEY }}
On peut passer autant de secrets que nécessaire — chaque ligne du bloc secrets: | déclare un secret avec son id et sa valeur.
Exemple 3 : monter un fichier de configuration complet
Parfois le secret n’est pas une simple valeur mais un fichier entier : un pip.conf, un settings.xml Maven, un fichier de credentials GCP. L’input secret-files de build-push-action permet de monter un fichier plutôt qu’une valeur scalaire.
Le Dockerfile (projet Python avec index privé) :
# syntax=docker/dockerfile:1
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
# pip.conf monté à son emplacement standard le temps de l'install
RUN --mount=type=secret,id=pip_conf,target=/etc/pip.conf \
pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
Le workflow :
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
secret-files: |
pip_conf=./pip.conf
Ici on utilise secret-files au lieu de secrets. La valeur est un chemin vers un fichier local dans le workspace du runner. Ce fichier peut lui-même avoir été généré par un step précédent à partir de secrets GitHub :
- name: Generate pip.conf
run: |
cat > pip.conf << EOF
[global]
index-url = https://${{ secrets.PYPI_USER }}:${{ secrets.PYPI_TOKEN }}@pypi.mycompany.com/simple/
EOF
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
secret-files: |
pip_conf=./pip.conf
Exemple 4 : cloner un repo Git privé pendant le build
Pour les dépendances qui ne passent pas par un package manager (un repo Git privé référencé en sous-module, un outil interne), BuildKit permet de monter un agent SSH.
Le Dockerfile :
# syntax=docker/dockerfile:1
FROM golang:1.23 AS build
WORKDIR /src
# Configurer Git pour utiliser SSH au lieu de HTTPS pour le domaine GitHub
RUN git config --global url."git@github.com:".insteadOf "https://github.com/"
# L'agent SSH est monté le temps du go mod download
RUN --mount=type=ssh \
go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /app/server
ENTRYPOINT ["/app/server"]
Le workflow — on utilise ssh-agent pour charger la clé, puis on la passe à buildx via ssh :
- uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.DEPLOY_KEY }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
ssh: default=${{ env.SSH_AUTH_SOCK }}
L’input ssh de build-push-action passe le socket de l’agent SSH au builder, qui le rend disponible aux instructions RUN --mount=type=ssh du Dockerfile.
Combiner secret mounts et multi-stage
Les secret mounts et les builds multi-stage se complètent. Le secret mount est la protection primaire : il garantit que le secret n’est jamais écrit dans un layer. Le multi-stage ajoute une couche de séparation : même les outils qui ont consommé le secret (le CLI AWS, le fichier .npmrc, etc.) ne sont pas dans l’image finale — seul le dernier stage est expédié.
C’est pour ça que tous les exemples ci-dessus utilisent un multi-stage : les secrets sont consommés dans un stage build, et l’image finale ne contient que les artifacts copiés via COPY --from=build.
Attention : mode=max et les layers intermédiaires en cache
Le multi-stage protège l’image finale — celle qui est poussée au registre et déployée. Mais si on utilise --cache-to mode=max (recommandé plus haut pour optimiser les cache hits sur les multi-stage), tous les layers intermédiaires sont exportés dans le cache, y compris ceux des stages de build.
En fonctionnement normal, ce n’est pas un problème : les secret mounts ne sont pas écrits dans les layers, donc il n’y a rien à fuiter dans le cache.
Mais si un bug dans le Dockerfile écrit accidentellement un secret sur le filesystem normal (par exemple une commande qui dump un token dans un fichier temporaire), ce fichier se retrouve dans un layer intermédiaire. Avec mode=max, ce layer est exporté dans le cache. Et le cache est stocké quelque part accessible : un registre (type=registry), le cache GitHub Actions (type=gha, accessible à quiconque peut lancer un workflow sur le repo), un bucket S3. Dans ce scénario, le multi-stage ne protège plus rien côté cache — la surface d’attaque est la même qu’avec un single-stage.
Concrètement, ça veut dire que le secret mount reste la seule vraie protection. Le multi-stage protège l’image livrée, mais pas le cache. Ce n’est pas une raison d’abandonner le multi-stage (il protège toujours contre la distribution de l’image), ni d’abandonner mode=max (les gains de cache sont réels). C’est une raison de ne jamais compter sur le multi-stage comme filet de sécurité pour les secrets — toute la rigueur doit être dans le --mount=type=secret.
Gestionnaires de secrets externes
HashiCorp Vault
- name: Import Secrets from Vault
uses: hashicorp/vault-action@v2
with:
url: https://vault.example.com:8200
token: ${{ secrets.VAULT_TOKEN }}
secrets: |
secret/data/ci/aws accessKey | AWS_ACCESS_KEY_ID ;
secret/data/ci/aws secretKey | AWS_SECRET_ACCESS_KEY
Vault supporte aussi l’authentification OIDC avec GitHub Actions, éliminant le besoin de stocker un token Vault comme secret GitHub.
AWS Secrets Manager
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions
aws-region: us-east-1
- uses: aws-actions/aws-secretsmanager-get-secrets@v1
with:
secret-ids: |
my-app/prod/db-creds
GCP Secret Manager
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/my-pool/providers/my-provider
service_account: my-sa@my-project.iam.gserviceaccount.com
- uses: google-github-actions/get-secretmanager-secrets@v2
with:
secrets: |-
db-password:my-project/db-password
1Password
- uses: 1password/load-secrets-action@v2
with:
export-env: true
env:
OP_SERVICE_ACCOUNT_TOKEN: ${{ secrets.OP_SERVICE_ACCOUNT_TOKEN }}
AWS_ACCESS_KEY_ID: op://DevOps/AWS/access-key-id
AWS_SECRET_ACCESS_KEY: op://DevOps/AWS/secret-access-key
Doppler
- uses: dopplerhq/secrets-fetch-action@v1
with:
doppler-token: ${{ secrets.DOPPLER_TOKEN }}
doppler-project: my-app
doppler-config: production
Doppler fournit du versioning pour les secrets, la synchronisation en temps réel, et des workflows collaboratifs.
OIDC : l’authentification sans credentials
GitHub Actions a un fournisseur OIDC intégré à https://token.actions.githubusercontent.com. Le flux :
Workflow GitHub Actions Fournisseur OIDC GitHub Fournisseur cloud
| | |
|--- 1. Demande JWT token ---->| |
|<-- 2. Retourne JWT signé ----| |
| |
|--- 3. Présente le JWT -------------------------------->|
|<-- 4. Retourne credentials cloud temporaires ----------|
| |
|--- 5. Accède aux ressources cloud avec creds temp --->|
- Le job demande un JWT au fournisseur OIDC de GitHub.
- GitHub retourne un JWT signé contenant des claims sur l’identité du workflow.
- Le workflow présente ce JWT au fournisseur cloud.
- Le fournisseur cloud valide la signature et les claims, puis émet un token d’accès de courte durée valide uniquement pour la durée du job.
- Le workflow utilise les credentials temporaires pour accéder aux ressources cloud.
Claims JWT disponibles pour les politiques de confiance
| Claim | Description | Exemple |
|---|---|---|
sub | Sujet (le plus important pour le trust) | repo:octo-org/octo-repo:environment:prod |
repository | Nom complet du repo | octo-org/octo-repo |
repository_owner | Organisation ou utilisateur | octo-org |
environment | Environnement de déploiement | production |
ref | Ref Git | refs/heads/main |
sha | SHA du commit | abc123... |
workflow | Fichier workflow | deploy.yml |
actor | Utilisateur déclencheur | octocat |
runner_environment | Type de runner | github-hosted |
Configuration OIDC avec AWS
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
id-token: write # REQUIS pour OIDC
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-role
aws-region: us-east-1
# Pas de AWS_ACCESS_KEY_ID ou AWS_SECRET_ACCESS_KEY nécessaire !
- run: aws s3 ls
Côté AWS, créer un IAM OIDC identity provider pointant vers https://token.actions.githubusercontent.com et un rôle IAM avec une trust policy qui restreint le claim sub :
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:ref:refs/heads/main",
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
}
}
}
Configuration OIDC avec GCP
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123456/locations/global/workloadIdentityPools/github-pool/providers/github-provider
service_account: deploy@my-project.iam.gserviceaccount.com
Pourquoi OIDC est supérieur
| Approche traditionnelle | Approche OIDC |
|---|---|
| Credentials long-lived stockés comme secrets GitHub | Aucun credential long-lived nulle part |
| Rotation manuelle requise | Automatique — tokens expirent après chaque job |
| Accès large (quiconque a le secret) | Fine-grained (restreint par repo, branche, environnement) |
| Risque de fuite de credentials | Rien à fuiter |
| Synchronisation de secrets entre environnements | Trust configuré une seule fois côté cloud |
Bonnes pratiques de sécurité
Moindre privilège :
- Utiliser des secrets d’environnement avec reviewers requis pour la production.
- Restreindre les secrets d’organisation aux seuls repos qui en ont besoin.
- Avec OIDC, contraindre les trust policies à des repos, branches et environnements spécifiques via le claim
sub. - Utiliser
permissions:dans les workflows pour restreindreGITHUB_TOKENaux seuls scopes nécessaires.
Rotation :
- OIDC élimine la rotation pour les credentials cloud — les tokens sont éphémères.
- Les gestionnaires de secrets externes (Vault, AWS Secrets Manager) supportent la rotation automatique.
Ne jamais hardcoder :
- Jamais de secrets dans le code source, les Dockerfiles, ou les fichiers de config CI.
- Jamais de
ARGouENVpour les secrets dans les Dockerfiles — utiliser--mount=type=secret. - Jamais de secrets en arguments de ligne de commande. Utiliser les variables d’environnement ou l’injection par fichier.
Prévention de l’injection de scripts :
# VULNÉRABLE — input utilisateur injecté directement dans le shell
- run: echo "Title: ${{ github.event.pull_request.title }}"
# SÛR — utiliser une variable d'environnement intermédiaire
- env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Title: $PR_TITLE"
Sécurité fork et PR :
- Les secrets ne sont PAS exposés aux workflows déclenchés depuis des forks.
- Attention avec
pull_request_target: il s’exécute dans le contexte du repo base avec accès aux secrets, mais checkout le code du fork — exploitable.
Pinner les actions par SHA :
# Bon : pinné à un SHA de commit
uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608
# Risqué : un tag peut être déplacé
uses: actions/checkout@v4
Arbre de décision rapide
Besoin d'un secret en CI/CD ?
│
├── C'est un credential cloud (AWS/GCP/Azure) ?
│ └── OUI → Utiliser OIDC (aucun secret stocké)
│
├── C'est utilisé pendant un build Docker ?
│ └── OUI → Utiliser BuildKit --mount=type=secret
│ JAMAIS ARG/ENV
│
├── C'est utilisé à travers plusieurs repos ?
│ └── OUI → Secret d'organisation ou gestionnaire externe (Vault, 1Password, Doppler)
│
├── C'est spécifique à un environnement (prod vs staging) ?
│ └── OUI → Secret d'environnement avec règles de protection
│
└── Sinon
└── Secret de repository, référencé via ${{ secrets.NAME }}
Registres Docker
Comment fonctionne un registre
Un registre de conteneurs est un service centralisé qui stocke, distribue et gère des images conteneur. Les registres implémentent la spécification OCI Distribution, qui définit une API HTTP standard pour le push et le pull.
Structure d’une image (OCI Image Spec) :
Les images sont composées de trois éléments arrangés en un Merkle DAG (graphe acyclique dirigé) :
- Index (niveau supérieur) : pointe vers plusieurs manifests d’image, permettant les images multi-plateforme (ex. linux/amd64, linux/arm64).
- Manifest : liste la config de l’image et un ensemble ordonné de blobs de layer. Chaque référence est un Content Descriptor contenant le media type, le digest, et la taille en octets.
- Layers : changesets du filesystem (archives tar) empilés pour produire le filesystem final. Chaque layer représente des ajouts, modifications, ou suppressions de fichiers.
Mécanisme push/pull :
- Push : le client upload chaque blob de layer individuellement (vérifié par digest), puis upload le manifest qui référence ces blobs.
- Pull : le client récupère le manifest par tag ou digest, puis télécharge uniquement les layers qu’il n’a pas déjà localement (déduplication par hash de contenu).
Tags vs digests
| Propriété | Tag | Digest |
|---|---|---|
| Format | Chaîne lisible (ex. v1.2.3, latest) | Hash SHA-256 (ex. sha256:eedaff45e3c7...) |
| Mutabilité | Mutable — peut être réassigné à un manifest différent | Immutable — réfère toujours au même contenu |
| Cas d’usage | Commodité, versioning | Reproductibilité, sécurité supply-chain |
Bonne pratique : pinner les images par digest dans les Dockerfiles de production pour la reproductibilité ; utiliser les tags pour la lisibilité humaine.
Comparatif des registres
| Registre | Type | Tier gratuit | Rate limits | Points forts |
|---|---|---|---|---|
| Docker Hub | Public/Privé | 1 repo privé, public illimité | Non-auth : 100 pulls/6h ; Personnel : 200 pulls/6h ; Payant : illimité | Registre par défaut, plus grand écosystème, Images Officielles |
| GitHub Container Registry (ghcr.io) | Public/Privé | Gratuit pour les images publiques | 10 GB par layer, timeout upload 10 min | Intégration native GitHub Actions via GITHUB_TOKEN, permissions granulaires |
| AWS ECR | Privé/Public | 500 MB/mois privé ; 50 GB/mois public | Pas de rate limit pour les users authentifiés | Intégration IAM, scan d’images, lifecycle policies, réplication cross-region |
| GCP Artifact Registry | Privé | Pay-as-you-go | Pas de limites dures | Multi-format (Docker, Maven, npm, etc.), géo-redundance par défaut |
| Azure ACR | Privé | Tiers Basic/Standard/Premium | Throughput dépendant du tier | Géo-réplication (Premium), ACR Tasks pour builds in-registry |
| Harbor (self-hosted) | Privé | Gratuit (Apache 2.0) | Self-managed | Scan de vulnérabilités, RBAC, LDAP/OIDC, réplication basée sur des politiques, CNCF graduated |
Cache Docker et optimisation des builds
Comment fonctionnent les layers Docker
Chaque instruction dans un Dockerfile crée un layer dans l’image finale. Les layers sont cachés par hash de contenu — si l’instruction et ses inputs n’ont pas changé, le layer caché est réutilisé.
Règle critique : l’invalidation du cache est en cascade. Quand le cache d’un layer est invalidé, tous les layers suivants doivent aussi être reconstruits, même si leur contenu serait identique. C’est pourquoi l’ordre des instructions est crucial.
Déclencheurs d’invalidation du cache :
| Type d’instruction | Méthode de vérification |
|---|---|
COPY, ADD, RUN --mount=type=bind | Checksums des métadonnées de fichiers (contenu + permissions ; mtime n’est pas considéré) |
RUN | Comparaison de la chaîne de commande uniquement ; ne détecte pas les changements upstream (ex. nouvelles versions de paquets) |
| Build secrets | Ne font pas partie du cache ; les changements de secrets n’invalident pas le cache |
Piège : RUN apk add curl utilisera les résultats cachés indéfiniment sauf si vous bustez explicitement le cache avec --no-cache ou --no-cache-filter.
Les backends de cache BuildKit
En CI/CD, où les environnements de build sont éphémères, les backends de cache externes sont essentiels.
Inline
Embarque les métadonnées de cache directement dans l’image de sortie.
docker buildx build --push -t registry/image:tag \
--cache-to type=inline .
# Import au build suivant :
docker buildx build --push -t registry/image:tag \
--cache-from type=registry,ref=registry/image:tag .
Simple à mettre en place mais ne scale pas bien avec les multi-stage builds. Supporte uniquement le mode min.
Registry
Stocke le cache comme image séparée dans un registre.
docker buildx build --push -t registry/image:tag \
--cache-to type=registry,ref=registry/cache-image:tag,mode=max \
--cache-from type=registry,ref=registry/cache-image:tag .
Séparation complète entre cache et image. Supporte mode=max pour les multi-stage. Fonctionne entre équipes distribuées.
GitHub Actions Cache (type=gha)
Utilise l’API Cache de GitHub nativement — le choix naturel pour les pipelines GitHub Actions.
docker buildx build --push -t registry/image:tag \
--cache-to type=gha,mode=max \
--cache-from type=gha .
BuildKit upload/download les blobs de cache via l’API REST de cache GitHub, authentifié automatiquement via les variables ACTIONS_CACHE_URL et ACTIONS_RUNTIME_TOKEN du runner.
Limité à 10 GB par repo. Les entrées sont évincées par âge et patterns d’accès (LRU).
S3
docker buildx build --push -t registry/image:tag \
--cache-to type=s3,region=us-east-1,bucket=my-cache-bucket,name=myapp \
--cache-from type=s3,region=us-east-1,bucket=my-cache-bucket,name=myapp .
Pattern multi-source en CI
Pattern critique pour maximiser les cache hits :
docker buildx build --push -t registry/image:latest \
--cache-from type=registry,ref=registry/cache:$BRANCH \
--cache-from type=registry,ref=registry/cache:main \
--cache-to type=registry,ref=registry/cache:$BRANCH,mode=max .
Import le cache de la branche courante d’abord, avec fallback sur main. Maximise les cache hits pour les feature branches.
mode=min vs mode=max
min(défaut) : exporte uniquement les layers présents dans l’image finale. Cache plus petit, import/export plus rapide.max: exporte tous les layers intermédiaires (y compris les stages de build). Cache plus gros, taux de hit plus élevé — essentiel pour les builds multi-stage.
Builds multi-stage
Les builds multi-stage utilisent plusieurs instructions FROM dans un seul Dockerfile. Chaque FROM commence un nouveau stage. On copie sélectivement les artifacts entre stages avec COPY --from, en laissant derrière tout ce qui n’est pas nécessaire dans l’image finale.
# Stage 1 : Build
FROM golang:1.23 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
# Stage 2 : Production
FROM gcr.io/distroless/static-debian12
COPY --from=build /app/server /app/server
ENTRYPOINT ["/app/server"]
L’image de build peut faire 1 GB+ ; l’image finale peut faire < 10 MB. Les outils de build, le code source, et les artifacts intermédiaires ne sont jamais livrés en production.
Nommer les stages et COPY --from
FROM node:20 AS deps
COPY package.json yarn.lock ./
RUN yarn install --frozen-lockfile
FROM node:20 AS builder
COPY --from=deps /node_modules ./node_modules
COPY . .
RUN yarn build
FROM nginx:alpine
COPY --from=builder /dist /usr/share/nginx/html
On peut aussi copier depuis des images externes :
COPY --from=nginx:latest /etc/nginx/nginx.conf /nginx.conf
Utiliser --target pour des stages spécifiques
# Construire uniquement le stage "builder" (utile pour test/debug)
docker buildx build --target builder -t myapp:dev .
BuildKit ne construit que les stages dont la cible dépend, en ignorant les stages non liés.
Ordre des instructions pour un cache optimal
Mauvais — tout changement de fichier source invalide l’installation des dépendances :
COPY . .
RUN npm install
RUN npm run build
Bon — les dépendances sont cachées séparément du code source :
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Ce pattern fonctionne pour tout écosystème :
| Langage | Fichiers de dépendances d’abord | Commande d’install | Puis copier le source |
|---|---|---|---|
| Node.js | package.json, package-lock.json | npm ci | COPY . . |
| Python | requirements.txt ou pyproject.toml | pip install -r requirements.txt | COPY . . |
| Go | go.mod, go.sum | go mod download | COPY . . |
| Rust | Cargo.toml, Cargo.lock | cargo fetch | COPY . . |
| Java | pom.xml ou build.gradle | mvn dependency:resolve | COPY . . |
Cache mounts pour les gestionnaires de paquets
Les cache mounts persistent les caches des package managers entre les builds sans les ajouter aux layers d’image. C’est l’optimisation la plus impactante pour les builds avec beaucoup de dépendances.
# Node.js
RUN --mount=type=cache,target=/root/.npm \
npm ci
# Python
RUN --mount=type=cache,target=/root/.cache/pip \
pip install -r requirements.txt
# Go
RUN --mount=type=cache,target=/go/pkg/mod \
--mount=type=cache,target=/root/.cache/go-build \
go build -o /app .
# Rust
RUN --mount=type=cache,target=/usr/local/cargo/registry \
--mount=type=cache,target=/usr/local/cargo/git/db \
--mount=type=cache,target=/app/target \
cargo build --release
# apt-get (note : sharing=locked est requis)
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt,sharing=locked \
apt-get update && apt-get install -y --no-install-recommends curl
Parallélisation des stages dans BuildKit
BuildKit parallélise automatiquement les stages indépendants. Si deux stages ne dépendent pas l’un de l’autre, ils se construisent en concurrence :
FROM golang:1.23 AS backend
WORKDIR /src
COPY backend/ .
RUN go build -o /app/api .
FROM node:20 AS frontend
WORKDIR /src
COPY frontend/ .
RUN npm ci && npm run build
# Les deux stages ci-dessus se construisent en parallèle !
FROM alpine:3.20
COPY --from=backend /app/api /app/api
COPY --from=frontend /src/dist /app/static
BuildKit reconnaît que backend et frontend sont indépendants et les planifie en concurrence. Le builder legacy les traiterait séquentiellement.
Conseils d’optimisation
.dockerignore
Toujours créer un .dockerignore pour exclure les fichiers qui grossissent le contexte de build et causent une invalidation de cache inutile :
.git
node_modules
dist
*.md
.env
.env.*
**/*.log
__pycache__
.pytest_cache
.vscode
.idea
Minimiser les layers
Combiner les commandes RUN liées avec && :
RUN apt-get update && \
apt-get install -y --no-install-recommends curl ca-certificates && \
rm -rf /var/lib/apt/lists/*
Toujours combiner apt-get update avec apt-get install dans le même RUN — les séparer cause des problèmes de cache où l’index des paquets devient périmé.
Syntaxe heredoc pour la lisibilité :
RUN <<EOF
apt-get update
apt-get install -y --no-install-recommends curl
rm -rf /var/lib/apt/lists/*
EOF
Choix des images de base
| Image de base | Taille | Shell | Package manager | libc | Idéal pour |
|---|---|---|---|---|---|
| Alpine | ~5-7 MB | Oui (busybox) | apk | musl | Conteneurs minimalistes ; bon équilibre taille/debuggabilité |
| Distroless (Google) | ~2-50 MB | Non | Non | glibc | Production Java, Python, Node.js, .NET ; focus sécurité |
| Scratch | 0 bytes | Non | Non | Aucune | Binaires Go/Rust compilés statiquement ; taille absolument minimale |
| Debian slim | ~75 MB | Oui | apt | glibc | Quand vous avez besoin de compatibilité glibc + shell pour debug |
| Ubuntu | ~80 MB | Oui | apt | glibc | Développement, compatibilité OS complète |
Guide de décision :
- Go/Rust (binaires statiques) :
scratchoudistroless/static - Java/Node.js/Python :
distrolesspour la production, images spécifiques au langage pour les stages de build - Besoin d’un shell pour debug ? : Alpine ou Debian slim
- Problèmes de compatibilité musl ? : Passer d’Alpine à Debian slim ou distroless
Exemple complet optimisé (Node.js)
# syntax=docker/dockerfile:1
# Stage 1 : Dépendances de production
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --only=production
# Stage 2 : Build
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
npm ci
COPY . .
RUN npm run build
# Stage 3 : Production
FROM node:20-alpine AS production
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -g 1001 -S appgroup && \
adduser -S appuser -u 1001 -G appgroup
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]
Exemple CI complet (GitHub Actions avec buildx)
name: Build and Push
on:
push:
branches: [main, "feature/**"]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
cache-from: |
type=gha,scope=${{ github.ref_name }}
type=gha,scope=main
cache-to: type=gha,scope=${{ github.ref_name }},mode=max
Résumé des stratégies d’optimisation clés
- Ordonner les instructions du moins changeant au plus changeant (dépendances OS → dépendances langage → code source).
- Utiliser les builds multi-stage pour séparer les dépendances de build des dépendances de runtime.
- Utiliser les cache mounts (
--mount=type=cache) pour les caches de package managers. - Utiliser
mode=maxdans--cache-topour cacher tous les layers intermédiaires, surtout pour les multi-stage. - Importer depuis plusieurs sources de cache (branche courante + main) en CI.
- Utiliser
.dockerignorepour empêcher les fichiers non pertinents de buster le cache. - Choisir des images de base minimales appropriées au langage (scratch/distroless pour les binaires statiques, Alpine pour l’usage général).
- Pinner les images de base par digest pour la reproductibilité et la sécurité supply-chain.
- Exécuter en tant que non-root avec UID/GID explicites.
Sources
GitHub Actions
- Understanding GitHub Actions
- About GitHub-hosted runners
- Workflow syntax for GitHub Actions
- Using secrets in GitHub Actions
- Security hardening for GitHub Actions
- About security hardening with OpenID Connect
- Managing environments for deployment
- Deploying with GitHub Actions
- GitHub Container Registry
Docker / BuildKit / Buildx
- Docker BuildKit
- Docker Builders and Drivers
- Multi-platform builds
- Docker Build Cache
- Cache backends
- Cache invalidation
- Docker Build Secrets
- Multi-stage builds
- Docker Build Best Practices
- docker/buildx README
- docker/setup-buildx-action
- docker/build-push-action