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-warmuppuiscache: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.phpchiffré (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 — 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"]; 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# 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:# 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
// 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# 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
// 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
Immutable artifact — build une fois (CI), déploie le même
image:tagpartout (staging → prod). JAMAIScomposer installsur la prod.Symlink atomic switch —
releases/timestamp/+current → release.ln -sfnest atomique sur Linux ext4/xfs. Combine avecphp-fpm reload(SIGUSR2) pour purger les fichiers ouverts.Health endpoint —
/healthzretourne 200 si DB + Redis + dépendances OK. Readiness probe k8s s'y branche. Distingue liveness (le process tourne) et readiness (le process peut servir).Migrations en deploy —
doctrine:migrations:migrate --no-interaction --allow-no-migration. Avant le symlink switch si backward compatible ; après si breaking (mais alors planifie une window).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.Secret rotation —
secrets:setré-encrypte. Combine avec un vault externe (Vault, AWS Secrets Manager, Doppler) pour les rotations automatiques. Évite les secrets dans.envcommitted.
🔄 Versions
| Symfony | Runtime / Deploy notes |
|---|---|
| 5.4 | Runtime component arrive (separate package). secrets stable. |
| 6.0 | Runtime intégré, public/index.php utilise autoload_runtime.php. |
| 6.3 | Asset Mapper (replaces Encore for simple cases) — no Node.js needed. |
| 6.4 | LTS. Vault encrypted with Sodium par défaut. |
| 7.0 | Suppression de chemins legacy (config/bundles.php requis, plus de fallback). |
| 7.1+ | Améliorations Asset Mapper, importmap. |
| PHP | Symfony | Notes deploy |
|---|---|---|
| 8.1 | 5.4–6.x | Min pour Symfony 6+ |
| 8.2 | 6.3+ | readonly classes, DNF types |
| 8.3 | 6.4+ | typed const, json_validate (perf) |
| 8.4 | 7.1+ | property hooks, asymmetric visibility |
FrankenPHP : v1.0 (mai 2024), v1.1+ production-grade. Caddy à l'intérieur → HTTP/3, HTTPS automatique.
⚠️ Pitfalls
composer installsur le serveur — accès git, accès Packagist, races deploy. Build dans CI, transfère artifact. Jamais sur le serveur de prod.cache:clearqui supprime les sessions — par défautvar/cache/est nettoyé. Si tes sessions sont en fichiers dansvar/sessions/, OK (chemin différent). Mais vérifie ton configframework.session.handler_id.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.Secrets en clair dans Dockerfile —
ENV STRIPE_KEY=sk_live_xxxfinit dansdocker history. Utilise--secret(BuildKit) ou env vars d'orchestrateur au runtime, pas au build.Migrations non-atomic —
ALTER TABLEqui 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..envcommitted avec credentials — classic. Ajoute.env.local,.env.*.localau.gitignore(Symfony le fait par défaut, vérifie).Container compilé pour la mauvaise env —
cache:warmupsans--env=prodwarmedev. Vérifievar/cache/prod/App_KernelProdContainer.preload.phpexiste.Worker mode + state global —
static $cache = []ou un singleton qui accumule → memory leak progressif. Watchmemory_get_usage()sur les workers FrankenPHP/RoadRunner.
🧪 Testing
# 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
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"]# .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# 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
// 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 :
| Phase | Migration | Code | Invariant |
|---|---|---|---|
| Expand | ADD COLUMN email_new (nullable) | écrit dans l'ancienne ET la nouvelle colonne | N-1 ignore email_new, N l'alimente |
| Migrate | backfill UPDATE ... SET email_new = email (par batch) | lit email_new avec fallback sur email | aucune colonne supprimée |
| Contract | DROP COLUMN email (ancienne) | ne lit plus que email_new | plus 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
// 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éthode | Downtime | Effet |
|---|---|---|
kill -USR2 php-fpm (graceful reload) | nul | nouveaux workers chargent le nouveau code, anciens finissent leurs requêtes |
cachetool opcache:reset --fcgi | nul | vide 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 endpoint | nul mais dangereux | reset 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,EntityManagerqui 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 awayintermittentes. - Services stateful : un logger qui buffer, un
RequestStackmal 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
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égie | Downtime | Rollback | Coût infra | Risque schéma | Quand |
|---|---|---|---|---|---|
| Recreate | oui (secondes-minutes) | rapide | x1 | faible (1 version à la fois) | batch interne, maintenance window OK |
| Rolling | nul | progressif | x1 + surge | élevé (N et N-1 coexistent) | défaut k8s, schéma backward-compat |
| Blue-green | nul | instantané (re-flip) | x2 pendant le switch | moyen (cutover net) | release risquée, besoin de rollback < 1s |
| Canary | nul | rapide (couper le canary) | x1 + canary | moyen | valider une version sur 5 % du trafic |
| Symlink atomique (VM) | nul | instantané (re-symlink) | x1 | faible (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 :
docker build -t app:a .
docker build -t app:b .
docker inspect --format '{{.Id}}' app:a app:b # doivent être identiquesSi 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.email → customer.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: 10 → pause → 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
- Deploy Symfony : https://symfony.com/doc/current/deployment.html
- Runtime component : https://symfony.com/doc/current/components/runtime.html
- FrankenPHP : https://frankenphp.dev
- Secrets : https://symfony.com/doc/current/configuration/secrets.html
- Deployer : https://deployer.org/docs/7.x/recipe/symfony
- Docker official PHP : https://hub.docker.com/_/php