Aller au contenu
TCP : établir et fermer une connexion

TCP : établir et fermer une connexion

200 Pratiquer ⏱ 1 h 15 reseaulinuxtcp

À la fin, vous saurez

  • Énoncer ce que TCP garantit (flux d'octets ordonné, fiable, bidirectionnel) et ce qu'il ne garantit pas
  • Lire les champs de l'en-tête TCP et reconnaître les drapeaux SYN, ACK, FIN, RST et PSH
  • Dérouler la poignée de main en trois temps et expliquer le rôle des files SYN et accept d'un serveur
  • Distinguer une connexion refusée (RST) d'une connexion sans réponse (délai dépassé), et en tirer un diagnostic
  • Expliquer les états TIME-WAIT et CLOSE-WAIT, et reconnaître quand leur accumulation signale un problème
  • Choisir entre keepalive TCP, keepalive HTTP et délais d'inactivité, et expliquer les SYN cookies

Prérequis

Testé avec curl 8.5.0 iproute2 6.1.0 noyau 7.0 (HWE) python 3.12.3 ubuntu 24.04 , vérifié le 5 octobre 2026

Pourquoi

Un lundi matin, la page de supervision de Signalements affiche deux symptômes en même temps. Depuis le poste de l'équipe, curl vers sig-app-2 échoue immédiatement avec « Connection refused ». Vers sig-app-1, la même commande reste bloquée de longues secondes, puis abandonne sur un délai dépassé. Deux échecs, deux messages, et deux causes qui n'ont rien à voir : le premier serveur répond qu'il n'y a personne sur le port 8000, le second ne répond pas du tout. Qui sait lire cette différence sait déjà où chercher ; qui ne la voit pas redémarre les deux machines et attend.

Une semaine plus tard, autre symptôme : après quelques jours de fonctionnement, l'API cesse d'accepter des connexions, et ss montre des milliers de connexions dans un état appelé CLOSE-WAIT. Ce n'est pas un problème de réseau : c'est un bogue dans le code, que l'état des connexions TCP révèle mieux que n'importe quel journal.

Ces situations ont la même origine : TCP, le protocole qui transporte presque tout ce que vous utilisez (HTTP, TLS, SSH, PostgreSQL), est une machine à états. Une connexion naît par un échange précis de trois segments, vit, puis meurt par un autre échange, et chaque étape laisse une trace observable. Cette leçon suit la requête GET /sante depuis l'ouverture de sa connexion jusqu'à sa fermeture, et apprend à lire ces traces. La leçon 11 s'occupera de ce qui se passe pendant la vie de la connexion : retransmissions, fenêtres, débit.

Les concepts

Ce que TCP garantit, et ce qu'il ne garantit pas

La leçon 9 a présenté UDP : des datagrammes indépendants, sans garantie de livraison ni d'ordre. TCP (Transmission Control Protocol), défini aujourd'hui par la RFC 9293 de 2022, qui rassemble et remplace la RFC 793 de 1981 et ses correctifs, offre à l'application tout autre chose : un flux d'octets entre deux programmes, avec quatre propriétés.

  • Orienté connexion : avant d'échanger des données, les deux extrémités se mettent d'accord par une poignée de main, et chacune garde un état pour la connexion.
  • Fiable : chaque octet envoyé est acquitté ; ce qui se perd est retransmis.
  • Ordonné : l'application reçoit les octets dans l'ordre où ils ont été écrits, même si les paquets arrivent dans le désordre.
  • Bidirectionnel (full duplex) : chaque sens est un flux indépendant, que l'on peut fermer séparément.

Deux choses manquent, et elles piègent régulièrement les développeurs :

  • TCP ne connaît pas les messages. Si le client écrit 100 octets puis 50 octets, le serveur peut lire 150 octets d'un coup, ou 30 puis 120. Les frontières d'écriture ne sont pas conservées. C'est au protocole applicatif de délimiter ses messages : HTTP/1.1 le fait avec des lignes vides et l'en-tête Content-Length, PostgreSQL avec une longueur au début de chaque message.
  • TCP ne garantit aucun délai. « Fiable » veut dire « ce qui arrive est correct et complet », pas « arrive à temps ». Une connexion peut rester ouverte et silencieuse pendant des minutes pendant que TCP retransmet. Les délais d'attente sont l'affaire de l'application.

L'en-tête TCP

Chaque morceau du flux voyage dans un segment TCP, lui-même transporté dans un paquet IP. Son en-tête fait 20 octets sans options, jusqu'à 60 avec :

ChampTailleRôle
Port source, port destination16 bits chacunIdentifient les deux programmes (leçon 9)
Numéro de séquence32 bitsPosition, dans le flux de l'émetteur, du premier octet de données du segment
Numéro d'acquittement32 bitsProchain octet attendu de l'autre côté : « j'ai tout reçu jusqu'à N - 1 »
Longueur d'en-tête4 bitsEn mots de 32 bits, pour savoir où commencent les données
Drapeaux8 bitsSYN, ACK, FIN, RST, PSH, URG, et ECE, CWR pour la notification de congestion
Fenêtre16 bitsPlace disponible dans le tampon de réception de l'émetteur (leçon 11)
Somme de contrôle16 bitsDétecte les corruptions
Pointeur urgent16 bitsHérité, presque jamais utilisé
Options0 à 40 octetsMSS, SACK, facteur d'échelle de fenêtre, horodatages...

Les drapeaux que vous rencontrerez tous les jours :

  • SYN (synchronize) : ouverture de connexion, annonce du numéro de séquence initial ;
  • ACK : le champ d'acquittement est valide (présent sur presque tous les segments après le premier) ;
  • FIN : « je n'enverrai plus rien », fermeture d'un sens ;
  • RST (reset) : « cette connexion n'existe pas » ou « j'abandonne », réinitialisation brutale ;
  • PSH (push) : « livrez ces données à l'application sans attendre », posé en pratique par la pile sur le dernier segment d'une écriture.

Les options se négocient dans les segments SYN : la taille maximale de segment (MSS, Maximum Segment Size) que chacun accepte de recevoir, l'autorisation des acquittements sélectifs (SACK), le facteur d'échelle de fenêtre et les horodatages (RFC 7323). Une option qui n'est pas proposée à l'ouverture ne peut plus être activée ensuite : c'est pourquoi un équipement intermédiaire qui retire les options des SYN dégrade toute la connexion.

La poignée de main en trois temps

Avant le premier octet de GET /sante, le répartiteur de charge (172.16.20.5) et sig-app-1 (172.16.20.11) échangent trois segments :

    sequenceDiagram
    participant C as Client 172.16.20.5:51234
    participant S as Serveur 172.16.20.11:8000
    Note over S: LISTEN
    C->>S: SYN, seq=x, options (MSS, SACK, wscale, horodatage)
    Note over C: SYN-SENT
    Note over S: SYN-RECEIVED
    S->>C: SYN+ACK, seq=y, ack=x+1, options
    Note over C: ESTABLISHED
    C->>S: ACK, seq=x+1, ack=y+1
    Note over S: ESTABLISHED
    C->>S: GET /sante HTTP/1.1 (PSH+ACK)
    S->>C: HTTP/1.1 200 OK (PSH+ACK)
  
  1. Le client envoie un SYN avec son numéro de séquence initial (ISN, Initial Sequence Number) x.
  2. Le serveur répond SYN+ACK : il acquitte (ack=x+1, le SYN compte pour un octet) et annonce son propre ISN y.
  3. Le client acquitte (ack=y+1). La connexion est établie des deux côtés.

Pourquoi trois segments et pas deux ? Parce que chaque sens a son propre numéro de séquence, et que chacun doit être annoncé et acquitté. Le troisième segment prouve au serveur que le client a bien reçu son ISN, donc qu'il existe vraiment à l'adresse annoncée.

L'ISN n'est pas zéro, ni un compteur prévisible. La RFC 6528 demande qu'il soit calculé à partir d'une horloge et d'une fonction de hachage secrète de l'adresse et des ports. Un ISN prévisible permettait, dans les années 1980 et 1990, d'injecter des segments dans la connexion d'autrui ou d'usurper une adresse IP sans voir les réponses (l'attaque de Kevin Mitnick, en 1994, reste l'exemple d'école).

Une connexion est identifiée par quatre valeurs : adresse et port source, adresse et port destination. Le port du client est un port éphémère, choisi par le noyau dans la plage net.ipv4.ip_local_port_range (de 32768 à 60999 par défaut sous Linux) ; la leçon 9 l'a présenté, la leçon 12 montre ce qui arrive quand cette plage s'épuise.

Côté serveur : les files SYN et accept

Un serveur qui appelle listen() ne traite pas lui-même les poignées de main : le noyau le fait pour lui, et range les connexions dans deux files :

  • la file SYN (connexions à moitié ouvertes, état SYN-RECEIVED) : le SYN+ACK est parti, le dernier ACK est attendu. Sa taille dépend de net.ipv4.tcp_max_syn_backlog.
  • la file accept (connexions établies, pas encore prises par l'application) : la poignée de main est terminée, mais Gunicorn n'a pas encore appelé accept(). Sa taille est l'argument backlog de listen(), plafonné par net.core.somaxconn (4096 par défaut depuis le noyau 5.4 ; 128 auparavant).

L'application ne voit une connexion qu'au moment où elle l'extrait de la file accept. Si elle est trop lente (tous les workers de Gunicorn occupés), la file se remplit. Quand elle est pleine, Linux ignore les nouveaux SYN ou les derniers ACK au lieu de répondre : le client retransmet, et finit par un délai dépassé. Ce comportement par défaut se règle avec net.ipv4.tcp_abort_on_overflow, qui fait répondre RST à la place ; la documentation de tcp(7) déconseille de l'activer, parce qu'un pic passager deviendrait alors une erreur pour le client au lieu d'un simple ralentissement.

Fermer une connexion

La fermeture est plus subtile que l'ouverture, parce que chaque sens se ferme séparément. Celui qui a fini d'écrire envoie un FIN ; l'autre l'acquitte, peut continuer à envoyer ses propres données, puis envoie son FIN à son tour :

    sequenceDiagram
    participant A as Ferme le premier (actif)
    participant B as Ferme le second (passif)
    A->>B: FIN
    Note over A: FIN-WAIT-1
    B->>A: ACK
    Note over B: CLOSE-WAIT
    Note over A: FIN-WAIT-2
    Note over B: l'application finit, puis appelle close()
    B->>A: FIN
    Note over B: LAST-ACK
    A->>B: ACK
    Note over A: TIME-WAIT (puis CLOSED)
    Note over B: CLOSED
  

Entre le premier FIN et le second, la connexion est à demi fermée (half-close) : le côté passif peut encore envoyer. C'est ce qu'utilise un client qui écrit toute sa requête, ferme son sens d'écriture avec shutdown(SHUT_WR), puis lit la réponse jusqu'au bout. Dans la pratique, le second FIN part souvent avec l'ACK du premier, et l'on compte trois segments au lieu de quatre.

Deux états de ce schéma comptent pour l'exploitation :

  • CLOSE-WAIT : le pair a fermé son sens, la pile l'a acquitté, et elle attend que l'application locale appelle close(). Rien dans le réseau ne fait sortir une connexion de cet état : seule l'application le peut.
  • TIME-WAIT : le côté qui a fermé en premier garde la connexion quelque temps après le dernier ACK, sans qu'aucune application ne s'en serve.

Pourquoi TIME-WAIT existe

TIME-WAIT irrite tous ceux qui découvrent des milliers de lignes de ce type dans ss. Il a deux raisons d'être, posées par la spécification :

  1. Le dernier ACK peut se perdre. Si c'est le cas, le pair retransmet son FIN. Il faut qu'un état existe encore pour y répondre par un nouvel ACK ; sans lui, la pile répondrait par un RST, et le pair croirait à une erreur.
  2. Des segments retardés de l'ancienne connexion peuvent encore circuler. Si une nouvelle connexion réutilisait immédiatement les mêmes quatre valeurs (adresses et ports), un vieux segment arrivé en retard pourrait être accepté comme une donnée de la nouvelle. Attendre que ces segments aient expiré évite la corruption. La RFC 1337 décrit ce qui arrive quand un RST met fin trop tôt à TIME-WAIT.

La spécification fixe la durée à deux fois la durée de vie maximale d'un segment (2MSL, la MSL valant deux minutes dans la RFC). Linux utilise une durée fixe de 60 secondes, non réglable par sysctl. Retenez la conséquence pratique : TIME-WAIT reste du côté de celui qui ferme en premier. Un client HTTP qui ouvre et ferme une connexion par requête accumule les TIME-WAIT chez lui, ce qui est sans danger côté serveur mais peut épuiser les ports éphémères d'un client très actif vers une même destination (leçon 12).

Un TIME-WAIT coûte peu : quelques centaines d'octets de mémoire noyau, pas de processus, pas de descripteur de fichier. Des dizaines de milliers ne posent en général aucun problème à un serveur. Le vrai remède à leur accumulation n'est pas un réglage du noyau, c'est la réutilisation des connexions (keep-alive HTTP, pools de connexions vers la base).

Refusé ou sans réponse : la distinction qui fait gagner une heure

Revenons au lundi matin. Quand un SYN arrive sur une machine joignable mais où aucun programme n'écoute sur le port, la pile TCP répond immédiatement par un RST. Le client reçoit l'erreur ECONNREFUSED, « Connection refused », en quelques millisecondes.

Quand le SYN n'obtient aucune réponse, le client ne sait rien. Il retransmet, en doublant le délai à chaque fois : avec net.ipv4.tcp_syn_retries à 6 (la valeur par défaut), la tentative dure environ deux minutes avant l'erreur ETIMEDOUT, « Connection timed out ». La plupart des programmes (curl avec --connect-timeout, les bibliothèques HTTP, les pilotes de bases de données) abandonnent plus tôt avec leur propre délai.

Ce que voit le clientCe qui s'est passéOù chercher
Refus immédiat (RST)La machine répond, mais rien n'écoute sur ce port, ou l'application écoute sur une autre adresse (127.0.0.1 au lieu de l'adresse privée)ss -ltnp sur le serveur ; service arrêté ; mauvaise adresse d'écoute
Refus immédiat, sans que le serveur ait rien envoyéUn pare-feu configuré pour rejeter (reject with tcp reset) a répondu à sa placeRègles de pare-feu, en notant que la plupart rejettent avec ICMP plutôt qu'avec RST
Délai dépasséLe SYN ou sa réponse s'est perdu : pare-feu ou groupe de sécurité qui jette (drop), machine éteinte, route absente au retour, file accept saturéeGroupes de sécurité, pare-feu, routes, état du serveur
« No route to host »Un routeur ou la pile locale a renvoyé une erreur ICMP « hôte injoignable », souvent faute de réponse ARP sur le réseau localLeçons 2, 5 et 6

Pour sig-app-2, le refus immédiat orientait vers le service : Gunicorn était arrêté. Pour sig-app-1, le délai dépassé orientait vers ce qui jette des paquets : une règle de pare-feu de l'hôte, ajoutée la veille, qui jetait tout ce qui venait du répartiteur.

Garder une connexion ouverte : keepalive et délais d'inactivité

Une connexion TCP inactive n'échange aucun segment. Si le pair disparaît (machine coupée net, câble arraché), l'autre extrémité n'en sait rien et garde la connexion ouverte indéfiniment. Deux mécanismes, souvent confondus, répondent à des problèmes différents :

  • Le keepalive TCP (option SO_KEEPALIVE d'une socket) envoie, après une période d'inactivité, des segments de sonde vides. Sans réponse, la connexion est déclarée morte. Les valeurs par défaut de Linux sont prudentes jusqu'à l'inutilité : première sonde après deux heures (tcp_keepalive_time = 7200 s), puis 9 sondes espacées de 75 secondes. Les applications qui s'en servent règlent leurs propres valeurs par socket (TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT, documentées dans tcp(7)) ; c'est le cas des pilotes PostgreSQL, avec les paramètres keepalives_idle de libpq.
  • Le keep-alive HTTP n'a rien à voir avec TCP : c'est la réutilisation d'une même connexion pour plusieurs requêtes HTTP successives, comportement par défaut de HTTP/1.1. Il évite une poignée de main (et une négociation TLS) par requête.

Entre les deux extrémités, les équipements à état (répartiteurs de charge, passerelles NAT, pare-feu) gardent eux aussi une entrée par connexion, et l'oublient après un délai d'inactivité. Le répartiteur de charge de Scaleway, par exemple, coupe par défaut une connexion cliente inactive depuis 5 minutes (timeout-client) et attend au plus 5 minutes la réponse d'un serveur (timeout-server). Une passerelle NAT qui a oublié une connexion jette les segments suivants ou répond par un RST : une connexion vers la base, ouverte dans un pool et restée inutilisée tout un week-end, échoue alors à la première requête du lundi. Le remède est d'envoyer des sondes keepalive plus souvent que le plus court délai d'inactivité du chemin, ou de faire vérifier les connexions du pool avant usage.

SYN flood et SYN cookies

La file SYN est une ressource limitée, que l'on peut remplir sans jamais terminer de poignée de main : c'est l'attaque par inondation de SYN (SYN flood), décrite dans la RFC 4987. L'attaquant envoie des milliers de SYN depuis des adresses usurpées ; le serveur répond SYN+ACK vers des machines qui n'ont rien demandé, garde chaque demande en mémoire, et finit par ne plus accepter les clients légitimes.

La parade de Linux, active par défaut (net.ipv4.tcp_syncookies = 1), s'appelle les SYN cookies : quand la file SYN déborde, le noyau ne garde plus rien en mémoire. Il encode l'essentiel de la demande (adresses, ports, MSS, un horodatage, un secret) dans le numéro de séquence de son SYN+ACK. Si le client revient avec un ACK valide, le noyau recalcule le cookie, vérifie qu'il correspond, et reconstruit la connexion. Les attaques volumétriques à grande échelle se traitent plus haut, chez l'opérateur ou le fournisseur de cloud (protection anti-DDoS) ; les SYN cookies protègent la machine elle-même.

En pratique

Toutes les sorties de cette section ont été produites sur une machine Ubuntu 24.04, avec LC_ALL=C pour obtenir les messages en anglais, sur l'interface locale 127.0.0.1 : aucun paquet ne quitte la machine. Les ports éphémères (56642, 42768...) seront différents chez vous. Travaillez dans un répertoire d'essai.

Une connexion du début à la fin

Écrivez un serveur minuscule, qui accepte une connexion, lit la requête, répond, puis ne ferme pas la connexion pendant 30 secondes, comme le ferait une application bloquée :

# serveur.py : accepte une connexion, répond, puis tarde à fermer
import socket, time

s = socket.socket()
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("127.0.0.1", 8000))
s.listen(8)
c, adresse = s.accept()
c.recv(1024)
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
time.sleep(30)          # l'application « oublie » d'appeler close()

Lancez-le en arrière-plan, puis un client qui envoie GET /sante, attend 5 secondes et ferme :

$ python3 serveur.py &
$ python3 -c '
import socket, time
c = socket.create_connection(("127.0.0.1", 8000))
c.sendall(b"GET /sante HTTP/1.1\r\nHost: sig\r\n\r\n")
print(c.recv(100))
time.sleep(5)
c.close()
time.sleep(10)' &

Pendant les 5 premières secondes, ss montre trois sockets :

$ ss -tan '( sport = :8000 or dport = :8000 )'
State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN 0      8          127.0.0.1:8000       0.0.0.0:*
ESTAB  0      0          127.0.0.1:8000     127.0.0.1:56642
ESTAB  0      0          127.0.0.1:56642    127.0.0.1:8000
  • -t limite aux sockets TCP, -a inclut celles qui écoutent, -n affiche les adresses et ports en chiffres.
  • Le filtre entre parenthèses ne garde que ce qui touche au port 8000 (syntaxe décrite dans ss(8)).
  • La socket LISTEN est celle du serveur ; sa colonne Send-Q affiche le backlog (8). Les deux lignes ESTAB sont les deux extrémités de la même connexion, vues depuis la même machine : côté serveur (8000 vers 56642) et côté client (56642 vers 8000).

Juste après le close() du client :

$ ss -tan '( sport = :8000 or dport = :8000 )'
State      Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN     0      8          127.0.0.1:8000       0.0.0.0:*
CLOSE-WAIT 1      0          127.0.0.1:8000     127.0.0.1:56642
FIN-WAIT-2 0      0          127.0.0.1:56642    127.0.0.1:8000

Le client a fermé en premier : il a envoyé son FIN, reçu l'acquittement, et attend le FIN du serveur (FIN-WAIT-2). Le serveur, lui, est en CLOSE-WAIT : sa pile a reçu le FIN, mais l'application n'a pas appelé close(). Le 1 dans Recv-Q est ce FIN, que la pile compte comme un octet non lu. C'est exactement la signature du bogue du « Pourquoi » : une application qui ne ferme pas les connexions que ses clients ont terminées.

Quand le serveur se termine enfin, sa socket est fermée, son FIN part, et il ne reste que ceci :

$ ss -tan '( sport = :8000 or dport = :8000 )'
State     Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
TIME-WAIT 0      0          127.0.0.1:56642    127.0.0.1:8000
$ ss -tano state time-wait '( sport = :8000 or dport = :8000 )'
Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
0      0          127.0.0.1:56642    127.0.0.1:8000 timer:(timewait,55sec,0)

Le TIME-WAIT est chez le client, qui a fermé le premier, alors même que son programme est terminé. L'option -o affiche les minuteries : il reste 55 secondes sur les 60 que Linux impose. Le filtre state time-wait ne garde que cet état ; ss(8) en accepte d'autres (established, close-wait, syn-sent...) et des groupes (connected, synchronized).

Provoquer un refus

Aucun programme n'écoute sur le port 8001. Trois clients, trois façons de dire la même chose :

$ python3 -c '
import socket, errno
try:
    socket.create_connection(("127.0.0.1", 8001))
except OSError as e:
    print(e.errno, errno.errorcode[e.errno], e.strerror)'
111 ECONNREFUSED Connection refused
$ curl -sS http://127.0.0.1:8001/sante; echo "code=$?"
curl: (7) Failed to connect to 127.0.0.1 port 8001 after 0 ms: Couldn't connect to server
code=7
$ nc -vz 127.0.0.1 8001; echo "code=$?"
nc: connect to 127.0.0.1 port 8001 (tcp) failed: Connection refused
code=1

L'appel système connect() a échoué avec ECONNREFUSED (numéro 111 sous Linux), parce que la pile a répondu au SYN par un RST. Remarquez le « after 0 ms » de curl : un refus est immédiat. curl ne recopie pas le texte de l'erreur système dans ce message ; son code de sortie 7 (« impossible de se connecter ») est documenté dans curl(1), et nc -v affiche le message exact. C'est l'une des raisons de garder nc sous la main pour diagnostiquer.

Saturer la file accept

Que se passe-t-il quand l'application ne prend plus les connexions ? Ce serveur appelle listen(2) puis n'appelle jamais accept(), et un client ouvre cinq connexions en parallèle :

$ python3 -c '
import socket, time
s = socket.socket(); s.bind(("127.0.0.1", 8002)); s.listen(2); time.sleep(8)' &
$ python3 -c '
import socket, time
cs = []
for i in range(5):
    c = socket.socket(); c.setblocking(False)
    try: c.connect(("127.0.0.1", 8002))
    except BlockingIOError: pass
    cs.append(c)
time.sleep(3)' &
$ ss -tan '( sport = :8002 or dport = :8002 )'
State    Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
LISTEN   3      2          127.0.0.1:8002       0.0.0.0:*
ESTAB    0      0          127.0.0.1:8002     127.0.0.1:42776
ESTAB    0      0          127.0.0.1:8002     127.0.0.1:42784
ESTAB    0      0          127.0.0.1:8002     127.0.0.1:42768
ESTAB    0      0          127.0.0.1:42768    127.0.0.1:8002
ESTAB    0      0          127.0.0.1:42776    127.0.0.1:8002
ESTAB    0      0          127.0.0.1:42784    127.0.0.1:8002
SYN-SENT 0      1          127.0.0.1:42790    127.0.0.1:8002
SYN-SENT 0      1          127.0.0.1:42804    127.0.0.1:8002

Sur une socket LISTEN, Recv-Q est le nombre de connexions dans la file accept (3) et Send-Q sa taille demandée (2) ; Linux accepte une connexion de plus que le backlog. Trois poignées de main ont abouti sans que l'application n'ait rien fait : c'est le noyau qui les a menées. Les deux dernières sont restées en SYN-SENT : le serveur a ignoré leurs SYN, et les clients retransmettent. Aucune erreur n'est remontée ; s'ils attendaient assez longtemps, ils finiraient en délai dépassé.

Le noyau compte ces débordements. Les compteurs ListenOverflows et ListenDrops de nstat augmentent à chaque SYN ignoré pour file pleine :

$ nstat -asz TcpExtListenOverflows TcpExtListenDrops
#kernel
TcpExtListenOverflows           6                  0.0
TcpExtListenDrops               6                  0.0
  • -a affiche les valeurs absolues depuis le démarrage, -s n'enregistre pas d'historique, -z affiche aussi les compteurs à zéro.

Sur sig-app-1, un ListenOverflows qui augmente signale des workers Gunicorn saturés : le réseau va bien, l'application ne suit pas.

Sous le capot

Où vit l'état d'une connexion. Chaque connexion est une structure du noyau (struct tcp_sock) qui contient les numéros de séquence, les fenêtres, les minuteries, les tampons d'émission et de réception. Le programme n'en tient qu'un descripteur de fichier. Quand un segment arrive, le noyau cherche la connexion correspondante dans une table de hachage indexée par les quatre valeurs (adresses et ports), puis applique les règles de la machine à états. Un segment qui ne correspond à aucune connexion et n'est pas un SYN vers un port en écoute reçoit un RST : c'est ce qui arrive quand un serveur redémarre et qu'un client lui envoie des données sur une connexion d'avant le redémarrage.

Les connexions sans propriétaire. Après close(), une connexion encore en FIN-WAIT-1, FIN-WAIT-2 ou TIME-WAIT n'appartient plus à aucun programme : on dit qu'elle est orpheline. Le noyau la termine seul. Pour éviter qu'un pair muet ne laisse une connexion orpheline indéfiniment en FIN-WAIT-2, Linux l'abandonne après net.ipv4.tcp_fin_timeout (60 secondes par défaut), comme le précise la documentation du noyau.

Ce que font tcp_tw_reuse et feu tcp_tw_recycle. net.ipv4.tcp_tw_reuse autorise un client à réutiliser, pour une nouvelle connexion sortante, un couple de ports encore en TIME-WAIT quand les horodatages TCP garantissent qu'aucun vieux segment ne sera confondu avec un nouveau. Sa valeur par défaut, 2, ne l'active que pour le trafic local (127.0.0.1) ; 1 l'active partout. Un ancien réglage, tcp_tw_recycle, raccourcissait TIME-WAIT côté serveur en s'appuyant sur les horodatages ; il cassait les clients situés derrière une même passerelle NAT, dont les horodatages ne sont pas cohérents entre eux, et il a été supprimé du noyau en version 4.12 (2017). Les vieux guides d'optimisation qui le recommandent encore échouent aujourd'hui avec « cannot stat /proc/sys/net/ipv4/tcp_tw_recycle ».

Les retransmissions du SYN. Un SYN sans réponse est retransmis après 1 seconde, puis le délai double à chaque essai : 1, 2, 4, 8, 16, 32 secondes, et une dernière attente de 64 secondes. Avec les 6 retransmissions par défaut, connect() n'abandonne qu'après environ 127 secondes. Ce comportement de recul exponentiel vient de la RFC 6298 ; la leçon 11 le retrouve pour les données.

Pièges courants

Confondre refus et délai. Un « Connection refused » dit que la machine est joignable : cherchez le service et son adresse d'écoute. Un « timed out » dit que les paquets se perdent : cherchez le pare-feu, le groupe de sécurité, la route. Les traiter de la même façon, c'est chercher au mauvais endroit la moitié du temps.

Un service qui écoute sur 127.0.0.1. curl http://127.0.0.1:8000/sante réussit sur le serveur, le répartiteur reçoit pourtant un refus. La colonne Local Address de ss -ltn le dit en un coup d'œil : 127.0.0.1:8000 n'accepte que les connexions locales, 0.0.0.0:8000 ou 172.16.20.11:8000 acceptent celles du réseau. Le cours Le cloud : les fondamentaux en fait une étape de son déploiement.

Des milliers de CLOSE-WAIT. Ce n'est jamais le réseau : c'est l'application qui ne ferme pas ses sockets après que le pair a fermé (exception non gérée avant le close(), connexion sortie d'un pool sans y être rendue). Les descripteurs de fichiers s'épuisent, et l'application finit par refuser tout travail avec « Too many open files ». ss -tanp state close-wait (avec sudo pour les processus d'un autre compte) dit quel programme les tient.

Vouloir supprimer TIME-WAIT. Les connexions en TIME-WAIT ne tiennent ni processus ni descripteur. Les réglages trouvés sur Internet pour les raccourcir (tcp_tw_recycle, des tcp_fin_timeout minuscules qui ne concernent d'ailleurs pas TIME-WAIT) sont au mieux inutiles, au pire dangereux. Si un client épuise ses ports, réutilisez les connexions.

Supposer qu'une connexion ouverte est vivante. Une connexion TCP sans trafic peut être morte depuis des heures sans que personne ne le sache : passerelle NAT qui a oublié l'entrée, serveur coupé net. Les pools de connexions vérifient leurs connexions, ou envoient des keepalives réguliers.

Lire Recv-Q et Send-Q de la même façon partout. Sur une socket établie, ce sont des octets en attente (non lus par l'application, ou non acquittés par le pair). Sur une socket LISTEN, le noyau y place le nombre de connexions dans la file accept et la taille de cette file. Les confondre fait conclure à un problème qui n'existe pas.

Sécurité

  • Le RST et le délai sont des informations pour un attaquant. Un balayage de ports (port scan) repose sur cette distinction : un RST signale une machine présente avec un port fermé, un SYN+ACK un port ouvert, le silence un filtrage. Un pare-feu qui jette les paquets plutôt que de les rejeter ne rend pas une machine invisible, mais il ralentit le balayage et en dit moins.
  • Les SYN cookies restent activés. Ne désactivez pas net.ipv4.tcp_syncookies, même si un guide de performance le suggère : il ne s'enclenche qu'en cas de débordement, c'est-à-dire précisément pendant une attaque.
  • Les numéros de séquence imprévisibles (RFC 6528) protègent contre l'injection de segments dans une connexion tierce. Linux les applique par défaut ; il n'y a rien à régler, mais il faut savoir que le chiffrement (TLS, SSH) reste la vraie protection contre la falsification des données en transit.
  • Les délais protègent aussi. Un serveur qui garde indéfiniment des connexions inactives se laisse épuiser par un client malveillant qui ouvre des connexions et n'envoie rien. Délais d'inactivité côté répartiteur et côté application, et limites de connexions par client, font partie du durcissement (la leçon 11 évoque les attaques lentes).
  • ss -tnp est un outil de réponse à incident. Une connexion sortante établie depuis un processus inattendu vers une adresse inconnue est un indice classique de compromission ; le cours Linux : premiers pas le rappelle.

En production

  • Dimensionnez la file accept avec l'application. Gunicorn expose l'option --backlog (2048 par défaut), plafonnée par net.core.somaxconn. Mais une grande file ne fait que retarder : si les workers ne suivent pas, les clients attendront plus longtemps avant d'échouer. Surveillez ListenOverflows, et ajoutez des workers ou des instances.
  • Alignez les délais d'inactivité sur tout le chemin. Client, répartiteur (timeout-client et timeout-server chez Scaleway), passerelle NAT, serveur d'application et base de données ont chacun le leur. La règle : chaque maillon garde la connexion plus longtemps que celui qui est devant lui, et les sondes keepalive passent plus souvent que le plus court délai. Sinon, des connexions sont coupées au milieu du chemin et les erreurs apparaissent au hasard.
  • Comptez les états. ss -s donne un résumé (connexions établies, TIME-WAIT, orphelines) qui se suit dans le temps ; les exportateurs de métriques système (node_exporter de Prometheus, présenté dans le cours Prometheus) l'exposent. Une croissance continue des CLOSE-WAIT est une alerte à poser.
  • Réutilisez les connexions. Un pool de connexions vers PostgreSQL, le keep-alive HTTP entre le répartiteur et Gunicorn, des clients HTTP partagés dans l'application : chaque connexion évitée économise une poignée de main, un TIME-WAIT, et souvent une négociation TLS. La leçon 11 montre que c'est aussi un gain de débit.
  • Chez Lyneko, les applications sont derrière un contrôleur d'entrée Traefik sur Kapsule : la connexion du navigateur s'arrête au répartiteur, une autre part du répartiteur vers Traefik, une troisième de Traefik vers le pod. Chaque saut a son propre cycle de vie TCP et ses propres délais, ce qu'il faut garder en tête en lisant un « 502 » ou un « 504 ».

Exercices

1. Refusé ou délai (niveau 100). Pour chaque message, dites ce qui s'est passé sur le réseau et où vous chercheriez en premier : (a) curl: (7) Failed to connect to 172.16.20.12 port 8000 after 1 ms ; (b) psql: error: connection to server at "172.16.20.30", port 5432 failed: Connection timed out ; (c) nc: connect to 172.16.20.11 port 8000 (tcp) failed: Connection refused, alors que curl http://127.0.0.1:8000/sante fonctionne sur sig-app-1.

Solution

(a) Échec immédiat : un RST est revenu, la machine est joignable mais rien n'écoute sur 8000 (service arrêté). On regarde systemctl status signalements et ss -ltn sur sig-app-2. (b) Aucune réponse au SYN : paquets jetés ou perdus. On regarde ce qui filtre entre l'instance et la base (règles d'accès de la base managée, pare-feu de l'hôte, route vers 172.16.20.30), voir la leçon 12. (c) La machine répond, mais rien n'écoute sur l'adresse 172.16.20.11:8000 : l'application n'écoute que sur 127.0.0.1. ss -ltn le montre ; il faut la lier à l'adresse privée.

2. Lire des états (niveau 200). Sur sig-app-1, ss -s signale 30 connexions établies, 4 000 en TIME-WAIT et 2 500 en CLOSE-WAIT. L'application commence à répondre « Too many open files ». Lequel de ces chiffres vous inquiète, pourquoi, et que cherchez-vous ?

Solution

Les 2 500 CLOSE-WAIT. Ce sont des connexions que le pair a fermées et que l'application n'a pas fermées : chacune garde un descripteur de fichier, d'où l'épuisement. C'est un bogue applicatif (un chemin de code qui oublie de fermer, souvent après une erreur). sudo ss -tanp state close-wait dit quel processus les tient, et la colonne Peer Address vers quoi (base de données, API externe). Les 4 000 TIME-WAIT ne tiennent aucun descripteur et ne sont pas en cause : le serveur ferme lui-même certaines connexions, ce qui est normal.

3. Observer la demi-fermeture (niveau 200). Modifiez le client de la section En pratique pour qu'il appelle c.shutdown(socket.SHUT_WR) juste après sendall, au lieu d'attendre puis de fermer, et qu'il lise ensuite la réponse. Relevez avec ss l'état des deux sockets pendant que le serveur dort. Que montre-t-il de la fermeture séparée des deux sens ?

Solution

Après shutdown(SHUT_WR), le client a envoyé son FIN : sa socket passe en FIN-WAIT-2 (après l'acquittement), celle du serveur en CLOSE-WAIT. Pourtant, le client reçoit encore la réponse HTTP/1.1 200 OK : le sens serveur vers client reste ouvert. C'est la demi-fermeture : chaque sens se ferme indépendamment, et le serveur peut continuer à écrire tant qu'il n'a pas fermé le sien. Quand le serveur ferme, le client passe en TIME-WAIT, puisqu'il a fermé en premier.

4. Dimensionner des délais (niveau 200). Signalements utilise un pool de connexions vers sig-db, à travers un chemin où un équipement à état oublie les connexions inactives depuis 10 minutes. Les connexions inutilisées la nuit échouent le matin. Proposez deux corrections, et les valeurs à choisir.

Solution

Première correction : activer le keepalive TCP sur les connexions du pool avec une période d'inactivité nettement inférieure à 10 minutes, par exemple une première sonde après 5 minutes, puis toutes les 30 secondes (avec libpq : keepalives_idle=300, keepalives_interval=30, keepalives_count=3). Les sondes entretiennent l'entrée de l'équipement à état et détectent les connexions mortes. Seconde correction, complémentaire : faire vérifier par le pool une connexion avant de la prêter (« pre-ping »), ou fermer les connexions inactives depuis plus d'une durée inférieure à 10 minutes. Les valeurs par défaut de Linux (2 heures) ne conviennent pas.

Récapitulatif

  • TCP offre un flux d'octets fiable, ordonné et bidirectionnel ; il ne conserve pas les frontières des messages et ne garantit aucun délai.
  • Une connexion s'ouvre en trois temps (SYN, SYN+ACK, ACK), avec des numéros de séquence initiaux imprévisibles et des options négociées une fois pour toutes.
  • Le noyau mène les poignées de main et range les connexions dans la file SYN, puis la file accept (taille : backlog, plafonnée par somaxconn) ; une file accept pleine fait ignorer les nouveaux SYN.
  • Chaque sens se ferme par un FIN ; CLOSE-WAIT attend l'application locale (en accumuler est un bogue) ; TIME-WAIT reste 60 secondes chez celui qui a fermé en premier, et il est utile.
  • RST immédiat = machine joignable, rien sur ce port ; délai dépassé = paquets jetés ou perdus. C'est la première bifurcation de tout diagnostic.
  • Le keepalive TCP sonde les connexions inactives (2 heures par défaut, à régler par socket) ; le keep-alive HTTP réutilise une connexion ; les équipements à état ont leurs propres délais d'inactivité.
  • Les SYN cookies protègent la file SYN contre l'inondation ; tcp_tw_recycle n'existe plus depuis Linux 4.12.

Pour aller plus loin

  • La RFC 9293, en particulier le diagramme d'états et la section sur la fermeture : c'est désormais le texte de référence unique de TCP.
  • L'article de Vincent Bernat sur TIME-WAIT, qui démonte avec précision les réglages à ne pas faire.
  • Les chapitres 12 et 13 de TCP/IP Illustrated, Volume 1, pour des captures commentées de chaque cas.
  • La leçon suivante, TCP : fiabilité, débit et congestion, qui ouvre la connexion établie pour voir comment TCP transporte les données.
Voir ma constellation →

Sources