Séance 3

Les limites de TCP : concevoir un protocole

Rémi Sharrock

BGD701 — Télécom Paris

Objectifs

  • Constater par l’expérience que TCP ne préserve pas les frontières entre messages
  • Comprendre pourquoi : TCP transporte un flux d’octets
  • Savoir découper un flux : délimiteur ou longueur préfixée
  • Écrire protocole.py, que nous utiliserons jusqu’à la fin du cours

Une expérience qui dérange

Un serveur qui envoie trois messages

import socket

serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
serveur.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
serveur.bind(("127.0.0.1", 50007))
serveur.listen(1)

connexion, _ = serveur.accept()
connexion.sendall(b"PREMIER")
connexion.sendall(b"DEUXIEME")
connexion.sendall(b"TROISIEME")
connexion.close()

Un client qui lit trois fois

import socket

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.connect(("127.0.0.1", 50007))

print("1er recv :", client.recv(1024))
print("2e  recv :", client.recv(1024))
print("3e  recv :", client.recv(1024))
client.close()

Ce qu’on espère

1er recv : b'PREMIER'
2e  recv : b'DEUXIEME'
3e  recv : b'TROISIEME'

Ce qu’on obtient

1er recv : b'PREMIERDEUXIEMETROISIEME'
2e  recv : b''
3e  recv : b''

Les trois messages ont fusionné. Ce n’est pas un bug de Python ni du réseau : c’est le fonctionnement normal de TCP.

Le pire : c’est intermittent

Attention

Sur localhost avec de petits messages, la fusion est quasi systématique.

Sur un vrai réseau, avec des messages plus gros, vous obtiendrez parfois le découpage attendu.

C’est la recette parfaite pour un bug qui « marche sur ma machine » et échoue en production une fois sur mille.

TCP est un flux d’octets

Le bon modèle mental

À retenir

TCP transporte un flux continu d’octets, comme un tuyau d’eau.

Il ne transporte pas des messages. La notion même de « message » n’existe que dans votre tête et dans votre code.

Trois sendall, c’est trois seaux d’eau versés dans le même tuyau. À la sortie, il n’y a plus trois seaux : il y a de l’eau.

Deux mécanismes

Coalescence

Les messages fusionnent.

sendall écrit dans un tampon ; le système regroupe avant d’émettre.

Un paquet porte ~40 octets d’en-têtes : envoyer 7 octets seuls serait du gaspillage.

Fragmentation

Un message se coupe.

Un paquet ne dépasse pas ~1500 octets.

10 000 octets envoyés d’un coup arrivent en 1448, puis 1448, etc.

Les quatre cas de recv(1024)

Résultat Signification
Exactement un message Le cas chanceux. Ne comptez jamais dessus.
Plusieurs messages collés Coalescence
Un morceau de message Fragmentation : le reste arrivera
b"" L’autre côté a fermé la connexion

Deux règles absolues

Source de la majorité des bugs réseau

  1. recv(n) renvoie au plus nn octets. Il peut en renvoyer 1, ou 3. Jamais plus.
  2. recv() qui renvoie b"" signifie connexion fermée — et non « pas de données pour l’instant ».

Sans le test du b"" dans une boucle : boucle infinie à 100 % d’un cœur.

Le paradoxe TCP / UDP

TCP UDP
Unité transportée flux d’octets datagramme
Frontières préservées non oui
Livraison garantie oui non
Ordre garanti oui non

UDP, le protocole « peu fiable », préserve les frontières. TCP, le « fiable », ne les préserve pas. Chacun résout un problème différent.

Le framing

À nous de délimiter

TCP ne le fait pas. Il existe deux techniques, et il n’y en a pas d’autres.

Technique 1 : le délimiteur

Un caractère marque la fin d’un message — presque toujours \n.

C’est ce que font HTTP, SMTP, IRC, Redis.

# Cote serveur
flux = connexion.makefile("r", encoding="utf-8")
for ligne in flux:                 # une ligne = un message
    commande = ligne.strip()
    if commande == "QUIT":
        break
    connexion.sendall((traiter(commande) + "\n").encode())

Son défaut

Simple, lisible, testable à la main avec telnet ou nc.

Mais si le message contient un \n ?

Il faut l’échapper, et tout se complique.

Et la technique convient mal aux données binaires — une image, un fichier compressé — qui contiennent des octets \n par hasard.

Technique 2 : la longueur préfixée

On annonce combien d’octets vont suivre, puis on les envoie.

[ 4 octets : taille = 9 ][ 9 octets : TROISIEME ]

Le lecteur procède en deux temps : lire exactement 4 octets pour connaître nn, puis lire exactement nn octets.

Aucune ambiguïté, quel que soit le contenu. Aucun octet n’est interdit.

Lire des octets

Pourquoi l’hexadécimal

Un octet vaut de 0 à 255. En décimal il occupe 1, 2 ou 3 chiffres : on ne sait plus où s’arrête chacun.

En base 16, un octet s’écrit toujours avec deux chiffres, de 00 à FF.

hexa 0 1 2 3 4 5 6 7 8 9 A B C D E F
déc. 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15

Convertir

𝟸𝙰=2×16+10=42𝙵𝙵=15×16+15=255 \texttt{2A} = 2 \times 16 + 10 = 42 \qquad \texttt{FF} = 15 \times 16 + 15 = 255

La notation 0x lève l’ambiguïté :

𝟶𝚡𝟷𝟷=17𝟷𝟷=11\texttt{0x11} = 17 \qquad\qquad \texttt{11} = 11

00 00 00 0B → le dernier octet vaut 11, les autres 0. L’entier est 11.

En Python

0x2A                    # 42   : litteral hexadecimal
int("2A", 16)           # 42   : texte hexa -> entier
hex(42)                 # '0x2a'
f"{42:02X}"             # '2A'

b"\x00\x00\x00\x0b".hex()      # '0000000b'
bytes.fromhex("0000000b")      # b'\x00\x00\x00\x0b'

int.from_bytes(b"\x00\x0b", "big")   # 11
(11).to_bytes(2, "big")              # b'\x00\x0b'

Encoder un entier : struct

import struct

octets = struct.pack("!I", 9)             # b'\x00\x00\x00\x09'
(valeur,) = struct.unpack("!I", octets)   # 9
  • ! — ordre réseau, dit gros-boutiste
  • I — entier non signé sur 4 octets

I n’est qu’une lettre parmi d’autres

Lettre B H I Q
Octets 1 2 4 8
Maximum 255 65 535 4,3 milliards 1,8×10191{,}8 \times 10^{19}

Majuscule = non signé. Minuscule = signé, même taille.

On enchaîne les lettres :

struct.pack("!HB", 6, 1)        # b'\x00\x06\x01' : 3 octets
struct.calcsize("!HB")          # 3

La taille choisie fixe la valeur maximale du protocole. Une longueur sur H, et aucun message ne dépassera jamais 65 535 octets.

Boutisme

Pour le nombre 11 sur deux octets :

Convention Octets Qui
gros-boutiste 00 0B les protocoles réseau
petit-boutiste 0B 00 les processeurs Intel et ARM

Sans le !

struct utilise l’ordre du processeur. Un client Intel envoie 0B 00, un serveur attendant du gros-boutiste lit 11×256=281611 \times 256 = 2816.

Mettez-le toujours.

La fonction qui manque à Python

def recevoir_exactement(sock, nb_octets):
    """Lit exactement nb_octets, ou leve une exception."""
    morceaux = b""
    while len(morceaux) < nb_octets:
        morceau = sock.recv(nb_octets - len(morceaux))
        if not morceau:                 # b"" : connexion fermee
            raise ConnectionError("Connexion fermee prematurement")
        morceaux += morceau
    return morceaux

C’est le cœur de tout code réseau correct. Sans elle, votre code n’est juste que par chance.

Sérialiser

Transporter autre chose que du texte

On ne peut envoyer que des octets. Transformer un objet Python en octets : sérialiser.

import json

objet = {"commande": "MAP", "fichier": "part-003.txt", "id": 3}

texte  = json.dumps(objet)         # str
octets = texte.encode("utf-8")     # bytes, pret a envoyer

retour = json.loads(octets.decode("utf-8"))

Trois formats

Format Avantages Inconvénients
JSON lisible, universel, débogable volumineux, lent
pickle natif Python, tous les objets faille de sécurité
Binaire (struct) compact, rapide rigide, illisible

Jamais pickle depuis le réseau

Désérialiser un pickle malveillant permet d’exécuter du code arbitraire sur votre machine.

C’est l’équivalent, côté données, du eval() refusé en séance 1.

protocole.py

Le code le plus important du cours

"""Envoi/reception de messages JSON avec longueur prefixee."""
import json
import struct

TAILLE_ENTETE = 4                   # "!I" = 4 octets
TAILLE_MAX = 100 * 1024 * 1024      # garde-fou : 100 Mo

def envoyer_message(sock, objet):
    """Serialise l'objet et l'envoie precede de sa taille."""
    charge = json.dumps(objet).encode("utf-8")
    entete = struct.pack("!I", len(charge))
    sock.sendall(entete + charge)

def recevoir_exactement(sock, nb_octets):
    """Lit exactement nb_octets sur le socket."""
    morceaux = b""
    while len(morceaux) < nb_octets:
        morceau = sock.recv(nb_octets - len(morceaux))
        if not morceau:
            raise ConnectionError("Connexion fermee prematurement")
        morceaux += morceau
    return morceaux

def recevoir_message(sock):
    """Lit un message complet et renvoie l'objet Python."""
    entete = recevoir_exactement(sock, TAILLE_ENTETE)
    (taille,) = struct.unpack("!I", entete)
    if taille > TAILLE_MAX:
        raise ValueError(f"Message trop grand : {taille} octets")
    charge = recevoir_exactement(sock, taille)
    return json.loads(charge.decode("utf-8"))

Sachez le réécrire de mémoire.

Deux détails qui comptent

TAILLE_MAX — sans ce garde-fou, un client annonçant 4 milliards d’octets fait allouer 4 Go à votre serveur, et le tue.

Ne jamais faire confiance à un nombre venu du réseau.

sendall(entete + charge) — un seul envoi plutôt que deux. Plus efficace, et cela évite un aller-retour réseau inutile.

Concevoir un protocole

Un protocole applicatif définit trois choses

  1. le vocabulaire — quelles commandes existent
  2. le format — quels champs contient chaque message
  3. l’enchaînement — qui parle, quand, et qui répond quoi

Le vocabulaire

Requête Réponse Rôle
PING PONG Vérifier que le serveur est vivant
CHARGE OK Demander la charge CPU
CALCUL OK Demander un calcul
QUIT (rien) Fermer proprement
— ERREUR Toute requête invalide

Le format

Toujours un champ "type" :

# Requete
{"type": "CALCUL", "a": 12, "b": 30, "operation": "+"}
# Succes
{"type": "OK", "resultat": 42}
# Echec
{"type": "ERREUR", "message": "operation inconnue"}

Le "type" systématique permet d’ajouter des commandes plus tard sans casser les clients : un type inconnu s’ignore proprement.

Le serveur

import socket
import threading
import os
from protocole import envoyer_message, recevoir_message

PORT = 50007

def traiter(requete):
    """Prend une requete (dict), renvoie une reponse (dict)."""
    type_requete = requete.get("type")

    if type_requete == "PING":
        return {"type": "PONG"}

    if type_requete == "CHARGE":
        c1, c5, c15 = os.getloadavg()
        return {"type": "OK", "charge": [c1, c5, c15]}

    if type_requete == "CALCUL":
        a, b = requete.get("a"), requete.get("b")
        op = requete.get("operation")
        if op == "+":
            return {"type": "OK", "resultat": a + b}
        if op == "*":
            return {"type": "OK", "resultat": a * b}
        return {"type": "ERREUR", "message": "operation inconnue"}

    return {"type": "ERREUR", "message": "type inconnu"}

def servir_client(connexion, adresse):
    try:
        while True:                      # plusieurs echanges
            requete = recevoir_message(connexion)
            if requete.get("type") == "QUIT":
                break
            envoyer_message(connexion, traiter(requete))
    except (ConnectionError, ValueError, OSError) as e:
        print(f"Fin de session avec {adresse} : {e}")
    finally:
        connexion.close()

Le client

import socket
from protocole import envoyer_message, recevoir_message

client = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
client.settimeout(5.0)
client.connect(("127.0.0.1", 50007))

envoyer_message(client, {"type": "PING"})
print(recevoir_message(client))       # {'type': 'PONG'}

envoyer_message(client, {"type": "CHARGE"})
print(recevoir_message(client))       # {'type': 'OK', 'charge': [...]}

envoyer_message(client, {"type": "CALCUL",
                         "a": 12, "b": 30, "operation": "+"})
print(recevoir_message(client))       # {'type': 'OK', 'resultat': 42}

envoyer_message(client, {"type": "QUIT"})
client.close()

La connexion reste ouverte pour plusieurs échanges : établir une connexion TCP coûte un aller-retour réseau complet.

Vous connaissiez déjà ce problème

HTTP utilise les deux techniques

POST /api/utilisateurs HTTP/1.1
Host: exemple.fr
Content-Type: application/json
Content-Length: 27

{"nom": "Dupont", "age": 42}
  • En-têtes délimités par des retours à la ligne — technique 1
  • Corps précédé de sa taille exacte — technique 2

Content-Length n’est pas une décoration

À retenir

C’est très exactement la longueur préfixée de notre protocole.py.

Sans elle, le serveur ne saurait pas où s’arrête le corps de la requête.

Tous les protocoles applicatifs affrontent ce problème, et tous le résolvent de l’une de ces deux manières.

Faire évoluer un protocole

On ne met jamais tout à jour en même temps

Pendant quelques minutes — ou quelques mois — l’ancienne et la nouvelle version coexistent.

Compatibilité Signification
ascendante (backward) Le nouveau code lit de l’ancien
descendante (forward) L’ancien code lit du nouveau

En pratique, avec JSON

# Version 1
{"type": "MAP", "id_map": 7, "fichier": "part-007.txt"}

# Version 2 : un champ en plus
{"type": "MAP", "id_map": 7, "fichier": "part-007.txt",
 "encodage": "latin-1"}
encodage = requete.get("encodage", "utf-8")   # bon
encodage = requete["encodage"]                # casse sur l'ancien format

Trois règles

Pour ne jamais casser un protocole en production

  1. tout nouveau champ est optionnel et a une valeur par défaut ;
  2. on ne renomme ni ne supprime jamais un champ obligatoire ;
  3. un champ inconnu est ignoré, jamais rejeté.

Texte ou binaire ?

JSON Binaire à schéma
Taille 3 à 10 fois plus gros compact
Lisibilité directe nulle sans le schéma
Champs désignés par leur nom désignés par un numéro
Usage API web, ce cours stockage massif, RPC interne

Protocol Buffers (Google), Thrift (Facebook), Avro (Hadoop).

Leur astuce : un champ est désigné par un numéro. On peut donc le renommer librement — mais jamais réutiliser un numéro.

À vous

Les exercices

  • 3.1 — Reproduire le problème. Avec puis sans sleep, puis avec des messages de 100 000 octets.
  • 3.2 — recevoir_exactement. La réécrire, puis la tester sur 10 000 octets envoyés un par un.
  • 3.3 — Le module protocole.py. Les trois fonctions, testées sur trois cas dont un piégeux.
  • 3.4 — Étendre le protocole. INFO, ECHO, SOMME, sans jamais faire planter le serveur.
  • 3.5 — Le serveur robuste. Quatre clients malveillants, dont un qui se tait : le déni de service.
  • 3.6 — Un client vers plusieurs serveurs. Déploiement par ssh, puis interrogation en parallèle.

Ce qu’il faut retenir

  • TCP transporte un flux d’octets, pas des messages
  • recv(n) renvoie au plus nn octets ; b"" = connexion fermée
  • Deux techniques de framing : délimiteur ou longueur préfixée
  • Un octet = deux chiffres hexadécimaux ; le ! impose le gros-boutiste
  • recevoir_exactement() est indispensable
  • Un timeout sur chaque connexion acceptée : sinon un client qui se tait immobilise un thread (déni de service)
  • Sérialiser = objet → octets. JSON ici, jamais pickle depuis le réseau
  • Un protocole = vocabulaire + format + enchaînement
  • Tout nouveau champ est optionnel ; un champ inconnu s’ignore