HTTP2

Published: 01-01-2017

Updated: 09-07-2017

By: Maxime de Roucy

tags: http2 web

HTTP/2 (que nous appellerons ici h2) est une version du protocole HTTP sortie en 2015 et détaillé dans le RFC 7540 ; son prédécesseur HTTP/1.1 (ici appelé h1) est sortie en 1997 et est standardisé dans le RFC 2068. Actuellement (112016), le support de ce nouveau protocole est souvent incomplet dans les navigateurs et les serveurs web. Dans cet articles j’essaierai de détailler les spécificités (du moins certaines) du standards. Les informations disponible dans ce poste sont principalement tirée de mes notes sur les conférences Velocity 2016 d’Amsterdam ainsi que de recherches personnels.

Je prendrais souvent l’exemple du serveur web H2O car c’est à ce jour celui dont le support de h2 est le plus abouti.

Liste des conférences :

différences

Les principales différences entre h1 et h2 sont :

multiplexing

performances

Sources :

En h1 entre 4 et 8 connexions TCP sont ouverte entre le client et le serveur (par domaine) et il n’était pas possible d’envoyer plusieurs requêtes ou réponses en même temps sur une même connexion.

Le débit autorisé dans une connexion TCP « standard » change au fils du temps et se découpe en plusieurs phases. Le schéma suivant représente une version simplifiée de l’évolution du débit autorisé dans une connexion TCP (TCP Tahoe).

TCP Slow-Start and Congestion Avoidance

CWND correspond à la fenêtre de congestion TCP (données qui peuvent être envoyée immédiatement sans attente d’ACK). Dans la phase de Slow-Start, CWND double à chaque RTT (Round-Trip Time) si aucun paquet n’est perdu et est divisé par 2 sinon. Lorsque CWND est supérieur ou égal à ssthresh (Slow-Start Threshold) on passe en mode « Congestion Avoidance ». Le débit est alors augmenté d’un MSS (Maximum Segment Size) par RTT.

Dans la suite de cette section je considère que seuls 6 connexions parallèle peuvent être ouvertes en h1.

x

schéma b
Dans des conditions réseau idéal (pas de perte de paquet), lorsqu’on transfert 6 fichiers en parallèle h1 est plus rapide que h2 car h1 dispose de 6 connexions TCP. Il a donc 6 fois plus de bande passante que h2 qui multiplexe toutes les réquêtes et transfert dans une seul connexion.
schéma a
Lorsqu’il y a plus de fichier a transférer, h1 doit attendre qu’une connexion se libère (que le transfert d’un des fichier se termine) pour commencer à transférer les autres fichiers. Des RTT sont perdus entre chaque transferts de fichiers. En h2 on peut commencer à transférer tous les fichiers dès le début, on ne « perd » pas de RTT. De plus h2 compresse les headers HTTP. (Je met un bémol sur ce paragraphe qui est le fruit d’une déduction personnel qui ne me satisfait qu’à moitié)
Lorsque le nombre de fichiers à transférer augmente le nombre de RTT perdus augmente et, même si le débit binaire de h1 est 6 fois élevé, les fichiers sont transféré plus rapidement en h2.
schéma a & b
Lorsque la qualité du lien physique diminue et que des pertes de paquets apparaissent h2 est plus impacté que h1. En h1 la perte d’un paquet affecte la CWND d’une seul connexion sur les 6 (schéma a & b).

http://conferences.oreilly.com/velocity/devops-web-performance-eu/public/schedule/detail/52820
CDF: Cumulative distribution function, pourcentage d’utilisateur ayant réussi à télécharger la page en un temps donné.

Dans Experiences with HTTP/2 in the real world (slides 13 et 14, reproduit dans le schéma précédent), Michael Gooding (akamai) indique qu’ en condition réel et sans optimisation particulière h2 est plus performant que h1 sur les réseau mobile. Sur les réseaux filaires h2 est très similaire à h1 sur un réseau filaire.

Note personnel

Je comprend que h1 puisse être plus performant que h2 étant donné qu’il dispose du débit de connexions TCP. Cependant je ne comprend pas pourquoi dans le graphique (b) les performances de h2 soit si proche de celle de h1.

Dans le graphique (a) je ne vois pas non plus comment h2 peut apporter une tel amélioration par rapport à h1. Les auteurs de Poster: HTTP/2 Performance in Cellular Networks explique ce phénomène par le multiplexing utilisé dans h2 (sans approfondir). Comme dit plus haut, mon explication des RTT « perdu » ne me satisfait qu’a moitié.

Les résultats de Poster: HTTP/2 Performance in Cellular Networks tendent à montrer que h2 devrait être moins performant que h1 ; excepté dans de « bonnes » conditions réseaux. Les résultats annoncés par Michael Gooding dans Experiences with HTTP/2 in the real world sont très peu détaillée et entre contradiction avec Poster: HTTP/2 Performance in Cellular Networks (à moins que les performances moyennes des réseaux cellulaire soit bonne). Il indique qu’ils sont tirés de mesures réel réalisées par Akamai (Real User Monitoring) et dans le diapositive 16 il met en garde « Lessons learned: Can’t trust simulated latency » ; deux slides plus loin il mentionne les résultat de Poster: HTTP/2 Performance in Cellular Networks… issues de simulations. Il parle de la latence lorsqu’il indique « Lessons learned: Can’t trust simulated latency » et des pertes de paquets lorsqu’il évoques les résultats de Poster: HTTP/2 Performance in Cellular Networks mais ça me dérange quand même un peu.

Bref, concernant l’impacte du multiplexing dans h2 (réseau mobile et filaire) il y a beaucoup de points qui ne sont pas très claires et qui mériteraient des recherches beaucoup plus approfondies.

connection reuse

En h1 entre 4 et 8 connexions TCP sont ouverte entre le client et le serveur (par domaine) et il n’était pas possible d’envoyer plusieurs requêtes ou réponses en même temps sur une même connexion. Il est courant de placer une partie des assets d’une page sur un domaine différent du domaine principal. Cette technique s’appelle sharding et est destinée à augmenter le nombre de connexions disponible et donc le nombre d’assets qu’on peut télécharger en parallèle.

La bande passante de ces connexions est très peu utilisée. Elles ne sortent parfois même pas de la phase de slow start.

h2 a été pensé pour qu’une seul connexion TCP soit utilisée. Le sharding va donc à l’encontre de ce qui est préconisé par h2 et peu détériorer ses performances.

On fait une requêtes initial à pelican.craoc.fr/search.html, le navigateur passe par les phases D, C, T et R puis, après parsing de la réponse, fait les requêtes pour les assets m.css et ts.js :

Imaginons que ces assets se trouve sur un autre FQDN, on fait du sharding :

On voit que lorsque le sharding est utilisé les temps D, C et T supplémentaire sont nécessaire pour télécharger les assets. Michael Gooding dans Experiences with HTTP/2 in the real world donne un exemple dans lequel il gagne 200ms en supprimant l’utilisation du sharding.

On ne peut pas empêcher le sharding car ça nécessiterait de modifier en profondeur l’architecture de nombreux site web. De plus il a une réelle utilité en h1, et de nombreux navigateur ne supporte pas (ou mal) h2.

Il est donc prévue dans le RFC 7540 que les navigateur puisse réutiliser la connexion (connection reuse) d’un assets pour un autre assets situé dans un autre domaine, du moment que le serveur fasse autorité sur les deux domaine. Plus simplement :

Par exemple, le site a.craoc.fr (DNS : 1.1.1.1 et 1.1.1.2) utilise des assets situé sur b.craoc.fr (DNS : 1.1.1.2 et 1.1.1.3). Le certificat utilisé sur a.craoc.fr couvre aussi b.craoc.fr.

Cette technique est généralement appelée le connection coalescing ou unsharding, même si dans le RFC elle est appelée « connection reuse ».

En cas d’erreur, le serveur qui reçoit une requête sur un domaine sur lequel il ne fait pas autorité peut retourner un erreur 421 (Misdirected Request).

priorités et dépendances

Étant donnée que toutes les requêtes/réponses sont multiplexées dans le même canal, l’envoie d’éléments non prioritaire (eg. images) peut entrainer un ralentissement du transfert d’élément plus prioritaire.

Pour palier à ce problème, h2 offre la possibilité de spécifier des dépendances et des poids aux différents éléments. Précision sur les dépendances : ce sont des dépendances sur la complétion du transfert. Lorsque B dépend de A, B ne commencera à être envoyé que lorsque le transfert de A sera terminé. La dépendance par défaut est la racine de l’arbre de dépendance (0x0 : pas de dépendance).

Les éléments qui sont transféré à un instant donnée sont priorisées via leur « poids », entre 1 (moins prioritaire) et 256 inclue (plus prioritaire), 16 par défaut. Le choix de la méthode de priorisation (WFQ, DRR…) est laissé au soin du serveur web. Apache utilise WFQ en O(log(N)), H2O approxime WFQ en O(1).

Un exemple d’arbre de dépendances généré par firefox :

image : arbre de dépendance de firefox

Certains navigateur (eg. safari, ancienne version de Chrome) n’utilise pas cette feature, ce qui nuis aux performances.

image : mauvais arbre dépendance

Pour palier à ce problème H2O peut détecter les navigateurs n’utilisant pas cette feature et prioriser lui même les éléments.

image: Download Timing H2O/Nginx

Une autre possibilité consiste à contrôler « manuellement » les requêtes via javascript (ça n’a rien à voir avec le protocole h2). On peut faire en sorte que les images ne soit requêtées qu’une fois certains autres assets téléchargé, cette technique se substitue aux dépendances h2. C’est assez long et laborieux à mettre en place mais ça fonctionne aussi en h1.

server push

Le navigateur à besoin du HTML, des scripts JS et des CSS pour commencer le rendering d’une page. Cependant, il ne peut connaitre les JS et les CSS (et potentiellement les autres pages HTML) à charger qu’une fois le premier HTML téléchargé et parsé. De plus, ces nouveaux JS, CSS et potentiellement HTML peuvent nécessiter le chargement d’autres JS/CSS/HTML. Il faut aussi télécharger les images et autres éléments mais ceux-ci ne sont pas bloquant pour le « first-pain » de la page ; le navigateur n’attend pas d’avoir téléchargé toutes les images pour commencer à afficher une page.

x

L’envoie de la première page HTML peut être long, notamment car le serveur doit calculer le rendu de la page (php, connections à la base de donnée…). Il y a donc une perte de temps entre le moment où le serveur reçoit la requête principale et l’envoie de la réponse. De même pour le client avec l’exécution des JS. Il y a donc plusieurs phases où rien ne transite entre le serveur et le client.

Dans cette illustration je considère que toutes les dépendances se trouve sur le même serveur (ce qui est rarement le cas), je ne traite pas l’initialisation TLS, et je considère qu’il n’y a qu’une connexion TCP (ce qui est faut en h1). image : h1 diagramme d'échange

h2 permet au serveur d’envoyer au client, après une requêtes, des éléments que celui-ci n’a pas explicitement demandé, et donc mettre à profit cette bande passante non utilisée en h1. Cette technique s’appelle le « serveur push ». On peut par exemple envoyer tous les JS et CSS nécessaire au rendu d’une page dès la réception de la requête du client, avant même d’avoir généré le HTML correspondant à celle-ci.

De le schéma suivant, j’ai considéré que le HTML était généré après le transfert d’exemple.css et exemple.js ; ce n’est pas forcément le cas. De plus, les transfert se font en réalité en parallèle via le multiplexing (exemple.js est envoyé en même temps que exemple.css).

image : h2 diagramme d'échange

slow start

En plus d’utiliser la bande passante dès le début de la transaction, cela à un autre avantage : préparer/warming up la connexion TCP. Le protocole TCP est fait de façon à ce que plus la quantité de donnée ayant transitée dans la connexion est importante, plus le débit de la connexion est important (jusqu’à la première erreur de transmission). C’est ce que j’ai essayé de représenter dans mon schéma h1 pour la transmission du HTML (14KB, 28KB, 56KB…). C’est le TCP slow start.

Colin Bendell (The promise of Push slide 78), prend l’exemple de la front page de www.rakuten.co.uk et montre que moins de 10% de la bande passante disponible a été utilisé au moment de la réception du premier HTML par le navigateur.

Envoyer les JS et CSS dés le début de la connexion permet d’augmenter le débit pour les futures transfert (HTML, images…) (un navigateur supportant h2 n’utilise qu’une connexion TCP pour échanger toutes les ressources nécessaire avec un serveur).

head-of-line blocking

Vu comme ça, ça semble génial, autant envoyer toutes les ressources dès le début de la connexion… en réalité ce n’est pas forcément une bonne idée. Prenons le cas d’une grosse images, lorsque le serveur web va commencer l’upload une grande quantité de donnée va être envoyée au noyau et mis dans le buffer de la connexion TCP (et TLS). Les éléments ne sont supprimés du buffer que lorsque les ACK correspondant sont reçus. De plus TCP oblige les données à être envoyée dans l’ordre. Si le HTML est généré alors que l’image est en train d’être transférée, le serveur web devra attendre que le cache du noyau soit vide pour pouvoir prioriser et commencer à envoyer ce HTML (plus prioritaire car nécessaire au rendering de la page) (h2 fait du multiplexing mais le premier bit du HTML sera quand même envoyé qu’une fois le cache vide). Ici, le problème n’est pas au niveau de h2 mais au niveau TCP, et est appelé le head-of-line blocking.

D’après Colin Bendell (The promise of Push slide 73), l’envoie d’une grosse images via push peut entrainer via le head-of-line blocking un délai d’une demi seconde sur le TTFB (time to first byte) (pour un CDF de 0.5).

Le « TLS buffer » est aussi appelé BIO dans certains documents.

image : TCP head-of-line blocking

CWND correspond à la fenêtre de congestion TCP (données qui peuvent être envoyée immédiatement sans attente d’ACK). « pool threshold » correspond à la limite après laquelle le noyau notifie l’application (où la couche d’abstraction supérieur ; ici TLS) que les donnée ont été écrites/envoyées (même si les données n’on pas réellement été envoyées).

Dans H2O, le problème est contourné (entre autre) en analysant l’état du « TCP send buffer » et en déplaçant le « poll threshold » le plus près possible du CWND (sous linux : CWND + 1 octet ; sinon ça devient instable). En cas de repriorisation, H2O peut donc envoyer de nouvelles données quasi instantanément.

contournement du head-of-line blocking dans H2O

J’ai trouvé plus de détaille sur ces optimisation dans les slides de la conférence Programming TCP for responsiveness de Kazuho Oku.

Première optimisation

Attendre la notification du noyau (poll threshold) avant d’écrire de nouvelle données. Cela permet de ne pas utiliser le buffer TLS.

// only call SSL_write when polls notifies the app
while (pool_for_write(fd) == SOCKET_IS_READY)
{
	SSL_write(…);
}

image : TCP head-of-line blocking, première optimisation

Deuxième optimisation

Analyser l’état du buffer TCP pour déplacer le « poll threshold » au plus près du CWND.

image : TCP head-of-line blocking, première optimisation

Troisième optimisation

Analyser l’état du buffer TCP pour générer des frames h2 de taille déterminé, pour ne pas trop dépasser « poll threshold ».

// calc size of data to send by calling getsockopt(TCP_INFO)
if (poll_for_write(fd) == SOCKET_IS_READY)
{
	capacity = CWND - nonACK + TWO_MSS - TLS_overhead;
	SSL_write(prepare_http2_frames(capacity));
}

image : TCP head-of-line blocking, première optimisation

conclusion

D’après les benchmarks fait par Kazuho Oku (slides 19 et 20), cela permet de gagner un RTT. Ce qui peut être important lorsque le serveur est situé à l’autre bout de la planète.

Cependant cette solution n’est pas suffisante car de nombreux appareils peuvent se placer entre le serveur et le client (proxy, TLS terminator, caches, solution pour réseaux mobile…).

Actuellement il ne semble pas y avoir de réel solution ; l’adoption du protocole QUIC remplaçant TCP, basé sur UDP (beaucoup moins restrictif que TCP) semble la solution privilégiée pour le future. QUIC est déjà implémenté dans Chromium et Chrome.

preload

Pour qu’un serveur web envoie des push, il faut qu’il sache quoi envoyer pour tel ou tel requête. On peut spécifier quoi push directement dans la configuration du serveur web (nginx, apache, H2O, ici « serveur web » n’inclue pas l’application !).

Par exemple lorsque le serveur est derrière un CDN.

image : lorsque le serveur est derrière un CDN

Lorsque le processing (génération du HTML) est long.

image : lorsque le processing est long

La difficulté consiste à savoir quoi envoyer lorsque le CDN/serveur web reçoit une requête. Cette solution n’est donc pas toujours possible ou pertinente.

On peut aussi laisser l’application web indiquer quoi envoyer via le keyword preload (markup ou HTTP header).

Lorsque le serveur web (ou tout intermédiaire) reçoit la réponse de l’application à envoyer au client et qu’il détecte le keyword preload dans un header « Link », celui-ci peut décider d’envoyer ou non l’asset désigné par le « Link » via un push. S’il envoie un push, le header « Link » est supprimé de la réponse.

Le client/browser, à la réception d’un « Link » preload (donc non transformé en push par le serveur), peut commencer à télécharger l’élément mentionné dès le parsing du HTML.

Pour le serveur il n’est pas forcément conseillé de transformer tous les Link preload en push. Laisser le navigateur faire ces requêtes lui permet de les ordonner par ordre de priorité (le serveur peut aussi le faire mais c’est moins pertinent). Ça évite aussi de re-transférer les éléments qui sont déjà en cache. Cependant, cela implique une communication supplémentaire entre le client et le serveur.

Aujourd’hui (18/12/2016) l’application ne peut transmettre les headers au serveur web que lorsque la génération du HTML est terminée. Un des problème du preload est que le serveur doit attendre la fin de la génération du HTML pour savoir quoi push.

Pour résoudre ce problème Kazuho Oku propose un Internet Draft établissant un nouveau code de retour HTTP : 103 Early Hints. Ce code permet au serveur d’application d’indiquer à tous les éléments traitant les réponses HTTP (client, serveur web, proxy, CDN…) quels sont les headers HTTP susceptibles d’être présent dans la réponse final. Chaque éléments peux choisir de passer ou non tout ou parti des réponses « Early Hints ». Un serveur web ou un CDN, au passage d’un « Early Hints » contenant un header « Link » avec preload peut décider d’envoyer cet asset via push. Cela permet à l’application de décider quoi marqué en preload (et donc potentiellement envoyer via push) avant d’avoir terminé la génération du HTML.

image : 103 Early Hints

De la même façon le client/browser pourrait commencer à ordonner et télécharger les éléments qui ne sont pas encore dans son cache.

HS

Il existe aussi preconnect qui indique au client d’initier une connections avec le serveur mentionné. prefetch indique au client de télécharger l’élément mentionné mais le navigateur peut choisir de faire cette action quand bon lui semble. Cette instruction est pensée pour permettre le téléchargement d’élément nécessaire au rendu de la prochaine page visitée. C’est une préparation pour la prochaine navigation.

Les éléments qui sont envoyés par le serveur via un push ne sont pas stocké directement dans le cache du navigateur mais dans un cache intermédiaire de session (source). Il sont gardé dans ce cache intermédiaire pendant 5 minutes et sont détruit s’ils ne sont pas utilisés. Il ne sont intégré au cache standard que lorsque le navigateur les utilisent vraiment (pour le rendering d’une page), ce qui doit normalement toujours être le cas étant donné que les éléments indiqué via preload sont généralement nécessaire au rendering (ce n’est pas obligé mais je ne vois aucun use case). Les éléments qui sont téléchargés par le navigateur via l’instruction preload sont directement intégrés dans le cache standard.

cache

RST_STREAM

Si le navigateur dispose déjà de tous les éléments nécessaires dans son cache il peut stopper les push via un « reset » (RST_STREAM) mais une partie de la bande passante aura été gâchée.

image : sans RST\_STREAM

image : avec RST\_STREAM

Un workshop donné par CloudFlare en juillet 2016 indique que 47% des éléments pushed sont reset par les navigateurs. Cependant durant la conférence The promise of Push (slide 51) Colin Bendell, via sont site canipush.com indique qu’aucun des principaux navigateur ne support l’envoie de RST_STREAM pour les assets javascript, css et xhr (woff a été ajouté après la conférence).

image : canipush

Pour me faire une idée j’ai donc effectué mes propres tests. J’ai mis en place un serveur full h2 de test via nghttp2 et ai testé si firefox et chromium retourne bien un RST_STREAM lorsque l’élément pushed est déjà dans leur cache.

Préparatifs avant de pouvoir lancer le serveurs :

max@laptop % openssl genrsa -out key.pem 2048
max@laptop % openssl req -new -key key.pem -out certificate.csr
…
Common Name (e.g. server FQDN or YOUR name) []:localhost
…
max@laptop % openssl x509 -req -in certificate.csr -signkey key.pem -out certificate.pem
max@laptop % wget https://pelican.craoc.fr/theme/css/main.css
max@laptop % cat > test.html
<!DOCTYPE html>
<html>
<head>
	<title>Page Title</title>
	<link rel="stylesheet" href="main.css" />
</head>
<body>

<h1>Heading</h1>
<p>paragraph.</p>

<!--img src="Patern_test.jpg"-->
<!--https://upload.wikimedia.org/wikipedia/commons/d/db/Patern_test.jpg-->
<!--img src="rc.jpg"-->
<!--https://edmullen.net/test/imagefiletest.php-->
<!--img src="gif_cravate.gif"-->
<!--http://big.assets.huffingtonpost.com/gif_cravate.gif-->

</body>
</html>

Comme vous pouvez le voir dans le html, j’ai fait plusieurs tests avec du css, des images jpg (Patern_test.jpg 36 kiB ; rc.jpg 5.7 MiB) et gif (8.6 MiB).

Lancement du serveur h2 (nghttpd) et test de sont fonctionnement avec un client h2 (nghttp).

max@laptop % nghttpd -v --address=::1 --push=/test.html=/main.css 8080 key.pem certificate.pem
IPv6: listen ::1:8080
[ALPN] client offers:
 * h2
 * h2-16
 * h2-14
SSL/TLS handshake completed
The negotiated protocol: h2
[id=1] [  5.651] send SETTINGS frame <length=6, flags=0x00, stream_id=0>
          (niv=1)
          [SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[id=1] [  5.652] recv SETTINGS frame <length=12, flags=0x00, stream_id=0>
          (niv=2)
          [SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
          [SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[id=1] [  5.652] recv SETTINGS frame <length=0, flags=0x01, stream_id=0>
          ; ACK
          (niv=0)
[id=1] [  5.652] recv PRIORITY frame <length=5, flags=0x00, stream_id=3>
          (dep_stream_id=0, weight=201, exclusive=0)
[id=1] [  5.652] recv PRIORITY frame <length=5, flags=0x00, stream_id=5>
          (dep_stream_id=0, weight=101, exclusive=0)
[id=1] [  5.652] recv PRIORITY frame <length=5, flags=0x00, stream_id=7>
          (dep_stream_id=0, weight=1, exclusive=0)
[id=1] [  5.652] recv PRIORITY frame <length=5, flags=0x00, stream_id=9>
          (dep_stream_id=7, weight=1, exclusive=0)
[id=1] [  5.652] recv PRIORITY frame <length=5, flags=0x00, stream_id=11>
          (dep_stream_id=3, weight=1, exclusive=0)
[id=1] [  5.652] recv (stream_id=13) :method: GET
[id=1] [  5.652] recv (stream_id=13) :path: /test.html
[id=1] [  5.652] recv (stream_id=13) :scheme: https
[id=1] [  5.652] recv (stream_id=13) :authority: localhost:8080
[id=1] [  5.652] recv (stream_id=13) accept: */*
[id=1] [  5.652] recv (stream_id=13) accept-encoding: gzip, deflate
[id=1] [  5.652] recv (stream_id=13) user-agent: nghttp2/1.17.0
[id=1] [  5.652] recv HEADERS frame <length=46, flags=0x25, stream_id=13>
          ; END_STREAM | END_HEADERS | PRIORITY
          (padlen=0, dep_stream_id=11, weight=16, exclusive=0)
          ; Open new stream
[id=1] [  5.652] send SETTINGS frame <length=0, flags=0x01, stream_id=0>
          ; ACK
          (niv=0)
[id=1] [  5.652] send PUSH_PROMISE frame <length=27, flags=0x04, stream_id=13>
          ; END_HEADERS
          (padlen=0, promised_stream_id=2)
          :method: GET
          :path: /main.css
          :scheme: https
          :authority: localhost:8080
[id=1] [  5.652] send HEADERS frame <length=93, flags=0x04, stream_id=13>
          ; END_HEADERS
          (padlen=0)
          ; First response header
          :status: 200
          server: nghttpd nghttp2/1.17.0
          cache-control: max-age=3600
          date: Sun, 18 Dec 2016 12:04:55 GMT
          content-length: 306
          last-modified: Sun, 18 Dec 2016 11:51:24 GMT
          content-type: text/html
[id=1] [  5.652] send HEADERS frame <length=42, flags=0x04, stream_id=2>
          ; END_HEADERS
          (padlen=0)
          ; First push response header
          :status: 200
          server: nghttpd nghttp2/1.17.0
          cache-control: max-age=3600
          date: Sun, 18 Dec 2016 12:04:55 GMT
          content-length: 6793
          last-modified: Thu, 07 Apr 2016 19:43:05 GMT
          content-type: text/css
[id=1] [  5.653] send DATA frame <length=306, flags=0x01, stream_id=13>
          ; END_STREAM
[id=1] [  5.653] stream_id=13 closed
[id=1] [  5.653] send DATA frame <length=6793, flags=0x01, stream_id=2>
          ; END_STREAM
[id=1] [  5.653] stream_id=2 closed
[id=1] [  5.654] recv GOAWAY frame <length=8, flags=0x00, stream_id=0>
          (last_stream_id=2, error_code=NO_ERROR(0x00), opaque_data(0)=[])
[id=1] [  5.654] closed

max@laptop % nghttp -v --null-out --get-assets 'https://localhost:8080/test.html'
[  0.002] Connected
The negotiated protocol: h2
[  0.016] recv SETTINGS frame <length=6, flags=0x00, stream_id=0>
          (niv=1)
          [SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
[  0.016] send SETTINGS frame <length=12, flags=0x00, stream_id=0>
          (niv=2)
          [SETTINGS_MAX_CONCURRENT_STREAMS(0x03):100]
          [SETTINGS_INITIAL_WINDOW_SIZE(0x04):65535]
[  0.016] send SETTINGS frame <length=0, flags=0x01, stream_id=0>
          ; ACK
          (niv=0)
[  0.016] send PRIORITY frame <length=5, flags=0x00, stream_id=3>
          (dep_stream_id=0, weight=201, exclusive=0)
[  0.016] send PRIORITY frame <length=5, flags=0x00, stream_id=5>
          (dep_stream_id=0, weight=101, exclusive=0)
[  0.016] send PRIORITY frame <length=5, flags=0x00, stream_id=7>
          (dep_stream_id=0, weight=1, exclusive=0)
[  0.016] send PRIORITY frame <length=5, flags=0x00, stream_id=9>
          (dep_stream_id=7, weight=1, exclusive=0)
[  0.016] send PRIORITY frame <length=5, flags=0x00, stream_id=11>
          (dep_stream_id=3, weight=1, exclusive=0)
[  0.016] send HEADERS frame <length=46, flags=0x25, stream_id=13>
          ; END_STREAM | END_HEADERS | PRIORITY
          (padlen=0, dep_stream_id=11, weight=16, exclusive=0)
          ; Open new stream
          :method: GET
          :path: /test.html
          :scheme: https
          :authority: localhost:8080
          accept: */*
          accept-encoding: gzip, deflate
          user-agent: nghttp2/1.17.0
[  0.017] recv SETTINGS frame <length=0, flags=0x01, stream_id=0>
          ; ACK
          (niv=0)
[  0.017] recv (stream_id=13) :method: GET
[  0.017] recv (stream_id=13) :path: /main.css
[  0.017] recv (stream_id=13) :scheme: https
[  0.017] recv (stream_id=13) :authority: localhost:8080
[  0.017] recv PUSH_PROMISE frame <length=27, flags=0x04, stream_id=13>
          ; END_HEADERS
          (padlen=0, promised_stream_id=2)
[  0.017] recv (stream_id=13) :status: 200
[  0.017] recv (stream_id=13) server: nghttpd nghttp2/1.17.0
[  0.017] recv (stream_id=13) cache-control: max-age=3600
[  0.017] recv (stream_id=13) date: Sun, 18 Dec 2016 12:04:55 GMT
[  0.017] recv (stream_id=13) content-length: 306
[  0.017] recv (stream_id=13) last-modified: Sun, 18 Dec 2016 11:51:24 GMT
[  0.017] recv (stream_id=13) content-type: text/html
[  0.017] recv HEADERS frame <length=93, flags=0x04, stream_id=13>
          ; END_HEADERS
          (padlen=0)
          ; First response header
[  0.017] recv (stream_id=2) :status: 200
[  0.017] recv (stream_id=2) server: nghttpd nghttp2/1.17.0
[  0.017] recv (stream_id=2) cache-control: max-age=3600
[  0.018] recv (stream_id=2) date: Sun, 18 Dec 2016 12:04:55 GMT
[  0.018] recv (stream_id=2) content-length: 6793
[  0.018] recv (stream_id=2) last-modified: Thu, 07 Apr 2016 19:43:05 GMT
[  0.018] recv (stream_id=2) content-type: text/css
[  0.018] recv HEADERS frame <length=42, flags=0x04, stream_id=2>
          ; END_HEADERS
          (padlen=0)
          ; First push response header
[  0.018] recv DATA frame <length=306, flags=0x01, stream_id=13>
          ; END_STREAM
[  0.018] recv DATA frame <length=6793, flags=0x01, stream_id=2>
          ; END_STREAM
[  0.018] send GOAWAY frame <length=8, flags=0x00, stream_id=0>
          (last_stream_id=2, error_code=NO_ERROR(0x00), opaque_data(0)=[])

Avec Firefox (tout test confondu : css, images, gif) ; j’ai fait plusieurs Ctrl-F5 et F5 à la suite :

max@laptop % nghttpd -v --address=::1 --push=/test.html=/main.css 8080 key.pem certificate.pem 2>&1 | grep RST
^C

Avec Chromium (tout test confondu : css, images, gif) ; j’ai fait plusieurs Ctrl-F5 et F5 à la suite :

max@laptop % nghttpd -v --address=::1 --push=/test.html=/main.css 8080 key.pem certificate.pem 2>&1 | grep RST
[id=2] [  4.166] recv RST_STREAM frame <length=4, flags=0x00, stream_id=6>
[id=2] [  5.162] recv RST_STREAM frame <length=4, flags=0x00, stream_id=10>
[id=2] [  6.147] recv RST_STREAM frame <length=4, flags=0x00, stream_id=12>
[id=2] [  6.839] recv RST_STREAM frame <length=4, flags=0x00, stream_id=14>
[id=2] [  7.653] recv RST_STREAM frame <length=4, flags=0x00, stream_id=16>
^C

Vu les résultats je mettrais un bémol sur les FAIL annoncés par le site canipush.com.

Pour les utilisateurs de chrom(e|ium) je conseille l’utilisation de chrome://net-internals avec lequel on peut analyser tous les échanges d’une session h2.

partage du cache

Lorsque l’asset est déjà en cache, il serait plus intéressant de bloquer l’envoie du push en amont plutôt que d’envoyé un reset. Pour cela il faut que le serveur ai connaissance de l’état du cache du navigateur.

Aujourd’hui (18/12/2016) il n’existe pas de solution permettant au navigateur d’indiquer l’état de son cache au serveur. Un internet draft a été proposé par l’auteur de H2O pour résoudre ce problème. Il est déjà implémenté dans une branche dédié du projet nghttp2 (cf issue 587)

Il permettrait au navigateur d’envoyer un digest de son cache (pour le domain en question) au serveur, juste après avoir envoyé sa première requête. Ce digest serait un set codé via l’algorithme Golomb-Rice.

Cette technique permettrait d’envoyer une liste d’environ 1000 urls en un seul paquet TCP, sachant que la probabilité d’avoir un faux positif coté serveur (push d’un élément déjà dans le cache) serait de 1256 (slides 27).

Ce principe d’envoi de digest peut déjà être utilisé via l’utilisation d’un script cache-digest.js envoyé au navigateur. Le digest est alors envoyé via un hearder HTTP « cache-digest » plutôt qu’une frame HTTP dédiée. Ce header est envoyé à chaque requête HTTP et est associé à celle-ci ; l’utilisation d’une frame HTTP dédiée permet d’associé le digest à la session HTTP entière. De plus le digest n’a pas besoins d’être envoyé à chaque requête. Côté serveur H2O et Apache (via le module mod_h2) sont compatible.

H2O supporte aussi une méthode de tracking du cache client via l’utilisation de cookies. Elle a l’avantage de ne pas nécessiter d’implémentation spécifique ou le lancement d’un script côté client. Elle manque cependant de précision.

performances

Même en résolvant les problèmes mentionné plus haut, la fonctionnalité « server-push » n’accélère pas forcément le chargement d’une page web.

« reprio » est une fonctionnalité de H2O permettant de détecter les clients ne faisant pas de priorisation (Chrome à l’époque) et de reprioriser les éléments côté serveur.

On peut voir que « reprio » améliore l’expérience utilisateur en rapprochant le début de la phase de load. Cela est dû au fait que le HTML et les CSS passe en priorité devant les images.

Avec « reprio:off » l’activation du push permet de rapprocher le début de la phase de load d’environ un RTT.

Le début de la phase de load ne change pas vraiment entre « reprio:on, push:off » et « reprio:on, push:on ». Personnellement, je ne me l’explique pas… L’auteur indique que le gain d’un RTT est trop faible pour être perceptible mais cela entre en contradiction avec mon précédent paragraphe.

En revanche, on observe un éloignement du début de la phase fist-pain lorsque push est activé. Cela est dû au fait que le HTML entre en compétition avec les CSS et JS pour la bande passante. Le HTML arrive donc plus tard au navigateur.

L’utilisateur observe donc une page blanche pendant moins longtemps ; ce qui peut être un plus. À contrario, c’est comme si le navigateur réagissait moins vite, les conséquence d’un clique sur un lien sont visible moins rapidement.

Voici deux graphiques tiré de la conférence The promise of Push de Colin Bendell. Chacun représente le CDF par rapport au temps de chargement d’un site web avec :

image : CDF/page load ; cosmetics site image : CDF/page load ; insurance site

Voici les recommandation de développeurs de chromium concernant le push :

compression

image : compression ratio par rapport à la taille du fichier

Les petits fichiers souffre d’un mauvais taux de compression, même avec les derniers algorithmes de compression (ici brotli).

Deux solutions sont actuellement à l’étude :

De plus, les performances des navigateurs web diminue avec le nombre de fichiers à télécharger (la version mobile de Chrome est même bloqué à 500 fichiers CSS).

En h2, comme en h1, il est recommandé de concaténer les petits (≲ 32KiB) fichiers (image sprite, css, js) pour améliorer leurs taux de compression et les performances des navigateurs.

Remerciement

Je tiens à remercier Kazuho Oku (BeNa Co., Ltd.) pour m’avoir autorisé à utiliser des illustrations et graphiques tirés :

Ainsi que Colin Bendell (Akamai) pour m’avoir autorisé à utiliser des illustrations et graphiques tirés des slides de sa conférence The promise of Push.