La mise en production
Livrer sans stress, voir ce qui se passe, et savoir revenir en arrière quand ça tourne mal.
Une mise en production qui fait peur est souvent une mise en production qu'on fait rarement, à la main, sans filet. Le jour où elle casse, personne ne sait vraiment ce qui a changé, ni comment revenir à l'état d'avant, ni si les sauvegardes fonctionnent.
Cette fiche rassemble ce qui rend une mise en production ennuyeuse, au bon sens du terme : une version préparée de la même façon à chaque fois, un déploiement qui ne coupe pas le site, un retour en arrière en une commande, des logs et une surveillance qui disent ce qui se passe, et des sauvegardes qu'on a déjà restaurées. Elle s'appuie sur les fiches 11 (Linux) et 12 (Docker).
Les commandes de déploiement, de retour en arrière et de sauvegarde sont des illustrations : aucune n'a été exécutée. La migration sans coupure (en SQL, sur SQLite en mémoire), le log structuré et le cache (en PHP pur) ont été exécutés. Les extraits Symfony et YAML sont des illustrations, non exécutées.
1.Préparer une version de production
Une application Symfony ne se met pas en production telle qu'elle tourne sur ton poste. Elle se construit : sans les outils de développement, avec un cache prêt, des assets compilés. Ces étapes doivent être automatiques et identiques à chaque version, idéalement dans l'intégration continue ou dans le Dockerfile (fiche 12).
# Préparer une version de production (dans l'intégration continue, ou dans le Dockerfile)
export APP_ENV=prod APP_DEBUG=0
composer install --no-dev --optimize-autoloader --no-interaction # sans outils de dev
composer dump-env prod # fige les variables .env dans .env.local.php (plus rapide)
php bin/console asset-map:compile # assets versionnés (AssetMapper), ou ton build front
php bin/console cache:warmup # le cache Symfony est prêt avant la première requête- Trois environnements au minimum : le développement (ton poste), la recette (staging, une copie de la production où l'on vérifie avant de livrer), et la production. Plus la recette ressemble à la production, plus elle attrape de problèmes.
APP_ENV=prodetAPP_DEBUG=0: sans eux, Symfony est lent et affiche des informations internes (fiche 10).- Une version = un artefact : une archive ou une image Docker, construite une seule fois, puis déployée telle quelle en recette et en production. On ne reconstruit pas différemment pour chaque environnement : ce qui a été vérifié en recette est exactement ce qui part en production.
2.Déployer sans coupure
Copier les nouveaux fichiers par-dessus les anciens, pendant que le site tourne, laisse une fenêtre où une requête mélange l'ancien et le nouveau code. Le principe d'un déploiement propre : préparer la nouvelle version à côté, puis basculer d'un coup.
# Déployer une version sur un serveur, sans Docker (étapes d'un outil comme Deployer)
RELEASE=/var/www/shop/releases/2026-11-03-1415 # un dossier neuf par version
# 1. Copier le code construit dans un dossier NEUF (l'ancienne version tourne toujours)
rsync -a build/ "$RELEASE"/
# 2. Relier ce qui doit survivre d'une version à l'autre
ln -s /var/www/shop/shared/.env.local.php "$RELEASE"/.env.local.php
ln -s /var/www/shop/shared/var/uploads "$RELEASE"/var/uploads
# 3. Migrer la base (des migrations compatibles avec l'ancien code : section 3)
php "$RELEASE"/bin/console doctrine:migrations:migrate --no-interaction
# 4. Basculer d'un coup : le lien « current » pointe vers la nouvelle version
ln -sfn "$RELEASE" /var/www/shop/current
# 5. Recharger PHP-FPM pour vider OPcache
sudo systemctl reload php8.4-fpm/var/www/shop/
├── current ──► releases/2026-11-03-1415 (le lien que sert nginx)
├── releases/
│ ├── 2026-11-02-0930 (la version précédente, gardée pour revenir)
│ └── 2026-11-03-1415 (la nouvelle version)
└── shared/ (ce qui survit aux versions)
├── .env.local.php
└── var/uploads/
- Le lien symbolique
currentpointe vers la version active. Le changer est instantané : il n'y a pas de moment où le code est à moitié copié. Des outils comme Deployer automatisent ces étapes. - Avec Docker, le principe est le même : on démarre les conteneurs de la nouvelle image, puis on y envoie le trafic. Kubernetes le fait avec la mise à jour progressive (fiche 12).
- Les interrupteurs de fonctionnalités (feature flags) séparent le déploiement de la mise en service : le code est livré désactivé, et on l'active plus tard, pour une partie des utilisateurs d'abord. Si ça se passe mal, on le désactive sans redéployer.
Un déploiement par mois embarque des dizaines de changements : s'il casse, impossible de savoir lequel est en cause. Plusieurs petits déploiements par semaine sont plus sûrs, chacun facile à comprendre et à annuler. Évite en revanche le vendredi soir et la veille de tes congés : il faut être disponible dans les heures qui suivent.
3.Les migrations sans coupure
Pendant le déploiement, l'ancien et le nouveau code tournent parfois en même temps, quelques secondes ou quelques minutes. Une migration qui renomme ou supprime une colonne casse alors l'ancien code. La technique étendre puis contracter (expand and contract) découpe le changement en étapes compatibles :
-- On veut renommer customer.name en customer.full_name, SANS couper le site.
-- Un RENAME direct casserait l'ancienne version du code,
-- encore en service pendant le déploiement.
CREATE TABLE customer (id INTEGER PRIMARY KEY, email TEXT NOT NULL, name TEXT NOT NULL);
INSERT INTO customer (email, name) VALUES ('alice@example.com', 'Alice Martin');
-- Version 1 du déploiement (ÉTENDRE) : ajouter la nouvelle colonne, facultative
ALTER TABLE customer ADD COLUMN full_name TEXT;
-- … et recopier les données existantes
UPDATE customer SET full_name = name WHERE full_name IS NULL;
-- L'ancien code lit toujours « name » : il fonctionne
SELECT id, name FROM customer;
-- Le nouveau code lit « full_name », et écrit dans les DEUX colonnes pendant la transition
SELECT id, full_name FROM customer;
-- Version 2, plus tard, quand plus aucun code n'utilise « name » (CONTRACTER)
ALTER TABLE customer DROP COLUMN name;
-- La table finale
SELECT id, email, full_name FROM customer;▸ L'ancien code lit toujours « name » : il fonctionne id | name ---+------------- 1 | Alice Martin (1 ligne) ▸ Le nouveau code lit « full_name », et écrit dans les DEUX colonnes pendant la transition id | full_name ---+------------- 1 | Alice Martin (1 ligne) ▸ La table finale id | email | full_name ---+-------------------+------------- 1 | alice@example.com | Alice Martin (1 ligne)
- Étendre : ajouter la nouvelle colonne, facultative, et recopier les données. L'ancien code, qui ignore la colonne, continue de fonctionner.
- Déployer le nouveau code, qui lit la nouvelle colonne et écrit dans les deux pendant la transition.
- Contracter, lors d'un déploiement suivant : quand plus aucun code n'utilise l'ancienne colonne, la supprimer.
C'est plus long qu'un simple renommage, mais aucune étape ne casse la version en cours. La fiche 8, sur Doctrine, reviendra sur la façon d'écrire ces migrations.
Si une coupure de quelques minutes est acceptable (une application interne, la nuit), une page de maintenance et une migration directe sont plus simples. Fais ce choix consciemment, et préviens les utilisateurs. Attention aussi aux grosses tables : ajouter un index ou modifier une colonne peut verrouiller la table pendant de longues minutes. Teste la migration sur une copie de la base de production avant.
4.Revenir en arrière
Une erreur 500 sur la page de paiement, cinq minutes après le déploiement. La priorité n'est pas de comprendre le bug : c'est de remettre le site en état. Le retour en arrière (rollback) doit donc être prévu, simple, et déjà essayé.
# Revenir à la version précédente du code : un seul lien à changer…
ln -sfn /var/www/shop/releases/2026-11-02-0930 /var/www/shop/current
sudo systemctl reload php8.4-fpm
# … ou, avec Docker et Kubernetes, redéployer l'image précédente
kubectl rollout undo deployment/shop- Le code revient en arrière facilement, à condition d'avoir gardé la version précédente (le dossier, ou l'image avec sa version précise).
- La base de données, c'est plus délicat : une migration qui a supprimé une colonne ne se défait pas sans perdre de données. C'est la deuxième raison d'être de la technique de la section 3 : chaque étape laisse la base compatible avec la version précédente du code.
- Revenir en arrière ou corriger en avant ? Si le correctif est évident et petit, livrer une nouvelle version corrigée (roll forward) est parfois plus rapide. Mais dans le doute, et en pleine panne, reviens d'abord en arrière, puis prends le temps de comprendre.
5.Les logs
En production, il n'y a ni dump() ni profiler : les logs sont tes yeux. Encore faut-il qu'ils disent quelque chose d'utile, et rien de dangereux.
<?php
declare(strict_types=1);
// Ce qu'on reçoit lors d'un paiement refusé
$request = [
'orderId' => 1042,
'email' => 'alice@example.com',
'cardNumber' => '4111111111111111', // numéro de carte de TEST, fictif
'password' => 'exemple-fictif',
];
// ✗ Un log en texte libre, qui recopie tout ce qu'on a sous la main
echo 'Paiement refusé pour ', json_encode($request), "\n\n";
// ✓ Un log structuré : un message stable, et seulement le contexte utile, sans donnée sensible
$record = [
'datetime' => '2026-11-03T14:15:02+01:00',
'level' => 'warning',
'message' => 'Payment declined',
'context' => [
'order_id' => $request['orderId'],
'card_last4' => substr($request['cardNumber'], -4),
'reason' => 'insufficient_funds',
],
];
echo json_encode($record, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), "\n";Paiement refusé pour {"orderId":1042,"email":"alice@example.com","cardNumber":"4111111111111111","password":"exemple-fictif"}
{
"datetime": "2026-11-03T14:15:02+01:00",
"level": "warning",
"message": "Payment declined",
"context": {
"order_id": 1042,
"card_last4": "1111",
"reason": "insufficient_funds"
}
}- Un message stable, avec les détails dans le contexte : « Payment declined » est le même pour tous les paiements refusés, ce qui permet de les compter et de les chercher. Monolog sépare naturellement le message et le contexte.
- Le bon niveau :
debugetinfopour suivre le fonctionnement normal,warningpour l'inhabituel,errorpour un échec à traiter,criticalpour ce qui doit réveiller quelqu'un. - Un format structuré (une ligne JSON par événement), envoyé sur la sortie d'erreur. Docker, systemd ou l'hébergeur le récupèrent, et un outil de centralisation permet de chercher dans les logs de tous les serveurs à la fois.
# config/packages/prod/monolog.yaml (extrait)
monolog:
handlers:
main:
type: fingers_crossed # garde les logs en mémoire…
action_level: error # … et ne les écrit que si une erreur survient
handler: nested
excluded_http_codes: [404, 405]
nested:
type: stream
path: php://stderr # la sortie d'erreur : récupérée par Docker ou systemd
level: debug
formatter: monolog.formatter.json # une ligne JSON par événementLe gestionnaire fingers_crossed garde les messages détaillés en mémoire, et ne les écrit que si une erreur survient pendant la requête : on a tout le contexte des requêtes qui échouent, sans noyer les logs sous les requêtes qui réussissent.
Mots de passe, jetons, clés d'API, numéros de carte complets, cookies de session (fiche 9). Les logs sont lus par plus de monde que la base de données, gardés longtemps, copiés dans d'autres outils. Limite aussi les données personnelles (adresses e-mail, adresses postales) au strict nécessaire : le RGPD s'applique aussi aux logs. Un identifiant (order_id) suffit presque toujours pour retrouver le reste en base.
6.La surveillance
Le pire moyen d'apprendre une panne, c'est par un client. La surveillance (monitoring) consiste à savoir qu'il y a un problème avant les utilisateurs, ou au moins en même temps.
- Une route de santé que l'on peut appeler à tout moment : le répartiteur de charge, Kubernetes (fiche 12) et l'outil de surveillance l'utilisent.
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\DBAL\Connection;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
// Une route légère, appelée par la surveillance, le répartiteur de charge ou Kubernetes
final class HealthController
{
#[Route('/health', methods: ['GET'])]
public function __invoke(Connection $connection): JsonResponse
{
try {
$connection->executeQuery('SELECT 1'); // la base répond-elle ?
} catch (\Throwable) {
return new JsonResponse(['status' => 'error'], Response::HTTP_SERVICE_UNAVAILABLE);
}
// Aucune information interne (version, configuration) : la route est publique
return new JsonResponse(['status' => 'ok']);
}
}- Une surveillance depuis l'extérieur : un service tiers appelle ton site toutes les minutes, depuis Internet. Si ton serveur entier est tombé, il ne peut pas te prévenir lui-même.
- Quatre indicateurs résument la santé d'une application web : la latence (le temps de réponse), le trafic (le nombre de requêtes), les erreurs (le taux de réponses 5xx), et la saturation (CPU, mémoire, disque, connexions à la base).
- Un suivi des erreurs : un outil qui regroupe les exceptions identiques, avec leur trace et leur contexte, et signale les nouvelles après un déploiement.
- Des alertes utiles : une alerte doit demander une action. Une alerte qui sonne pour rien dix fois par jour finit ignorée, et la vraie passe inaperçue.
Pas besoin d'un tableau de bord de quarante graphiques pour commencer. Une route de santé surveillée de l'extérieur, une alerte sur les erreurs 5xx, et une alerte sur l'espace disque couvrent déjà la majorité des pannes. Le reste s'ajoute quand un incident montre ce qui manquait.
7.Sauvegarde et restauration
Une sauvegarde qu'on n'a jamais restaurée n'est pas une sauvegarde : c'est un espoir. Le jour où on en a besoin, on découvre qu'elle est vide, incomplète, ou que personne ne sait la restaurer.
# Sauvegarder : un fichier au format « custom » de PostgreSQL, compressé
pg_dump --format=custom --file=/backups/shop-2026-11-03.dump shop
# Copier hors du serveur, chiffré (le serveur peut disparaître avec ses sauvegardes)
# … avec l'outil de stockage de ton hébergeur, ou rsync vers une autre machine
# RESTAURER, régulièrement, dans une base de TEST : sinon on ne sait pas si ça marche
createdb shop_restore_test
pg_restore --dbname=shop_restore_test --no-owner /backups/shop-2026-11-03.dump
# Les données sont-elles là ?
psql shop_restore_test -c 'SELECT count(*) FROM customer_order;'- La règle 3-2-1 : trois copies des données, sur deux supports différents, dont une hors du site. Une sauvegarde sur le même serveur que la base disparaît avec lui.
- Deux questions à poser au métier, avant la panne :
- le RPO (Recovery Point Objective) : combien de données acceptons-nous de perdre ? Une sauvegarde chaque nuit, c'est jusqu'à 24 heures de commandes perdues ;
- le RTO (Recovery Time Objective) : combien de temps de panne acceptons-nous ? Restaurer une base de 50 Go prend du temps, à mesurer à l'avance.
- Teste la restauration régulièrement, dans une base séparée, et vérifie que les données sont bien là. Note le temps que ça prend, et écris la procédure : le jour J, il faudra la suivre sous stress.
- N'oublie pas les fichiers : les documents envoyés par les utilisateurs (
var/uploads, ou le stockage objet) ne sont pas dans la base.
Elle contient tout ce que contient la base : comptes, données personnelles, parfois des secrets. Chiffre-la, limite qui peut y accéder, et supprime les anciennes selon une durée de conservation définie. Garde aussi au moins une copie qu'un attaquant ayant pris le contrôle du serveur ne peut ni modifier ni supprimer : c'est la seule protection contre un rançongiciel.
8.Le cache
Un cache garde le résultat d'un travail coûteux pour le resservir sans le refaire. C'est la mémoïsation de la fiche 1, à l'échelle d'une application. Il en existe plusieurs niveaux :
| Niveau | Ce qu'il garde | Exemple |
|---|---|---|
| Navigateur et CDN | Des réponses HTTP entières | Cache-Control, ETag |
| Reverse proxy | Des pages entières, pour tous les utilisateurs | Le cache HTTP de Symfony, Varnish |
| Application | Le résultat d'un calcul ou d'une requête | Le composant Cache de Symfony, avec Redis |
| PHP | Le code PHP déjà compilé | OPcache |
Le principe et son piège principal, en PHP pur :
<?php
declare(strict_types=1);
// Un cache minimal avec durée de vie, et une horloge qu'on avance à la main (fiche 3)
final class TtlCache
{
private array $items = [];
public function __construct(private int $now = 0) {}
public function advance(int $seconds): void
{
$this->now += $seconds;
}
// « cache-aside » : on regarde dans le cache, sinon on calcule et on range
public function get(string $key, int $ttl, callable $compute): mixed
{
$item = $this->items[$key] ?? null;
if ($item !== null && $item['expiresAt'] > $this->now) {
echo " [cache] $key trouvé\n";
return $item['value'];
}
echo " [calcul] $key recalculé\n";
$value = $compute();
$this->items[$key] = ['value' => $value, 'expiresAt' => $this->now + $ttl];
return $value;
}
public function delete(string $key): void
{
unset($this->items[$key]);
}
}
$prices = ['clavier' => 4990]; // la « base de données »
$cache = new TtlCache();
// use (&$prices) : la fonction lit la base AU MOMENT du calcul, pas une copie figée
$showPrice = function () use ($cache, &$prices): void {
$price = $cache->get('price_clavier', 3600, function () use (&$prices): int {
return $prices['clavier'];
});
echo " prix affiché : $price\n";
};
echo "Premier affichage :\n";
$showPrice();
echo "Deuxième affichage :\n";
$showPrice();
echo "Le prix passe à 3990 en base, sans invalider le cache :\n";
$prices['clavier'] = 3990;
$showPrice();
echo "On invalide le cache au moment de la modification :\n";
$cache->delete('price_clavier');
$showPrice();
echo "Une heure plus tard, l'entrée a expiré d'elle-même :\n";
$cache->advance(3601);
$showPrice();Premier affichage : [calcul] price_clavier recalculé prix affiché : 4990 Deuxième affichage : [cache] price_clavier trouvé prix affiché : 4990 Le prix passe à 3990 en base, sans invalider le cache : [cache] price_clavier trouvé prix affiché : 4990 On invalide le cache au moment de la modification : [calcul] price_clavier recalculé prix affiché : 3990 Une heure plus tard, l'entrée a expiré d'elle-même : [calcul] price_clavier recalculé prix affiché : 3990
Le troisième affichage montre l'ancien prix : la donnée a changé, et le cache ne le sait pas. Il y a deux façons de limiter ce problème : une durée de vie (au pire, la donnée est périmée pendant une heure), et l'invalidation (supprimer l'entrée au moment où la donnée change). Dans Symfony :
<?php
use Symfony\Component\HttpKernel\Attribute\Cache;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
final class CatalogController extends AbstractController
{
// Cache HTTP : la page publique peut être gardée 10 minutes par les proxys et les CDN
#[Cache(public: true, maxage: 600)]
#[Route('/catalogue')]
public function index(TagAwareCacheInterface $cache, ProductRepository $products): Response
{
// Cache applicatif : le résultat d'un calcul coûteux, avec une étiquette
$compute = function (ItemInterface $item) use ($products): array {
$item->expiresAfter(3600);
$item->tag(['products']);
return $products->findBestSellers(10);
};
$bestSellers = $cache->get('best_sellers', $compute);
return $this->render('catalog/index.html.twig', ['bestSellers' => $bestSellers]);
}
}
// Quand un produit change : invalider tout ce qui porte l'étiquette « products »
// $cache->invalidateTags(['products']);#[Cache]ajoute les en-têtes HTTP qui autorisent les proxys et les CDN à garder la page.- Les étiquettes (tags) permettent d'invalider d'un coup tout ce qui dépend des produits.
- OPcache est le cache le plus rentable : il évite de recompiler le code PHP à chaque requête.
; docker/php/prod.ini : OPcache garde le code PHP compilé en mémoire
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
; Ne plus vérifier si les fichiers ont changé : plus rapide, mais il faut
; recharger PHP-FPM (ou redémarrer le conteneur) à chaque déploiement
opcache.validate_timestamps=0Le cache ajoute un état de plus à comprendre, et des bugs difficiles (« chez moi c'est à jour, chez lui non »). Ne cache que ce que tu as mesuré comme lent (fiche 1), pas « au cas où ». Et si une requête est lente, regarde d'abord ses index (fiche 7) : un cache masque le problème au lieu de le corriger.
Une page qui contient des données personnelles (le panier, le nom de l'utilisateur, une facture) ne doit jamais être mise dans un cache partagé (public) : le visiteur suivant verrait les données du précédent. Symfony rend privées par défaut les réponses qui utilisent la session. Et une clé de cache construite à partir d'un en-tête que l'utilisateur contrôle peut permettre d'empoisonner le cache pour tout le monde.
Pièges fréquents
| Piège | Ce qui arrive | La parade |
|---|---|---|
| Un déploiement fait à la main, différemment à chaque fois | Une étape oubliée, une panne difficile à comprendre | Un script ou un pipeline, toujours le même |
| Copier les fichiers par-dessus la version en cours | Des requêtes servies par un mélange d'ancien et de nouveau code | Une nouvelle version à côté, puis bascule |
| Renommer ou supprimer une colonne en un seul déploiement | L'ancien code plante pendant la transition, retour en arrière impossible | Étendre, puis contracter |
| Ne pas garder la version précédente | Aucun retour en arrière rapide | Dossiers de versions, ou images versionnées |
| Un log qui recopie toute la requête | Mots de passe et cartes dans les logs | Un message stable, un contexte choisi |
| Pas de surveillance extérieure | La panne est signalée par un client | Une route de santé surveillée depuis Internet |
| Des alertes qui sonnent pour rien | La vraie alerte est ignorée | Une alerte = une action à faire |
| Des sauvegardes jamais restaurées | Découvertes inutilisables le jour J | Un test de restauration régulier |
| La sauvegarde sur le même serveur | Perdue avec lui | La règle 3-2-1 |
| Une page personnelle en cache public | Un utilisateur voit les données d'un autre | Cache privé, ou pas de cache |
Oublier de recharger PHP-FPM avec validate_timestamps=0 | L'ancien code continue de tourner | Le rechargement dans le script de déploiement |
Checklist avant une mise en production
Avant
- La version a été construite automatiquement, testée (fiche 3) et vérifiée en recette.
APP_ENV=prod,APP_DEBUG=0, et la checklist de sécurité de la fiche 10 est passée.- Les migrations sont compatibles avec la version actuellement en production.
- Je sais comment revenir à la version précédente, et elle est encore disponible.
- Une sauvegarde récente existe, et la dernière restauration de test a réussi.
- Je suis disponible dans les heures qui suivent.
Après
- La route de santé répond, et les pages principales fonctionnent.
- Le taux d'erreurs et le temps de réponse n'ont pas bougé.
- Aucune nouvelle exception n'apparaît dans le suivi des erreurs.
- Le déploiement est noté : quoi, quand, par qui.
Conseils
- Automatise un peu à chaque fois. Chaque étape faite à la main est une étape qu'on oubliera un jour. Commence par un script, il deviendra un pipeline.
- Répète le retour en arrière et la restauration à froid, un jour calme. En pleine panne, on n'apprend pas : on applique ce qu'on connaît.
- Écris un court compte rendu après chaque incident : ce qui s'est passé, comment on l'a vu, ce qu'on change pour que ça ne se reproduise pas. Sans chercher de coupable : le but est d'améliorer le système, pas de juger quelqu'un.
- Garde une page de procédures à jour : comment déployer, revenir en arrière, restaurer, où sont les logs. La personne de garde à 3 heures du matin, ce sera peut-être toi.
- N'aie pas honte d'une panne. Toutes les équipes en ont. Ce qui compte, c'est le temps qu'il faut pour la voir, la réparer, et en tirer une leçon.