TPs - DOK

TP 1 · Conteneurisation de l'application legacy Symfony et du front Angular en multi-stage

Objectifs

  1. Écrire des Dockerfiles multi-stage qui séparent la construction de l'exécution.
  2. Ordonner les instructions pour tirer parti du cache de build.
  3. Produire des images qui tournent sans root, sans tag latest et sans secret embarqué.
  4. 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. Pourquoi composer install est-il lancé avant COPY . . et avec --no-autoloader ?

3.3 · Étape app (à écrire)

Contraintes :

  1. Partir de php:${PHP_VERSION}-fpm-alpine.
  2. Installer les extensions opcache et pdo_mysql avec docker-php-ext-install (MySQL servira au TP 2).
  3. Activer le php.ini de production : mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini".
  4. Copier /app depuis l'étape vendor. Le code doit appartenir à root ; seul le dossier var/ appartient à www-data.
  5. Définir APP_ENV=prod et APP_DEBUG=0. Déclarer aussi ARG APP_VERSION=dev et le recopier dans ENV APP_VERSION : la version s'affichera dans /api/info et servira au TP 5.
  6. Passer en USER www-data, puis préchauffer le cache : php bin/console cache:warmup.
  7. Exposer 9000 et lancer php-fpm en 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 champ user de /api/info ? À quel utilisateur correspond-elle ?

❓ Q7. Où sont écrits les logs d'accès de api-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 prend docker stop ? Remplacez temporairement le CMD par la forme shell CMD node dist/server.js; echo fin, reconstruisez sous le tag acf-fn:shell, relancez et comparez. Regardez docker exec fn ps dans 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:8080 affiche le front et la réponse JSON de /api/info
  • [ ] Aucune image ne tourne en root (id ne renvoie pas uid=0)
  • [ ] Aucune image de base en latest
  • [ ] Modifier un fichier source ne relance ni npm ci ni composer install
  • [ ] acf-front pèse moins de 10 % de acf-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