TPs - DOK
TP 1 · Conteneurisation de l'application legacy Symfony et du front Angular en multi-stage
Objectifs
- Écrire des Dockerfiles multi-stage qui séparent la construction de l'exécution.
- Ordonner les instructions pour tirer parti du cache de build.
- Produire des images qui tournent sans root, sans tag
latestet sans secret embarqué. - Appliquer la règle « un processus par conteneur » à une application PHP-FPM.
Architecture visée à la fin du TP
navigateur
│ http://localhost:8080
▼
┌─────────────────┐
│ front (Nginx) │ fichiers Angular compilés
│ port 8080 │ + proxy /api et /fn
└───┬─────────┬───┘
/api/ │ │ /fn/
▼ ▼
┌──────────────┐ ┌──────────────┐
│ api-web │ │ fn (Node) │
│ (Nginx) 8080 │ │ port 3000 │
└──────┬───────┘ └──────────────┘
│ FastCGI
▼
┌──────────────┐
│ api │
│ PHP-FPM 9000 │
└──────────────┘
réseau Docker « acf-net »
Quatre conteneurs, quatre processus principaux. Aucun Compose à ce stade : il arrive au TP 2.
Partie 0 · Préparation (5 min)
unzip acf-fil-rouge-starter.zip -d ~/formation
cd ~/formation/acf-fil-rouge
ls api front fn
Aucun PHP ni Node n'est requis sur le poste. Pour éviter de retaper le préfixe de vos images :
export REG=registry.example.org/acf # communiqué par le formateur
export TAG=1.0.0
Partie 1 · Le contre-exemple (10 min)
Créez front/Dockerfile.naif avec le contenu suivant. Ne le corrigez pas, il sert de référence.
FROM node:latest
COPY . /app
WORKDIR /app
RUN npm install
RUN npx ng build
EXPOSE 4200
CMD npx ng serve --host 0.0.0.0
cd front
time docker build -f Dockerfile.naif -t acf-front:naif .
docker image ls acf-front:naif
docker run --rm acf-front:naif whoami
Modifiez une ligne du template dans src/app/app.ts (le titre par exemple) et relancez le même build.
❓ Q1. Notez la taille de l'image, l'utilisateur d'exécution et le temps du second build. Quelle étape repart de zéro alors que les dépendances n'ont pas changé ? Pourquoi ?
❓ Q2. Listez au moins quatre défauts de ce Dockerfile pour un usage en production.
Partie 2 · Front Angular en multi-stage (15 min)
2.1 · .dockerignore
Créez front/.dockerignore. Il doit exclure au minimum node_modules/, dist/, .angular/ et .git.
2.2 · Dockerfile
Créez front/Dockerfile en respectant ces contraintes :
| Étape | Contraintes |
|---|---|
build |
Image node:24-alpine. Copier d'abord package.json et package-lock.json, installer avec npm ci, puis copier le reste et lancer npx ng build --configuration production |
runtime |
Image nginxinc/nginx-unprivileged:1.30-alpine (Nginx sans root, écoute sur 8080). Copier uniquement /src/dist/front/browser depuis l'étape build vers /usr/share/nginx/html |
2.3 · Configuration Nginx
Le front doit rediriger /api/ et /fn/ vers les autres conteneurs. Les images Nginx officielles passent les fichiers /etc/nginx/templates/*.template à envsubst au démarrage : on paramètre donc les adresses par variables d'environnement.
Créez front/docker/nginx/default.conf.template :
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
location /api/ {
proxy_pass ${API_UPSTREAM};
}
location /fn/ {
proxy_pass ${FN_UPSTREAM};
}
location / {
try_files $uri $uri/ /index.html;
}
}
Dans l'étape runtime, copiez ce fichier dans /etc/nginx/templates/ et déclarez les valeurs par défaut :
ENV API_UPSTREAM=http://api-web:8080 \
FN_UPSTREAM=http://fn:3000
2.4 · Build et comparaison
docker build -t acf-front:$TAG .
docker image ls acf-front
docker run --rm acf-front:$TAG id
Refaites la modification de app.ts et relancez le build.
❓ Q3. Quelle étape est maintenant servie par le cache (CACHED) ? Quel gain de temps et de taille par rapport à la partie 1 ?Partie 3 · API Symfony en PHP-FPM (20 min)
L'image Symfony se construit en trois étapes dans un seul Dockerfile :
composer:2 ──────────► vendor (dépendances + autoloader + .env compilé)
│
├──► app php:8.4-fpm-alpine → image acf-api
│
└──► web nginx-unprivileged → image acf-api-web
(uniquement public/)
3.1 · .dockerignore
Créez api/.dockerignore : exclure .git, vendor/, var/, .env.local, .env.*.local.
3.2 · Étape vendor
# syntax=docker/dockerfile:1
ARG PHP_VERSION=8.4
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative \
&& composer dump-env prod
❓ Q4. Pourquoicomposer installest-il lancé avantCOPY . .et avec--no-autoloader?
3.3 · Étape app (à écrire)
Contraintes :
- Partir de
php:${PHP_VERSION}-fpm-alpine. - Installer les extensions
opcacheetpdo_mysqlavecdocker-php-ext-install(MySQL servira au TP 2). - Activer le
php.inide production :mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini". - Copier
/appdepuis l'étapevendor. Le code doit appartenir à root ; seul le dossiervar/appartient àwww-data. - Définir
APP_ENV=prodetAPP_DEBUG=0. Déclarer aussiARG APP_VERSION=devet le recopier dansENV APP_VERSION: la version s'affichera dans/api/infoet servira au TP 5. - Passer en
USER www-data, puis préchauffer le cache :php bin/console cache:warmup. - Exposer 9000 et lancer
php-fpmen forme exec :CMD ["php-fpm"].
Bonus : ajouter un fichier docker/php/opcache.ini avec opcache.validate_timestamps=0 et le copier dans $PHP_INI_DIR/conf.d/.
3.4 · Étape web et configuration Nginx
PHP-FPM ne parle pas HTTP, il parle FastCGI. Il faut un Nginx devant lui, dans un autre conteneur.
Créez api/docker/nginx/default.conf.template :
server {
listen 8080;
server_name _;
root /app/public;
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass ${PHP_FPM_ADDR};
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
internal;
}
location ~ \.php$ {
return 404;
}
}
Ajoutez l'étape web : image nginxinc/nginx-unprivileged:1.30-alpine, ENV PHP_FPM_ADDR=api:9000, copie du template, copie de /app/public depuis l'étape vendor.
3.5 · Build des deux cibles
cd ../api
docker build --target app -t acf-api:$TAG .
docker build --target web -t acf-api-web:$TAG .
docker image ls | grep acf-
❓ Q5. Le second build est quasi instantané. Pourquoi ?
Partie 4 · Assemblage sur un réseau Docker (10 min)
Les noms de conteneurs servent de noms DNS sur un réseau utilisateur. L'ordre de démarrage compte : Nginx refuse de démarrer si le nom de son amont n'est pas résolu.
docker network create acf-net
docker run -d --name api --network acf-net -e APP_SECRET=$(openssl rand -hex 16) acf-api:$TAG
docker run -d --name api-web --network acf-net acf-api-web:$TAG
docker run -d --name fn --network acf-net acf-fn:$TAG # après la partie 6, sinon ignorez
docker run -d --name front --network acf-net -p 8080:8080 acf-front:$TAG
docker ps --format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
Si vous n'avez pas encore construit acf-fn, le conteneur front échouera au démarrage (host not found in upstream "fn"). Lancez-le alors avec -e FN_UPSTREAM=http://api-web:8080 en attendant la partie 6.
Tests :
curl -s http://localhost:8080/api/health
curl -s http://localhost:8080/api/info
docker logs api-web
docker logs api
Ouvrez http://localhost:8080 dans le navigateur et cliquez sur « Interroger l'API ».
❓ Q6. Quelle valeur affiche le champuserde/api/info? À quel utilisateur correspond-elle ?
❓ Q7. Où sont écrits les logs d'accès deapi-web? Pourquoi est-ce important pour Kubernetes ?
Partie 5 · Mesures et contrôles (10 min)
L'administrateur système complète le tableau :
| Image | Taille | Utilisateur (id) |
Nombre de couches | Build après modif. d'un fichier source |
|---|---|---|---|---|
acf-front:naif |
||||
acf-front:1.0.0 |
||||
acf-api:1.0.0 |
||||
acf-api-web:1.0.0 |
Commandes utiles :
docker image ls --format '{{.Repository}}:{{.Tag}} {{.Size}}'
docker history acf-api:$TAG
docker image inspect -f '{{len .RootFS.Layers}} couches, user={{.Config.User}}' acf-api:$TAG
docker run --rm --entrypoint id acf-api:$TAG
Tentez d'écrire dans le code de l'application depuis le conteneur :
docker exec api sh -c 'touch /app/src/pirate.php'
docker exec api sh -c 'touch /app/var/ok && echo écriture var/ OK'
❓ Q8. Pourquoi la première commande échoue-t-elle ? En quoi est-ce une protection ?
Tag et envoi au registre (préparation du TP 2) :
for img in acf-front acf-api acf-api-web; do
docker tag $img:$TAG $REG/$img:$TAG
docker push $REG/$img:$TAG
done
Partie 6 · Bonus : la fonction Node (15 min)
Écrivez fn/Dockerfile en trois étapes :
| Étape | Rôle |
|---|---|
build |
npm ci complet puis npm run build (compilation TypeScript vers dist/) |
deps |
npm ci --omit=dev : uniquement les dépendances d'exécution |
runtime |
node:24-alpine, NODE_ENV=production, copie de node_modules depuis deps et de dist depuis build, USER node, CMD ["node", "dist/server.js"] |
cd ../fn
docker build -t acf-fn:$TAG .
docker run -d --name fn --network acf-net acf-fn:$TAG
docker rm -f front && docker run -d --name front --network acf-net -p 8080:8080 acf-front:$TAG
curl -s "http://localhost:8080/fn/echeance?jours=30"
Testez l'arrêt propre :
time docker stop fn
docker logs fn | tail -2
❓ Q9. Combien de temps prenddocker stop? Remplacez temporairement leCMDpar la forme shellCMD node dist/server.js; echo fin, reconstruisez sous le tagacf-fn:shell, relancez et comparez. Regardezdocker exec fn psdans les deux cas. Expliquez l'écart.
Nettoyage
docker rm -f front api-web api fn
docker network rm acf-net
Conservez les images : elles servent au TP 2.
Critères de réussite
- [ ]
http://localhost:8080affiche le front et la réponse JSON de/api/info - [ ] Aucune image ne tourne en root (
idne renvoie pasuid=0) - [ ] Aucune image de base en
latest - [ ] Modifier un fichier source ne relance ni
npm cinicomposer install - [ ]
acf-frontpèse moins de 10 % deacf-front:naif - [ ] Le code de l'API n'est pas modifiable par le processus PHP-FPM
TP 1 · Corrigé
Les fichiers complets sont dans l'archive acf-fil-rouge-corrige.zip. Ce document reprend les Dockerfiles et les réponses attendues aux questions.
front/Dockerfile
# syntax=docker/dockerfile:1
########## Étape 1 : build Angular ##########
FROM node:24-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY . .
RUN npx ng build --configuration production
########## Étape 2 : Nginx non root ##########
FROM nginxinc/nginx-unprivileged:1.30-alpine AS runtime
ENV API_UPSTREAM=http://api-web:8080 \
FN_UPSTREAM=http://fn:3000
COPY docker/nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=build /src/dist/front/browser /usr/share/nginx/html
EXPOSE 8080
api/Dockerfile
# syntax=docker/dockerfile:1
ARG PHP_VERSION=8.4
########## Étape 1 : dépendances Composer ##########
FROM composer:2 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction
COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative \
&& composer dump-env prod
########## Étape 2 : runtime PHP-FPM ##########
FROM php:${PHP_VERSION}-fpm-alpine AS app
RUN docker-php-ext-install opcache pdo_mysql \
&& mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini"
COPY docker/php/opcache.ini "$PHP_INI_DIR/conf.d/zz-opcache.ini"
WORKDIR /app
COPY --from=vendor /app /app
RUN mkdir -p var && chown -R www-data:www-data var
ARG APP_VERSION=dev
ENV APP_ENV=prod APP_DEBUG=0 APP_VERSION=${APP_VERSION}
USER www-data
RUN php bin/console cache:warmup
EXPOSE 9000
CMD ["php-fpm"]
########## Étape 3 : serveur web Nginx devant PHP-FPM ##########
FROM nginxinc/nginx-unprivileged:1.30-alpine AS web
ENV PHP_FPM_ADDR=api:9000
COPY docker/nginx/default.conf.template /etc/nginx/templates/default.conf.template
COPY --from=vendor /app/public /app/public
EXPOSE 8080
api/docker/php/opcache.ini :
opcache.enable=1
opcache.memory_consumption=128
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
fn/Dockerfile
# syntax=docker/dockerfile:1
FROM node:24-alpine AS build
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
FROM node:24-alpine AS deps
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --no-audit --no-fund
FROM node:24-alpine AS runtime
ENV NODE_ENV=production PORT=3000
WORKDIR /app
COPY package.json ./
COPY --from=deps /src/node_modules ./node_modules
COPY --from=build /src/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]
Réponses attendues
Q1. Taille de l'ordre du gigaoctet et demi, exécution en root, second build aussi long que le premier. COPY . /app précède npm install : toute modification d'un fichier source invalide la couche du COPY et toutes les suivantes, donc l'installation des dépendances est rejouée.
Q2. node:latest (build non reproductible), exécution en root, npm install au lieu de npm ci (le lock n'est pas garanti), node_modules et les sources embarqués en production, serveur de développement ng serve en production, CMD en forme shell (signaux non transmis), pas de .dockerignore (un node_modules local serait copié dans le contexte).
Q3. La couche RUN npm ci est CACHED. Build réduit à la compilation Angular. Image finale de l'ordre de 50 à 60 Mo contre plus d'un gigaoctet.
Q4. Les dépendances changent rarement, le code souvent. En installant les dépendances à partir des seuls composer.json / composer.lock, la couche reste en cache tant que le lock est inchangé. --no-autoloader évite de générer un autoloader qui ne connaît pas encore src/ : il est généré après la copie du code. --no-scripts évite les scripts Symfony (cache:clear) qui échoueraient sans le code applicatif.
Q5. L'étape vendor est partagée par les cibles app et web : elle est déjà en cache. BuildKit ne construit que les étapes nécessaires à la cible demandée.
Q6. 82, l'UID de www-data dans les images Alpine officielles de PHP.
Q7. Sur la sortie standard et la sortie d'erreur du conteneur (les fichiers access.log et error.log de l'image sont des liens vers /dev/stdout et /dev/stderr). docker logs puis kubectl logs les lisent directement, et les agents de collecte Kubernetes récupèrent les flux des conteneurs sans configuration applicative.
Q8. /app/src appartient à root et le processus tourne en www-data. Une faille applicative (upload, injection) ne permet pas de réécrire le code servi. Au jour 3, on ira plus loin avec readOnlyRootFilesystem: true dans le securityContext.
Q9. Forme exec : moins d'une seconde, node est PID 1 et son gestionnaire SIGTERM ferme le serveur. Forme shell : environ 10 secondes. /bin/sh -c est PID 1, ne relaie pas SIGTERM à node, et Docker finit par envoyer SIGKILL à l'expiration du délai (10 s par défaut pour docker stop, 30 s pour terminationGracePeriodSeconds en Kubernetes). Conséquence en production : requêtes coupées à chaque déploiement.
Pièges fréquents observés
| Symptôme | Cause | Correction |
|---|---|---|
host not found in upstream "api" au démarrage de api-web |
api n'est pas démarré ou pas sur le même réseau |
Démarrer api d'abord, vérifier --network acf-net |
File not found. renvoyé par l'API |
SCRIPT_FILENAME pointe vers un chemin absent du conteneur PHP |
root /app/public identique côté Nginx et WORKDIR /app côté PHP |
Permission denied sur var/cache |
cache:warmup lancé avant chown ou avant USER |
Respecter l'ordre chown puis USER puis cache:warmup |
COPY failed: ... docker/nginx |
Dossier exclu par le .dockerignore |
Ne pas exclure docker/ |
| Build Angular qui télécharge tout à chaque fois | COPY . . avant npm ci |
Copier d'abord les manifestes de dépendances |