Pourquoi le code backend qui fonctionne localement échoue sous une charge réelle en production.
Un aperçu pratique des hypothèses relatives à l’environnement, à la base de données, à la sécurité et à la fiabilité qui fonctionnent normalement en local mais provoquent des pannes dès que du trafic réel arrive en production.
Sources: 10 Backend Mistakes That Work Perfectly on localhost but Break in Production
Celui-ci fonctionne parfaitement sur votre machine. Mais résistera-t-il lorsque du trafic réel arrivera ? Voici 10 erreurs de backend qui peuvent transformer silencieusement une application bien fonctionnante en un désastre en production.
En local, tout semble normal. Les requêtes reviennent rapidement. Les consultations renvoient les bons données. Le connexion fonctionne. Docker démarre sans problème. Les tests passent. Puis vous déploiez, ouvrez l’URL en ligne, et quelque chose tombe en panne.
Parfois, l’application refuse de démarrer.
Parfois, les utilisateurs se voient bloqués de leurs comptes.
Parfois, la base de données ralentit soudainement de manière extrême.
Parfois, tout fonctionne correctement pendant quelques minutes avant que des erreurs de connexion ne commencent à s’accumuler.
Et parfois, le service semble parfaitement normal en apparence tout en causant des dommages discrets en sous-main.
Ce qui est frustrant, c’est que la plupart de ces pannes n’ont rien à voir avec des algorithmes complexes.
Ils proviennent de suppositions mineures et non examinées.
Des suppositions telles que :
- ">Nous n’aurons pas autant de trafic."
- ">La base de données se trouve sur ce même serveur."
- ">Ce paramètre d’environnement sera toujours défini."
- ">Une seule connexion à la base de données suffit."
- ">Les requêtes seront toujours traitées rapidement."
- ">Les fichiers enregistrés sur le disque y resteront toujours."
- ">Personne ne sollicitera excessivement cet endpoint."
- ">L’API ne recevra que des données valides."
- ">Le conteneur n’aura jamais de mémoire insuffisante."
- ">Un processus en cours signifie une application fonctionnelle."
Votre configuration locale vous protège de la plupart de ces problèmes.
En environnement de production, aucune telle protection n’existe.
Cet article passe en revue 10 erreurs de backend qui fonctionnent parfaitement sur localhost mais causent des dommages réels une fois déployées.
Aucune d’entre elles n’est abstraite ou hypothétique.
Il s’agit de raccourcis qui semblent tout à fait raisonnables tant que l’on se concentre sur le développement d’une fonctionnalité.
C’est précisément ce qui les rend risqués.
1. Traiter les variables d’environnement comme une configuration optionnelle
Commençons par l’une des erreurs les plus fréquentes.
Disons que vous développez une API sur votre ordinateur portable.
La configuration de la connexion à la base de données ressemble à ceci :
DATABASE_URL=postgresql://postgres:password@localhost:5432/myapp
JWT_SECRET=my-super-secret-key
PORT=5000
L’application démarre sans problème.
Rien ne semble anormal.
Puis, sans crier gare :
Error: password authentication failed
Ou peut-être :
JWT_SECRET is undefined
Ou bien :
ECONNREFUSED 127.0.0.1:5432
Votre premier réflexe pourrait être de blâmer la plateforme d’hébergement.
Ce n’est que rarement la cause réelle.
En réalité, votre application s’appuyait discrètement sur des paramètres qui n’existaient que sur votre machine de développement.
Le piège du localhost
En local, vous pouvez avoir quelque chose comme ceci :
DATABASE_URL=postgresql://localhost/myapp
Tandis que en production, c’est configuré comme suit :
DATABASE_URL=postgresql://db.internal:5432/myapp
Il s’agit de deux configurations complètement différentes.
La situation empire lorsque les développeurs intégrent directement la configuration dans le code source :
const dbHost = "localhost";
const dbUser = "postgres";
const dbPassword = "password";
Cela fonctionne très bien.
Jusqu’au moment où ça ne fonctionne plus.
La production n’est pas votre ordinateur portable.
La base de données peut se trouver sur :
- un serveur distinct
- un conteneur différent
- un fournisseur de bases de données gérées
- un réseau interne privé
- une zone de disponibilité différente
- un fournisseur de cloud complètement différent
Votre application a besoin d’un moyen de découvrir où se trouve réellement cette base de données.
La configuration doit provenir de l’environnement
Un modèle plus résilient se présente comme ceci :
const config = {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: process.env.PORT || 5000
};
Mais une erreur plus subtile persiste ici.
Il ne suffit pas de simplement récupérer des valeurs à partir des variables d’environnement.
Il faut également vérifier qu’elles sont bien présentes.
Par exemple :
const requiredEnv = [
"DATABASE_URL", "JWT_SECRET"
];
for (const key of requiredEnv) {
if (!process.env[key]) {
throw new Error(`Missing required environment variable: ${key}`);
}
}
Désormais, l’application refuse de démarrer si une configuration nécessaire manque.
Ce type d’échec précoce est en fait une bonne chose.
Comparez-le à un scénario où l’application démarre normalement et ne tombe en panne que lorsque le premier utilisateur tente de se connecter.
Voir quelque chose comme :
Missing required environment variable: JWT_SECRET
pendant le déploiement est bien mieux que d’en prendre connaissance à travers un ticket de support agacé.
Ne fournissez pas en silence des valeurs par défaut dangereuses
Ce raccourci est tentant :
const jwtSecret = process.env.JWT_SECRET || "secret";
Il facilite le développement local.
Cela ouvre également la porte à une faille de sécurité.
Supposons que vous oubliez de définir JWT_SECRET dans votre environnement de production.
L’application ne plantera pas.
Elle recourra simplement en silence à :
secret
Désormais, votre système en ligne signe des tokens avec une valeur que n’importe qui pourrait deviner.
Une version plus sûre ressemble à ceci :
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET must be configured");
}
Les valeurs de fallback ont du sens pour des paramètres inoffensifs.
Elles représentent un risque pour tout ce qui concerne la sécurité.
La validation de la configuration doit avoir lieu au démarrage
Un modèle à adopter consiste à traiter votre configuration comme une dépendance obligatoire, qui doit être satisfaite avant que quoi que ce soit d’autre ne fonctionne.
Par exemple :
function loadConfig() {
if (!process.env.DATABASE_URL) {
throw new Error("DATABASE_URL is required");
}
if (!process.env.JWT_SECRET) {
throw new Error("JWT_SECRET is required");
}
return {
databaseUrl: process.env.DATABASE_URL,
jwtSecret: process.env.JWT_SECRET,
port: Number(process.env.PORT || 5000)
};
}
const config = loadConfig();
Cela donne à votre application un contrat clair sur lequel s’appuyer.
Soit la configuration est valide, soit le processus ne démarre jamais.
Cet équilibre en vaut vraiment la peine.
2. Pointer vers localhost pour les services situés en dehors de votre application
Cette erreur mérite une attention particulière car elle apparaît constamment dès que le code quitte l’ordinateur du développeur.
Disons que votre backend communique avec PostgreSQL.
Sur votre ordinateur portable, la configuration est la suivante :
Backend → localhost:5432 → PostgreSQL
Et tout fonctionne correctement.
Puis vous containerisez le backend avec Docker.
PostgreSQL s’exécute désormais dans un conteneur séparé.
DATABASE_HOST=localhost
Et alors vous obtenez :
connection refused
Que se passe-t-il ?
localhost ne fait plus référence à ce que vous croyiez.
Dans le conteneur :
localhost
fait référence à :
cet ensemble de conteneurs spécifique.
Et non :
le conteneur qui exécute votre base de données.
Une mauvaise compréhension de cette seule distinction explique une grande partie des problèmes liés à la déploiement de Docker.
Le réseau Docker exige un modèle mental différent
Imaginez trois conteneurs distincts :
backend
database
redis
Vous pouvez les définir comme ceci :
services:
backend:
build: .
depends_on:
- database
- redis
database:
image: postgres
redis:
image: redis
Depuis l’intérieur du conteneur backend, vous ne pouvez pas simplement accéder à :
localhost:5432
À la place, vous adressez l’autre conteneur par son nom de service :
database:5432
La même logique s’applique à Redis :
redis:6379
La topologie réseau résultante ressemble à ceci :
backend
|
+---- database:5432
|
+---- redis:6379
Cela peut sembler étranger au début, car les configurations locales font généralement fonctionner tout directement sur la machine hôte. En environnement de production, des limites réseau réelles apparaissent entre les composants.
Les services cloud introduisent le même piège
Lorsqu’on travaille localement, votre application peut accéder à Redis à :
localhost:6379
Mais en production, Redis est hébergé sur un service géré situé ailleurs.
La configuration appropriée pourrait être :
REDIS_URL=redis://redis-production.example.com:6379
Votre application ne devrait pas avoir besoin de connaître cette différence.
C’est l’idée principale.
Rédigez votre code pour consommer la configuration de cette manière :
createRedisClient({
url: process.env.REDIS_URL
});
Plutôt que d’encoder manuellement les détails de connexion comme ceci :
createRedisClient({
host: "localhost",
port: 6379
});
Préserver une structure de configuration cohérente entre les environnements
Les valeurs changeront d’un environnement à l’autre.
La structure, elle, ne doit pas changer.
Par exemple, en développement :
DATABASE_URL=postgresql://localhost:5432/myapp
REDIS_URL=redis://localhost:6379
Et en production :
DATABASE_URL=postgresql://production-db:5432/myapp
REDIS_URL=redis://production-redis:6379
Même base de code.
Valeurs différentes selon l’environnement.
C’est précisément cette cohérence que vous recherchez.
3. Ouvrir une nouvelle connexion à la base de données à chaque requête
Cette erreur particulière peut rester invisible pendant longtemps avant de causer des problèmes.
Vous écrivez une route comme ceci :
app.get("/users", async (req, res) => {
const client = new Client({
connectionString: process.env.DATABASE_URL
});
await client.connect();
const result = await client.query("SELECT * FROM users");
await client.end();
res.json(result.rows);
});
Elle fonctionne correctement.
Vous la testez manuellement.
Les réponses arrivent rapidement.
Vous la mettez en production.
Puis le trafic réel arrive.
Finalement, vous commencez à voir des erreurs telles que :
too many connections
ou :
remaining connection slots are reserved
ou vous remarquez que l’utilisation du CPU de votre base de données augmente constamment.
Quelle en est la cause ?
Chaque requête entrante ouvre une nouvelle connexion à la base de données.
Avec 100 requêtes simultanées, cela peut représenter 100 connexions distinctes.
Avec 1 000 requêtes, la situation devient bien pire.
Créer des connexions est coûteux
Établir une connexion n’est pas gratuit. Selon votre base de données et votre configuration, cela peut impliquer :
- la mise en place de la connexion réseau
Répéter tout cela pour chaque requête gaspille des ressources. C’est pourquoi les applications s’appuient généralement sur un pool de connexions à la place.
Avec PostgreSQL, cela se présente comme suit :
const { Pool } = require("pg");
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
max: 10
});
Et alors votre route devient :
app.get("/users", async (req, res) => {
const result = await pool.query(
"SELECT id, name, email FROM users"
);
res.json(result.rows);
});
Le pool maintient un ensemble de connexions réutilisables prêtes à l’emploi.
Visuellement, cela ressemble à ceci :
┌── connection 1
Request ────────┼── connection 2
├── connection 3
├── connection 4
└── connection 5
Plutôt que cela :
Request 1 → new connection
Request 2 → new connection
Request 3 → new connection
Request 4 → new connection
Les pools peuvent encore poser des problèmes à grande échelle
Même avec un pool en place, une mauvaise configuration reste possible.
Supposons que vous exécutiez 10 instances backend, et que chacune configure :
pool.max = 20
Cela représente un risque potentiel de :
10 × 20 = 200
Connexions. Si votre base de données limite le nombre de connexions à 100, vous dépassez déjà votre budget, même si la configuration du pool pour chaque instance individuelle semble raisonnable en soi.
C’est précisément ce type de problème qui ne se manifeste jamais sur l’ordinateur d’un seul développeur.
En local, vous pourriez observer :
1 application × 10 connections = 10
En production :
10 applications × 20 connections = 200
L’extension horizontale de votre application peut multiplier discrètement la charge qu’elle impose à la base de données.
La taille du pool est une question d’ensemble système
Lorsque vous déterminez quelle taille doit avoir un pool, vous devez prendre en compte :
database connection limit
↓
number of application instances
↓
pool size per instance
↓
background workers
↓
admin/monitoring connections
Ce n’est pas quelque chose que l’on choisit arbitrairement.
4. Retourner chaque colonne de la base de données au client
Cette erreur en particulier ne fait rarement planter votre application immédiatement.
C’est justement pour cette raison qu’elle est risquée.
Imaginez une table utilisateurs avec ces champs :
id
name
email
password_hash
phone
address
created_at
updated_at
internal_notes
Vous écrivez une requête comme ceci :
SELECT * FROM users;
Puis vous transmettez directement les lignes à la réponse :
res.json(result.rows);
Cela fonctionne. Le frontend reçoit son objet utilisateur. Vous pouvez continuer.
Mais en réalité, vous avez introduit plusieurs problèmes en même temps.
Premièrement : exposition des données
La réponse peut ressembler à ceci :
{
"id": 12,
"name": "John",
"email": "john@example.com",
"password_hash": "...",
"internal_notes": "..."
}
Même si le champ du mot de passe est haché plutôt que sous forme de texte brut, il n’y a aucune raison qu’il parvienne jamais au client. De plus, tous les champs réservés à l’intérieur du système que vous avez oubliés sont également envoyés avec lui.
Deuxièmement : bande passante
Supposons que chaque enregistrement pèse 2 KB. Pour mille enregistrements, cela fait :
2 KB × 1,000 = 2 MB
Imaginez maintenant que la table s’élargisse avec le temps. Vous pourriez finir par servir :
20 fields
alors que l’interface utilisateur consomme réellement seulement :
id
name
avatar
La base de données n’a aucune idée de ce dont a réellement besoin votre interface utilisateur — cette décision relève de la couche API.
Ainsi, au lieu de :
SELECT * FROM users;
vous devriez écrire :
SELECT
id,
name,
avatar_url
FROM users;
Désormais, le payload ne contient que ce dont a besoin cet endpoint en particulier.
Cela devient encore plus important avec les jointures
Considérez une requête comme celle-ci :
SELECT *
FROM users
JOIN orders ON orders.user_id = users.id
JOIN payments ON payments.order_id = orders.id;
Cela peut récupérer une quantité énorme de données que vous n’aviez jamais l’intention d’envoyer nulle part. C’est un raccourci tentant lorsque vous êtes encore en train de développer, avec une logique du type :
"Je vais simplement renvoyer tout le contenu et laisser l’interface utilisateur choisir ce dont elle a besoin."
Résistez à cette envie.
La structure d’une réponse API doit être intentionnelle. Par exemple :
const users = result.rows.map(user => ({
id: user.id,
name: user.name,
avatar: user.avatar_url
}));
Ou, encore mieux, transférez cette responsabilité à une couche DTO ou de sérialisation dédiée.
Le contrat API est important
Qu’est-ce que cet endpoint promet de retourner ?
Et non :
Quelles colonnes existent dans la table sous-jacente ?
Cette distinction devient d’autant plus cruciale à mesure que la base de code s’agrandit.
5. Ignorer les index de base de données jusqu’à ce que la production ralentisse
Ce cas constitue peut-être l’un des exemples les plus clairs de quelque chose qui semble parfaitement fonctionnel en environnement local mais qui se transforme silencieusement en problème en production.
Prenons une requête comme celle-ci :
SELECT * FROM users
WHERE email = 'john@example.com';
Tandis que votre base de données de développement contient :
500 users
la requête est renvoyée instantanément.
Vous la mettez en production. Six mois s’écoulent, et la table contient désormais :
5 million users
Soudainement, cette même requête peut devenir considérablement plus lente, car la base de données peut devoir parcourir une grande partie de la table pour trouver une correspondance.
La réaction typique est :
"Mais cette requête était rapide avant !"
Bien sûr — parce qu’à l’époque, la table était minuscule.
Les index existent pour cette raison
En ajoutant quelque chose comme :
CREATE INDEX idx_users_email
ON users(email);
cela permet à PostgreSQL de localiser les lignes correspondantes bien plus efficacement qu’un parcours complet de la table.
Néanmoins, la leçon à retenir n’est pas :
Indexer tout simplement tout.
C’est une erreur en soi. Les index ne sont pas gratuits — ils :
- occupent de l’espace disque
- ralentissent les écritures
- nécessitent une maintenance continue
- aident pas pour tous les types de requêtes
- ne valent pas la peine d’être ajoutés à chaque colonne
La question à se poser plutôt est :
Quelles requêtes cette application exécute-t-elle réellement en pratique ?
Index basé sur les schémas d’accès
Si votre application exécute régulièrement quelque chose comme :
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
un index composite de ce type pourrait être pertinent :
CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);
Le choix approprié d’index dépend toujours de votre moteur de base de données spécifique, de la manière dont les données sont distribuées, du comportement du planificateur de requêtes, ainsi que de votre charge de travail réelle — c’est précisément pourquoi l’indexation par intuition ne fonctionne pas.
Utiliser EXPLAIN
L’un des outils diagnostiques les plus utiles dans PostgreSQL est :
EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id = 123
ORDER BY created_at DESC
LIMIT 20;
Il vous montre exactement comment la base de données a exécuté une requête donnée, en remplaçant des suppositions comme :
Cette requête devrait s’exécuter rapidement
par des preuves concrètes :
C’est ce que fait réellement la base de données
Cette évolution de la mentalité est extrêmement importante.
Les données locales peuvent être trompeuses
Voici une règle à intégrer : mesurer les performances sur un petit ensemble de données local ne vous apprend que très peu sur le comportement de la même requête en production.
Une requête qui s’exécute en:
5 ms
avec 1 000 lignes peut se comporter complètement différemment lorsque vous travaillez avec:
10 million rows
Pour tester les performances de manière significative, il faut des volumes de données similaires à ceux que vous verrez réellement en production.
6. Croire que tout ce que vous enregistrez sur disque y restera
Ce problème apparaît souvent lors du téléchargement de fichiers.
Sur votre propre ordinateur, vous pourriez écrire quelque chose comme:
app.post("/upload", upload.single("file"), (req, res) => {
console.log(req.file.path);
});
Le fichier téléchargé se retrouve quelque part comme:
/uploads/photo.jpg
Vous rechargez l’application.
Le fichier est exactement là où vous l’aviez laissé.
Rien ne semble anormal.
Ensuite, vous envoyez l’application dans un conteneur ou sur une plateforme d’hébergement éphémère.
Un nouveau déploiement est lancé.
Soudain, le fichier a disparu.
Les demandes de support commencent à arriver :
Où sont passées mes images ?
Pourquoi cela se produit-il ?
Parce que le système de fichiers de votre machine locale fonctionne très différemment du stockage présent dans de nombreux environnements de production.
Un conteneur suit généralement un cycle de vie tel que :
created
running
destroyed
recreated
Lorsque ce conteneur est détruit, tout ce qui a été enregistré uniquement sur son disque local disparaît avec lui.
De nombreuses plateformes d’hébergement traitent par conception les fichiers enregistrés localement comme des éléments non durables.
Cessez de considérer votre serveur d’application comme un stockage à long terme
Pour gérer les téléchargements en production, l’approche habituelle consiste à stocker les fichiers dans un système de stockage d’objets dédié plutôt que sur le disque du serveur lui-même.
Cela donne une architecture à peu près comme celle-ci :
Client
↓
Backend
↓
Object Storage
↓
Database stores file metadata
Votre base de données, quant à elle, n’a besoin de conserver que une référence — quelque chose comme ceci :
id
user_id
filename
storage_key
content_type
created_at
Les vrais octets du fichier se trouvent dans le service de stockage d’objets, et non dans votre base de données relationnelle.
Ce partage des responsabilités rend les choses beaucoup plus simples à comprendre.
La base de données et la couche de stockage résolvent des problèmes différents
Les bases de données excellent dans le traitement de :
- des enregistrements structurés
- des relations entre enregistrements
- des garanties transactionnelles
- la consultation et le filtrage
Le stockage d’objets est conçu pour :
- des images
- de la vidéo
- des PDF et d’autres documents volumineux
- des ressources statiques en général
Tenter de faire en sorte qu’un seul système s’occupe des deux tâches entraîne généralement des problèmes à terme.
Un piège similaire : intégrer des fichiers téléchargés dans votre base de code
Il n’est pas rare de trouver des projets organisés comme ceci :
project/
uploads/
src/
package.json
où les fichiers téléchargés par l’utilisateur se trouvent directement à côté des fichiers sources de l’application.
Cette approche échoue rapidement.
Imaginez :
100,000 uploaded images
ces fichiers situés à l’intérieur de votre dossier de projet.
Les déploiements deviennent extrêmement lents et chaotiques.
Le code de l’application et le contenu généré par les utilisateurs suivent des cycles de vie complètement différents, ils doivent donc être stockés dans des systèmes séparés.
7. Déployer une API sans limitation de fréquence
Travailler en local crée une situation trompeuse.
Il est probable que vous soyez le seul client à utiliser l’API.
Vous envoyez des requêtes :
GET /api/users
une fois.
Puis à nouveau.
Peut-être encore quelques fois.
Rien ne se brise.
En environnement de production, les choses ne se passent pas aussi gentiment.
Un seul appelant — par accident ou intentionnellement — peut envoyer des milliers de requêtes en peu de temps.
Prenons un point d’entrée comme celui-ci :
POST /api/login
En l’absence de tout mécanisme de limitation, un attaquant pourrait le submerger par des tentatives de connexion répétées :
attempt 1
attempt 2
attempt 3
...
attempt 10,000
Même une vérification de mot de passe parfaitement sécurisée ne permet pas d’empêcher l’abus du point d’entrée à grande échelle.
La limitation de débit introduit un point de contrôle
Conceptuellement, cela fonctionne comme suit :
Client
↓
Rate limiter
↓
Application
Au lieu de laisser les requêtes circuler sans limite, on peut fixer un plafond pour une catégorie donnée de points d’entrée à environ :
100 requests / minute
Lorsqu’un appelant dépasse ce seuil, il reçoit :
429 Too Many Requests
Les valeurs appropriées varient en fonction du cas d’usage — un point d’entrée de recherche publique n’a pas besoin du même plafond qu’un autre type de point d’entrée :
POST /login
ou :
POST /forgot-password
Il ne s’agit pas seulement du trafic malveillant
Les limites de vitesse protègent également contre les bugs du côté client.
Imaginez un morceau de code frontend erroné comme celui-ci :
setInterval(() => {
fetch("/api/notifications");
}, 100);
Cette seule fenêtre du navigateur envoie désormais des requêtes à votre API sans arrêt.
Imaginons maintenant que cette erreur se produise chez 10 000 utilisateurs.
Votre backend se retrouve soudainement confronté à un vrai problème.
Un limiteur de vitesse constitue une sécurité contre ce type de comportement incontrôlé.
La limitation de vitesse devient plus complexe lors du scaling horizontal
Supposons que votre application fonctionne derrière trois instances backend distinctes :
Server A
Server B
Server C
Si chaque instance conserve son propre compteur en mémoire, un client pourrait obtenir trois fois la quantité autorisée, une par serveur.
Afin d’imposer une limite cohérente sur toutes les instances, les équipes recourent généralement à un stockage partagé comme Redis.
La configuration ressemble alors à ceci :
┌── Server A
Client → LB ──┼── Server B
└── Server C
|
Redis
Avec cet état partagé, chaque instance applique systématiquement la même limite.
Encore une fois, cela reste invisible lorsque vous exécutez un seul processus localement — il n’y a pas d’état distribué à gérer tant que la production ne pose pas le problème.
8. En supposant que les requêtes s’achèvent toujours en un délai raisonnable
Ce point peut surprendre.
Supposons que vous créiez une extrémité de requête comme suit :
app.get("/report", async (req, res) => {
const data = await generateHugeReport();
res.json(data);
});
Localement, le rapport couvre :
1,000 records
et s’achève en :
300 ms
Cela semble correct.
En production, ce même rapport couvre désormais :
5 million records
et la requête prend maintenant :
45 seconds
Pendant ce temps, votre proxy inversé pourrait être configuré pour abandonner après :
30 seconds
ce qui fait que le client finit par voir :
504 Gateway Timeout
même si votre backend continue de fonctionner en arrière-plan sans faire de bruit.
Cette incohérence crée un mode d’échec confus.
Les tâches longues ne doivent pas faire partie d’un cycle demande-réponse
Lorsqu’une tâche risque de prendre du temps, il est généralement préférable de la gérer en tant que tâche en arrière-plan plutôt que de faire attendre directement le client.
Au lieu d’un flux comme :
Client
↓
POST /generate-report
↓
Wait 45 seconds
↓
Response
on passe à quelque chose comme :
Client
↓
POST /generate-report
↓
Job created
↓
202 Accepted
avec le traitement réel ayant lieu séparément :
Queue
↓
Worker
↓
Generate report
↓
Store result
Le client peut alors vérifier l’état via quelque chose comme :
GET /reports/:id
ou simplement être notifié une fois la tâche terminée.
Les files d’attente en arrière-plan s’occupent du travail lourd
Exemples courants de tâches qui doivent s’exécuter en dehors du cycle principal de demande incluent :
- l’envoi d’e-mails
- la génération de PDF
En évitant que ces opérations ne perturbent le thread principal, votre API principale reste rapide et réactive.
N’oubliez pas les délais d’expiration
Toute requête que votre service envoie à un système externe doit disposer d’un délai d’expiration raisonnable.
Par exemple :
fetch(url, {
signal: AbortSignal.timeout(5000)
});
Si vous omettez cela, une dépendance bloquée peut faire en sorte que votre application attende indéfiniment.
Imaginez cette chaîne :
API
↓
Payment service
↓
hangs
Votre requête est maintenant bloquée, en attente de quelque chose qui ne répondra peut-être jamais.
Imaginez maintenant que cela se produise avec des centaines de requêtes simultanées.
À un moment donné, votre serveur manquera de ressources disponibles pour gérer quoi que ce soit d’autre.
Chaque dépendance a besoin d’un plan de secours
Pour chaque service externe avec lequel votre application communique, vous devez être en mesure de répondre à une question précise :
Que se passe-t-il si ce service ne répond pas ?
Pas celle-ci :
Que se passe-t-il lorsque tout fonctionne correctement ?
Cette deuxième question est facile à répondre.
C’est la première qui détermine réellement si votre système reste opérationnel sous pression.
9. Faire confiance aux entrées du client
Cette erreur est presque gênante à souligner, car le code en cause semble souvent tout à fait raisonnable.
Prenons un point d’entrée comme celui-ci :
app.post("/users", async (req, res) => {
const { name, email } = req.body;
await createUser(name, email);
res.json({
message: "User created"
});
});
À première vue, rien ne semble anormal.
Mais imaginez ce qui se passe si le corps de la requête ressemble à ceci :
{
"name": "",
"email": "hello"
}
Ou à ceci :
{
"name": null,
"email": null
}
Ou encore à ceci :
{
"name": "A".repeat(1000000),
"email": "..."
}
Que se passe-t-il si le client inclut des champs que votre code n’a jamais prévus ?
Lors du développement local, vous avez tendance à envoyer des requêtes de test soigneusement construites.
En production, votre API reçoit tout ce que quiconque décide de lui envoyer.
La validation doit avoir lieu au niveau du bord
Par exemple, vous pourriez définir une forme comme suit :
const schema = {
name: "string",
email: "email"
};
Avec une bibliothèque de validation, vous pouvez imposer des règles telles que :
name required
name length <= 100
email required
email valid
Il n’est pas vraiment important de choisir quelle bibliothèque de validation spécifique utiliser.
Ce qui compte, c’est la règle fondamentale :
N’assumez jamais que le client a envoyé ce à quoi vous vous attendiez.
N’oubliez pas que « le client » peut être :
- votre propre interface utilisateur
- une application mobile
- une intégration de tiers
Les vérifications côté client ne constituent pas une couche de sécurité
Une objection courante est :
"Mais je valide déjà cela côté frontend, avec React."
Cela est effectivement utile pour l’expérience utilisateur.
Cependant, cela ne protège en rien votre backend.
N’importe qui peut ignorer complètement votre code frontend.
Tout ce qu’il faut, c’est exécuter :
curl
directement vers votre point d’entrée API.
Votre code côté serveur doit toujours appliquer les règles lui-même.
L’injection SQL est un risque apparenté
Évitez de construire des requêtes en concaténant des chaînes, comme ceci :
const query = `
SELECT *
FROM users
WHERE email = '${email}'
`;
Préférez plutôt les requêtes paramétrées :
const result = await pool.query(
"SELECT * FROM users WHERE email = $1",
[email]
);
De cette manière, le pilote de base de données conserve les valeurs des paramètres séparément de la structure de la requête.
La validation des entrées et les requêtes paramétrées ciblent des menaces différentes, mais un backend de niveau production a besoin des deux.
10. Penser que « l’application est en cours d’exécution » signifie qu’elle est « saine »
Ce pourrait être le malentendu le plus fondamental de cette liste.
Votre serveur démarre :
Server listening on port 5000
Les journaux le confirment :
Application started successfully.
À ce stade, il est tentant de penser :
Génial. Nous avons terminé.
Cela n’est pas nécessairement vrai.
Un processus peut continuer à fonctionner même lorsque l’application elle-même est en réalité endommagée.
Considérez cet état :
Backend process: running
Database: unavailable
Redis: unavailable
Payment provider: unavailable
Queue: stuck
Du point de vue du système d’exploitation, le processus est actif et en bon état.
Votre tableau de bord de surveillance peut afficher :
CPU: 10%
Memory: 20%
Process: alive
Pendant ce temps, les utilisateurs réels voient :
500 Internal Server Error
Les vérifications de santé comblent en partie ce manque
Un point d’entrée de santé de base comme :
GET /health
peut simplement renvoyer :
{
"status": "ok"
}
Mais même cela peut être trompeur.
Si /health ne confirme que le serveur HTTP répond aux requêtes, il ne dit rien sur le fonctionnement des dépendances réellement utilisées par votre application.
Une configuration plus complète sépare deux points d’entrée : l’un
/health
pour une vérification de disponibilité de base, et un autre
/ready
pour la prêté à l’emploi.
D’un point de vue conceptuel, la distinction est la suivante :
Liveness: "Is this process alive?"
et :
Readiness:
"Can this instance actually serve traffic?"
Vérification spécifique de la santé de la base de données
Vérifier la disponibilité peut aller plus loin en confirmant que la connexion à la base de données fonctionne réellement, par exemple en exécutant :
SELECT 1;
Si la base de données n’est pas accessible, l’application peut se déclarer comme non prête.
Cela permet aux outils d’orchestration d’arrêter d’envoyer du trafic vers une instance qui ne peut pas traiter correctement les requêtes.
L’état de fonctionnement ne dit pas grand-chose à lui seul
99.99% uptime
tout en offrant à ses utilisateurs une expérience extrêmement lente et frustrante.
C’est pourquoi la surveillance en production doit suivre bien plus que le simple fait que le processus soit actif, y compris des éléments tels que :
- la latence des requêtes
- le taux d’erreurs
- la capacité de traitement
- les performances de la base de données
- l’utilisation de la CPU
- l’utilisation de la mémoire
- la profondeur de file d’attente
- les pannes des services externes
Une métrique particulièrement utile est :
p95 latency
Au lieu de demander :
Quel est le temps de réponse moyen ?
il est plus révélateur de demander :
Combien de temps prennent 95 % des requêtes ?
Les moyennes ont tendance à cacher les expériences les plus négatives.
Considérez ce scénario :
99 requests = 50 ms
1 request = 30 seconds
Le temps de réponse moyen ici peut ne pas sembler du tout alarmant.
Mais cette seule requête lente représente néanmoins une expérience vraiment mauvaise pour celui qui l’a envoyée.
L’erreur supplémentaire : enregistrer tout sauf les parties utiles
Cette erreur mérite d’être mentionnée car elle a sauvé plus d’une session de débogage.
Quelque chose tombe en panne en production.
Tout ce que vous trouvez, c’est :
Something went wrong
C’est tout le message.
Aucun identifiant de requête.
Aucune information sur la route.
Aucun contexte utilisateur.
Aucune trace d’erreur.
Aucune indication sur quelle appel de base de données a eu lieu.
Aucune date et heure suffisamment précises pour établir un lien avec quoi que ce soit.
Seule une ligne simple indique :
Something went wrong
À ce stade, vous diagnostiquez le problème sans aucune information concrète.
À quoi ressemble une entrée de journal utile
Lorsqu’un problème survient, les questions auxquelles vous souhaitez obtenir des réponses sont :
When?
Where?
Which request?
Which service?
What failed?
How long did it take?
What dependency was involved?
Une entrée de journal bien structurée capture ce contexte, à peu près comme ceci :
{
"level": "error",
"requestId": "abc-123",
"route": "/api/orders",
"method": "POST",
"duration": 843,
"error": "Database timeout"
}
Ce format vous fournit réellement des éléments sur lesquels travailler.
N’ajoutez jamais de données sensibles dans les journaux
En même temps, faites attention à ne pas enregistrer par inadvertance :
password
access token
refresh token
credit card information
private keys
Les journaux existent pour vous aider à diagnostiquer les problèmes, et non pour donner aux attaquants un autre problème.
Pourquoi ces problèmes n’apparaissent jamais sur votre ordinateur portable
On peut se demander pourquoi tant de développeurs répètent les mêmes erreurs si elles sont si fréquentes.
La réponse est qu’un environnement de développement local et un environnement de production sont des systèmes fondamentalement différents.
Voici la différence.
À quoi ressemble le développement local
Généralement, on a affaire à :
1 developer
1 application
1 database
small dataset
low traffic
fast network
known inputs
stable filesystem
single process
La production, quant à elle, peut impliquer :
thousands of users
multiple application instances
millions of database rows
unpredictable traffic
network latency
invalid requests
external dependencies
ephemeral containers
background workers
multiple regions
Ce n’est pas une version réduite du même système — c’est un système complètement différent.
Le code source peut rester inchangé.
L’environnement qui l’entoure, lui, ne l’est pas.
L’habitude de se fier au scénario idéal
Il est naturel de ne tester que le flux réussi.
Un exemple typique :
Create user
↓
Login
↓
Create order
↓
Payment succeeds
↓
Return response
Cette séquence représente le scénario idéal.
Mais le trafic en production n’est jamais limité aux scénarios idéaux.
Il faut également prendre en compte des situations comme :
What if the database is slow?
What if Redis is unavailable?
What if the payment service times out?
What if the user sends invalid JSON?
What if the request is duplicated?
What if two requests update the same record?
What if the process restarts?
What if the container disappears?
What if the database connection pool is exhausted?
What if the user clicks the button five times?
What if the request arrives twice?
What if the response is lost after the database transaction succeeds?
Dans un système distribué, ce ne sont pas des cas limites rares.
Ce sont des phénomènes courants.
Passer de « Fonctionne-t-il ? » à « Que se passe-t-il s’il échoue ? »
Avec l’expérience acquise en matière de systèmes backend, nos instincts évoluent.
Au début, la question est :
Ce code fonctionne-t-il ?
Au fil du temps, elle devient :
Que se passe-t-il si ce code échoue ?
Cette deuxième question est bien plus utile.
Prenons cette ligne :
const result = await paymentService.charge();
Le raisonnement d’un débutant s’arrête à :
Si l’appel réussit, on passe à autre chose.
Le raisonnement orienté vers la production continue :
Que se passe-t-il s’il y a un temps d’attente excessif ?
Puis :
Que se passe-t-il si la transaction est effectuée mais que la réponse à mon API expire ?
Alors :
Que se passe-t-il si le client réessaie la demande ?
Alors :
Le client pourrait-il être facturé deux fois ?
À ce stade, on analyse les modes de défaillance, qui constituent en réalité l’essence de l’ingénierie backend.
Idempotence : ce qui garantit la sécurité des flux de paiement
Prenant toujours le même exemple : imaginez qu’un utilisateur appuie
Pay
La demande atteint votre serveur.
La transaction est réussie.
Mais la connexion se coupe avant que la réponse ne revienne.
Le client ne peut pas savoir si le paiement a vraiment été effectué, alors il réessaie.
Désormais, le serveur a traité :
Payment request #1 → success
Payment request #2 → success
Ce qui pourrait facilement entraîner :
Customer charged twice.
L’idempotence existe précisément pour éviter cela.
payment_7f8c123
Request
↓
Idempotency key
↓
Already processed?
├── Yes → return previous result
└── No → process → store result
- les paiements
- la création de commandes
- l’inscription aux comptes
- l’envoi d’e-mails
- les appels aux API de tiers
Les transactions : Un autre élément qui semble fonctionner jusqu’à ce qu’il ne le fasse plus
Pensez à ce qui se passe lorsque une commande est créée.
Le flux de travail peut comprendre plusieurs étapes :
Create order
Create order items
Update inventory
Create payment record
Si vous n’encadrez pas ces opérations dans une transaction, l’une d’elles peut échouer au milieu de la séquence.
Imaginons ce scénario :
Create order ✓
Create items ✓
Update inventory ✓
Payment record ✗
À ce stade, votre base de données se trouve dans un état inachevé.
Les transactions existent pour permettre de regrouper des opérations qui doivent toutes réussir ou échouer en tant qu’unité.
Voici à quoi cela ressemble dans la pratique :
BEGIN;
INSERT INTO orders (...);
INSERT INTO order_items (...);
UPDATE inventory
SET quantity = quantity - 1
WHERE product_id = 123;
COMMIT;
Et lorsque quelque chose échoue au cours du processus :
ROLLBACK;
Néanmoins, encadrer chaque opération dans une seule et même transaction massive n’est pas non plus la bonne approche.
Les limites des transactions nécessitent une conception réfléchie.
Des transactions qui durent trop longtemps peuvent engendrer leurs propres problèmes.
La question à se poser en permanence est :
Si cela échoue en cours de route, dans quel état le système se trouve-t-il alors ?
Les requêtes N+1 : le meurtrier silencieux des performances
Il existe un autre problème qui a tendance à rester caché pendant le développement local.
Disons que vous récupérez une liste d’utilisateurs :
SELECT * FROM users LIMIT 100;
Et ensuite, pour chaque utilisateur individuel, vous allez chercher leurs commandes :
SELECT * FROM orders WHERE user_id = ?;
Cela revient à :
1 query for users
+
100 queries for orders
=
101 queries
Il s’agit du piège bien connu des requêtes N+1.
Si vous n’avez que 10 utilisateurs :
11 queries
C’est gérable.
Mais si vous passez à 10 000 utilisateurs :
10,001 queries
vous vous retrouvez soudainement face à un véritable problème de performances.
La solution n’est pas nécessairement de « combiner tout en une seule requête massive ».
En fonction de votre situation, vous pourriez recourir à :
- des jointures
- un chargement précoce
- le regroupement en lots
IN (...) clausesLe plus important est de pouvoir repérer ce schéma dès le début.
La pagination n’est pas optionnelle pour de grandes collections
GET /users
Et la requête qui le sous-tend :
SELECT *
FROM users;
fonctionne très bien lorsque vous avez :
50 users
Mais que se passe-t-il lorsque vous devez gérer :
5 million users?
Envoyer cinq millions d’enregistrements à un client n’est certainement pas souhaitable.
La pagination vous permet de limiter cela.
GET /users?page=2&limit=50
ou une pagination basée sur un curseur :
GET /users?cursor=abc123&limit=50
La pagination par curseur fonctionne généralement mieux pour des ensembles de données volumineux ou en constante évolution, car elle évite certains problèmes de performance et de cohérence liés à des offsets très importants.
Encore une fois, le problème fondamental est :
localhost data = small
production data = potentially huge
Votre API doit être conçue en tenant compte de cette lacune.
Les fuites de mémoire ne se manifestent pas toujours immédiatement
C’est un autre type de bug qui ne se révèle qu’en environnement de production.
Votre application démarre en utilisant :
Memory: 150 MB
Une heure plus tard :
300 MB
Six heures plus tard :
600 MB
Et finalement :
Out of memory
Puis elle plante et redémarre.
À ce moment-là, tout semble normal à nouveau.
Ce schéma est un signe clair qu’il y a quelque chose qui retient de la mémoire qu’il ne devrait pas.
Les causes les plus fréquentes incluent :
- des tableaux qui grandissent sans limite
Lorsque vous testez localement, l’application reste rarement en exécution suffisamment longtemps pour que ce type de problème se manifeste.
Dans un environnement de production, elle fonctionne suffisamment longtemps pour que cette fuite devienne problématique.
C’est en partie pourquoi observer le comportement d’une application sur de longues périodes est si précieux.
Le cache peut également perturber la production
En théorie, le cache semble être une solution évidente :
Database → slow
Redis → fast
Ainsi, l’instinct est de mettre en cache tout ce qui est possible.
Mais ensuite les données sous-jacentes changent.
Et l’API continue de renvoyer des informations obsolètes.
On se retrouve alors avec un déséquilibre comme celui-ci :
Database:
balance = 100
Cache:
balance = 80
L’API répond par :
80
Et l’utilisateur veut savoir :
Pourquoi mon solde est-il incorrect ?
Le cache vous oblige à répondre à des questions telles que :
- Quand les entrées doivent-elles expirer ?
- Quand le cache devient-il invalide ?
- Quelle est la solution de secours en cas de panne de Redis ?
- Que se passe-t-il lorsque deux serveurs écrivent sur la même clé ?
- Est-il acceptable de fournir des données obsolètes dans ce cas ?
- Que doit se passer immédiatement après un déploiement ?
La partie difficile avec le cache n’a jamais été le stockage des données.
C’est déterminer le moment où les données stockées cessent d’être fiables.
Les pannes de cache doivent faire l’objet d’une stratégie
Si Redis disparaît de manière inattendue, votre application :
fail completely
ou bien elle :
fall back to database
Il n’y a pas de réponse universelle — cela dépend du système que vous construisez.
Ce qui est important, c’est que vous effectuiez cet appel délibérément, et non par hasard.
Secrets dans Git : L’erreur qui peut devenir un incident de sécurité
Ce point suivant semble aller de soi, pourtant il se produit encore fréquemment.
Vous créez un fichier :
DATABASE_PASSWORD=...
JWT_SECRET=...
API_KEY=...
Puis vous exécutez :
git add .
git commit -m "config"
git push
À ce stade, le secret est intégré à l’historique de Git.
Supprimer le fichier par la suite ne l’efface pas — sa valeur peut toujours être récupérée à partir des commits antérieurs.
Une meilleure approche consiste à conserver :
.env
dans .gitignore, et à commiter plutôt un modèle :
.env.example
avec des éléments de remplacement tels que :
DATABASE_URL=
JWT_SECRET=
REDIS_URL=
Les valeurs réelles doivent être injectées via les paramètres d’environnement de votre plateforme de déploiement ou un gestionnaire de secrets dédié.
Et si un véritable secret finit par figurer dans un commit :
Ne vous contentez pas de supprimer la ligne et de penser que c’est réglé.
Rotulez immédiatement ce secret.
Tenez-le pour compromis, que vous puissiez prouver qu’il a été consulté ou non.
CORS n’est pas une authentification
Voici un autre point qui prête à confusion :
Access-Control-Allow-Origin: *
CORS régule la manière dont les navigateurs gèrent les requêtes跨-origin.
Il ne fait rien pour sécuriser réellement votre API.
Rien n’empêche qu’un point de terminaison serveur soit consulté directement via :
curl
Postman
mobile apps
backend services
scripts
CORS n’a jamais été conçu pour servir de couche d’authentification.
Votre API a toujours besoin de :
- authentification
- autorisation
- vérification des entrées
- limitation des fréquences
- contrôle d’accès
La sécurité doit être appliquée explicitement, et non simplement par la politique du navigateur.
L’authentification et l’autorisation sont différentes
Mélanger ces deux concepts entraîne de véritables failles de sécurité.
Réponses à l’authentification :
Qui êtes-vous ?
Réponses à l’autorisation :
Avez-vous le droit de faire cela ?
Prenons en compte une demande telle que :
GET /users/123
L’utilisateur peut très bien être authentifié.
Mais cela ne dit rien sur le fait s’il devrait avoir la permission de consulter :
user 123
Si la personne qui soumet la demande est :
user 456
le serveur doit encore déterminer si :
456 → allowed to access 123?
Une erreur fréquente se présente comme ceci :
if (req.user) {
return getUser(req.params.id);
}
Ce code ne vérifie que si quelqu’un est connecté.
Il ne contrôle jamais s’il a l’autorisation d’accéder à ce ressource spécifique.
Un modèle plus sûr prend en compte la propriété ou des permissions explicites :
if (req.user.id !== req.params.id) {
return res.status(403).json({
error: "Forbidden"
});
}
Les règles spécifiques varieront en fonction de l’application.
Mais le principe fondamental reste identique partout :
Se connecter ne confère pas un accès illimité à tout dans le système.
Le déploiement fait partie de l’application
Une leçon à retenir dès le début est que le déploiement n’est pas une phase distincte ajoutée à la fin du développement.
C’est intégré au travail d’ingénierie lui-même.
Votre application n’est pas seulement :
source code
C’est une combinaison :
source code
+
configuration
+
database
+
network
+
storage
+
secrets
+
dependencies
+
monitoring
+
deployment process
Quelqu’un qui ne comprend que le code de l’application peut encore se sentir submergé une fois que les choses passent en production.
Il n’est pas nécessaire de maîtriser chaque détail de Kubernetes pour être efficace.
Mais il est indispensable d’avoir une compréhension fonctionnelle de l’environnement dans lequel votre code s’exécute réellement.
Une liste de contrôle pour la prêté à la production que j’utilise réellement
Configuration
- Les variables d’environnement requises sont-elles validées au démarrage ?
- Les secrets sont-ils hors du code source ?
- Les paramètres par défaut pour la production sont-ils sûrs plutôt que pratiques ?
- Les configurations de développement et de production sont-elles séparées ?
Base de données
- Le pooling de connexions est-il configuré ?
- La taille du pool est-elle adaptée ?
- Les requêtes importantes sont-elles indexées ?
- Les requêtes lentes ont-elles réellement fait l’objet d’un profilage ?
- Les transactions sont-elles utilisées là où c’est nécessaire ?
- Les migrations sont-elles suivies via un système de contrôle de version ?
API
- Les données d’entrée reçues sont-elles validées ?
- Les en-têtes de réponse sont-ils structurés intentionnellement ?
- Les grands ensembles de résultats sont-ils paginés ?
- La taille du corps des requêtes est-elle limitée ?
Sécurité
- Les mots de passe sont-ils hachés à l’aide d’un algorithme sécurisé ?
- Les secrets sont-ils stockés de manière sécurisée ?
- L’autorisation est-elle réellement appliquée ?
- Les requêtes SQL sont-elles paramétrées ?
- Une limitation de fréquence est-elle mise en œuvre là où c’est essentiel ?
- Les champs sensibles sont-ils exclus des journaux ?
Infrastructure
- Y a-t-il quelque chose qui dépend de manière incorrecte de
localhost? - Les données persistantes sont-elles conservées en dehors des conteneurs éphémères ?
- Les points d’entrée de vérification de santé sont-ils exposés ?
- Une fermeture propre est-elle mise en œuvre ?
- Que se passe-t-il réellement lorsque l’application est redémarrée ?
Prestations
- Le système a-t-il été testé avec des volumes de données réalistes ?
- Y a-t-il des schémas de requêtes N+1 cachés ?
- Les tâches coûteuses sont-elles transférées à des jobs en arrière-plan ?
- Les caches ont-ils une taille limitée ?
- L’utilisation de la mémoire est-elle suivie ?
Observabilité
- Les journaux d’erreurs contiennent-ils suffisamment de contexte pour être utiles ?
- Le suivi des requêtes est-il mis en place ?
- La latence et les taux d’erreur sont-ils surveillés ?
- Les pannes de base de données apparaissent-elles dans les outils de surveillance ?
- Les alertes sont-elles réellement configurées ?
Mise hors service en douceur : le petit détail oublié par beaucoup
Imaginez que votre serveur reçoive un :
SIGTERM
signal parce que la plateforme d’hébergement souhaite redémarrer l’instance.
Si le processus s’arrête instantanément, toutes les requêtes encore en cours peuvent échouer complètement.
Une mise hors service en douceur gère cela mieux.
L’idée en code ressemble à peu près à ceci :
process.on("SIGTERM", async () => {
console.log("Shutdown signal received");
server.close(async () => {
await pool.end();
console.log("Server closed");
process.exit(0);
});
});
L’implémentation précise variera en fonction de votre framework.
Mais la séquence de base reste identique :
Stop accepting new requests
↓
Finish active requests
↓
Close database connections
↓
Exit
Cela devient d’autant plus important lorsque vous exécutez plusieurs instances de l’application en même temps.
La logique de tentative peut aggraver les problèmes
Lorsqu’une requête vers un service externe échoue, tenter à nouveau semble être la solution évidente :
try {
await callService();
} catch {
await callService();
}
Cela semble inoffensif en soi.
Mais imaginez maintenant mille requêtes suivant toutes le même schéma.
Supposons que la dépendance commence à répondre lentement.
Chacune de ces requêtes échouées est automatiquement réessayée.
La dépendance en difficulté reçoit alors encore plus de charge qu’auparavant.
Ce schéma a un nom : une tempête de tentatives.
La logique de tentative doit être conçue intentionnellement, en tenant compte de facteurs tels que :
- un plafond de tentatives
- un backoff exponentiel
- du jitter
- des délais de timeout raisonnables
- des garanties d’idempotence
- la rupture de circuit là où c’est pertinent
Tenter à nouveau n’est pas automatiquement un mécanisme de récupération.
Dans certains cas, c’est précisément cela qui transforme un petit problème en panne totale.
Pourquoi les bugs en production semblent si différents
Un bug local typique se résume généralement à quelque chose comme :
I made a mistake.
Un incident en production typique ressemble plutôt à :
Several individually reasonable things interacted badly.
À titre d’illustration :
Traffic increases
↓
More API instances
↓
More database connections
↓
Database reaches connection limit
↓
Queries fail
↓
Requests retry
↓
More load
↓
System becomes even less stable
Aucune des lignes individuelles concernées ne semble nécessairement dangereuse isolément.
Le véritable problème réside dans la manière dont elles interagissent sous charge.
C’est précisément pour cette raison que l’ingénierie backend déplace progressivement son focus des fonctions isolées vers le comportement du système dans son ensemble.
La phrase la plus dangereuse en développement backend
Peut-être que la phrase la plus risquée dans tout le travail backend est :
">Personne ne va faire ça."
Personne ne va :
- Envoyer 10 000 requêtes en même temps
- Télécharger un fichier de 500 MB
- Faire un double-clic sur le bouton d’envoi
- Envoyer du JSON mal formaté
- Demander un million de lignes d’un seul coup
- Pourvoir utiliser un client obsolète
- Accéder directement à l’API au lieu d’utiliser l’interface utilisateur
- Faire planter le service externe en raison d’un temps d’attente dépassé
- Déclencher deux mises à jour en même temps
- Réinitialiser le serveur au milieu d’une requête
Mais, tôt ou tard, quelqu’un le fait bel et bien.
Parfois par accident. Parfois à cause d’une erreur ailleurs dans le système. Parfois en raison de problèmes réseau. Parfois simplement parce que votre produit commence à gagner en popularité.
Dans un environnement de production, il n’est pas nécessaire que des acteurs malveillants révèlent des hypothèses fragiles : l’échelle même suffit à les mettre en évidence.
Concevez pour l’échec, pas seulement pour le succès
Un changement majeur dans la façon de penser des développeurs backend expérimentés est le suivant : lorsqu’on écrit une fonction, il ne faut pas s’arrêter à se demander ce qui se passe en cas de succès. Il faut également se demander ce qui se passe en cas d’échec.
What happens when it succeeds?
What happens when it fails?
Lorsque vous appelez la base de données, demandez-vous ce qui se passe si elle est inaccessible.
What if it's unavailable?
Lorsque vous appelez Redis, demandez-vous ce qui se passe si sa réponse est lente.
What if it's slow?
Lorsque vous envoyez un e-mail, demandez-vous ce qui se passe si le délai de réponse du fournisseur expire.
What if the provider times out?
Lorsque vous téléchargez un fichier, demandez-vous ce qui se passe si le stockage échoue juste après que l’enregistrement dans la base de données a déjà été créé.
What if storage fails after the database record is created?
Lorsque vous créez une commande, demandez-vous ce qui se passe si la même demande arrive deux fois.
What if the request is repeated?
Lorsque vous traitez un paiement, demandez-vous ce qui se passe si le client ne reçoit jamais de réponse.
What if the client never receives the response?
Lors du déploiement, demandez ce qui se passerait si la nouvelle version plante au démarrage.
What if the new version crashes?
Poser systématiquement ces questions modifie la manière dont vous concevez votre architecture.
Un modèle mental simple pour le développement de backends en production
Lors de la création d’une fonctionnalité backend, il est utile de l’analyser à travers cinq niveaux de réflexion.
1. Correctitude
La fonctionnalité fait-elle réellement ce pour quoi elle a été conçue ?
Create order → order actually gets created
2. Sécurité
Quelqu’un pourrait-il effectuer une action à laquelle il n’aurait pas le droit ?
User A → access User B's order
3. Performance
Que se passe-t-il lorsque le volume de données ou le trafic augmente considérablement ?
100 users → 10 million users
4. Fiabilité
Que se passe-t-il lorsque les systèmes sur lesquels cette fonctionnalité dépend tombent en panne ?
Database unavailable
5. Opérabilité
Si quelque chose tombe en panne, pouvez-vous vraiment le diagnostiquer et le réparer ?
Request failed at 03:42:17
En parcourant ces cinq couches, on constate souvent un nombre surprenant de problèmes avant même qu’ils n’atteignent la production.
De « Ça marche » à « Ça résiste »
Il existe un véritable écart entre un logiciel qui fonctionne simplement et un logiciel capable de résister aux conditions réelles du monde extérieur.
feature works
Une build de production doit prouver plusieurs choses :
feature works
feature fails safely
feature handles load
feature protects data
feature can be monitored
feature can recover
feature can be deployed
feature can be rolled back
C’est un standard bien plus élevé à atteindre. C’est aussi ce qui rend l’ingénierie backend véritablement intéressante en tant que discipline.
Les 10 erreurs à nouveau
Voici un bref résumé de tout ce qui a été abordé.
1. Configuration d’environnement manquante
Ne supposez pas que la configuration existant sur votre ordinateur portable sera également présente en production. Vérifiez les variables d’environnement nécessaires au démarrage et émettez une erreur claire en cas de manque.
2. Codage statique de localhost
Dans un conteneur, localhost ne fait référence qu’à ce conteneur lui-même. Il convient de s’appuyer sur un mécanisme de découverte des services adapté et une configuration qui s’adapte à chaque environnement.
3. Création de connexions de base de données par requête
Utilisez un pool de connexions et tenez compte du nombre total de connexions sur toutes les instances en cours d’exécution de votre application.
4. Retour de toutes les données depuis la base de données
Évitez d’utiliser SELECT * par habitude. Ne renvoyez que les champs dont vos utilisateurs API ont réellement besoin.
5. Ignorance des index
Une requête qui fonctionne bien avec mille lignes peut devenir inefficace avec des millions. Testez avec des volumes de données similaires à ceux en production.
6. Traitement du stockage local comme étant permanent
Les conteneurs, ainsi que de nombreuses plateformes cloud en général, ne garantissent pas la persistance des fichiers locaux. Redirigez le contenu téléchargé par les utilisateurs vers un stockage conçu pour assurer sa durabilité.
7. Aucune limitation de débit
Les clients ne contrôlent pas la fréquence à laquelle ils appellent votre API. Mettez en place des limites pour les points d’entrée sensibles ou coûteux à gérer.
8. Demandes synchrones longues
Le traitement intensif doit généralement avoir lieu en arrière-plan, et non attendre qu’une demande HTTP soit traitée.
9. Faire confiance aux données fournies par le client
Vérifiez à nouveau tout au niveau du backend. Les vérifications effectuées uniquement côté frontend ne sont jamais suffisantes.
10. Confondre « processus actif » et « application en bon état »
Faites appel à des vérifications d’état, à une surveillance, à des journaux structurés, à des métriques et à des alertes qui aient réellement un sens.
Conclusions finales
Ce qui est frappant avec les problèmes de production, c’est que presque aucun d’eux ne semble alarmant au premier abord.
localhost codé en dur ne semble pas être un problème. L’absence d’un index ne semble pas être un problème. Ouvrir une nouvelle connexion à la base de données pour chaque requête ne semble pas être un problème. L’absence de délai d’expiration ne semble pas être un problème. Une route API sans limites ne semble pas être un problème. Écrire directement un fichier sur le disque ne semble pas être un problème.
Jusqu’au moment où des utilisateurs réels apparaissent.
C’est alors que ces petites hypothèses, apparemment inoffensives, commencent à entrer en collision les unes avec les autres. Et soudain, on se retrouve face à :
slow APIs
database connection exhaustion
memory spikes
timeouts
failed deployments
missing files
duplicate payments
security issues
L’objectif n’est pas de prévoir à l’avance chaque échec imaginable. Cela est impossible. L’objectif est d’arrêter à construire des systèmes en partant du principe que tout se passera toujours sans heurt.
Car ce ne sera pas le cas.
Les réseaux tombent en panne. Les bases de données ralentissent. Les conteneurs doivent être redémarrés. Les utilisateurs envoient des données que vous n’attendiez pas. Les dépendances en aval se déconnectent. Le trafic augmente brusquement sans prévenir. Les serveurs manquent de mémoire. Les requêtes sont dupliquées. Les déploiements échouent.
Rien de tout cela n’a besoin d’être catastrophique, à condition que votre système ait été conçu en tenant compte de ces possibilités dès le début.
Ce qui distingue vraiment un backend fonctionnant uniquement sur localhost d’un backend capable de tenir en production, ce n’est pas le langage de programmation choisi. C’est la mentalité derrière la conception.
Sur localhost, la question est simplement : ça marche ?
En production, la question importante est : que se passe-t-il quand ça ne marche pas ?
Cette deuxième question, posée encore et encore, vous apprendra bien plus sur l’ingénierie backend que n’importe quel framework ou outil.
Si vous mettez en place votre premier système de production, résistez à l’envie de tout corriger d’un coup. Commencez petit : assurez-vous que la gestion des configurations est correcte, ajoutez un mécanisme de pooling des connexions, validez chaque entrée, créez des index en fonction des façons dont vous interrogez réellement les données, gérez explicitement les délais d’expiration, stockez les fichiers à l’endroit approprié, ajoutez un enregistrement des événements pertinent, surveillez ce qui est important, et réfléchissez aux scénarios d’échec au fur et à mesure. Ensuite, continuez à répéter ce processus.
Être prêt pour la production n’est pas quelque chose que l’on coche une fois puis sur quoi on oublie. C’est une habitude que l’on développe au fil du temps en tant que développeur.
Petit à petit, cela devient la différence entre un système qui ne fait que dire « ça marche sur ma machine » et un système qui tient vraiment la route lorsque c’est crucial.