Accueil / Articles

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.

Éditeur : REACTAPP.TOP39 min de lecture8945 mots

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
  • authentification
  • négociation TLS
  • initialisation de la session
  • affectation des ressources
  • démarrage d’un processus ou d’un thread du côté de la base de données
  • 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
  • traitement d’images
  • traitement de vidéos
  • élaboration de rapports
  • importation de données
  • envoi de notifications
  • génération de fichiers d’export volumineux
  • exécution de tâches planifiées
  • 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
  • un script automatisé
  • une version obsolète de votre application
  • quelqu’un ayant de mauvaises intentions
  • 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 (...) clauses
  • chargeurs de données
  • requêtes conçues spécifiquement pour éviter ce schéma
  • Le 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
  • cache sans limite de suppression
  • écouteurs d’événements qui ne sont jamais nettoyés
  • objets volumineux restant inutilement en mémoire
  • références qui persistent bien plus longtemps que nécessaire
  • flux qui ne sont pas gérés correctement
  • 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 ?
  • Des délais d’attente raisonnables ont-ils été configurés ?
  • 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.

    Lectures complémentaires