Le problème de HTTP/2

Ou de l'importance d'avoir HTTP/2 de bout en bout

Introduction : une commande au bistro

Vous entrez dans un bistro. Un déjeuner simple et rapide suffira, vous êtes pressé ! Vous vous installez, lisez la carte et rapidement vous faites votre choix. Ça sera un sandwich, un verre de jus d’agrumes frais et un cookie fait maison pour le dessert.

Le serveur arrive, prend la commande pour le sandwich… et s’en va aussitôt le chercher ! Pour passer le reste de votre commande, vous appelez donc un deuxième serveur. Vous demandez votre jus et encore une fois, le deuxième serveur s’en va aussitôt. Même chose pour le troisième serveur qui sera chargé de prendre en charge le cookie.

Vous en conviendrez, cet établissement a un curieux mode de fonctionnement. Pourtant, nous procédons de façon analogue quand nous naviguons sur le Web avec le protocole HTTP/1.1.

Le problème de HTTP/1

HTTP/1.1 fonctionne sur le principe une connexion TCP par requête/réponse. Quand on récupère une ressource, tout va bien : on ouvre une connexion TCP, on envoie une requête HTTP dedans (pour demander la ressource /index.html par exemple) et on obtient la réponse du serveur en retour sur cette même connexion TCP.

Mais une page d’un site Web moderne se compose rarement du seul document HTML. Il nous faut en général au moins les choses suivante pour une seule page :

  • le document HTML ;
  • le fichier favicon ;
  • la (ou les) feuille(s) de style CSS ;
  • le (ou les) script(s) JavaScript ;
  • des images.

Dans ce cas, le navigateur va essayer d’optimiser son travail pour afficher la page le plus vite possible. Il peut pour cela ouvrir plusieurs connexions TCP en parallèle pour y faire les requêtes en même temps. Voici un exemple typique des requêtes qu’on pourrait faire en parallèle :

GET /index.html
GET /favicon.ico
GET /assets/css/framework.css
GET /assets/css/style.css
GET /assets/js/framework.js
GET /assets/js/script.js
GET /static/img/header.webp
GET /static/img/logo.svg

Évidemment, certaines ressources sont dépendantes des autres, un certain ordre doit donc être respecté pour afficher une page au plus vite. Les frameworks JS ou CSS sont un cas typique de ressources prioritaires car ils conditionnent l’affichage de la page. A contrario, les images peuvent être récupérées à la fin car une latence d’affichage est admissible.

Tout cela n’est pas très optimal, il faut ouvrir beaucoup de connexions en parallèle et y passer une requête dans chacune d’entre elles et récupérer les multiples réponses.

Dans la vie réelle, un seul serveur prend commande des trois articles et les ramène d’un coup sur un seul plateau. Nous appelons cela le multiplexage et c’est l’une des idées majeures introduite par HTTP/2.

Le multiplexage de HTTP/2

Imaginons le cas où nous tenterions de multiplexer des requêtes avec HTTP/1.1. Nous demanderions sur une même connexion TCP les ressources suivantes et nous attendrions les trois réponses à la fin :

GET /script.js
GET /bootstrap.css
GET /style.css

Les trois réponses obtenues :

HTTP/1.1 200 OK
Date: Wed, 16 Dec 2020 10:35:54 GMT
Content-Type: text/css; charset=utf-8
Content-Length: 420
[...]

HTTP/1.1 200 OK
Date: Wed, 16 Dec 2020 10:35:54 GMT
Content-Type: text/css; charset=utf-8
Content-Length: 420042
[...]

HTTP/1.1 200 OK
Date: Wed, 16 Dec 2020 10:35:54 GMT
Content-Type: application/javascript; charset=utf-8
Content-Length: 1337
[...]

Comment savoir quelle réponse correspond à quelle requête ? Nous pouvons certes nous douter que la réponses contenant Content-Type: application/javascript correspond à notre requête pour GET /script.js car c’est la seule réponse JS. Mais comment pourrions-nous l’inférer pour les deux autres ?

Il s’agit là de tout le problème de HTTP/1.1 : il ne permet pas de multiplexer les transmissions entre le client et le serveur car il ne propose aucun moyen standardisé ou fiable de faire correspondre une réponse à une requête.

HTTP/2 introduit la notion de stream pour gérer l’entremêlement des réponses. À chaque requête nous faisons correspondre un stream ID. Les réponses du serveur n’arrivent pas nécessairement dans l’ordre des requêtes mais elles sont estampillées (taguée) du stream ID qui correspond à la requête à laquelle elle font écho. Nous pouvons donc associer correctement chaque réponse à sa requête, et ainsi éviter d’essayer d’afficher un script JavaScript comme si c’était une image.

Mais ce n’est pas tout. Les réponses peuvent être envoyées de façon non séquentielle, découpée. Chaque bout de réponse indique son stream ID, ce qui permet de la recomposer correctement à la fin (démultiplexage).

Multiplexage HTTP/2 : chaque couleur correspond à un stream. L'échange a lieu au sein d'une unique connexion TCP grâce aux aux étapes de multiplexage (MUX) et démultiplexage (DEMUX). Cette étape de démultiplexage permet de ré-assembler les échanges correctement.
Multiplexage HTTP/2 : chaque couleur correspond à un stream. L'échange a lieu au sein d'une unique connexion TCP grâce aux aux étapes de multiplexage (MUX) et démultiplexage (DEMUX). Cette étape de démultiplexage permet de ré-assembler les échanges correctement.

Pour comparer avec HTTP/1, le schéma suivant représente les trois requêtes parallèles.

Chaque échange HTTP dispose de sa propre connexion TCP exécutée en parallèle avec les autres
Chaque échange HTTP dispose de sa propre connexion TCP exécutée en parallèle avec les autres

HTTP/2 apporte d’autres améliorations substantielles par rapport à son prédécesseur, mais le multiplexage semble être la plus remarquable d’entre elles.

Les problèmes de HTTP/2

HTTP/2 ne vient pas sans son lot de difficultés. S’il est vrai que la technique vue permet d’économiser de la bande passante et des ressources réseau, il revient à l’applicatif (code client ou code serveur) d’en payer le prix en complexité d’implémentation et en ressource de calcul car les étapes de mux/demux ne sont pas gratuites.

La version HTTP/1.1 pourra donc être privilégiée dans certains cas sans un réel besoin des forces de HTTP/2. Nous penserons notamment aux interactions avec une API : il s’agit généralement de requêtes séquentielles et synchrones entre le programme et l’API, mettant ainsi en question la pertinence de HTTP/2.

Autre problème : dans un déploiement Web réel, il est commun de mettre en place un serveur frontal comme reverse-proxy devant un ou des serveurs applicatifs. Le but est généralement de terminer une connexion en TLS, de faire du load-balancing ou encore du fail-over. Ce rôle de serveur frontal (frontend server) peut être endossé par exemple par un serveur classique comme Nginx ou d’autres programmes plus spécialisés comme HAProxy.

Le souci étant que le serveur qui fait office de terminaison doit vraisemblablement par la suite communiquer en HTTP/1 avec un serveur applicatif comme Gunicorn, voire utiliser un protocole qui n’est pas HTTP du tout (FastCGI ou uwsgi par exemple). Cela met donc potentiellement en échec tous les efforts précédemment accomplis par l’usage de HTTP/2.

La grande question est donc la suivante : est-il si intéressant de déployer HTTP/2 entre le client et le serveur frontal comme cela ?

Terminaison HTTP/2 avec une communication HTTP/1 derrière
Terminaison HTTP/2 avec une communication HTTP/1 derrière

Nous l’avons vu, cela implique donc une étape de mux/demux au niveau de l’échange entre le serveur frontal et le serveur applicatif derrière. Ces opérations ne sont pas gratuites en CPU et il vaudrait mieux qu’elle se rentabilisent en se traduisant par un gain pour le client : c’est le but de HTTP/2, après tout, n’est-ce pas ?

Pour palier à cela, on peut donc privilégier la mise en place de bout en bout quand c’est possible. Mais ce n’est pas si simple : il faut que les protocoles s’y prêtent bien et que les briques soient assez intelligentes entre elles pour éviter le travail inutile.

Présentons sans plus tarder un tel exemple : il s’agit d’une configuration entre un HAProxy frontal et un Nginx derrière lui. Depuis sa version 2.0, HAProxy gère bien les backend HTTP/2 et est assez intelligent pour éviter les étape de mux/demux si son frontend est lui aussi utilisé en HTTP/2. (cf. https://www.haproxy.com/fr/blog/haproxy-2–0-and-beyond/#end-to-end-http-2)

Dans l’exemple qui suit, Nginx sert les requêtes en local en utilisant HTTP/2 en clair avec HAProxy. Ce qui entre dans HAProxy entre aussi dans Nginx verbatim, sans étape intermédiaire de mux/demux. Idem dans l’autre sens.

Contrairement à une croyance populaire, HTTP/2 n’a techniquement pas besoin de reposer sur une couche TLS. Ce sont les navigateurs qui imposent cela de fait. Ici, c’est HAProxy qui prend en charge TLS avec le client, mais la couche TLS n’a aucunement besoin d’être en place de bout en bout.

Côté HAProxy 2.0+ :

frontend main
    bind :::443 v4v6 ssl crt /path/to/cert.pem alpn h2,http/1.1
    mode http
    default_backend nginx
  
backend nginx
    mode http
    server local_nginx 127.0.0.1:444 check proto h2

Côté Nginx (1.18) :

server {
  # Pas de gestion de TLS/ALPN ici
  
    listen 127.0.0.1:444 http2 default_server;

    root /var/www/html;

    location / {
        gzip_static on;
        try_files $uri $uri/ =404;
    }

Dans une configuration comme cela, nous avons donc ce genre de liaison en images :

HTTP/2 de bout en bout : aucune étape de mux/demux par le serveur frontal
HTTP/2 de bout en bout : aucune étape de mux/demux par le serveur frontal

Pensez donc bien à vérifier si vous avez la possibilité de mettre en place HTTP/2 de bout en bout ! ^^



4 commentaires

HTTP/1.1 fonctionne sur le principe une connexion TCP par requête/réponse.

Par défaut, en HTTP/1.1 la connexion TCP est persistante et le navigateur peut donc utiliser la même connexion TCP pour plusieurs requêtes à la fois. Par contre, il doit effectivement attendre la fin de la réponse à une requête avant d’envoyer la prochaine requête.

Même HTTP/1.0 a la possibilité de garder la connexion TCP ouverte avec le header Keep-Alive.

HTTP/1.1 fonctionne sur le principe une connexion TCP par requête/réponse.

Par défaut, en HTTP/1.1 la connexion TCP est persistante et le navigateur peut donc utiliser la même connexion TCP pour plusieurs requêtes à la fois. Par contre, il doit effectivement attendre la fin de la réponse à une requête avant d’envoyer la prochaine requête.

Même HTTP/1.0 a la possibilité de garder la connexion TCP ouverte avec le header Keep-Alive.

Berdes

Bonne remarque, en fait je me rends compte qu’il y a un souci car j’ai précisé explicitement la v1.1 alors qu’il est d’usage de réutiliser les connexions. Par ailleurs cela va un peu à l’encontre de ce qui est dit plus tard où il est sous entendu que les connexions en HTTP/1 sont ré-utilisées, quand on présente justement la difficulté que cela introduit au niveau de l’entremêlement des réponses que tu décris.

Notons également qu’on pouvait déjà multiplexer en HTTP/1.1 (https://fr.wikipedia.org/wiki/Pipelining_HTTP largement supporté, bien que souvent désactivé par défaut sur les navigateurs). Personnellement j’avais clairement vu la différence à l’époque quand je l’ai activé dans Firefox. Je dois avouer que HTTP/2, la différence a été moins flagrante, ptêt juste à cause du reste (meilleure connexion ou PC…). Sachant qu’à l’arrivée de HTTP/2 Firefox a supprimé le support de cette fonctionnalité en 1.1.

+0 -0

Notons également qu’on pouvait déjà multiplexer en HTTP/1.1 (https://fr.wikipedia.org/wiki/Pipelining_HTTP largement supporté, bien que souvent désactivé par défaut sur les navigateurs). Personnellement j’avais clairement vu la différence à l’époque quand je l’ai activé dans Firefox.

Breizh

Attention, il ne s’agit pas de multiplexage mais uniquement de pipelining : les requêtes restent envoyées dans un certain ordre et elle doivent être reçues dans le même ordre. C’est du moins ce que l’article semble décrire.

Le modèle reste séquentiel, c’est juste qu’on réutilise la même connexion TCP pour plusieurs aller-retours.

Je dois avouer que HTTP/2, la différence a été moins flagrante, ptêt juste à cause du reste (meilleure connexion ou PC…). Sachant qu’à l’arrivée de HTTP/2 Firefox a supprimé le support de cette fonctionnalité en 1.1.

Le souci de HTTP/2 c’est que ça ne sera pas forcément flagrant si la couche n’est implémentée qu’en façade. En général HTTP/2 s’avère efficace entre le client et un serveur de fichiers statiques. (Par exemple une page HTML statique avec plein de petites photos statiques). Sinon on tombe dans le problème décrit : derrière le rideau, il faut repasser sur des modèles plus classiques et non multiplexés (FastCGI, HTTP/1, uwsgi, …).

Ça ne m’étonne pas plus que ça que le gain perçu par HTTP/2 soit relatif, je pense qu’on apprécie surtout d’éviter l’overhead des connexions TCP dans la plupart des cas (ce que HTTP/1 permettait déjà, en effet).

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte