Skip to content

Deployment

TL;DR — Un déploiement Symfony prod se construit autour de 4 invariants : (1) APP_ENV=prod, APP_DEBUG=0, (2) composer install --no-dev --optimize-autoloader, (3) cache:clear --no-warmup puis cache:warmup (container compilé), (4) opcache.validate_timestamps=0 + preload. Pour zero-downtime : symlinks atomiques (style Capistrano/Deployer) ou Docker rolling update. Secrets via .env.local.php chiffré (vault Symfony), env vars d'orchestrateur, ou Vault/AWS SM. FrankenPHP + Runtime change la donne en supprimant FPM.

🧠 Mental model — ASCII diagram + analogy

   git push origin main


   ┌──────────────────────────────────────────────────────┐
   │ CI: build immutable artifact                         │
   │  - composer install --no-dev -o                      │
   │  - bin/console cache:warmup                          │
   │  - bin/console asset-map:compile (or webpack build)  │
   │  - generate preload.php                              │
   │  - docker build → tag :sha-abc123                    │
   └──────────────────────────────────────────────────────┘


   ┌──────────────────────────────────────────────────────┐
   │ CD: rollout (zero-downtime)                          │
   │                                                      │
   │  Strategy A (VM + symlink):                          │
   │    /var/www/releases/2026-05-23-1200/                │
   │    /var/www/current → symlink                        │
   │    1. unpack new release                             │
   │    2. doctrine:migrations:migrate                    │
   │    3. flip symlink                                   │
   │    4. reload php-fpm / send SIGUSR2                  │
   │                                                      │
   │  Strategy B (Kubernetes/Docker):                     │
   │    1. push image                                     │
   │    2. kubectl rollout (maxUnavailable=0)             │
   │    3. readiness probe waits for /healthz             │
   │    4. old pods drained                               │
   └──────────────────────────────────────────────────────┘

Analogie : un déploiement est un changement de scène au théâtre. La nouvelle scène se monte en coulisses (build), on bascule le rideau (symlink ou pod swap), et l'ancienne se démonte. À aucun moment le spectateur (= user) ne voit l'envers du décor.

🛠️ Code minimal — realistic snippet (PHP 8.2+)

dockerfile
# Dockerfile — multi-stage, prod-ready
# syntax=docker/dockerfile:1.7

# Stage 1 : composer deps (cached separately)
FROM composer:2.7 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install \
      --no-dev \
      --no-scripts \
      --no-autoloader \
      --prefer-dist \
      --no-progress

# Stage 2 : runtime
FROM php:8.3-fpm-alpine AS runtime
RUN apk add --no-cache icu-dev libzip-dev oniguruma-dev && \
    docker-php-ext-install opcache intl pdo_pgsql zip && \
    pecl install apcu redis && docker-php-ext-enable apcu redis

# OPcache + preload config
COPY docker/opcache.ini /usr/local/etc/php/conf.d/
COPY docker/php.ini    /usr/local/etc/php/conf.d/

WORKDIR /var/www/app
COPY --from=vendor /app/vendor ./vendor
COPY . .
RUN composer dump-autoload --classmap-authoritative --no-dev && \
    APP_ENV=prod APP_DEBUG=0 \
        bin/console cache:warmup --env=prod && \
    chown -R www-data:www-data var

USER www-data
EXPOSE 9000
CMD ["php-fpm", "-F"]
ini
; docker/opcache.ini
opcache.enable=1
opcache.enable_cli=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.save_comments=1
opcache.jit=tracing
opcache.jit_buffer_size=128M
opcache.preload=/var/www/app/config/preload.php
opcache.preload_user=www-data

realpath_cache_size=4096K
realpath_cache_ttl=600
yaml
# docker-compose.yml — local prod-like
services:
  app:
    image: myapp:${TAG:-latest}
    environment:
      APP_ENV: prod
      APP_DEBUG: '0'
      APP_SECRET: ${APP_SECRET}
      DATABASE_URL: postgresql://app:${DB_PASS}@db:5432/app?serverVersion=16
      REDIS_URL: redis://redis:6379
    depends_on:
      db: { condition: service_healthy }
      redis: { condition: service_started }
    healthcheck:
      test: ["CMD", "php", "-r", "exit(file_get_contents('http://localhost/healthz') ? 0 : 1);"]
      interval: 10s
      timeout: 3s
      retries: 3

  web:
    image: nginx:1.27-alpine
    volumes:
      - ./docker/nginx.conf:/etc/nginx/nginx.conf:ro
    ports: ['80:80']
    depends_on: [app]

  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_USER: app
      POSTGRES_PASSWORD: ${DB_PASS}
      POSTGRES_DB: app
    healthcheck:
      test: ['CMD', 'pg_isready', '-U', 'app']
    volumes: [pgdata:/var/lib/postgresql/data]

  redis:
    image: redis:7-alpine
    command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru

volumes:
  pgdata:
bash
# Secrets — Symfony Vault
bin/console secrets:generate-keys --env=prod
bin/console secrets:set DATABASE_URL --env=prod
bin/console secrets:set STRIPE_SECRET --env=prod

# At runtime, secrets are decrypted automatically via env() with secrets://
# In .env.prod:
DATABASE_URL=secrets://DATABASE_URL

# The decryption key goes to config/secrets/prod/prod.decrypt.private.php
# Commit the encrypted vault, NEVER commit the private key
php
<?php
// deploy.php — Deployer (style Capistrano), à lancer avec `dep deploy prod`
namespace Deployer;

require 'recipe/symfony.php';

set('repository', '[email protected]:acme/app.git');
set('keep_releases', 5);
set('shared_files', ['.env.local']);
set('shared_dirs', ['var/log', 'var/sessions']);
set('writable_dirs', ['var']);

host('prod')
    ->set('remote_user', 'deploy')
    ->set('hostname', 'app.example.com')
    ->set('deploy_path', '/var/www/app');

// Ordre critique : migrate AVANT le flip de symlink si backward-compatible,
// sinon l'ancien code peut frapper un schéma qu'il ne connaît pas (et inversement).
after('deploy:symlink', 'database:migrate');
after('deploy:symlink', 'cachetool:opcache:reset'); // purge OPcache du nouveau release
after('deploy:failed', 'deploy:unlock');            // libère le lock si crash
yaml
# FrankenPHP + Symfony Runtime — modern alternative to PHP-FPM
# Dockerfile
FROM dunglas/frankenphp:1-php8.3-alpine
COPY --link . /app
WORKDIR /app
RUN install-php-extensions opcache intl pdo_pgsql apcu redis @composer && \
    composer install --no-dev -o && \
    APP_ENV=prod bin/console cache:warmup
ENV FRANKENPHP_CONFIG="worker /app/public/index.php"
ENV APP_RUNTIME=Runtime\\FrankenPhpSymfony\\Runtime
php
<?php
// public/index.php with Symfony Runtime
use App\Kernel;

require_once dirname(__DIR__).'/vendor/autoload_runtime.php';

return function (array $context) {
    return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
};

🎯 Patterns courants

  1. Immutable artifact — build une fois (CI), déploie le même image:tag partout (staging → prod). JAMAIS composer install sur la prod.

  2. Symlink atomic switchreleases/timestamp/ + current → release. ln -sfn est atomique sur Linux ext4/xfs. Combine avec php-fpm reload (SIGUSR2) pour purger les fichiers ouverts.

  3. Health endpoint/healthz retourne 200 si DB + Redis + dépendances OK. Readiness probe k8s s'y branche. Distingue liveness (le process tourne) et readiness (le process peut servir).

  4. Migrations en deploydoctrine:migrations:migrate --no-interaction --allow-no-migration. Avant le symlink switch si backward compatible ; après si breaking (mais alors planifie une window).

  5. FrankenPHP + worker mode — bootstrap Symfony une fois, sert N requêtes (comme RoadRunner, Octane). Multiplie le throughput x3-10. Attention : services stateful = fuites. Audite avec Symfony\Component\HttpKernel\Event\KernelEvent::FINISH_REQUEST.

  6. Secret rotationsecrets:set ré-encrypte. Combine avec un vault externe (Vault, AWS Secrets Manager, Doppler) pour les rotations automatiques. Évite les secrets dans .env committed.

🔄 Versions

SymfonyRuntime / Deploy notes
5.4Runtime component arrive (separate package). secrets stable.
6.0Runtime intégré, public/index.php utilise autoload_runtime.php.
6.3Asset Mapper (replaces Encore for simple cases) — no Node.js needed.
6.4LTS. Vault encrypted with Sodium par défaut.
7.0Suppression de chemins legacy (config/bundles.php requis, plus de fallback).
7.1+Améliorations Asset Mapper, importmap.
PHPSymfonyNotes deploy
8.15.4–6.xMin pour Symfony 6+
8.26.3+readonly classes, DNF types
8.36.4+typed const, json_validate (perf)
8.47.1+property hooks, asymmetric visibility

FrankenPHP : v1.0 (mai 2024), v1.1+ production-grade. Caddy à l'intérieur → HTTP/3, HTTPS automatique.

⚠️ Pitfalls

  1. composer install sur le serveur — accès git, accès Packagist, races deploy. Build dans CI, transfère artifact. Jamais sur le serveur de prod.

  2. cache:clear qui supprime les sessions — par défaut var/cache/ est nettoyé. Si tes sessions sont en fichiers dans var/sessions/, OK (chemin différent). Mais vérifie ton config framework.session.handler_id.

  3. OPcache non purgé après deploy — symbtome : nouveau code, ancien comportement. Solutions : (a) cachetool opcache:reset, (b) php-fpm reload, (c) preload regen + restart. Toujours dans le hook post-deploy.

  4. Secrets en clair dans DockerfileENV STRIPE_KEY=sk_live_xxx finit dans docker history. Utilise --secret (BuildKit) ou env vars d'orchestrateur au runtime, pas au build.

  5. Migrations non-atomicALTER TABLE qui locke en MySQL pendant 5min → outage. Utilise migration online (pt-online-schema-change, gh-ost) ou planifie. Symfony peut filer le SQL via --dry-run.

  6. .env committed avec credentials — classic. Ajoute .env.local, .env.*.local au .gitignore (Symfony le fait par défaut, vérifie).

  7. Container compilé pour la mauvaise envcache:warmup sans --env=prod warme dev. Vérifie var/cache/prod/App_KernelProdContainer.preload.php existe.

  8. Worker mode + state globalstatic $cache = [] ou un singleton qui accumule → memory leak progressif. Watch memory_get_usage() sur les workers FrankenPHP/RoadRunner.

🧪 Testing

bash
# Smoke test post-deploy
curl -fsS https://app.example.com/healthz \
  || { echo "Deploy failed health check"; exit 1; }

# Verify OPcache loaded
curl -fsS https://app.example.com/_internal/opcache-status \
  | jq '.opcache_enabled, .cached_scripts'

# Verify preload
php -r "var_dump(opcache_get_status()['preload_statistics']['classes'] ?? 0);"

Pour tester un Dockerfile : docker build --target runtime localement, docker run avec les env vars prod-like, hit /healthz.

🎬 Cas d'usage concrets

Scénario 1 — Cabinet juridique sur Scaleway souverain (Septeo, Cellence)

Un éditeur de SaaS DMS pour cabinets d'avocats doit héberger des données couvertes par le secret professionnel et exigences RGPD strictes (zone UE, idéalement France). Le choix s'oriente vers Scaleway (Paris/Amsterdam) ou OVHcloud, certifiés SecNumCloud pour les cabinets sensibles (avocats d'État, défense). Le déploiement combine : Kapsule (Kubernetes managé Scaleway), Object Storage (équivalent S3) pour les PJ et signatures eIDAS, RDB Managed PostgreSQL avec chiffrement at-rest, Redis managé pour cache/sessions. La CI GitLab pousse une image Docker multi-stage signée Cosign sur le registry interne Scaleway. Le déploiement est blue-green via un Ingress Nginx, avec deux Deployment qui se relaient. Les secrets (clés API Legifrance, clés HSM signature) sont injectés via Scaleway Secret Manager + secrets:set Symfony Vault pour les non-sensibles. La sauvegarde quotidienne PostgreSQL est répliquée dans une seconde région Scaleway (résilience zone). La conformité ISO 27001 + HDS (hébergement données de santé pour cabinets en médecine légale) est documentée par Scaleway, pas à refaire.

Scénario 2 — Industrie on-prem Kubernetes (Michelin, Schneider Electric, Vinci)

Un grand industriel français déploie ses applications Symfony métier (gestion MES, supervision énergie) sur un cluster Kubernetes on-prem (OpenShift Red Hat ou Rancher). Les contraintes : aucun accès internet sortant depuis les pods (proxy obligatoire), images Docker signées et scannées via Trivy + Sigstore, déploiement GitOps via ArgoCD qui synchronise un repo Git de manifests Helm. Symfony tourne avec FrankenPHP en mode worker, packagé via une image PHP 8.3 multi-stage : stage composer (installation deps no-dev), stage assets (build Webpack Encore ou AssetMapper), stage final frankenphp/frankenphp:1.5-php8.3 avec OPcache preload activé. Les Liveness/Readiness probes pointent sur /healthz (custom controller qui vérifie DB + Redis + bus messages). Le rolling update est progressif (maxSurge=1, maxUnavailable=0). Les logs JSON Monolog sont collectés par Fluentd → Elasticsearch interne. Les métriques Prometheus exposées via nicwortel/symfony-prometheus-exporter-bundle. Le scaling horizontal (HPA) se déclenche sur CPU > 70%.

Scénario 3 — FrankenPHP e-commerce (Veepee, Asphalte, ManoMano)

Une boutique en ligne en croissance forte (250 req/s en pic) migre de PHP-FPM + Nginx vers FrankenPHP pour gagner en performance. Le déploiement est sur AWS ECS Fargate avec image Docker dunglas/frankenphp:1.5-php8.3-alpine. Le mode worker garde l'application en mémoire entre les requêtes (vs PHP-FPM qui boot à chaque request) — gain mesuré : 12ms → 3ms sur l'overhead framework. OPcache preload de 1 200 classes (entités Doctrine + serializer metadata + routing). L'autoscaling ECS est piloté par la métrique custom RequestsPerTarget Application Load Balancer. Les déploiements zéro downtime via le rolling update Fargate (target group draining 30s). Les workers Messenger (async, notifier) tournent dans une Service ECS séparée, sans le port 80. Les assets statiques sont servis depuis CloudFront + S3, l'app ne fait que de la logique. Mercure (intégré FrankenPHP) sert les pushes de stock en temps réel aux 30 000 visiteurs simultanés sans serveur séparé. Coût AWS divisé par 1.6 vs l'ancien stack PHP-FPM 8 fois plus de conteneurs.

🛠️ Exemple end-to-end

Cas : Dockerfile FrankenPHP + manifest Kubernetes + pipeline CI GitHub Actions + healthcheck applicatif.

dockerfile
# Dockerfile
ARG PHP_VERSION=8.3
ARG FRANKEN_VERSION=1.5

FROM composer:2.7 AS vendor
WORKDIR /app
COPY composer.json composer.lock symfony.lock ./
RUN composer install --no-dev --no-scripts --prefer-dist --no-interaction --no-progress

FROM node:22-alpine AS assets
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY assets ./assets
COPY config/ ./config/
COPY importmap.php ./
RUN npm run build

FROM dunglas/frankenphp:${FRANKEN_VERSION}-php${PHP_VERSION}-alpine AS runtime
ENV APP_ENV=prod \
    APP_DEBUG=0 \
    SERVER_NAME=":80" \
    FRANKENPHP_CONFIG="worker ./public/index.php"

RUN install-php-extensions \
        pdo_pgsql redis opcache intl zip apcu

COPY --chown=www-data:www-data --from=vendor /app/vendor /app/vendor
COPY --chown=www-data:www-data --from=assets /app/public/build /app/public/build
COPY --chown=www-data:www-data . /app
WORKDIR /app

RUN php bin/console cache:warmup --env=prod \
    && php -d opcache.enable_cli=1 -d opcache.preload=/app/config/preload.php -r "echo 'preload OK';"

HEALTHCHECK --interval=15s --timeout=3s --start-period=20s --retries=3 \
    CMD wget -q -O- http://127.0.0.1/healthz | grep -q '"status":"ok"'

USER www-data
EXPOSE 80
CMD ["frankenphp", "run", "--config", "/etc/caddy/Caddyfile"]
yaml
# .github/workflows/deploy.yml
name: deploy
on:
    push:
        branches: [main]

jobs:
    build-and-deploy:
        runs-on: ubuntu-24.04
        permissions: { id-token: write, contents: read }
        steps:
            - uses: actions/checkout@v4
            - uses: docker/setup-buildx-action@v3
            - uses: docker/login-action@v3
              with:
                  registry: rg.fr-par.scw.cloud
                  username: nologin
                  password: ${{ secrets.SCW_SECRET_KEY }}
            - name: Build & push
              uses: docker/build-push-action@v6
              with:
                  context: .
                  push: true
                  cache-from: type=gha
                  cache-to: type=gha,mode=max
                  tags: |
                      rg.fr-par.scw.cloud/cabinet/dms:${{ github.sha }}
                      rg.fr-par.scw.cloud/cabinet/dms:latest
            - name: Sign image
              uses: sigstore/cosign-installer@v3
            - run: cosign sign --yes rg.fr-par.scw.cloud/cabinet/dms:${{ github.sha }}
            - name: Deploy via ArgoCD
              run: |
                  argocd app set dms --helm-set image.tag=${{ github.sha }}
                  argocd app sync dms --prune
                  argocd app wait dms --health --timeout 300
yaml
# k8s/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
    name: dms-web
spec:
    replicas: 4
    strategy:
        rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
    template:
        spec:
            containers:
                - name: web
                  image: rg.fr-par.scw.cloud/cabinet/dms:latest
                  ports: [{ containerPort: 80 }]
                  env:
                      - { name: APP_ENV, value: prod }
                      - { name: DATABASE_URL, valueFrom: { secretKeyRef: { name: dms-secrets, key: db-url } } }
                  livenessProbe:
                      httpGet: { path: /healthz, port: 80 }
                      initialDelaySeconds: 20
                      periodSeconds: 10
                  readinessProbe:
                      httpGet: { path: /healthz/ready, port: 80 }
                      periodSeconds: 5
                  resources:
                      requests: { cpu: 250m, memory: 256Mi }
                      limits: { cpu: 1000m, memory: 512Mi }
php
<?php
// src/Controller/HealthController.php
declare(strict_types=1);

namespace App\Controller;

use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Contracts\Cache\CacheInterface;

final readonly class HealthController
{
    public function __construct(
        private Connection $db,
        private CacheInterface $cache,
    ) {}

    #[Route('/healthz', name: 'health_live', methods: ['GET'])]
    public function liveness(): JsonResponse
    {
        return new JsonResponse(['status' => 'ok', 'service' => 'dms', 'ts' => time()]);
    }

    #[Route('/healthz/ready', name: 'health_ready', methods: ['GET'])]
    public function readiness(): JsonResponse
    {
        $checks = [];
        try {
            $this->db->executeQuery('SELECT 1');
            $checks['db'] = 'ok';
        } catch (\Throwable $e) {
            $checks['db'] = 'fail';
        }
        try {
            $this->cache->get('health.ping', fn () => 'pong');
            $checks['cache'] = 'ok';
        } catch (\Throwable $e) {
            $checks['cache'] = 'fail';
        }
        $ok = !in_array('fail', $checks, true);
        return new JsonResponse(['status' => $ok ? 'ok' : 'degraded', 'checks' => $checks], $ok ? 200 : 503);
    }
}

Couverture : image OCI multi-stage + CI signature Cosign + déploiement ArgoCD + manifest avec probes + endpoint health applicatif. Le pipeline est reproductible sur Scaleway, OVHcloud, ou cluster on-prem.


🏗️ Comment un staff engineer raisonne

1. Migrations + rolling deploy : le piège du « N et N-1 qui coexistent »

Pendant un rolling update (maxUnavailable=0), deux versions du code tournent simultanément : les anciens pods (N-1) et les nouveaux (N) servent du trafic en même temps, parfois pendant plusieurs minutes. La migration de schéma, elle, est instantanée et globale. Donc chaque migration doit être compatible avec le code qui l'entoure des deux côtés.

La seule façon sûre de faire évoluer un schéma sans downtime est le pattern expand / contract (aussi appelé parallel change), en 3 déploiements distincts :

PhaseMigrationCodeInvariant
ExpandADD COLUMN email_new (nullable)écrit dans l'ancienne ET la nouvelle colonneN-1 ignore email_new, N l'alimente
Migratebackfill UPDATE ... SET email_new = email (par batch)lit email_new avec fallback sur emailaucune colonne supprimée
ContractDROP COLUMN email (ancienne)ne lit plus que email_newplus aucun code ne référence l'ancienne

Un renommage de colonne « atomique » (RENAME COLUMN) en un seul deploy casse forcément N-1, qui cherche l'ancien nom. Le tableau ci-dessus transforme un changement breaking en trois changements backward-compatible.

Règle de décision rapide :

Migration additive (ADD COLUMN nullable, ADD INDEX CONCURRENTLY, ADD TABLE)
    → safe AVANT le flip. Lock court ou nul.

Migration destructive (DROP/RENAME COLUMN, NOT NULL, type change, DROP TABLE)
    → JAMAIS dans le même deploy que le code qui en dépend.
      Découpe en expand → backfill → contract sur 2-3 releases.

ALTER qui locke (MySQL < 8.0 pré-instant DDL, gros ADD COLUMN avec default)
    → fenêtre planifiée OU outil online (gh-ost, pt-online-schema-change).

PostgreSQL : CREATE INDEX CONCURRENTLY ne locke pas en écriture mais ne peut pas tourner dans une transaction — donc dans une migration Doctrine, surcharge isTransactional() pour retourner false.

php
<?php
// migrations/Version20260601_AddIndexConcurrently.php
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;

final class Version20260601_AddIndexConcurrently extends AbstractMigration
{
    // CONCURRENTLY interdit dans une transaction — Doctrine wrappe par défaut.
    public function isTransactional(): bool
    {
        return false;
    }

    public function up(Schema $schema): void
    {
        $this->addSql('CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_order_status ON "order" (status)');
    }
}

2. Pourquoi opcache.validate_timestamps=0 impose un reset explicite

En prod on veut validate_timestamps=0 : OPcache ne vérifie jamais le mtime des fichiers .php, ce qui supprime un stat() par include et par requête (gain mesurable à fort trafic). Le prix : le code en mémoire n'est jamais ré-évalué. Après un deploy, le process PHP-FPM continue d'exécuter l'ancien bytecode jusqu'à ce qu'on force une purge.

Les options, du plus brutal au plus chirurgical :

MéthodeDowntimeEffet
kill -USR2 php-fpm (graceful reload)nulnouveaux workers chargent le nouveau code, anciens finissent leurs requêtes
cachetool opcache:reset --fcginulvide le cache OPcache via le socket FPM, sans toucher au process
Restart conteneur (k8s rolling)nul (rolling)process neuf = OPcache + preload neufs
opcache_reset() HTTP endpointnul mais dangereuxreset partagé entre workers, thundering herd de recompilation

Le preload (opcache.preload) ajoute une subtilité : il est chargé une fois au démarrage du master FPM, pas par worker. On ne peut pas le rafraîchir sans redémarrer le master (un USR2 ne suffit pas toujours selon la version). En conteneur c'est trivial — le nouveau release = nouveau conteneur = nouveau master. Sur VM avec symlink, c'est l'argument décisif pour préférer un systemctl restart php-fpm à un simple reload après un changement de preload.

3. Worker mode (FrankenPHP/RoadRunner) : ce qui fuit et comment le détecter

En mode worker, le kernel Symfony est booté une fois puis sert N requêtes. Tout ce qui était « nettoyé » par la mort du process FPM survit désormais entre requêtes. Les fuites classiques :

  • État statique : static $cache = [] qui grossit, registries qui accumulent, EntityManager qui garde toutes les entités managées (l'identity map ne se vide jamais → memory leak certain).
  • Connexions : une connexion DB/Redis morte (timeout côté serveur) reste « ouverte » côté worker → erreurs MySQL server has gone away intermittentes.
  • Services stateful : un logger qui buffer, un RequestStack mal nettoyé, des locks non relâchés.

Le runtime Symfony émet kernel.reset entre requêtes pour réinitialiser les services taggués kernel.reset (Doctrine s'y branche pour clear() l'EM). Tout service custom qui garde de l'état doit implémenter ResetInterface :

php
<?php
use Symfony\Contracts\Service\ResetInterface;

final class RequestScopedAccumulator implements ResetInterface
{
    /** @var array<string,mixed> */
    private array $buffer = [];

    public function add(string $k, mixed $v): void { $this->buffer[$k] = $v; }

    // Appelé automatiquement entre deux requêtes en mode worker.
    public function reset(): void
    {
        $this->buffer = [];
    }
}

Détection en prod : exporte memory_get_usage(true) par worker comme métrique Prometheus ; une courbe en escalier qui monte sans redescendre = fuite. Filet de sécurité : --max-requests=500 (Messenger / config worker) recycle le process avant que la fuite ne devienne critique — c'est un patch, pas un fix.

4. Tradeoff stratégies de déploiement

StratégieDowntimeRollbackCoût infraRisque schémaQuand
Recreateoui (secondes-minutes)rapidex1faible (1 version à la fois)batch interne, maintenance window OK
Rollingnulprogressifx1 + surgeélevé (N et N-1 coexistent)défaut k8s, schéma backward-compat
Blue-greennulinstantané (re-flip)x2 pendant le switchmoyen (cutover net)release risquée, besoin de rollback < 1s
Canarynulrapide (couper le canary)x1 + canarymoyenvalider une version sur 5 % du trafic
Symlink atomique (VM)nulinstantané (re-symlink)x1faible (cutover net)mono-serveur, pas de k8s

Le vrai axe de décision n'est pas « lequel est le plus moderne » mais « quelle est ta tolérance au rollback et ta contrainte de schéma ». Blue-green offre le rollback le plus net mais double l'infra et exige que les deux couleurs partagent la même DB (donc mêmes contraintes expand/contract). Canary suppose une observabilité solide pour décider automatiquement go/no-go.


🔁 Quand utiliser / éviter

Utiliser :

  • Multi-stage Docker builds : artifact léger, reproductible, signable.
  • Symfony Vault pour secrets versionnés avec le code (compatible Git, audit log).
  • FrankenPHP / Runtime pour des APIs à fort trafic — gain perf significatif.
  • Deployer pour des projets non-containerisés (legacy, serveur unique).

Éviter :

  • Editer du code en prod via SSH. Tu perds la traçabilité, le prochain deploy écrase.
  • Hot-reload sans health check. Tu peux servir du trafic vers un pod broken.
  • Stocker des credentials en clair dans config/. Préfère env vars + vault.
  • Migrations couplées au deploy : si la migration échoue, le deploy aussi. Décorrèle : déploie le code → puis migrate via job dédié.

🏋️ Exercices

Progression : reproduire → durcir pour la prod → casser puis réparer. Fais-les sur un vrai projet Symfony 7.x, pas en lecture seule.

1. Image immutable reproductible (implement)

Objectif : produire une image Docker multi-stage dont le digest est identique sur deux builds successifs du même commit.

Construis le Dockerfile multi-stage (vendor → assets → runtime), build deux fois et compare les digests :

bash
docker build -t app:a .
docker build -t app:b .
docker inspect --format '{{.Id}}' app:a app:b   # doivent être identiques

Si les digests diffèrent, traque la source de non-déterminisme.

Indice / Solution

Les coupables habituels : timestamps de fichiers (COPY préserve le mtime → fige-le ou utilise --link), ordre non déterministe de composer dump-autoload (utilise --classmap-authoritative), npm ci (déterministe) vs npm install (non), et les RUN apk add sans version pinnée. Pin les versions de base (php:8.3.14-fpm-alpine, pas :8.3). Vérifie aussi que .dockerignore exclut var/, .git/, node_modules/ sinon le contexte varie.

2. Healthcheck à deux niveaux + readiness gate (implement → production-grade)

Objectif : un /healthz (liveness) qui ne teste QUE le process, et un /healthz/ready (readiness) qui teste DB + Redis + bus Messenger, avec un timeout par dépendance.

Étends le HealthController du chapitre : ajoute un check Messenger (transport joignable), enveloppe chaque check dans un timeout (300 ms) pour qu'une dépendance lente ne fasse pas timeout la probe entière, et retourne 503 si readiness échoue mais 200 pour liveness même si la DB est down.

Indice / Solution

La distinction est critique : si /healthz (liveness) testait la DB, une panne DB ferait redémarrer tous les pods par k8s (liveness fail → kill), aggravant l'incident. Liveness = « le PHP répond ». Readiness = « je peux servir du trafic utile » → k8s retire le pod du load balancer sans le tuer. Pour le timeout par dépendance : pcntl_alarm est fragile en FPM ; préfère un timeout au niveau du driver (PDO::ATTR_TIMEOUT, Redis::setOption(OPT_READ_TIMEOUT)) ou un Symfony\Component\Clock + budget. Cache le résultat readiness 1-2 s pour éviter de marteler la DB à chaque probe (periodSeconds: 5 × N pods).

3. Migration zero-downtime expand/contract (production-grade)

Objectif : renommer une colonne customer.emailcustomer.contact_email sur une table de 10M lignes, sans downtime ni lock long, pendant qu'un rolling deploy tourne.

Écris les trois migrations (expand, backfill par batch, contract) et le code intermédiaire qui écrit dans les deux colonnes puis lit avec fallback. Le backfill doit se faire par lots de 5 000 lignes pour ne pas locker la table.

Indice / Solution

Deploy 1 (expand) : ADD COLUMN contact_email nullable + le code écrit dans les deux. Backfill hors deploy via une commande console UPDATE ... WHERE contact_email IS NULL LIMIT 5000 en boucle avec sleep (ou un message Messenger récursif). Deploy 2 : le code ne lit plus que contact_email. Deploy 3 (contract) : DROP COLUMN email. Sur Postgres, ne mets pas NOT NULL directement (lock de validation full-table) : ajoute la contrainte NOT VALID puis VALIDATE CONSTRAINT séparément. Sur MySQL 8, instant DDL rend l'ADD COLUMN instantané mais pas le backfill.

4. Casser puis réparer : OPcache fantôme (break-then-fix)

Objectif : reproduire le bug « nouveau code, ancien comportement » puis le corriger proprement dans le hook de deploy.

Avec opcache.validate_timestamps=0, déploie une modif de contrôleur sans purger OPcache (juste un COPY des fichiers sur un conteneur déjà up, ou un git pull sur une VM). Constate que l'ancien comportement persiste. Puis corrige via cachetool opcache:reset --fcgi, mesure l'impact, et explique pourquoi un endpoint HTTP opcache_reset() est une mauvaise idée à fort trafic.

Indice / Solution

opcache_reset() purge le cache partagé entre TOUS les workers d'un coup → la requête suivante de chaque worker doit recompiler tout le code à froid simultanément = pic CPU + latence (thundering herd / cache stampede). À 250 req/s, ça se voit. La bonne approche : remplacer le process (rolling restart conteneur) ou cachetool qui passe par le socket FPM sans servir de trafic pendant le reset. En conteneur immutable, le problème n'existe pas : nouveau release = nouvelle image = nouveau process = OPcache vierge. C'est l'argument numéro un en faveur des artifacts immutables vs git pull en prod.

5. Fuite mémoire en worker mode (break-then-fix, hard)

Objectif : provoquer une fuite mémoire en mode worker FrankenPHP, l'observer via une métrique, puis la colmater.

Crée un service singleton avec private array $seen = [] que tu remplis à chaque requête sans jamais vider. Lance FrankenPHP en worker mode, bombarde avec wrk/hey, et trace memory_get_usage(true). Constate la courbe en escalier. Corrige en implémentant ResetInterface.

Indice / Solution

Le service accumule car le mode worker ne détruit pas le process entre requêtes. Implémente ResetInterface::reset() pour vider $seen ; le runtime appelle reset() sur tous les services taggués kernel.reset à chaque FINISH_REQUEST. Vérifie aussi que Doctrine clear() bien son EM (sinon l'identity map fuit indépendamment de ton service). Filet de sécurité production : configure le worker pour se recycler après N requêtes (--max-requests), mais documente que c'est un garde-fou, pas la correction. Bonus : expose php_memory_bytes en Prometheus et pose une alerte sur la dérivée positive soutenue.

6. Pipeline CD avec rollback automatique (architect)

Objectif : un pipeline GitHub Actions qui déploie en canary 10 %, surveille le taux d'erreur 5xx pendant 2 min, et rollback automatiquement si le SLO est dépassé.

Branche un canary (ArgoCD Rollouts ou Flagger), interroge Prometheus après le déploiement du canary (rate(http_requests_total{status=~"5.."}[2m])), et argocd app rollback / abort si le seuil dépasse 1 %. Sinon, promeus à 100 %.

Indice / Solution

Avec Argo Rollouts, définis une AnalysisTemplate qui query Prometheus et fixe un failureLimit. Le rollout passe automatiquement les steps setWeight: 10pause → analyse → setWeight: 100, et abort si l'analyse échoue. Sans Argo, un step run: qui curl Prometheus + exit 1 en cas de dépassement déclenche le job de rollback (needs: + if: failure()). Le piège : un canary à 10 % du trafic peut ne pas voir une erreur rare ; combine taux d'erreur ET latence p99, et garde une fenêtre d'analyse assez longue pour que le signal soit statistiquement valable.

🎤 En entretien

Q : Tu as opcache.validate_timestamps=0 en prod. Tu déploies, le nouveau code ne s'applique pas. Pourquoi, et comment tu le règles sans downtime ? OPcache garde le bytecode compilé en mémoire et ne vérifie plus le mtime des fichiers — l'ancien code tourne jusqu'à une purge explicite. Solution propre : remplacer le process (rolling restart conteneur) ou cachetool opcache:reset via le socket FPM ; jamais opcache_reset() HTTP qui provoque un cache stampede de recompilation à froid. En artifact immutable le problème disparaît car chaque release est un process neuf.

Q : Rolling deploy avec maxUnavailable=0. Quelle migration de schéma va casser, et comment tu l'évites ? Pendant le rolling, N-1 et N coexistent et frappent le même schéma. Une migration destructive (RENAME/DROP COLUMN, NOT NULL) casse forcément une des deux versions. On applique le pattern expand/contract : déploie d'abord additif (nouvelle colonne nullable, code à double écriture), backfille hors deploy par batch, puis dans un deploy ultérieur supprime l'ancienne colonne quand plus aucun code ne la référence.

Q : Quand préfères-tu blue-green à un rolling update ? Quand le rollback doit être quasi instantané et net : blue-green garde l'ancienne version entièrement up, le switch est un flip de routeur/ingress et le rollback un re-flip en < 1s, sans coexistence prolongée de versions. Le coût : ×2 infra pendant le switch et les deux couleurs partagent la même DB, donc les contraintes expand/contract s'appliquent quand même. Rolling est moins cher mais le rollback est progressif et la coexistence N/N-1 dure plus longtemps.

Q : FrankenPHP en worker mode multiplie le throughput. Quel est le risque principal et comment tu le surveilles ? Le kernel est booté une fois et sert N requêtes : tout état qui survivait à la mort du process FPM (statics, identity map Doctrine, connexions, buffers) fuit désormais entre requêtes. On implémente ResetInterface sur les services stateful (réinitialisés à chaque kernel.reset), on s'assure que l'EM se clear(), on exporte memory_get_usage() par worker en Prometheus pour détecter la courbe en escalier, et on configure --max-requests comme garde-fou (pas comme correctif).

🔗 Liens

Bibliothèque tech perso — Achref