Docker et l'orchestration
Empaqueter une application pour qu'elle tourne pareil partout, puis la faire vivre sur plusieurs serveurs.
« Ça marche sur ma machine. » Sur le serveur, la version de PHP est différente, une extension manque, la configuration n'est pas la même. Docker règle ce problème : il empaquette l'application avec tout ce dont elle a besoin pour tourner, et ce paquet s'exécute de la même façon sur ton poste, en intégration continue et en production.
Tu utilises déjà Docker dans ce projet : c'est lui qui exécute les exemples PHP des fiches et qui sert les pages. Cette fiche part de là, va jusqu'à une image prête pour la production, puis présente l'orchestration : faire tourner des conteneurs sur plusieurs serveurs, les redémarrer, les mettre à jour sans coupure. C'est la transition vers la fiche 13.
Les Dockerfile, les fichiers Compose, les manifestes Kubernetes et les commandes sont des illustrations : aucun n'a été exécuté ni construit pour préparer cette fiche, et aucune image n'a été téléchargée. Les noms d'images et les versions sont des exemples à adapter.
1.Image et conteneur
Deux mots reviennent sans cesse, et la comparaison avec PHP aide à les distinguer :
- Une image est un modèle figé : un système de fichiers minimal, PHP, les extensions, ton code, et la commande à lancer. C'est l'équivalent d'une classe.
- Un conteneur est une image en cours d'exécution, avec son propre état. C'est l'équivalent d'un objet : on peut lancer plusieurs conteneurs à partir de la même image.
- Un registre (Docker Hub, GitLab, GitHub, ou celui de ton hébergeur) stocke les images, comme Packagist stocke les paquets Composer.
Un conteneur n'est pas une machine virtuelle. Une machine virtuelle simule un ordinateur complet, avec son propre système. Un conteneur partage le noyau Linux de l'hôte, et n'isole que ses processus, ses fichiers et son réseau. Il démarre donc en une fraction de seconde et pèse beaucoup moins lourd. En contrepartie, l'isolation est moins forte : c'est pourquoi la section 8 compte.
Une règle guide tout le reste : un conteneur est jetable. On le supprime et on en relance un autre à tout moment. Tout ce qui doit survivre (la base de données, les fichiers envoyés) vit en dehors, dans un volume ou un service externe.
2.Les commandes de base
docker run --rm -it php:8.4-cli php -v # lancer un conteneur jetable, et le supprimer après
docker ps # les conteneurs en cours (-a : aussi les arrêtés)
docker logs -f shop-php-1 # suivre la sortie d'un conteneur
docker exec -it shop-php-1 sh # ouvrir un terminal DANS un conteneur qui tourne
docker stop shop-php-1 # l'arrêter
docker images # les images présentes sur la machine
docker system df # la place occupée par images, conteneurs, volumesdocker exec est précieux pour comprendre un problème : il ouvre un terminal dans le conteneur, où l'on retrouve les commandes de la fiche 11. Mais ne corrige jamais rien à la main dans un conteneur : la modification disparaît avec lui. On corrige l'image, puis on la reconstruit.
3.Écrire un Dockerfile
Une image se construit à partir d'un Dockerfile : une recette, lue de haut en bas.
# Une première image pour une application Symfony avec PHP-FPM
FROM php:8.4-fpm
# Les extensions PHP nécessaires (ici : PostgreSQL et OPcache)
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev \
&& docker-php-ext-install pdo_pgsql opcache \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /var/www/app
# On copie d'abord les fichiers de dépendances, puis le reste du code :
# tant que composer.json ne change pas, cette étape reste en cache
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction
COPY . .FROMpart d'une image existante. Les images officiellesphpfournissent PHP et l'outildocker-php-ext-installpour les extensions.RUNexécute une commande pendant la construction.COPYcopie des fichiers dans l'image.WORKDIRchoisit le dossier de travail.- Chaque instruction crée une couche (layer). Docker garde les couches en cache, et ne reconstruit qu'à partir de la première qui a changé.
D'où l'ordre du fichier : on copie composer.json et composer.lock, on installe les dépendances, et seulement ensuite on copie le code. Modifier un contrôleur ne relance pas composer install, et la construction prend quelques secondes au lieu de quelques minutes.
Le fichier .dockerignore exclut ce qui ne doit pas entrer dans l'image. C'est aussi une protection : sans lui, COPY . . embarquerait ton .env.local et tout l'historique Git.
# .dockerignore : ce qui ne doit JAMAIS entrer dans l'image
.git
.env.local
.env.*.local
var/
vendor/
node_modules/
tests/4.Une image de production
L'image de la section 3 fonctionne, mais contient Composer, qui n'a rien à faire en production, et tourne en root. Une image de production suit quelques règles de plus :
# Étape 1 : installer les dépendances, avec Composer (cet outil ne finira pas dans l'image)
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction \
--optimize-autoloader
# Étape 2 : l'image finale, avec seulement ce qui sert à faire tourner l'application
FROM php:8.4-fpm AS app
RUN apt-get update \
&& apt-get install -y --no-install-recommends libpq-dev \
&& docker-php-ext-install pdo_pgsql opcache \
&& rm -rf /var/lib/apt/lists/*
COPY docker/php/prod.ini /usr/local/etc/php/conf.d/zz-prod.ini
WORKDIR /var/www/app
COPY --from=vendor /app/vendor ./vendor
COPY --chown=www-data:www-data . .
ENV APP_ENV=prod APP_DEBUG=0
RUN php bin/console cache:warmup && chown -R www-data:www-data var/
# Ne pas tourner en root : une faille dans l'application ne donne pas root dans le conteneur
USER www-data- La construction en plusieurs étapes (multi-stage build) : une première étape installe les dépendances avec Composer, la seconde ne récupère que le dossier
vendor/. L'image finale est plus petite, et contient moins d'outils qu'un attaquant pourrait utiliser. - Un utilisateur sans privilèges (
USER www-data) : une faille dans l'application ne donne pas les droits root dans le conteneur. - Des versions précises :
php:8.4-fpmplutôt quephp:latest. Aveclatest, la même recette peut produire une image différente d'un jour à l'autre. - Le cache Symfony préparé à la construction (
cache:warmup), avecAPP_ENV=prodetAPP_DEBUG=0(fiche 10). - Une image par version, étiquetée avec un numéro (
shop:1.8.2) ou l'identifiant du commit Git. C'est ce qui rend le retour en arrière possible (fiche 13).
Le modèle symfony-docker, recommandé par la documentation de Symfony, fournit une configuration complète et maintenue, basée sur FrankenPHP (un serveur d'application PHP qui remplace nginx et PHP-FPM). Pour un nouveau projet, partir de ce modèle est souvent plus sûr que d'écrire son Dockerfile de zéro. Comprendre les sections précédentes reste indispensable pour l'adapter et le dépanner.
5.Les volumes : ce qui doit survivre
Tout ce qu'un conteneur écrit dans son propre système de fichiers disparaît quand on le supprime. Pour garder des données, on monte un volume dans le conteneur :
# Volume nommé : géré par Docker, il survit à la suppression du conteneur
docker volume create shop_db_data
docker run -d --name db -v shop_db_data:/var/lib/postgresql/data postgres:17
# Montage d'un dossier de l'hôte (« bind mount ») : pratique en développement,
# le code modifié sur ta machine est visible tout de suite dans le conteneur
docker run --rm -v "$PWD":/app -w /app php:8.4-cli php bin/console about
docker volume ls # lister les volumes- Un volume nommé est géré par Docker. C'est le bon choix pour les données d'une base en développement.
- Un montage de dossier (bind mount) relie un dossier de ta machine au conteneur. C'est ce que fait ce projet de formation avec
.:/app: idéal en développement, pour voir tes modifications sans reconstruire l'image.
En développement, PostgreSQL dans un conteneur est parfait. En production, une base gérée par l'hébergeur (sauvegardes automatiques, mises à jour, réplication) est souvent plus sûre qu'une base dans un conteneur que tu dois surveiller et sauvegarder toi-même (fiche 13). Si tu la gardes dans Docker, ses données doivent être sur un volume, et sauvegardées.
6.Les réseaux et les ports
Les conteneurs d'un même réseau Docker se parlent directement, en utilisant le nom du service comme nom d'hôte : depuis le conteneur PHP, la base de données s'appelle db. Docker joue le rôle d'un petit DNS (fiche 9).
Pour qu'un service soit joignable depuis l'extérieur de Docker, il faut publier un port : "8080:80" relie le port 8080 de la machine au port 80 du conteneur.
"5432:5432"publie le port sur toutes les interfaces de la machine : si le serveur est sur Internet, ta base de données aussi.- Docker modifie lui-même les règles réseau du système, et contourne le pare-feu
ufw(fiche 11) :ufwaffiche le port comme fermé alors qu'il est ouvert. - La parade : ne publie que ce qui doit être public (le reverse proxy, ports 80 et 443), et lie le reste à la machine locale avec
"127.0.0.1:8080:80", comme le fait lecompose.yamlde ce projet. La base de données, elle, n'a besoin d'aucun port publié : les autres services y accèdent par le réseau interne.
7.Docker Compose
Une application réelle a plusieurs conteneurs : PHP, nginx, la base, parfois Redis ou un gestionnaire de messages. Docker Compose les décrit dans un seul fichier, et les démarre ensemble. Voici celui de ce projet de formation, que tu utilises déjà :
# Le compose.yaml de CE projet de formation, pour commencer simplement
services:
php:
image: php:8.4-cli
working_dir: /app
volumes:
- .:/app # le dossier du projet, monté dans le conteneur
user: "${UID:-1000}:${GID:-1000}" # les fichiers créés gardent ton utilisateur
profiles: [tools] # ne démarre pas avec « docker compose up »
web:
image: nginx:1.27-alpine
volumes:
- ./fiches:/usr/share/nginx/html:ro # :ro = en lecture seule
ports:
- "127.0.0.1:8080:80" # visible seulement depuis ta machineEt celui d'une application Symfony plus complète :
# compose.yaml d'une application Symfony : PHP-FPM, nginx et PostgreSQL
services:
php:
build: .
# DATABASE_URL pointe vers « db », le nom du service : postgresql://app:…@db:5432/app
env_file: .env.docker.local # hors de Git : contient les vraies valeurs
depends_on:
db:
condition: service_healthy # attendre que PostgreSQL soit vraiment prêt
nginx:
image: nginx:1.27-alpine
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./public:/var/www/app/public:ro
ports:
- "127.0.0.1:8000:80"
depends_on:
- php
db:
image: postgres:17
environment:
POSTGRES_USER: app
POSTGRES_DB: app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
secrets:
- db_password
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD", "pg_isready", "-U", "app"]
interval: 5s
retries: 10
# Pas de « ports » : la base n'est joignable QUE par les autres services
volumes:
db_data:
secrets:
db_password:
file: ./secrets/db_password.txt # hors de Git (.gitignore)- Chaque service part d'une image (
image:) ou d'un Dockerfile (build:). depends_onavecservice_healthyattend que PostgreSQL réponde vraiment, grâce à sonhealthcheck, et pas seulement que son conteneur ait démarré.- La base n'a pas de
ports: elle n'est joignable que par les autres services. - Les valeurs sensibles restent hors de Git, dans
.env.docker.localet dans le dossiersecrets/(section 8).
docker compose up -d # construire si besoin, puis démarrer en arrière-plan
docker compose ps # l'état de chaque service
docker compose logs -f php # suivre les logs d'un service
docker compose exec php php bin/console doctrine:migrations:migrate
docker compose down # arrêter et supprimer les conteneurs (les volumes restent)
docker compose down --volumes # … et les volumes : les DONNÉES sont perduesPour une application sur un seul serveur, Compose en production est un choix raisonnable et simple à comprendre. Ses limites apparaissent quand il faut plusieurs serveurs, des mises à jour sans aucune coupure ou un redémarrage automatique sur une autre machine en cas de panne : c'est le rôle de l'orchestration (section 9).
8.Secrets et sécurité des conteneurs
Une image se partage : elle est poussée dans un registre, téléchargée sur plusieurs serveurs, parfois rendue publique par erreur. Rien de secret ne doit y entrer.
# ✗ À NE JAMAIS FAIRE : le secret est gravé dans l'image
ENV STRIPE_SECRET_KEY=exemple-de-cle-fictive
ARG DATABASE_PASSWORD
RUN echo "$DATABASE_PASSWORD" > /tmp/pass && ./configure.sh && rm /tmp/pass
# Même supprimé par « rm », le fichier reste lisible dans la couche précédente,
# et « docker history » montre les ENV et les ARG à quiconque possède l'image.- Les secrets arrivent au démarrage, jamais à la construction : par des variables d'environnement, ou mieux, par des fichiers montés (
secrets:de Compose,Secretde Kubernetes). Un fichier monté n'apparaît pas dansdocker inspect, contrairement à une variable d'environnement. - Une couche est éternelle : supprimer un fichier dans une instruction suivante ne l'efface pas des couches précédentes.
- Pas de root dans le conteneur (
USER, section 4). - Des images de confiance : les images officielles ou celles de ton équipe, avec une version précise. Une image inconnue de Docker Hub peut contenir n'importe quoi.
- Analyse les images avec un scanner de vulnérabilités (Docker Scout, Trivy) dans l'intégration continue, comme
composer auditpour le PHP (fiche 10), et reconstruis-les régulièrement pour récupérer les correctifs du système. - Le groupe
dockeréquivaut à root sur la machine : n'y ajoute que les personnes qui doivent l'être. Pour la même raison, ne monte jamais/var/run/docker.sockdans un conteneur, et évite--privileged.
9.L'orchestration : quand un serveur ne suffit plus
Avec Compose sur un seul serveur, plusieurs questions restent sans réponse : que se passe-t-il si le serveur tombe ? Comment absorber un pic de trafic ? Comment mettre à jour sans couper le site ? Un orchestrateur répond à ces questions. On lui décrit l'état voulu (« trois copies de l'application, toujours en marche »), et il fait en permanence ce qu'il faut pour l'atteindre :
- Placer les conteneurs sur les serveurs disponibles, selon les ressources libres.
- Réparer : redémarrer un conteneur qui plante, le relancer ailleurs si un serveur disparaît.
- Répartir le trafic entre les copies, et n'envoyer des requêtes qu'à celles qui sont prêtes.
- Mettre à jour progressivement : remplacer les copies une par une, et revenir en arrière si les nouvelles ne démarrent pas.
- Adapter le nombre de copies à la charge.
Kubernetes est devenu l'orchestrateur de référence, et presque tous les fournisseurs de cloud en proposent une version gérée. Docker Swarm, intégré à Docker, est plus simple mais beaucoup moins utilisé. Les plateformes d'hébergement spécialisées (les « PaaS ») font souvent ce travail sans qu'on voie l'orchestrateur.
Kubernetes demande d'apprendre beaucoup de notions, de maintenir un cluster (ou de le payer), et de surveiller davantage de choses. Pour une application qui tient sur un serveur, avec un peu de coupure acceptable lors d'un déploiement, un serveur avec Compose, ou une plateforme d'hébergement, coûte bien moins cher en temps et en argent. L'orchestration se justifie par un vrai besoin de disponibilité ou de montée en charge, pas parce que « tout le monde le fait ».
10.Kubernetes, les notions clés
Kubernetes a son vocabulaire. Voici les notions qu'on rencontre dès le premier jour :
| Notion | Ce que c'est | L'équivalent Compose |
|---|---|---|
| Cluster et nœud | L'ensemble des machines, et chacune d'elles | Le serveur |
| Pod | La plus petite unité : un ou plusieurs conteneurs qui vivent ensemble | Un conteneur |
| Deployment | « Je veux N copies de ce pod, dans cette version » | Un service, avec replicas |
| Service | Une adresse stable qui répartit le trafic entre les pods | Le nom du service sur le réseau |
| Ingress | L'entrée HTTP(S) depuis l'extérieur, par nom de domaine | Le reverse proxy et ses ports publiés |
| ConfigMap et Secret | La configuration et les secrets, injectés dans les pods | environment, env_file, secrets |
| Sondes (probes) | Les questions « es-tu vivant ? » et « es-tu prêt ? » | healthcheck |
Tout se décrit en YAML, dans des manifestes. Voici une application en trois copies :
# Kubernetes : « je veux 3 copies de mon application, toujours en marche »
apiVersion: apps/v1
kind: Deployment
metadata:
name: shop
spec:
replicas: 3
selector:
matchLabels:
app: shop
strategy:
type: RollingUpdate # remplacer les copies une par une, sans coupure
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app: shop
spec:
containers:
- name: app
image: registry.example.com/shop:1.8.2 # une version précise, jamais « latest »
ports:
- containerPort: 8080
envFrom:
- secretRef:
name: shop-secrets # les secrets, injectés au démarrage
readinessProbe: # « prêt à recevoir du trafic ? »
httpGet:
path: /health
port: 8080
livenessProbe: # « encore vivant ? » sinon, redémarré
httpGet:
path: /health
port: 8080
initialDelaySeconds: 10
resources:
requests: { cpu: 250m, memory: 256Mi } # ce qu'il faut réserver
limits: { memory: 512Mi } # ce qu'il ne doit pas dépasserreplicas: 3: Kubernetes garde trois pods en marche. Si un pod plante, il est remplacé.- La mise à jour progressive (
RollingUpdate) : à chaque nouvelle version, un pod neuf démarre, et un ancien n'est retiré qu'une fois le neuf prêt. Aucune coupure, à condition que l'application le supporte (les migrations de base compatibles, fiche 13). - La sonde
readinessProbe: tant qu'elle échoue, le pod ne reçoit pas de trafic. La sondelivenessProbe: si elle échoue, le pod est redémarré. Une route/healthlégère (fiche 13) répond aux deux. - Les ressources :
requestsréserve de la place sur un nœud,limitsplafonne la mémoire. Sans limite, un pod qui fuit de la mémoire peut gêner tous ses voisins. - L'image a une version précise. Revenir en arrière, c'est redéployer la version précédente.
Le Service et l'Ingress font entrer le trafic jusqu'aux pods :
# Service : une adresse stable devant les copies, qui répartit les requêtes
apiVersion: v1
kind: Service
metadata:
name: shop
spec:
selector:
app: shop
ports:
- port: 80
targetPort: 8080
---
# Ingress : faire entrer le trafic HTTPS de l'extérieur vers le Service
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: shop
spec:
tls:
- hosts: [shop.example.com]
secretName: shop-tls
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: shop
port:
number: 80Comme l'image doit servir directement du HTTP pour que les sondes et le Service fonctionnent simplement, on utilise souvent FrankenPHP (section 4), ou un pod qui contient deux conteneurs : nginx et PHP-FPM.
Les Secret de Kubernetes ne sont, par défaut, qu'encodés en base64, pas chiffrés : quiconque peut lire les manifestes ou l'API du cluster les lit. Limite strictement les droits d'accès au cluster, ne commite jamais un manifeste Secret avec ses valeurs, et utilise un gestionnaire de secrets (celui du fournisseur de cloud, ou un outil dédié) quand c'est possible.
La suite logique, déployer une nouvelle version, la surveiller et revenir en arrière, est l'objet de la fiche 13.
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Modifier un conteneur à la main | La correction disparaît au prochain redémarrage | Corriger le Dockerfile et reconstruire |
| Des données dans le conteneur, sans volume | Perdues à la suppression du conteneur | Un volume, ou un service externe |
COPY . . avant composer install | Chaque modification réinstalle toutes les dépendances | Copier composer.* d'abord |
Pas de .dockerignore | .env.local et .git dans l'image | Un .dockerignore dès le départ |
Un secret dans ENV ou ARG | Lisible par quiconque possède l'image | Secrets injectés au démarrage |
| Un conteneur qui tourne en root | Une faille donne root dans le conteneur | USER sans privilèges |
L'étiquette latest | Une image différente d'un jour à l'autre, retour en arrière impossible | Des versions précises |
"5432:5432" sur un serveur public | La base exposée sur Internet, malgré ufw | Pas de port publié, ou 127.0.0.1: |
docker compose down --volumes par réflexe | Les données de la base sont effacées | down seul, sauf intention claire |
| Kubernetes pour un petit projet | Beaucoup de complexité pour peu de bénéfice | Compose sur un serveur, ou une plateforme d'hébergement |
| Pas de sondes dans Kubernetes | Du trafic envoyé à des pods pas encore prêts | readinessProbe et livenessProbe |
Checklist d'une image de production
- L'image part d'une image officielle, avec une version précise.
- La construction est en plusieurs étapes, et l'image finale ne contient ni Composer ni les dépendances de développement.
- Un
.dockerignoreexclut.git,.env.local,var/etvendor/. - Aucun secret dans le Dockerfile, ni dans les variables de construction.
- Le conteneur tourne avec un utilisateur sans privilèges.
- Chaque version de l'image a une étiquette unique (numéro de version ou commit).
- Seuls les ports nécessaires sont publiés, et la base n'en publie aucun.
- Les données persistantes sont sur des volumes sauvegardés, ou dans un service géré.
- L'image est analysée par un scanner de vulnérabilités dans l'intégration continue.
Conseils
- Pars du
compose.yamlde ce projet. Lis-le ligne par ligne avec cette fiche : chaque ligne y a une raison d'être. - Utilise
docker compose execpour comprendre, jamais pour réparer. - Regarde la taille et l'historique d'une image avec
docker imagesetdocker history: ils montrent ce qui l'alourdit, et ce qu'elle révèle. - Apprends Kubernetes seulement quand un projet en a besoin, et de préférence avec une version gérée par un hébergeur. Les notions de la section 10 suffisent pour lire des manifestes et discuter avec une équipe qui s'en occupe.
- Garde le développement et la production proches : la même image de base, les mêmes versions. C'est tout l'intérêt de Docker.