A place to cache linked articles (think custom and personal wayback machine)
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

index.md 38KB

title: Optimisez Nginx pour de meilleurs performances url: https://buzut.net/optimiser-nginx/ hash_url: b33059055c archive_date: 2024-03-13 og_image: https://d33wubrfki0l68.cloudfront.net/f87f6e0bd31d5f46d08cde68fbb8ee51bb3a9e91/969de/img/logo.png description: Nginx est réputé pour sa rapidité, mais quand votre serveur fait face à un trafic énorme, il est possible d’en tirer encore plus. Voyons comment ! favicon: https://buzut.net/img/favicon.png language: fr_FR

Nginx de part sa robustesse, sa structure minimaliste et son fonctionnement événementiel asynchrone est un serveur web plébiscité pour ses performances. Mais ce n’est pas parce qu’on a une Porsche qu’il ne faut pas tenter de la rendre encore plus puissante !

Pour des sites à fort trafic, un serveur bien optimisé signifie deux choses : des requêtes servies plus vite et un besoin en machines (scaling horizontal) inférieur. Alors pourquoi s’en priver ?

Au programme

  1. Les workers
  2. Les buffers
  3. Les timeouts
  4. La compression
  5. Le cache statique
  6. L’open file cache
  7. Le logging
  8. La spéciale TLS
  9. Optimisations TCP
  10. La thread pool

Les workers

La première chose à explorer est le fichier de configuration général de Nginx : /etc/nginx/nginx.conf. Nous allons ici définir le nombre de workers ainsi que le nombre de connexions pour chacun d’entre eux.

Concentrons nous d’abord sur worker_processes. Cette directive spécifie le nombre total de workers à créer au démarrage de Nginx. La valeur optimale est d’en avoir un par cpu core. Si vous avez un VPS – lesquels ont souvent un vCPU avec un seul vCore – il arrive souvent que la valeur par défaut soit supérieure au nombre total de cores. Ce n’est pas très grave, néanmoins, les processus supplémentaires vont un peu se tourner les pouces… pas bien utile. Pour définir cette valeur, il nous suffit donc de déterminer le nombre total de cores :

# grep -c ^processor /proc/cpuinfo
12

Nous avons notre réponse, ici : 12.

Passons maintenant à la directive worker_connections. Elle spécifie combien de connections simultanées chaque worker est en mesure d’établir. Étant donné qu’une connexion nécessite un file descriptor au minimum, une bonne base est d’établir ce nombre en fonction des limites de notre système. Dans le cas de Nginx en reverse proxy, il faut un file descriptor pour la connexion client et un autre vers le serveur proxifié, soit deux par connexion.

# ulimit -n
1024

Par défaut, la limite du nombre de fichiers ouverts est souvent de 1024. Selon la configuration du système, cette limite peut être très largement augmentée. Voyez mon article sur ulimit afin de modifier ces configurations.

Vous lirez peut-être dans certains articles qu’il faut définir la directive worker_rlimit_nofile. Cette dernière a justement pour but de configurer le ulimit. Il faut impérativement que le serveur démarre en root avant de passer à un utilisateur non privilégié, sans quoi il ne pourra augmenter cette limite. Par ailleurs, s’il y a d’autres logiciels qui utilisent le même utilisateur – nginx sous Debian – il est bon de leur en laisser quelques uns. Vous devrez dans ce cas avoir une valeur pour worker_connections légèrement inférieur à worker_rlimit_nofile.

Pour ma part, sauf cas particuliers, je trouve plus fiable de paramétrer le ulimit en utilisant la configuration du système et de se contenter d’attribuer la même valeur au ulimit et à worker_connections (si Nginx est seul à tourner avec ce user).

En faisant worker_processes x worker_connections, on peut déjà se faire une idée du nombre de connexions simultanées que Nginx va pouvoir encaisser. Bien entendu, d’autres paramètres entrent en ligne de compte.

Par ailleurs, il ne s’agit pas non plus de s’obstiner à atteindre le plus grand nombre possible de connexions. Mieux vaut en effet servir 1000 connexions simultanées en 1s et passer aux 1000 suivantes, que d’en servir 2000 en 4s.

En dernier lieu, on permet aux workers d’accepter plusieurs nouvelles connexions de manière simultané en activant multi_accept. Cela peut être d’une grande utilité lors de pics de trafic.

multi_accept on;

Les buffers

Les buffers permettent à Nginx de travailler en RAM plutôt que sur le disque. Je ne vais pas vous faire un dessin, vous savez bien que les accès disque sont infiniment plus lents que le travail en RAM. On va donc configurer les buffers pour que notre serveur puisse travailler en mémoire autant que faire se peut.

Il y a quatre types de buffers :

client_body_buffer_size
Le buffer qui récupère les données clients (typiquement les données POST).
client_header_buffer_size
Celui-ci s'occupe également des données du client, mais concerne l'en-tête. Généralement, 1k suffit ici amplement (la valeur par défaut). Qui plus est, dans le cas où cette limite est dépassée, alors c'est la directive large_client_header_buffers qui s'applique.
client_max_body_size
La taille maximum des requêtes envoyées par le client. Si vous autorisez des uploads de fichiers, il s'agit d'y penser ici. Si cette limite est dépassée, Nginx retourne une erreur 413.
large_client_header_buffers
Taille et nombre maximum que peuvent atteindre les buffers pour les en-têtes. Au delà, une erreur est retournée.

Les réglages des buffers sont plus délicats et subjectifs que ceux des workers. Cela dépend énormément de votre application. client_body_buffer_size définit la quantité maximale des données GET ou POST qui seront stockées en RAM. La question à se poser est donc la suivante : quelle proportion de vos requêtes sont supérieur à une taille donnée ? Ces requêtes nécessitent-elles d’être traitée très rapidement ? Par exemple, pour l’upload de gros fichiers, il est tout à fait tolérable d’écrire les fichiers sur le disque. En effet, le temps supplémentaire liée à l’écriture sur le disque est dérisoire par rapport au temps d’upload.

En revanche, il faut bien avoir à l’esprit que plus cette limite est élevée, plus votre serveur prête le flanc aux attaques DoS, puisqu’il allouera potentiellement plus de mémoire aux requêtes et arrivera donc potentiellement plus rapidement à saturation en RAM.

La logique prévalente pour les buffers est souvent d’essayer et d’aviser ! Dans une application permettant l’upload de fichiers, je laisserais client_body_buffer_size à sa valeur par défaut de 16k (sur processeurs 64 bits), idem pour client_header_buffer_size qui est de 1k.

client_max_body_size aura ici une valeur élevée puisque je veux autoriser l’upload de fichiers (disons 20m, là encore tout dépend du type de fichiers !). Enfin, je serais plus conservateur que les défauts (4 8k) pour large_client_header_buffers, et mettrais 2 3k car mon applications possède des headers relativement modestes. En résumé :

client_body_buffer_size 16K;
client_header_buffer_size 1k;
client_max_body_size 20m;
large_client_header_buffers 2 3k;

J’insiste de nouveau sur ce point, tout dépend de votre application. Si votre applicatif soumet de gros articles en POST, il faudra peut-être revoir à la hausse les 16k de client_body_buffer_size. Il n’y a ici pas de règle générale.

Proxy buffers

Il existe des buffers spécifiquement dédiés aux proxies. Dans le cas où les buffers sont désactivés, Nginx commence l’envoie des données au client aussitôt qu’il les reçoit du backend serveur. Si le client est rapide, tout est pour le mieux. Cependant, si le client est moins véloce, ce fonctionnement oblige à conserver une connexion ouverte entre Nginx – le serveur de proxy – et le backend serveur ; ce qui peut s’avérer dommageable.

L’activation des buffers permet donc à Nginx de d’abord récupérer l’ensemble des données de la requêtes depuis le backend serveur, de libérer ce dernier, puis de servir les données au client.

proxy_buffering
Contrôle l'activation du buffer pour le proxy (activé par défaut).
proxy_buffer_size
Définie la taille du buffer pour les en-têtes de la réponse. 8k par défaut sur systèmes 64 bits. On peut ici laisser la valeur par défaut car les en-têtes dépassent rarement 8k.
proxy_buffers
Détermine la taille et le nombre des buffers pour le corps de la réponse. Une fois n'est pas coutume, la valeur dépendra grandement de votre application. La valeur par défaut (toujours pour les systèmes 64 bits) est de 8 buffers de 8k. Il s'agit de paramètres s'appliquant par requête. Ainsi, le réglage par défaut permettra de stocker dans les buffers des réponses jusqu'à 64kb. À vous de voir si votre application retourne des résultats plus importants (sachant qu'ensuite les fichiers sont écrits sur le disque).

proxy_buffering on;
proxy_buffer_size 1k;
proxy_buffers 12 4k;

Les timeouts

Question existentielle s’il en est, les timeouts peuvent avoir un impact conséquent à la fois sur la vitesse ressentie par les utilisateurs, mais aussi sur la charge du serveur. Les timeouts se divisent également en plusieurs directives :

client_body_timeout
Ce timeout s'applique au body. Il définit le temps maximum entre deux opérations d'écriture (pas le temps total de transfert donc). Admettons que je veuille transférer de gros fichiers (plusieurs centaines de MB), je pourrais fixer ce timeout à 30s (défaut 60s). Si le client n'envoie aucune donnée dans ce laps de temps, le serveur émet une erreur 408.
client_header_timeout
Même logique que la directive précédente mais le timeout s'applique bien ici à la totalité de la transaction. Néanmoins, les en-têtes étant beaucoup plus légères, je me contenterai pour ma part d'établir le timeout à 10s (défaut 60s) pour les headers.
keepalive_timeout
Cette directive permet à la fois de spécifier le keepalive timeout mais également le header Keep-Alive: timeout=durée. Avec des serveurs tels qu'Apache où le serveur conserve un thread par connexion ouverte, de telles connexions impliquent une consommation de mémoire. Cependant, avec des serveurs événementiels comme Nginx, ce coût est relativement faible et il n'est donc pas très impactant en consommation ressources. Une fois n'est pas coutume, la valeur idéale dépendra de la typologie de votre application. L'effet du keepalive se fait surtout ressentir lorsque de nombreux éléments sont à télécharger puisqu'on gagne nbr_ressources x temps_connexion_tcp. C'est évidemment moins flagrant pour une requête unique. Si votre application nécessite de nombreuses requêtes à intervalles régulières (polling ajax par exemple), il pourra être judicieux de conserver un keepalive long. Au contraire, dans le cas d'un site plus statique, comme un blog, si aucune autre connexion n'est envisagée après le chargement de la page et de ses ressources, on pourra avoir un keepalive assez court. L'intérêt du keepalive est d'autant plus grand si vous êtes en https. En effet, à chaque ouverture de connexion, il vous faudra renégocier un Three-way handshake TLS, ce qui prend du temps et demande de la puissance au serveur. Le keepalive est à 75s par défaut, il n'y a vraiment pas de règles en la matière. On peut estimer que 30s est une valeur standard, mais on pourra même l'abaisser à 10s dans notre exemple de blog. HTML5 boilerplate le définit même à 300 dans sa configuration Nginx ! Comme il n'est pas très risqué de laisser la valeur élevée, on peut donc aisément le mettre à plusieurs minutes et ne l'abaisser qu'en cas de problème de performances face à un fort trafic.
send_timeout
C'est en quelque sorte le pendant inverse des body et header timeouts. Ici, le s'agit de définir le temps après lequel le serveur coupe la connexion si le client ne reçoit plus la réponse. Par défaut, 60s.
keepalive_requests
Cette directive détermine le nombre de requêtes au bout duquel le connexion sera fermée. La valeur par défaut est à 100, ce qui est assez confortable et il n'est souvent pas nécessaire de modifier cette valeur. Néanmoins, si votre application nécessite le chargement de très nombreuses ressources (> 100), il peut être intéressant d'augmenter cette valeur pour qu'elle soit légèrement supérieur au nombre de ressources à charger.

Comme expliqué, étant donné le faible impact de performance sur Nginx, je préfère être généreux au départ et abaisser les timeouts au besoin :

client_body_timeout 30;
client_header_timeout 10;
keepalive_timeout 30;
send_timeout 60;
keepalive_requests 100;

La compression

Il ne fait aucun doute que la compression permet d’accélérer les transferts sur le réseau. Il est aussi un fait que compresser les ressources demande un peu de puissance processeur. Comme il existe plusieurs niveau de compression, il s’agit de trouver le juste milieu.

gzip on;
gzip_comp_level 5;
gzip_min_length 1000;
gzip_proxied any;
gzip_types
        application/atom+xml
        application/javascript
        application/json
        application/ld+json
        application/manifest+json
        application/rss+xml
        application/vnd.geo+json
        application/vnd.ms-fontobject
        application/x-font-ttf
        application/x-web-app-manifest+json
        application/xhtml+xml
        application/xml
        font/opentype
        image/bmp
        image/svg+xml
        image/x-icon
        text/cache-manifest
        text/css
        text/plain
        text/vcard
        text/vnd.rim.location.xloc
        text/vtt
        text/x-component
        text/x-cross-domain-policy;

Voici en général mes réglages. Le niveau de compression 5 est le juste milieu selon moi – on pourra toujours le baisser si on a trop de charge CPU. On ne compresse pas les fichiers de taille inférieure à 1ko (on n’y gagnera pas grand chose), on compresse sans distinction les éléments qui proviennent d’un proxy et on active la compression pour les fichiers textes, le js, les css, le json, le xml et d’autres un peu moins courants. Vous ne trouverez ici pas les images jpeg et png car ce sont déjà des fichiers compressés.

Sachez également que la directive gzip_buffers peut s’avérer intéressante. Elle définie le nombre et la taille des buffers alloué à la compression. Par défaut, sur les architectures 64 bits, jusqu’à 16 buffers de 8k sont autorisés.

Tout va ici dépendre de la taille de vos fichiers. Il s’agit, comme expliqué dans ce post d’arbitrer entre le nombre de buffers (gérer de nombreux buffers consomme un peu de CPU) ou attribuer plus d’espace aux buffers (utilise plus d’espace mémoire). Si vous n’avez pas assez d’espace dans les buffers pour contenir l’ensemble de la réponse, Nginx attendra qu’une partie de la réponse soit envoyée au client et utilisera ensuite l’espace libéré.

Pré-compression

Enfin, sachez aussi qu’il est possible, avec le module gzip_static, de pré-compresser des fichiers pour que Nginx puisse les servir sans avoir à les compresser à la volée.

Vous en avez peut-être entendu parler, Brotli est un algorithme de compression plus récent que Gzip qui offre de bien meilleurs taux de compression. Le module Brotli développé par Google n’est pas nativement intégré à Nginx et nécessite de le recompiler.

Nous ne pourrons donc pas compresser directement en Brotli. Cependant, nous pouvons profiter de la pré-compression en Brotli lors du processus de build et servir ces fichiers via quelques directives. C’est la stratégie que j’adopte avec Apache. On va donc créer un fichier de config pour Nginx /etc/nginx/conf.d/static_brotli.conf.

set $extension "";

# si le navigateur accepte brotli
if ($http_accept_encoding ~ br) {
    set $extension .br;
}

# et que le fichier .br existe sur le disque
if (-f $request_filename$extension) {
    rewrite (.*) $1$extension break;
}

# on désactive gzip et en envoie en .br
location ~ /*.html.br {
    gzip off;
    types {}
    default_type text/html;
    add_header Content-Encoding br;
    add_header Vary "Accept-Encoding";
}

location ~ /*.css.br$ {
    gzip off;
    types {}
    default_type text/css;
    add_header Content-Encoding br;
    add_header Vary "Accept-Encoding";
}

location ~ /*.js.br$ {
    gzip off;
    types {}
    default_type application/javascript;
    add_header Content-Encoding br;
    add_header Vary "Accept-Encoding";
}

location ~ /*.svg.br$ {
    gzip off;
    types {}
    default_type image/svg+xml;
    add_header Content-Encoding br;
    add_header Vary "Accept-Encoding";
}

Il ne vous reste plus qu’à l’inclure depuis n’importe quelle directive server ou location. Par exemple, depuis un fichier de configuration d’un site :

server {
    listen [::]:443;
    listen 443;

    server_name super-site.com;
    root /var/www;

    charset utf-8;

    location / {
        try_files $uri $uri.html $uri/ =404;
    }

    # tous vos fichiers seront servis en brotli pour les navigateurs compatibles
    include conf.d/static_brotli.conf;

    # brotli ne fonctionne qu'en TLS
    include conf.d/ssl.conf;
    ssl_certificate /etc/letsencrypt/live/super-site.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/super-site.com/privkey.pem;
    ssl_trusted_certificate /etc/letsencrypt/live/super-site.com/chain.pem;
}

Le cache statique

Grand classique du web, permettre aux navigateurs et proxies intermédiaires de placer certaines ressources en cache, c’est éviter au serveur d’avoir à les renvoyer plus tard. Selon la fréquence à laquelle changent vos fichiers, on peut attribuer une période de validité plus ou moins longue au cache. Disons deux mois pour l’exemple :

location ~* .(jpg|jpeg|png|gif|ico|css|js)$ {
  expires 60d;
}

Nous cachons donc pour deux mois tous les fichiers d’extensions suivantes : jpg, jpeg, png, gif, ico, css, js.

L’open file cache

Ce type de cache permet de conserver les métadonnées en mémoire, et donc de limiter l’I/O. Voici un exemple de configuration :

open_file_cache max=2000 inactive=5m;
open_file_cache_valid 2m;
open_file_cache_min_uses 2;
open_file_cache_errors on;

Cette configuration indique au serveur de mettre en cache 2000 open file descriptors et de les fermer si aucune requête les concernant n’est demandé au bout de 5 minutes. La validité des informations en cache est revérifiée après 2 minutes et un fichier doit être requêté un minimum de deux fois afin d’être valide pour le cache. Enfin, les fichiers d’erreurs sont ici également valables pour le cache.

Le logging

Demander à NGINX d’écrire des logs, c’est encore lui demander une chose supplémentaire. Évidemment, le plus performant, c’est encore de ne pas logger ! On parle bien de l’access_log, pas de l’error_log qu’il n’est pas question de supprimer. D’autant plus que, théoriquement, il ne devrait pas y avoir trop d’erreurs donc la charge est faible concernant ce dernier.

Pour l’access_log, si vous ne l’exploitez pas, le mieux est encore de le désactiver complètement avec access_log off;. Cette directive prend sa place dans http, server ou location. Vous pouvez donc définir un comportement global directement dans le nginx.conf et/ou affiner seulement pour certains domaines ou répertoires.

Si vous ne souhaitez pas le désactiver totalement, d’autres options s’offrent à nous. Déjà, autant utiliser un buffer afin de réduire les I/O disque.

access_log /var/log/nginx/access.log combined buffer=10m flush=5m;

Dans l’exemple précédent, vous écrivez les logs sur le disque soit lorsque le buffer de 10MB est plein, soit toutes les 10mn (première condition remplie). Cela est bien entendu à moduler selon votre trafic. Pour un blog avec quelques centaines de visites pas jour, les 10MB de logs ne seront jamais atteints en 5 minutes. En revanche, pour un site à très fort trafic, à vous de voir s’il n’est pas judicieux d’augmenter la taille du buffer. Gardez cependant à l’esprit qu’en cas de crash, tout ce qui n’est pas écrit sur le disque est perdu.

Pour logger mais logger moins, il est également possible de logger de manière conditionnelle. On peut par exemple n’être intéressé que par les erreurs 4xx ou vouloir écrémer le gros des 2xx et 3xx qui indiquent juste que tout fonctionne bien.

# on ne log pas les 2xx & 3xx
map $status $loggable {
    ~^[23]  0;
    default 1;
}

access_log /var/log/nginx/access.log combined buffer=10m flush=5m if=$loggable;

Petite précision au passage, la directive map prend sa place dans un bloc http. Si vous la placez dans un fichier de configuration vhost dans server ou location, vous aurez une belle erreur. On place dans notre directive map au niveau http et la directive access_log dans location si elle ne doit pas s’appliquer de manière globale.

Par ailleurs, il arrive que l’on veuille définir une règle en fonction de plusieurs variables. Prenons un exemple concret. Nous avons une api à fort trafic et nous ne voulons donc pas tout logger par défaut. Nous nous contentons de logguer les erreurs avec la règle vue ci-dessus.

Néanmoins, certaines url sont particulièrement sensibles. Par exemple, nous avons un endpoint qui permet de créer un compte utilisateur. Nous ne voulons pas laisser un bot créer des comptes à la volée. Nous allons donc logger cette url en particulier et demander à fail2ban de bloquer les ips qui y accèdent de manière récurrente.

# on ne log pas les 2xx & 3xx
map $status $loggable_status {
    ~^[23]  0;
    default 1;
}

# ici si l'url contient signup, on logge
map $request $loggable {
    ~signup 1;
    default $loggable_status;
}

access_log /var/log/nginx/access.log combined buffer=10m flush=5m if=$loggable;

La spéciale TLS

Bien que Nginx nomme toutes les variables SSL, vous savez très bien que le protocole est définitivement à bannir au profit de TLS. Petite parenthèse, si la sécurité vous intéresse, sachez qu’au delà du protocole, c’est aussi la suite de chiffrement qui assure la sécurité. Vous pouvez tester la sécurité de votre site avec ce scanner SSL/TLS et générer vos configurations serveurs avec le générateur de Mozilla.

Bref, revenons-en à nos moutons. Nous l’avons dit, le three-way handshake prend du temps et est couteux en ressources. Puisque le client et le serveur ce sont déjà entendus, il est possible de dire à Nginx de mettre en cache cette session. Ainsi, lorsqu’il voudra de nouveau établir une connexion, le client n’aura plus qu’à se rappeler aux bons souvenirs de Nginx. Le three-way handshake ne compte plus que deux allez-retours entre client et serveur et Nginx s’épargne quelques calculs cryptographiques. Bien que datant de 2011, ce post vous éclairera sur la théorie derrière cette histoire de cache.

Trois paramètres sont à considérer :

ssl_session_cache
Il permet de définir si on active le cache on non, de spécifier le type de cache (builtin ou shared) ainsi que la taille de ce dernier. Il est à none par défaut. La doc Nginx établit que 1MB permet de stocker 4000 sessions. À vous de juger combien de à sessions vous estimez devoir faire face et la durée de rétention que vous leur accordez. N'ayez crainte, au pire des cas Nginx invalidera les sessions prématurément, il n'y aura pas d'effusion de sang.
ssl_session_timeout
Précise la durée au bout de laquelle la session est invalidée.
ssl_buffer_size
Permet de déterminer pour l'envoie des données. Gros débat sur la question de la taille de ce dernier, une fois de plus, tout dépend du type de contenus que vous servez.

Et bien entendu quelques valeurs d’exemples :

ssl_session_cache shared:SSL:10m;
ssl_session_timeout 24h;
ssl_buffer_size 1400;

En plus des caches, il y a une dernière optimisation qu’il est possible de réaliser, elle a pour doux nom l’OSCP stapling. Lorsqu’un serveur fourni un certificat, le client en vérifie la validité en interrogeant l’autorité émettrice du certificat. Évidemment, cela demande une requête supplémentaire au client. On peut éviter cela en demandant au serveur de joindre directement l’autorité émettrice et d’ainsi “agraffer” (d’où le stampling) la réponse signée et horodatée de l’autorité afin de prouver la validité du certificat.

ssl_stapling on;
ssl_stapling_verify on;
resolver 8.8.8.8 8.8.4.4 216.146.35.35 216.146.36.36 valid=60s;
resolver_timeout 2s;

Il est possible de préciser un resolver, en cas d’absence de la directive resolver, le serveur DNS par défaut sera interrogé. C’est d’ailleurs souvent avantageux car dans le cas d’un serveur, il se trouve en général dans le même datacenter.

Optimisations TCP

On arrive là dans le domaine de l’optimisation de pointe ! Il est possible de spécifier à Nginx la manière dont quelques options de TCP doivent être gérées. De manière synthétique, ces réglages vont nous permettre de :

  • sendfile : d’optimiser la copie des données depuis le file descriptors vers le buffer du kernel (ok, c’est compliqué !),
  • tcp_nopush : n’envoyer les données que lorsque le paquet IP est rempli (quantité de données égale au MSS),
  • tcp_nodelay : diminuer la latence de 200ms avant l’envoie des données sur le réseau (on attend ici pas que le paquet soit rempli. Utilisés conjointenenement avec tcp_nopush, NGINX n’active l’option tcp_nodelay que lorsqu’on atteint la fin des données à envoyer et qu’il n’est pas possible de remplir un paquet).

Pour comprendre tout cela dans les détails, vous pouvez vous référer à ce très bon article qui plonge littéralement dans les spécificité du noyau.

sendfile on;
tcp_nopush on;
tcp_nodelay on;

Dans le cas de très nombreuses connexions entre le proxy et le serveur backend, on peut faire face à un problème. Le TCP Maximum Segment Lifetime définit la durée de vie maximale d’une connexion TCP et impacte directement le TIME_WAIT – temps à attendre avant de fermer une connexion TCP, égale à 2*MSL.

Là où le bas peut blesser, c’est qu’un grand nombre de connexions en TIME_WAIT peut empêcher d’en ouvrir de nouvelles si on arrive à épuisement du nombre de connexions ouvertes entre deux ip – défini par le couple port/ip, soit le nombre de ports définis dans /proc/sys/net/ipv4/ip_local_port_range, en général ± 30 000. Dans cette configuration, on ne pourra effectuer plus de 30000/(2 x MSL) requêtes par secondes, soit 30000/(2 x 30), soit 500 req/sec.

Plusieurs solutions sont possibles :

  • activer keepalive pour maintenir les connexions ouvertes,
  • utiliser net.ipv4.tcp_tw_reuse afin de recycler plus rapidement les connexions en TIME_WAIT,
  • diminuer le tcp_fin_timeout.

Le plus efficace et le moins risqué selon moi est d’utiliser keepalive. Cependant, tous les serveurs ne le prennent pas en charge (Haproxy par exemple). Il vous reste donc au choix de diminuer le tcp_fin_timeout ou d’utiliser net.ipv4.tcp_tw_reuse (ou les deux). Je vous invite à lire cet article de qualité, lequel traite du problème de connexions en TIME_WAIT et des remèdes potentiels (dont tcp_fin_timeout).

Vous pourrez modifier ces paramètres respectivement dans /proc/sys/net/ipv4/tcp_fin_timeout et /proc/sys/net/ipv4/tcp_tw_reuse.

En dernier lieu, nous allons nous pencher sur quelques optimisations qui s’effectuent au niveau du block server, elles se placeront donc en général dans un VHOST.

Nous l’avons déjà vu plus haut, une connexion TCP s’effectue en trois temps, cela porte le doux nom de Three-way handshake. En gros, cela demande trois échanges de trames TCP/IP entre le client et le serveur. Le premier envoie un paquet SYN (demande de connexion [synchronize]), le second répond SYN/ACK (en gros : 5/5, Roger! [synchronize acknowledge]) et enfin, le client confirme que tout est ok avec un ACK.

À partir de là, la connexion est ouverte, et théoriquement, un socket est créé et le processus en écoute sur ce socket est réveillé : attribution de ressources Nginx dans le cas présent. Pour autant, tant qu’aucune vraie requête n’est effectuée, Nginx n’a rien à faire. L’option TCP_DEFER_ACCEPT du kernel permet donc de ne réveiller le processus que lors de l’envoie effectif de données. Cette option se traduit dans nginx par deferred.

Dans un autre registre, ce n’est pas tout à fait lié à TCP, mais j’en parle ici quand même, activer le HTTP/2 peut avoir un impact significatif sur les performances. Pour plus de détails sur le protocole, je vous laisse jeter un œil sur l’article Wikipedia.

Les deux optimisations dont nous venons de parler se définissent dans le bloc server et au niveau de la directive listen.

server {
    # on active deferred
    # pour ipv6 sur le port 80 (http)
    listen [::]:80 default_server deferred;

    # pour ipv4 sur le port 80 (http)
    listen 80 default_server deferred;

    # pour ipv6 sur le port 443 (https)
    listen [::]:443 ssl http2 deferred;

    # pour ipv4 sur le port 443 (https)
    listen 443 ssl http2 deferred;
    return 444;
}

Quelques précisions supplémentaires sur ce morceau de config. Vous noterez que l’on active HTTP/2 seulement pour le TLS. Il est possible de l’activer en HTTP non sécurisé, mais les navigateurs ne le supportent pas.

Vous remarquez aussi peut-être le default_server. Cela indique à Nginx d’utiliser ce VHOST si l’en-tête Host n’est pas passée avec le requête. Dans le cas d’un accès direct via l’ip par exemple. Et en dernier lieu, return 444 signifie que dans ces cas là, la connexion sera réinitialisée puisque si plusieurs sites sont hébergés sur cette même ip:port, en l’absence de Host, il n’est pas possible de savoir lequel on doit servir.

Il y a de nombreuses optimisations potentielles au niveau de la pile TCP/IP, non spécifiques à Nginx, nombre d’entre elles sont détaillées dans cette très bonne ressource dont j’encourage la lecture.

La thread pool

Il arrive que certaines opérations bloquantes soient lentes (comme la lecture de fichiers sur le disque). Les requêtes de fichiers de taille importante qui ne tiennent pas en mémoire par exemple, bloqueront un thread de Nginx jusqu’à ce que le disque retourne le fichier en question. Admettons-le, c’est assez dommage. Heureusement, Nginx a une solution pour ça !

Il s’agit de placer cette requête dans une “file d’attente” et de traiter d’autres requêtes en attendant que le disque ait fini son opération de lecture. Ainsi, on ne bloque pas plusieurs requêtes en attendant une I/O pour l’une d’entre elle, on place cette dernière en attente et on utilise le thread ainsi libéré pour servir d’autres requêtes.

Un article sur le blog de Nginx détaille le fonctionnement de la thread pool et présente un benchmark où les performances en charge sont multipliées par 9 ! Il faut pour cela utiliser l’option aio :

location / {
  root /var/www;
  aio threads;
  directio 5m;
}

Ici, vous remarquez aussi l’option directio. Cette dernière s’utilise toujours conjointement à aio. Lorsque vous activez aussi l’option sendfile vue plus haut, aio est utilisé pour les fichiers de taille égale ou supérieure à directio, tout le reste utilise sendfile.

N’hésitez pas à me faire part de vos remarques et compléments d’infos en commentaires. Avez-vous été confronté à des problèmes ? Avez-vous pu augmenter les performances ? Faites-nous part de vos expériences !