Et si on régulait la latence d'un service web comme on régule la tension d'un circuit ?

Un voyage en théorie du contrôle, de FastAPI à Spring Boot, avec un détour par AWS.

Le problème que tout SRE connaît

Un mardi matin, votre service HTTP commence à ralentir. La P95 grimpe doucement de 180 ms à 350 ms, le SLA à 250 ms est dépassé, et l'alerte PagerDuty arrive trois minutes plus tard. Vous ouvrez Grafana, vous comptez le nombre de threads occupés, vous ajustez à la main le nombre de workers, ou bien vous laissez l'auto-scaler ajouter une instance — qui mettra deux minutes à démarrer pendant que vos clients voient des timeouts.

Cette scène se rejoue partout dans l'industrie. Et derrière, le même réflexe : on réagit à la dégradation, on ne la régule pas. C'est comme conduire en regardant uniquement les obstacles déjà heurtés.

Et pourtant, les automaticiens — ceux qui font des régulateurs pour des centrales thermiques, des avions ou de simples convertisseurs d'alimentation — savent depuis cinquante ans comment résoudre ce genre de problème. C'est ce qu'on a voulu vérifier : leurs outils tiennent-ils encore debout face à un service HTTP moderne ?

Le moment "et si on regardait ça autrement"

Reprenons un objet familier en électronique : un convertisseur Buck DC-DC. Sa fonction est triviale — transformer 12 V en 5 V de manière stable, même si la charge ou la tension d'entrée varie.

Comment fait-il ? Il mesure la tension de sortie, la compare à une consigne (5 V), et ajuste le rapport cyclique du MOSFET en temps réel. Mille fois par seconde. Sans intervention humaine.

Maintenant regardons votre service HTTP :

Convertisseur Buck Service HTTP
Tension de référence (5 V) Latence cible (250 ms)
Pont diviseur (mesure) P95 sur fenêtre glissante 5 s
Compensateur RC Régulateur PID logiciel
MOSFET (actionneur) Sémaphore de concurrence N
Charge résistive Trafic Poisson λ req/s

Structurellement, c'est exactement le même problème : une entrée pilotable, une sortie mesurable, et l'envie de garder la sortie stable malgré les perturbations. Pourquoi alors traite-t-on l'un avec une équation différentielle de 1960 et l'autre avec un Excel et de l'huile de coude ?

C'est cette question qui a guidé tout le travail.

La boucle de régulation, version logicielle

Voici l'idée centrale, en un schéma :

flowchart LR R[Consigne r<br/>P95 = 250 ms] --> Sub((−)) Sub --> PID[Régulateur PID] PID -->|N workers| Plant[Service HTTP<br/>FastAPI ou Spring Boot] Plant -->|Latence P95| Capteur[Capteur P95<br/>fenêtre glissante 5 s] Capteur --> Sub Lambda[Trafic Poisson λ] -.perturbation.-> Plant

Tout ce qu'il y a à faire pour faire boucler le système, c'est :

  1. Mesurer la P95 toutes les 0,5 seconde.
  2. Calculer l'erreur : e = consigne − mesure.
  3. Appliquer la formule du PID : u = Kp·e + Ki·∫e + Kd·de/dt.
  4. Pousser la nouvelle valeur de N (concurrence autorisée) au service.
  5. Recommencer.

C'est tout. Une trentaine de lignes de Python tournent à côté du service, lisent un endpoint /metrics, écrivent un endpoint /admin/concurrency. Le service lui-même n'a pas besoin d'être réécrit.

Mais évidemment, le diable est dans les coefficients : combien doivent valoir Kp, Ki, Kd ? Trop petits, le régulateur réagit trop lentement et laisse passer les pics. Trop grands, il sur-réagit et fait osciller le système. C'est là que la science commence.

Étape 1 — Mesurer le système (boucle ouverte)

L'automaticien ne devine pas ses coefficients. Il identifie d'abord le comportement du système avant d'y connecter quoi que ce soit. La procédure est étonnamment simple :

  1. On laisse tourner le service à charge constante (110 requêtes/seconde dans nos expériences).
  2. On change brutalement N (de 7 à 10 workers, par exemple).
  3. On enregistre la P95 toutes les 0,5 seconde pendant 20 secondes.

Le résultat ressemble à une courbe exponentielle qui converge vers une nouvelle valeur. En automatique on appelle ça un modèle premier ordre avec retard pur (FOPDT), et il se décrit en trois paramètres :

  • K (gain statique) — combien la latence change pour un worker en plus
  • τ (constante de temps) — à quelle vitesse le système réagit
  • θ (retard pur) — combien de temps avant que la réaction commence

scipy.optimize.curve_fit ajuste ces trois paramètres en quelques millisecondes. Sur FastAPI on obtient :

K = −149 ms par worker (un worker en plus → 149 ms en moins de P95)
τ = 0,31 seconde         (le système réagit en moins d'une seconde)
θ = 0,99 seconde         (avec un retard d'environ une seconde)

Avec ces trois nombres en main, un automaticien peut maintenant prescrire des coefficients PID via les formules de Ziegler–Nichols, vieilles de 1942. La méthode est bête comme chou : Kp = 1,2·τ/(K·θ), et hop.

Étape 2 — Mais Ziegler–Nichols donne un régulateur trop mou

Premier constat : appliqué à la lettre, ZN donne Kp ≈ 2,5·10⁻³. En simulation, ça marche, mais ça met plus de 80 secondes pour atteindre la consigne. Inutilisable pour un service web où une dégradation de 80 secondes c'est déjà une demi-vie d'incident.

Pourquoi ZN se trompe ici ? Parce qu'il a été conçu pour des process industriels lents (régulation thermique, niveau d'eau), pas pour des systèmes informatiques où on peut se permettre d'être plus agressif puisque les pertes en cas d'instabilité sont moins catastrophiques qu'une centrale thermique en surchauffe.

On a donc fait ce que tout ingénieur fait : on a multiplié Kp par environ 7, ajusté à la main et observé. Après quelques essais on a convergé sur :

Kp = -0.018    # gain proportionnel
Ti = 4.0 s     # constante d'intégration
Td = 0.8 s     # constante de dérivation
Tf = 3.0 s     # filtre passe-bas sur la mesure (essentiel pour le bruit du quantile)

Le résultat est saisissant sur le test "départ chargé" — le service démarre avec N=6 (sous-dimensionné, P95 ≈ 1000 ms), consigne à 250 ms :

Régulateur Fraction du temps > SLA
P seul 73 %
PI 29 %
PID complet 18 %

Multiplier par 4 la respect du SLA, simplement en ajoutant deux constantes. Pas de réécriture du service, pas de cluster supplémentaire.

Étape 3 — Le test de la vérité : Spring Boot

À ce stade, on aurait pu publier le papier "PID fonctionne sur FastAPI" et rentrer satisfait. Sauf qu'aucun SRE ne déploie de FastAPI en production critique. La vraie question, c'était : est-ce que ça tient sur la JVM ?

On a refait exactement la même expérience d'identification sur un service Spring Boot 3 (Java 21, Tomcat embarqué). Échelon N: 9 → 13, capture de la P95, ajustement FOPDT.

Le résultat a été inattendu.

R² = −196,7

Pour comprendre ce nombre, il faut savoir que R² = 1 signifie "modèle parfait", R² = 0 signifie "aussi bon que la moyenne", et R² négatif signifie "pire que la moyenne". −197 veut dire que le modèle est 197 fois pire qu'une simple ligne horizontale. Le FOPDT, qui avait si bien marché sur FastAPI, est complètement faux sur Spring Boot.

Pourquoi ? Trois coupables :

  • Compilation JIT : la JVM ré-optimise du code chaud au moment exact où on change N, modifiant les temps de réponse.
  • Garbage Collector G1 : pauses de 5 à 50 ms qui dépendent de l'allocation, pas de la charge HTTP.
  • Pool de threads Tomcat : se redimensionne tout seul en interne selon des règles qui lui sont propres.

Bref, le moteur Java est un système non-linéaire avec sa propre dynamique cachée. Le modèle linéaire qu'on avait identifié sur Python est inutilisable.

Le coup de théâtre : le PID marche quand même

C'est ici que le résultat devient intéressant. On a quand même testé le régulateur sur Spring Boot, en partant du principe "essayons, on verra bien". On a balayé trois jeux de gains :

Configuration Kp Tf P95 moyenne % > SLA
Agressif −0,018 3 s 200 ms 5,0 %
Modéré −0,012 5 s 213 ms 5,0 %
Conservateur −0,008 6 s 223 ms 21,4 %

Les deux premiers atteignent 5 % de violations SLA, sur un service où le modèle linéaire est inapplicable. Comment est-ce possible ?

La réponse tient en deux mots : robustesse intrinsèque. Un PID bien filtré (Tf long) ne réagit pas aux microperturbations JVM. Il se contente de regarder l'erreur moyennée sur quelques secondes et de pousser N dans la bonne direction. Il n'a pas besoin de comprendre la JVM, il a juste besoin de savoir que si la P95 monte, il faut ajouter des workers ; si elle baisse trop, il en enlève.

C'est exactement la même philosophie qu'un thermostat dans votre salon : il ne modélise pas les courants d'air ni la chaleur émise par votre ordinateur portable. Il regarde la température, la compare à la consigne, et allume ou éteint la chaudière. La robustesse du contrôle vient justement de ne pas dépendre d'un modèle précis.

L'expérience décisive : 180 secondes, 5 stratégies

Pour vraiment trancher, on a comparé le PID adaptatif à trois stratégies statiques (N fixe) que tout opérateur a déjà testées dans sa vie :

graph LR A[N = 6 fixe<br/>SOUS-PROVISION] -->|89% > SLA| Crash[Service inutilisable] B[N = 10 fixe<br/>ORACLE PARFAIT] -->|1,3% > SLA| Idéal[Configuration idéale] C[N = 50 fixe<br/>DEFAULT TOMCAT] -->|0% > SLA| Gaspi[5× sur-provisionné] D[PID adaptatif] -->|10% > SLA| Trouve[Trouve N≈10 tout seul] style A fill:#fdd style B fill:#dfd style C fill:#ffd style D fill:#ddf

Trois enseignements ressortent :

  1. N = 6 est catastrophique (89 % des requêtes au-dessus du SLA). C'est le piège de l'opérateur qui veut "économiser des ressources" sans comprendre la dynamique de file d'attente.

  2. N = 50 est massivement sur-provisionné (0 % de violations, mais 5× plus de ressources que nécessaire). C'est la configuration Tomcat par défaut, et c'est ce qui tourne aujourd'hui dans des milliers d'applications Spring Boot.

  3. Le PID retrouve N ≈ 10 dynamiquement, sans le savoir. Il converge vers la même valeur que l'oracle statique, mais sans qu'on lui ait dit que 10 était la bonne réponse. C'est exactement l'utilité d'un régulateur : faire de l'optimisation continue sans connaissance préalable.

Mais AWS ne fait-il pas déjà ça ?

Question légitime. Le AWS Application Auto Scaling, le Kubernetes HPA, l'Azure VMSS — tous ces produits font de l'asservissement de capacité. Sur quel principe ?

On a fait tourner 24 heures un service ECS Fargate en production, asservi par AWS sur la métrique RequestCountPerTarget (consigne 100 req/min/cible). Voici ce qui se passe :

  • AWS régule le RPS/cible, pas la latence directement.
  • L'actionneur AWS est le nombre de tâches (instances), pas la concurrence interne.
  • La période de contrôle d'AWS est de 60 secondes minimum.

C'est donc un PI saturé sur une métrique proxy — exactement le pattern qu'on étudie, mais à une échelle temporelle 120 fois plus lente, et avec un compromis fondamental : on régule un proxy (RPS) qui n'est corrélé à la latence que tant que le service de fond ne change pas.

Et c'est là que ça casse : qu'arrive-t-il si une BDD partagée ralentit, si une JVM fait un cold-start, si un cache devient froid ? Le RPS reste constant, mais la P95 explose. AWS ne le voit pas, car il regarde une métrique qui a perdu sa corrélation avec ce qui compte vraiment.

Notre approche est complémentaire :

  • AWS gère la tendance diurne (variations sur 10–60 min, inter-instances).
  • Notre PID gère la microvariabilité sous-minute, à l'intérieur d'une instance.

Les deux peuvent — et devraient — coexister.

Le pipeline complet en un schéma

Pour celles et ceux qui veulent reproduire la démarche, voici le flux complet :

flowchart TD Start([Service HTTP en prod]) --> Q1{Modèle FOPDT<br/>identifié ?} Q1 -->|Oui FastAPI-like| ZN[Ziegler-Nichols<br/>Kp = 1.2·τ/Kθ] Q1 -->|Non JVM, GC, JIT| Empirique[3 jeux a priori<br/>agressif / modéré / conservateur] ZN --> Affinage[Affinement empirique<br/>Kp ×7 typiquement] Empirique --> Test[Test BF 120 s<br/>mesurer % > SLA] Affinage --> Test Test --> Q2{Stabilité OK ?<br/>% SLA ≤ 5%} Q2 -->|Non| Ajust[Réduire Kp / augmenter Tf<br/>activer bande morte δ] Q2 -->|Oui| Shadow[Déployer en shadow mode<br/>PID actif, u_max bridé] Ajust --> Test Shadow --> Prod[Bascule active<br/>monitoring continu]

L'élégance du processus, c'est que chaque étape est mesurable. Pas de gut feeling, pas d'astrologie : tu identifies, tu calcules, tu testes, tu ajustes. Cinq minutes d'identification, dix minutes de test, et tu as un régulateur quantifié, falsifiable, reproductible.

Ce que ça change pour l'industrie

Au-delà du résultat scientifique, voici ce que cette démarche apporte concrètement :

1. Une alternative aux règles ad hoc. Aujourd'hui, dans 90 % des cas, la valeur du pool Tomcat ou de max_workers Uvicorn est figée à l'install et jamais touchée. Notre approche prouve qu'on peut la rendre dynamique avec quelques lignes de code et zéro modification du service.

2. Une économie de ressources mesurable. Sur l'expérience Spring Boot, le PID atteint le même SLA que N = 50 (la config Tomcat par défaut) avec 5× moins de workers. Pour une flotte de production, ça représente une réduction directe de coût d'infrastructure.

3. Une meilleure résistance aux pannes partielles. Si la BDD ralentit, le PID le voit dans la P95 et adapte N. Le scaler RPS d'AWS ne le voit pas. Notre approche est par construction immune aux dérives de proxy.

4. Un langage commun avec les automaticiens. En adoptant le vocabulaire et les outils du contrôle classique (FOPDT, Ziegler–Nichols, anti-windup, EWMA), on ouvre la collaboration avec des ingénieurs qui ont soixante ans d'expérience à gérer des systèmes bouclés. La théorie est mature, robuste, enseignée partout. Il suffit d'oser l'appliquer hors de son territoire historique.

Les limites honnêtes

Cet article ne serait pas honnête sans mentionner ce qui ne marche pas :

  • On a testé sur un seul host avec une BDD en mémoire. Une vraie BDD partagée introduit des couplages thermiques qu'on n'a pas explorés.
  • La charge est stationnaire. Sur un profil diurne, il faudrait du gain scheduling (changer Kp selon l'heure de la journée).
  • Le retard de mesure est non négligeable. La fenêtre glissante de 5 secondes introduit un déphasage d'environ 2,5 secondes. Un scraping Prometheus à 1 seconde réduirait ça, mais ajouterait du bruit.
  • Le tuning sur Spring Boot reste empirique. On a balayé manuellement trois configurations ; un Bayesian optimization ferait probablement mieux. C'est dans la liste des travaux futurs.

Conclusion : la régulation, sport d'équipe ingénieur–automaticien

Le message à retenir tient en une phrase : un service HTTP est un système bouclable, et le PID — outil de 1922 — y fonctionne aussi bien qu'en électronique de puissance, à condition de respecter trois précautions :

  1. Filtrer la mesure (Tf ≥ 3 s) car le quantile P95 est intrinsèquement bruité.
  2. Respecter le signe du gain (Kp < 0 ici, car ajouter des workers réduit la latence).
  3. Activer l'anti-windup, parce que la saturation du sémaphore est routinière, pas exceptionnelle.

Pour les SRE, c'est une opportunité d'aller plus loin que le simple seuil ou la règle if/else. Pour les automaticiens, c'est un terrain de jeu où leurs outils trouvent une utilité moderne. Pour les architectes, c'est une couche de plus dans l'arsenal du résilience engineering — complémentaire, pas concurrente, des auto-scalers cloud existants.

Et concrètement, si vous voulez essayer demain matin :

  1. Instrumentez votre service avec un endpoint /metrics qui expose la P95 sur 5 s.
  2. Wrappez votre limite de concurrence dans un endpoint /admin/concurrency.
  3. Lancez le sidecar de contrôle (≈ 30 lignes de Python).
  4. Identifiez vos gains via un échelon en canary, 5 minutes suffisent.
  5. Déployez en shadow mode, observez 24 h, basculez en mode actif.

La théorie a un siècle. Les outils sont libres. Les résultats sont mesurables. Le reste, c'est de la curiosité d'ingénieur.

Code, données et figures reproductibles : github.com/<ton-org>/latence-stability-of-software Article scientifique complet : paper/paper_en.pdf (IEEE format).