Aller au contenu
English
Compte : non connecté, synchronisation possible Mon apprentissage
Selectionner le thème
English

Nginx : publier WebSocket, SSE et gRPC sans coupure

Par mis à jour le

Améliorer cette page

Logo NGINX

Un flux long (WebSocket, SSE, gRPC) ne se publie pas comme une page : Nginx doit transmettre la poignée de main, ne rien retenir en tampon, parler le bon protocole au backend et attendre plus longtemps, sur ces routes seulement. Cette leçon s'adresse à une personne qui publie déjà une application derrière Nginx et qui voit une connexion temps réel se couper, arriver en retard ou ne jamais s'établir. Elle enseigne les directives de chaque transport, les délais côté backend et la façon de prouver qu'un flux fonctionne. Les délais de lecture de la requête par le client, client_body_timeout et client_header_timeout, relèvent de la leçon Nginx limites.

La leçon traite les trois transports un par un, puis les délais qu'ils partagent.

  • Transmettre une poignée de main WebSocket avec Upgrade et Connection.
  • Livrer des événements SSE au rythme où le backend les émet, avec proxy_buffering.
  • Publier un service gRPC avec grpc_pass et lire son statut sans le confondre avec le statut HTTP.
  • Allonger les délais de lecture et d'envoi vers le backend, route par route.
  • Prouver qu'un flux fonctionne par une mesure de rythme et un échange dans les deux sens.

La leçon suppose que vous savez déjà relayer une requête vers un backend et lire un échange HTTP.

  • Publier une application avec proxy_pass : Publier une application avec Nginx.
  • Lire une requête, une réponse et leurs en-têtes : Lire HTTP avant de configurer un proxy.
  • Valider et recharger une configuration (nginx -t, systemctl reload nginx) : Installer Nginx.
  • Une machine Debian 13 avec sudo, où Nginx 1.31.8 est installé depuis le dépôt de l'éditeur (nginx.org) : la configuration vit dans /etc/nginx/conf.d/ et les workers tournent sous l'utilisateur nginx.

Pourquoi un proxy HTTP ordinaire casse-t-il les flux longs ?

Section intitulée « Pourquoi un proxy HTTP ordinaire casse-t-il les flux longs ? »

Un proxy configuré pour des pages suppose une requête courte, une réponse qui se termine, et un échange qui tient en quelques secondes ; un flux long viole ces trois hypothèses. Un WebSocket est une connexion qui, après une poignée de main (un premier échange HTTP ordinaire où le client demande à changer de protocole), devient un canal bidirectionnel où les deux côtés envoient des messages quand ils veulent. Le SSE (Server-Sent Events) est une réponse HTTP qui ne se termine pas : le serveur y écrit des événements au fil du temps. gRPC est un protocole d'appels de procédure qui circule sur HTTP/2.

Chacun exige quelque chose de différent du proxy, et c'est ce qui rend le diagnostic déroutant : le même Nginx peut très bien servir les pages du site et échouer sur ces trois routes, chacune pour une raison propre.

TransportCe qu'il exige de NginxSymptôme quand c'est absent
WebSocketTransmettre Upgrade et Connection au backendLa poignée de main n'aboutit pas, ou aboutit sans que rien ne circule
SSENe rien mettre en tamponLes événements arrivent tous ensemble à la fin
gRPCHTTP/2 côté client (http2 on;), grpc_pass vers le backendL'appel échoue au niveau du transport
Les troisUn délai de lecture plus long que le silence normal du fluxLa connexion se coupe en pleine conversation

La dernière ligne du tableau est la plus insidieuse. Nginx ne ferme pas une connexion parce qu'elle dure, mais parce qu'aucune donnée n'a circulé pendant le délai. Un flux qui reste silencieux dix secondes, sous un délai de lecture réglé à dix secondes, est coupé alors que rien n'est cassé.

Comment faire passer un WebSocket avec Upgrade et Connection ?

Section intitulée « Comment faire passer un WebSocket avec Upgrade et Connection ? »

Pour un WebSocket, Nginx doit retransmettre les en-têtes Upgrade et Connection du client, qu'il supprime sinon. Ces deux en-têtes sont dits hop-by-hop : ils décrivent la connexion entre deux maillons voisins, pas la requête de bout en bout, et un proxy ne les propage donc pas de lui-même. Or c'est précisément Upgrade: websocket qui demande au backend de basculer le protocole. Sans lui, le backend répond comme à une requête ordinaire.

Voici un location de WebSocket pour une application temps réel fictive qui écoute sur le port 9000.

# Dans le contexte http, hors de tout server
map $http_upgrade $connexion_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name temps-reel.lab.test;
location /live/ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connexion_upgrade;
proxy_set_header Host $host;
}
}

Le bloc map calcule la valeur de Connection à partir de Upgrade. Si le client envoie un Upgrade, Nginx transmet Connection: upgrade ; s'il n'en envoie pas, il transmet Connection: close. C'est la réponse au piège classique : écrire proxy_set_header Connection "upgrade"; en dur, comme on le trouve partout, force la bascule de protocole sur toute requête qui traverse ce location, y compris celles qui n'en demandent aucune. Cela fonctionne tant que la route ne sert que du WebSocket, mais ce n'est pas un modèle universel.

Aucune directive proxy_http_version n'est nécessaire : Nginx 1.31.8 parle HTTP/1.1 à ses backends par défaut, et c'est cette version du protocole qui connaît le mécanisme Upgrade. Sur cette route, le map ci-dessus décide seul de la valeur de Connection envoyée. Les connexions persistantes vers les backends sont actives d'office, mais elles ne concernent pas cette route : une fois la bascule faite, la connexion devient un tunnel dédié à un seul client. Elles sont détaillées dans la leçon Répartir la charge avec upstream.

Si le site est servi en HTTPS, le client utilise wss:// et c'est le server en listen 443 ssl décrit dans Nginx TLS qui déchiffre le trafic avant ce location (la terminaison TLS) : la configuration ci-dessus ne change pas.

Comment vérifier qu'un WebSocket fonctionne vraiment ?

Section intitulée « Comment vérifier qu'un WebSocket fonctionne vraiment ? »

La poignée de main réussie est la moitié de la preuve ; l'autre moitié est un message qui revient. Un 101 Switching Protocols montre que le backend a accepté la bascule, pas que les trames circulent ensuite. Une connexion établie qui ne transporte rien est la version la plus difficile à diagnostiquer de ce défaut, parce que tout paraît en ordre côté client comme côté serveur, et que seul l'échange fait défaut.

Pour la poignée de main seule, curl suffit : il envoie les en-têtes de bascule et affiche la réponse.

Fenêtre de terminal
curl -i -N --max-time 5 \
-H 'Connection: Upgrade' \
-H 'Upgrade: websocket' \
-H 'Sec-WebSocket-Version: 13' \
-H 'Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==' \
http://temps-reel.lab.test/live/

La première ligne de la réponse doit être HTTP/1.1 101 Switching Protocols. Un 400, un 200 ou une réponse de page signifie que l'Upgrade n'est pas arrivé au backend. Le test du lab va plus loin : il envoie une trame (un message WebSocket) de texte après la poignée de main et exige que le backend la renvoie, ce que curl ne sait pas faire. Un client WebSocket réel, ou un court script, devient alors nécessaire pour la seconde moitié.

Comment livrer un flux SSE sans que Nginx le retienne ?

Section intitulée « Comment livrer un flux SSE sans que Nginx le retienne ? »

Un flux SSE exige proxy_buffering off; sur sa route : avec le tampon actif, tous les événements arrivent, mais ensemble et trop tard. La mise en tampon (buffering) est le comportement par défaut de Nginx : il lit la réponse du backend aussi vite que possible, la range en mémoire, puis la distribue au client à son rythme. C'est excellent pour une page, parce que le backend est libéré tout de suite. C'est fatal pour un flux, parce que Nginx attend d'avoir assez de données avant d'écrire vers le client.

Regardez le schéma de haut en bas. Dans le premier cas, le backend émet trois événements espacés d'une seconde et le client ne reçoit rien avant que Nginx décide de vider son tampon. Dans le second cas, chaque événement ressort dès qu'il entre.

Séquence entre un backend SSE, Nginx et un client. Avec proxy_buffering on, le backend émet trois événements à une seconde d'intervalle, Nginx les retient, puis les livre ensemble au client. Avec proxy_buffering off, chaque événement est transmis au client dès qu'il arrive.ClientNginxBackend SSENginx retientlesévénementsalt[proxy_buffe-ring on][proxy_buffe-ring off]événement 1 (t = 0 s)événement 2 (t = 1 s)événement 3 (t = 2 s)événements 1, 2 et 3ensembleévénement 1 (t = 0 s)événement 1 tout desuiteévénement 2 (t = 1 s)événement 2 tout desuite
Le tampon ne perd aucun événement, il détruit leur rythme : un flux devient un lot. Source · SVG
Échap pour fermer, + et − pour zoomer, défilement pour se déplacer. 100 %

La correction tient en une directive, posée dans le location du flux et non au niveau du server ou de http.

location /evenements/ {
proxy_pass http://127.0.0.1:9001;
proxy_buffering off;
}

Le choix du contexte compte autant que la directive. Le tampon reste utile pour toutes les autres routes, qui servent des pages et libèrent leur backend rapidement : le désactiver globalement dégrade tout le reste pour réparer une seule route. Une alternative existe côté application : un backend qui répond avec l'en-tête X-Accel-Buffering: no demande à Nginx de ne pas mettre cette réponse en tampon, ce qui laisse la décision à l'application. Les deux se combinent.

Un test qui compte les événements reçus passe au vert avec un proxy qui retient tout, parce que les événements finissent par arriver. Ce qui distingue un flux d'un lot est le rythme. La validation du lab mesure donc un délai d'arrivée : le backend émet six événements, un par seconde, et elle chronomètre le temps nécessaire pour en recevoir soixante octets, soit les deux premiers, qui doit rester sous quatre secondes. Sans tampon ce temps vaut environ deux secondes : une pour le rythme du backend, une seconde pour que curl s'aperçoive que head est parti. Avec un tampon, rien n'arrive avant la fin du flux, donc six secondes.

Le seuil de quatre secondes sépare donc bien les deux cas, et le lab a raison d'employer cette commande pour son backend : avec ses événements de trente-six octets, deux secondes et six secondes tombent de part et d'autre du seuil. La portée de ce réglage s'arrête là. Avec des événements deux fois plus courts, la commande rendrait quatre secondes sans aucun tampon, le seuil serait atteint, et le verdict basculerait du mauvais côté.

Vous pouvez mesurer la même chose à la main, en mieux : plutôt que d'attendre un nombre d'octets, visez le premier octet. %{time_starttransfer} le rapporte directement, sans dépendre de la taille des événements. Les trois autres options servent à ne mesurer que cela : -s tait la barre de progression, -o /dev/null jette le corps puisqu'on ne veut que le chronomètre, et --max-time 20 borne l'attente, un flux d'événements ne se terminant pas de lui-même.

Fenêtre de terminal
curl -sN --max-time 20 -o /dev/null \
-w 'premier octet : %{time_starttransfer}s\n' \
http://temps-reel.lab.test/evenements/

Sans tampon sur Nginx, la valeur reste sous la seconde ; avec un tampon, elle monte à six secondes environ pour ce flux de six événements, le temps que le backend finisse d'émettre.

N'utilisez pas time curl … | head -c 60 pour cette mesure, même si la tournure est répandue : time chronomètre le pipeline entier, et curl ne s'arrête qu'à sa première écriture après la sortie de head. La raison tient en une phrase : head attend soixante octets, donc plusieurs événements, et curl ne l'apprend qu'à l'événement suivant. Ce que cette commande rend dépend donc de la taille des événements et non du proxy : avec les trente-six octets du lab, environ deux secondes ; avec des événements deux fois plus courts, quatre secondes, mesurées au banc, alors que le premier octet était arrivé tout de suite, et l'on conclut à un tampon qui n'existe pas.

Une précision sur -N : avec -o /dev/null, il ne change pas la valeur mesurée, puisque le corps n'est pas affiché. Il redevient nécessaire dès que vous retirez -o /dev/null pour voir les événements arriver, car curl met alors sa propre sortie en tampon.

Un dernier point sur le SSE : une absence de données n'est pas une fermeture. Un flux d'événements peut rester muet longtemps entre deux changements d'état, et c'est le délai de lecture, vu plus bas, qui décide si Nginx coupe ce silence. Les serveurs SSE émettent souvent une ligne de commentaire (une ligne commençant par :, que le client ignore) à intervalle régulier, pour que la connexion ne paraisse jamais inactive.

gRPC se publie avec grpc_pass et non proxy_pass, sur un server où http2 on; active HTTP/2. gRPC transporte ses appels sur HTTP/2, la version du protocole qui multiplexe plusieurs échanges sur une seule connexion et qui sait envoyer des trailers, des en-têtes placés après le corps. gRPC y place son statut. Un proxy HTTP/1 ne sait faire ni l'un ni l'autre, d'où l'échec.

HTTP/2 s'active par la directive http2 on; (contextes http et server, défaut off, disponible depuis la 1.25.1) ; le paramètre http2 de listen est déprécié. Voici un service gRPC fictif qui écoute en clair sur le port 50052, publié sur son propre port.

server {
listen 50051;
http2 on;
server_name grpc.lab.test;
location /catalogue.Stock/ {
grpc_pass grpc://127.0.0.1:50052;
}
}

Deux choix se lisent dans cet exemple. Le préfixe grpc:// indique un backend en clair (HTTP/2 sans TLS, souvent appelé h2c) ; grpcs:// en désignerait un chiffré. Et le service a son propre server sur son propre port : le mêler au site HTTP/1 obligerait à choisir, sur un même listen, entre deux protocoles qui ne se parlent pas de la même façon. Le chemin du location est celui de la méthode gRPC, de la forme /paquet.Service/Methode.

Comment lire un statut gRPC sans le confondre avec le statut HTTP ?

Section intitulée « Comment lire un statut gRPC sans le confondre avec le statut HTTP ? »

Un appel gRPC rendu par un refus applicatif porte un code HTTP 200, et c'est un statut gRPC, placé dans les trailers, qui dit ce qui s'est passé. Les deux statuts sont distincts : le premier dit que la requête HTTP s'est bien déroulée, le second dit ce que l'application en a fait. Même un appel réussi répond :status 200 et porte son grpc-status dans les trailers. INVALID_ARGUMENT signifie que la requête est refusée parce que son contenu est invalide, UNAVAILABLE que le service ne peut pas répondre pour l'instant.

La différence a une conséquence pratique forte. Un client gRPC n'a pas de politique de nouvelle tentative par défaut : c'est la configuration du client qui liste les codes à rejouer, et UNAVAILABLE, qui peut réussir la fois suivante, y figure typiquement ; un INVALID_ARGUMENT, qui échouera toujours, n'a aucune raison d'y figurer. Si le proxy transforme le second en premier, un client ainsi configuré peut rejouer une requête qui n'aboutira jamais, et charger le backend pour rien. Le lab le mesure donc des deux côtés : un appel normal doit aboutir, et un appel dont la charge provoque un refus applicatif doit revenir comme un refus, pas comme une panne de transport. Le client fourni par le lab, client-grpc, imprime ok, erreur ou transport selon ce qu'il observe, ce qui rend la distinction lisible sans lire un seul octet de trailer.

Quels délais allonger, et pour quelles routes seulement ?

Section intitulée « Quels délais allonger, et pour quelles routes seulement ? »

Les délais vers le backend se règlent route par route : un flux long a besoin d'un proxy_read_timeout plus grand qu'une page, et l'accorder à tout le site laisse des connexions mortes s'accumuler. Deux délais gouvernent ce que Nginx attend du backend : proxy_read_timeout borne le temps d'attente entre deux lectures consécutives sur la connexion vers le backend, et proxy_send_timeout celui entre deux écritures. Leur valeur par défaut est de 60 secondes. Pour gRPC, les directives équivalentes s'appellent grpc_read_timeout et grpc_send_timeout.

Le mot décisif est « entre deux ». Ce délai ne limite pas la durée totale de la connexion : il se réinitialise à chaque donnée reçue. Un flux qui émet un événement toutes les trente secondes ne se coupe jamais sous un délai de soixante, alors qu'un WebSocket silencieux pendant une minute est fermé. La documentation de Nginx le dit pour le WebSocket : par défaut, la connexion se ferme si le backend ne transmet aucune donnée pendant 60 secondes.

location /live/ {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connexion_upgrade;
# Valeurs propres à cette route : un échange peut se taire un moment
proxy_read_timeout 1h;
proxy_send_timeout 1h;
}

Pour choisir la valeur, partez du plus long silence normal du flux : l'intervalle entre deux messages, ou le battement (ping) que l'application s'impose, plus une marge. Une heure n'est pas un chiffre universel, c'est un exemple pour une application qui peut se taire longtemps. Un chiffre énorme « pour être tranquille » transforme une protection en risque.

La raison de cette prudence est l'effet inverse. Quand un backend ferme proprement la connexion, Nginx s'en aperçoit tout de suite et libère le client, quelle que soit la valeur du délai. Mais quand le backend disparaît sans prévenir (machine éteinte, câble coupé), Nginx n'a aucun signal de fermeture à lire. Il ne lui reste alors, par défaut, que deux issues : une écriture vers le backend qui échoue, ou le délai qui expire. En effet, proxy_socket_keepalive, qui active le keepalive TCP vers le backend (des sondes périodiques qui détectent un correspondant disparu), est à off par défaut.

Avec un délai réglé à vingt-quatre heures, la connexion vers un backend mort peut donc rester ouverte jusqu'à vingt-quatre heures, si aucune écriture ne l'a fait échouer avant. Multipliée par chaque client, cette connexion orpheline peut finir par épuiser le proxy, et le symptôme apparaît des semaines après sous la forme « le site est lent ». Ce comportement dépend du moment et de la manière dont le backend disparaît : ne le tenez pas pour acquis avant de l'avoir observé. Le geste qui le constate est un comptage des connexions établies, ss -tn state established '( sport = :80 )' : leur nombre ne doit pas rester gonflé. Ce n'est pas ce que mesure le lab. Sa dernière épreuve prend le problème par l'autre bout, le silence : un flux se tait quinze secondes là où le réglage de départ coupe à dix, et il doit survivre. C'est la même nuance qu'au paragraphe précédent, un flux bavard ne déclenchant jamais proxy_read_timeout, si bien que le défaut reste invisible jusqu'au jour où la conversation n'a rien à dire.

Quand c'est le client qui part, la situation est inverse : proxy_ignore_client_abort est à off par défaut, et Nginx ferme alors sa connexion vers le backend. Le backend peut ainsi être prévenu qu'un abonné SSE a disparu et cesser d'émettre pour lui, mais cela reste à observer : la documentation décrit le client qui part sans attendre de réponse, alors qu'un abonné SSE part en cours de réponse, et c'est le comportement de votre backend qui dit s'il s'arrête.

Quelle différence entre délais côté client et côté backend ?

Section intitulée « Quelle différence entre délais côté client et côté backend ? »

Les délais côté client protègent Nginx d'un client lent ; les délais côté backend décident combien de temps Nginx attend un serveur silencieux. Ce sont des directives différentes, pour des menaces différentes, et les confondre conduit à régler la mauvaise.

QuestionDirectiveOù se règle-t-elle
Un client envoie-t-il son corps trop lentement ?client_body_timeoutNginx limites
Un client lit-il la réponse trop lentement ?send_timeout (60 secondes par défaut)Hors périmètre de cette leçon et de Nginx limites
Le backend se tait-il trop longtemps ?proxy_read_timeout, grpc_read_timeoutCette leçon
Nginx écrit-il trop lentement vers le backend ?proxy_send_timeout, grpc_send_timeoutCette leçon

Cette leçon ne règle ni la taille des requêtes ni le débit : ces limites appartiennent à la leçon Nginx limites, qui détaille les limites de charge à poser sur une route.

Chaque transport tombe en panne d'une façon reconnaissable : lisez le symptôme avant de modifier quoi que ce soit. Le tableau croise ce que vous observez avec la cause la plus probable dans le cadre de cette leçon, et la vérification qui la confirme.

SymptômeCause probableVérification
Le client WebSocket échoue à l'ouvertureUpgrade ou Connection non transmis au backendLe curl d'en-têtes de bascule doit rendre un 101
Le WebSocket s'ouvre puis plus rien ne circuleLes trames ne reviennent pas : backend muet, ou connexion coupée par un délai trop courtEnvoyer une trame et attendre son retour, pas seulement le 101
Les événements SSE arrivent tous ensembleproxy_buffering actif sur cette routecurl -sN -w '%{time_starttransfer}' : plusieurs secondes
Le flux se coupe à intervalle fixeproxy_read_timeout inférieur au silence normalComparer l'intervalle de coupure à la valeur du délai
L'appel gRPC échoue avec une erreur de transportproxy_pass au lieu de grpc_pass, ou http2 on; absentnginx -t, puis le client du lab doit imprimer ok
Un refus applicatif revient comme pannePassage par un proxy HTTP ordinaireLe client du lab doit imprimer erreur sur la charge de refus
Le proxy ralentit des semaines aprèsConnexions orphelines, délais trop longsCompter les connexions établies avant et après une coupure du backend

Un symptôme n'a souvent qu'une cause dans ce tableau, mais deux défauts peuvent cohabiter : un WebSocket corrigé n'est pas pour autant protégé d'un délai trop court. Corrigez une chose, vérifiez, puis passez à la suivante, comme le fait la validation du lab.

Le lab pose trois transports derrière un Nginx qui les traite tous comme des pages : un WebSocket, un flux SSE de six événements espacés d'une seconde, et un service gRPC sur un port dédié. Vous corrigez la configuration jusqu'à ce que la poignée de main WebSocket aboutisse et qu'une trame revienne, que les premiers événements SSE arrivent en moins de quatre secondes, qu'un appel gRPC passe et qu'un refus applicatif reste un refus, puis qu'un flux resté silencieux quinze secondes survive au délai de lecture.

La validation note ces quatre mesures sur le comportement du proxy, pas sur les directives que vous avez tapées : vous pouvez donc la rejouer après chaque modification.

Un détail de l'énoncé du lab demande grpc_pass sur un server dont le listen porte http2, la forme dépréciée. Elle reste acceptée, au prix d'un avertissement que nginx -t affiche, et le lab la valide comme l'autre puisqu'il mesure le comportement du service : écrivez http2 on;, qui est la forme de cette leçon.

  • Un flux long viole les hypothèses d'un proxy de pages : requête courte, réponse qui se termine, échange rapide. Chaque transport exige autre chose.
  • WebSocket : Upgrade et Connection retransmis, car ce sont des en-têtes hop-by-hop que Nginx supprime sinon. HTTP/1.1 vers le backend est le défaut.
  • Un map sur $http_upgrade évite de forcer Connection: upgrade sur toute requête, ce qui n'est pas un modèle universel.
  • SSE : proxy_buffering off; dans le location du flux seulement. Le tampon ne perd aucun événement, il détruit leur rythme.
  • gRPC : grpc_pass et http2 on; sur son propre server. Un refus applicatif ne doit jamais devenir une panne de transport, sans quoi un client configuré pour réessayer rejoue une requête vouée à l'échec.
  • Les délais proxy_read_timeout et grpc_read_timeout mesurent le silence entre deux lectures, pas la durée de la connexion. Réglez-les par route, à partir du plus long silence normal.
  • Un délai démesuré peut garder ouvertes les connexions vers un backend disparu sans prévenir : mesurez les connexions établies avant et après une coupure.
  • Une preuve de flux se prend sur le rythme et sur l'échange dans les deux sens, jamais sur le seul nombre d'événements ou la seule poignée de main.

Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.

Contrôle de connaissances

Validez vos connaissances avec ce quiz interactif

8 questions
8 min.
70% requis

Informations

  • Le chronomètre démarre au clic sur Démarrer
  • Questions à choix multiples, vrai/faux et réponses courtes
  • Vous pouvez naviguer entre les questions
  • Les résultats détaillés sont affichés à la fin

Lance le quiz et démarre le chronomètre

Ce projet vous est utile ?

Les guides, formations, quiz et labs restent gratuits. Si vous les utilisez régulièrement, vous pouvez contribuer à leur maintenance et à leur développement.

Devenir membre de soutien (nouvel onglet) Soutenir une fois (nouvel onglet) En savoir plus

Suivez aussi le projet sur LinkedIn