Nginx chez EGPHP

La porte d'entrée, construite dans les règles.

Nginx décide de ce qui atteint votre site : quel serveur et quel pool PHP répondent, ce qui vient du cache et ce qui est refoulé dès la porte. Voici comment notre équipe le met en place, configuration à l'appui.

Fonctionne sur et construit avec
  • NGINX
  • PHP
  • Redis

Une requête, de la porte jusqu'à votre code

La plupart des requêtes n'ont jamais besoin de PHP. Chaque étape ci-dessous peut répondre seule, et seul ce qui reste passe à la suivante.

  1. La connexion

    Chiffrée en TLS 1.3, et une seule connexion transporte tous les fichiers de la page en HTTP/2 ou HTTP/3.

  2. Limites

    Trop de requêtes depuis une même adresse, trop de connexions ouvertes ou un corps de requête plus gros que ce que le site accepte : la réponse part d'ici.

    429 ou 413 : arrêt ici
  3. Chemins interdits

    Les fichiers cachés, les sauvegardes et tout script placé dans un dossier d'upload sont refusés avant que PHP n'en entende parler.

    403 ou 404 : arrêt ici
  4. Fichiers statiques

    Images, feuilles de style et scripts partent directement du disque, avec de longues durées de cache.

    Servi : arrêt ici
  5. Cache de pages

    Un visiteur non connecté reçoit une page terminée depuis la mémoire.

    Succès du cache : arrêt ici
  6. Le répartiteur

    Choisit le serveur et le pool PHP qui ont de la place pour la requête.

  7. Le pool propre au compte

    PHP tourne sous l'utilisateur du compte, dans ses propres dossiers, avec son propre php.ini.

Répartition de charge entre serveurs

Quand un serveur ne suffit plus, Nginx se place devant plusieurs et répartit le trafic entre eux. La méthode décide qui prend la requête suivante.

Un exemple : adresses et valeurs sont données à titre indicatif

Le choix par défaut. Chaque serveur prend la requête suivante à tour de rôle : adapté quand les serveurs se valent et que les requêtes coûtent à peu près autant.

La requête suivante va au serveur qui a le moins de connexions ouvertes : adapté quand certaines requêtes sont longues, comme les rapports, les exports et les uploads.

L'adresse du visiteur choisit le serveur, si bien que le même visiteur retombe toujours sur le même. Pour les applications qui gardent leurs sessions sur le disque local ; mieux encore, nous déplaçons les sessions vers Redis et le besoin disparaît.

conf.d/app.conf
upstream app_servers {
    # round robin: the default, no line needed    least_conn;    ip_hash;
    server 10.0.0.11:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.13:8080 max_fails=3 fail_timeout=30s;
    server 10.0.0.14:8080 backup;    # no backup server with ip_hash
    keepalive 32;
}

server {
    server_name example.com;
    location / {
        proxy_pass http://app_servers;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_set_header Host $host;
        proxy_next_upstream error timeout http_502 http_503;
    }
}
max_fails · fail_timeout
Contrôles de santé sur le trafic réel : après trois échecs en trente secondes, un serveur est écarté pendant trente secondes, puis remis à l'essai.
backup
Un serveur de secours qui ne reçoit du trafic que lorsque tous les autres serveurs sont indisponibles.
proxy_next_upstream
Une requête qui rencontre une erreur ou un délai dépassé est retentée sur le serveur suivant. Une requête qui modifie des données n'est pas envoyée deux fois : une commande n'est donc jamais passée en double.
keepalive
Les connexions vers les serveurs restent ouvertes entre les requêtes au lieu d'être rouvertes pour chacune.
weight
Un serveur plus puissant peut recevoir une plus grande part des requêtes.
down
Un serveur est retiré pour maintenance par un simple rechargement, pendant que les autres assurent le trafic.

Répartition de charge entre pools PHP

La même idée à l'intérieur d'un seul serveur. Un site peut avoir plusieurs pools PHP-FPM derrière lui : un pool peut redémarrer pendant que l'autre répond, et le travail lent ne prend jamais les workers dont la boutique a besoin.

Nginx ~ \.php$^~ /admin/
shop-a.sock Boutique, pool A Répond Répond et prend les requêtes de B
shop-b.sock Boutique, pool B Répond Redémarre avec un nouveau réglage : aucun visiteur ne l'attend
shop-admin.sock Administration et rapports Ses propres workers, à l'écart de la boutique
sites/example.com.confUn exemple
upstream php_shop {
    server unix:/run/php/shop-a.sock max_fails=2 fail_timeout=10s;
    server unix:/run/php/shop-b.sock max_fails=2 fail_timeout=10s;
    keepalive 8;
}

# server {}
location ~ \.php$ {
    try_files $uri =404;
    include fastcgi.conf;
    fastcgi_pass php_shop;
    fastcgi_keep_conn on;
    fastcgi_next_upstream error timeout;
}

location ^~ /admin/ {
    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi.conf;
        fastcgi_pass unix:/run/php/shop-admin.sock;
    }
}
  • Deux pools, un site

    Les deux pools exécutent le même code sous le même compte. Un nouveau réglage PHP atteint un pool, puis l'autre, et les visiteurs continuent d'être servis tout du long.

  • Basculement

    fastcgi_next_upstream renvoie une requête vers l'autre pool quand le socket du premier refuse la connexion ou dépasse son délai. Une requête qui modifie des données n'est pas envoyée deux fois.

  • Une voie pour le travail lent

    L'espace d'administration, les exports et les rapports ont leur propre pool : un rapport d'une heure ne peut pas accaparer les workers dont un client a besoin pour régler sa commande.

Les règles à la porte

La protection commence avant que PHP n'exécute une seule ligne. Ces règles entrent dans chaque bloc server que nous écrivons, ajustées au site.

sites/example.com.confUn exemple : les valeurs sont fixées site par site
# http {}
limit_req_zone  $binary_remote_addr zone=perip:10m rate=10r/s;
limit_req_zone  $binary_remote_addr zone=login:10m rate=5r/m;
limit_conn_zone $binary_remote_addr zone=conns:10m;server {
    listen 443 ssl;
    http2 on;
    server_name example.com;
    limit_req  zone=perip burst=40 nodelay;
    limit_conn conns 30;
    limit_req_status 429;
    location = /wp-login.php {
        limit_req zone=login burst=5 nodelay;
        include fastcgi.conf;
        fastcgi_pass php_shop;
    }    client_max_body_size 64m;
    client_header_timeout 15s;
    client_body_timeout 15s;    location ~ /\.(?!well-known/) { deny all; }
    location ~* \.(?:bak|old|orig|sql|swp)$ { deny all; }    location ~* ^/(?:wp-content/uploads|uploads|files)/.*\.php$ {
        deny all;
    }    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi.conf;
        fastcgi_pass php_shop;
    }    ssl_protocols TLSv1.2 TLSv1.3;
    server_tokens off;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;}
  1. Limites de débit et de connexions

    Chaque adresse a droit à un débit régulier, avec une marge pour une courte rafale ; au-delà, Nginx répond 429 et PHP n'est jamais réveillé. La page de connexion a sa propre limite, bien plus stricte, qui coupe l'herbe sous le pied à qui tente de deviner les mots de passe.

  2. Taille du corps et délais

    Un upload plus gros que ce que le site accepte est refusé d'emblée, et un client qui envoie sa requête très lentement est déconnecté au lieu de garder une connexion ouverte.

  3. Fichiers cachés et restes

    Tout ce dont le nom commence par un point, comme .git ou .env, ainsi que les copies de sauvegarde de fichiers, n'est jamais servi. La seule exception est le dossier qu'utilisent les vérifications de certificat.

  4. Les uploads n'exécutent jamais PHP

    Un dossier qui reçoit des fichiers de visiteurs les sert en tant que fichiers. Un script déposé là est refusé, quel que soit son nom.

  5. Seulement un fichier PHP qui existe

    try_files $uri =404 ne transmet une requête à PHP que si ce fichier précis se trouve sur le disque. Cela referme le piège classique illustré ci-dessous.

  6. En-têtes et TLS

    HSTS maintient les navigateurs en HTTPS, les autres en-têtes empêchent le navigateur de deviner le type d'une page et un autre site de l'afficher dans un cadre, et le serveur n'annonce pas sa version. TLS 1.2 et 1.3 uniquement.

    Comment nous sécurisons le serveur lui-même

Le piège qu'ouvre une seule ligne manquante

/uploads/avatar.jpgUn vrai fichier : une image avec du code PHP caché à l'intérieur /shell.phpN'existe pas
Sans la règle

Sur une configuration ancienne ou négligée, PHP remonte le chemin, trouve l'image et exécute le code qu'elle contient.

Avec la règle

Une 404, et rien ne s'exécute. cgi.fix_pathinfo=0 côté PHP et security.limit_extensions dans le pool sont les deuxième et troisième verrous de la même porte.

Chaque compte est une pièce entre quatre murs

Sur un serveur partagé par plusieurs clients, un site ne doit jamais pouvoir lire un autre site. Nous construisons ce mur par couches, pour qu'aucun réglage ne le porte à lui seul.

shopCompte
Utilisateur Linux
shop
Pool PHP
/run/php/shop.sock
Dossiers autorisés
/home/shop
Réglages
pool.d/shop.conf
clinicCompte
Utilisateur Linux
clinic
Pool PHP
/run/php/clinic.sock
Dossiers autorisés
/home/clinic
Réglages
pool.d/clinic.conf
blogCompte
Utilisateur Linux
blog
Pool PHP
/run/php/blog.sock
Dossiers autorisés
/home/blog
Réglages
pool.d/blog.conf
Le code de la boutique qui tente d'atteindre les fichiers de la clinique file_get_contents('/home/clinic/public_html/config.php') Permission deniedopen_basedir restriction in effect
  1. Son propre utilisateur Linux

    Chaque site tourne sous son propre utilisateur, et chaque dossier personnel est fermé aux autres comptes. C'est le mur qui tient même si l'on fait abstraction de PHP.

  2. Son propre pool PHP-FPM

    Un pool par compte, joignable uniquement par son propre socket. Un site surchargé ou en panne épuise ses propres workers, pas ceux de ses voisins.

  3. open_basedir et include_path

    PHP ne peut ouvrir des fichiers que dans les dossiers du compte, et n'y cherche que les fichiers inclus. Un script qui demande un chemin en dehors est refusé avant que le disque ne soit touché.

  4. Ses propres fichiers temporaires et sessions

    Les uploads en cours et les fichiers de session restent dans le compte, pas dans un /tmp partagé où n'importe quel site pourrait lire les sessions connectées d'un autre.

  5. Aucune fonction dont le site n'a pas besoin

    open_basedir n'est pas un bac à sable : il n'arrête pas un programme lancé depuis PHP. Les fonctions qui exécutent des commandes shell sont donc désactivées compte par compte, sauf si le site en a réellement besoin.

php/8.3/fpm/pool.d/shop.confUn exemple
[shop]
user  = shop
group = shop
listen = /run/php/shop.sock
listen.owner = www-data
listen.group = www-data
listen.mode  = 0660

pm = ondemand
pm.max_children = 12
pm.process_idle_timeout = 10s

php_admin_value[open_basedir] = /home/shop/public_html:/home/shop/lib:/home/shop/tmp
php_admin_value[include_path] = .:/home/shop/lib
php_admin_value[upload_tmp_dir] = /home/shop/tmp
php_admin_value[sys_temp_dir] = /home/shop/tmp
php_admin_value[session.save_path] = /home/shop/tmp/sessions
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
php_admin_flag[allow_url_include] = off

php.ini, par compte

Chaque compte a ses propres réglages, inscrits dans son propre pool. Certains sont fixés par nous, d'autres peuvent être modifiés par le site, et la différence tient en un mot.

php_admin_value : fixé

Défini pour le compte. Ni ini_set() ni un fichier .user.ini ne peuvent le modifier.

memory_limit = 256M
max_execution_time = 60
open_basedir = /home/shop/…
disable_functions = exec,…

php_value : une valeur de départ

Notre valeur par défaut pour le compte. Le site peut l'augmenter ou la réduire là où PHP le permet.

upload_max_filesize = 32M
post_max_size = 32M
max_input_vars = 3000

.user.ini : propre au site

Un fichier dans le dossier du site pour les réglages qu'il a le droit de modifier. Par défaut, PHP le relit toutes les cinq minutes.

upload_max_filesize = 64M
post_max_size = 64M
display_errors = Off

Un exemple : les valeurs sont fixées site par site

Des limites qui doivent concorder

Taille d'upload
upload_max_filesizePHP≤ post_max_sizePHP≤ client_max_body_sizeNginx

Si Nginx autorise moins, l'upload est refusé avec une 413 avant que PHP ne le voie ; si c'est PHP qui autorise moins, le fichier arrive et il est jeté.

Durée
max_execution_timePHP< request_terminate_timeoutPHP-FPM≤ fastcgi_read_timeoutNginx

PHP doit s'arrêter lui-même en premier, avec une erreur qu'il peut journaliser ; le pool arrête ce que PHP n'a pas pu arrêter ; et Nginx attend assez longtemps pour ne pas afficher une 504 pendant que PHP travaille encore.

Le cache, par couches

Chaque couche garde quelque chose de différent, et une requête ne descend qu'aussi loin que nécessaire. Passez d'un visiteur à l'autre pour voir où chacun s'arrête.

  1. NavigateurFeuilles de style, scripts et images avec une version dans leur nom, gardés un an.
    A déjà les fichiersA déjà les fichiers
  2. Cache de pages NginxDes pages terminées, gardées de quelques secondes à quelques minutes.
    Succès : la page est servie iciIgnoré : un cookie de connexion ou de panier rend la page personnelle
  3. OPcachePHP compilé une fois et gardé en mémoire.
    Pas atteintLe code s'exécute sans être relu ni recompilé
  4. RedisLes réponses répétées de la base de données, et les sessions.
    Pas atteintLa plupart des réponses viennent de la mémoire
  5. Base de donnéesLa source de vérité.
    Pas atteintInterrogée seulement pour ce que Redis n'avait pas
conf.d/cache.confUn exemple : les valeurs sont fixées site par site
# http {}
fastcgi_cache_path /var/cache/nginx/shop levels=1:2
                   keys_zone=shop:50m inactive=60m max_size=2g;

map $http_cookie $skip_cache {
    default                     0;
    ~wordpress_logged_in_       1;
    ~woocommerce_items_in_cart  1;
}

# location ~ \.php$ {}
fastcgi_cache shop;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_valid 200 301 10s;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache     $skip_cache;
fastcgi_cache_use_stale updating error timeout http_500 http_503;
fastcgi_cache_background_update on;
fastcgi_cache_lock on;
La clé
Schéma, méthode, hôte et adresse complète, pour que deux pages différentes ne partagent jamais une entrée.
Le contournement
Une session connectée ou un panier contourne le cache dans les deux sens : la page n'en est pas servie et n'y est pas stockée. Les formulaires envoyés en POST ne sont jamais mis en cache.
Servir l'ancienne copie pendant la mise à jour
Quand une entrée expire, une seule requête la rafraîchit en arrière-plan pendant que tous les autres reçoivent encore la copie précédente. Si PHP échoue, la dernière page valide est servie.
Une règle souvent oubliée
Un add_header dans un bloc location remplace tous les en-têtes définis plus haut : nous gardons donc les en-têtes de sécurité dans un seul fragment, inclus partout où des en-têtes sont ajoutés.

La vitesse, et le reste d'une bonne configuration

Les réglages qui rendent chaque site du serveur plus rapide, et les habitudes qui rendent un changement sûr.

Un changement appliqué sans rien interrompre
$ sudo nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ sudo systemctl reload nginx

La nouvelle configuration est d'abord testée ; si le test échoue, rien ne change. Au rechargement, de nouveaux workers démarrent avec elle pendant que les anciens terminent les requêtes en cours, et aucun visiteur n'est coupé.

log_format timed '$remote_addr "$request" $status $body_bytes_sent '
                 'rt=$request_time urt=$upstream_response_time '
                 'cache=$upstream_cache_status';

Chaque ligne de journal enregistre la durée totale, le temps pris par PHP et ce qu'a fait le cache : une page lente est ainsi imputée à Nginx, à PHP ou au réseau au lieu d'être devinée.

http2 on; listen 443 quic;
Une seule connexion transporte toute la page ; HTTP/3 s'y ajoute là où la compilation de Nginx et le réseau le permettent.
gzip · brotli
Le texte est compressé avant de partir, avec Brotli là où le module est compilé. Les images et la vidéo ne sont pas compressées deux fois.
sendfile · tcp_nopush
Le noyau envoie lui-même les fichiers du disque vers le réseau, en paquets pleins.
open_file_cache
Nginx retient quels fichiers existent et où ils se trouvent, au lieu d'interroger le disque à chaque requête.
worker_processes auto
Un worker par cœur de processeur, chacun gérant de nombreuses connexions dans une seule boucle d'événements.
Cache-Control: immutable
Les fichiers avec une version dans leur nom sont mis en cache pour un an et ne sont plus jamais revérifiés.

Ce qui se trouve derrière la porte d'entrée

Regroupé selon le rôle de chaque élément, le tout décidé dans la configuration Nginx.

Connexions

  • Connexions chiffréesTLS 1.3 chiffre la connexion et HTTP/2 transporte tous les fichiers d'une page sur une seule connexion.
  • Limites de débit et de tailleTrop de requêtes, trop de connexions ouvertes ou un corps trop volumineux sont traités dès la porte.

Livraison

  • Fichiers statiquesImages, styles et scripts partent directement du disque, avec de longues durées de cache.
  • Cache de pagesUn visiteur non connecté reçoit une page terminée depuis la mémoire.
  • Répartition de chargeLes requêtes vont au serveur et au pool PHP qui ont de la place pour elles.

Protection

  • Chemins interditsLes fichiers cachés, sauvegardes et scripts dans les dossiers d'envoi sont refusés avant que PHP n'en entende parler.
  • En-têtes de sécuritéGardés dans un seul extrait, inclus partout où des en-têtes sont ajoutés.
  • Comptes cloisonnésChaque compte exécute PHP sous son propre utilisateur, dans ses propres dossiers.

Changer la configuration sans lâcher un visiteur

Nginx applique une nouvelle configuration sur place. Voici l'ordre des événements tel que le décrit sa documentation.

  1. Tester le fichier

    La commande nginx -t lit toute la configuration et s'arrête à la première erreur, avant que quoi que ce soit ne tourne dessus.

  2. Envoyer le signal de rechargement

    Le processus maître reçoit le signal et vérifie la nouvelle configuration. Si la vérification échoue, il revient en arrière et l'ancienne configuration continue de servir.

  3. De nouveaux workers démarrent

    Si la vérification réussit, le maître démarre de nouveaux processus workers qui utilisent la nouvelle configuration.

  4. Les anciens workers terminent

    Les anciens workers n'acceptent plus de nouvelles connexions et terminent les requêtes qu'ils tiennent déjà.

  5. Les anciens workers s'arrêtent

    Quand le dernier visiteur est servi, ils s'arrêtent et il ne reste que des workers sur la nouvelle configuration.

Documentation Nginx : contrôler nginx

Confiez-nous le site. Nous construirons sa porte d'entrée.

Dites-nous où il tourne aujourd'hui et ce dont il a besoin. Nous examinons d'abord la configuration actuelle, puis proposons la configuration Nginx et PHP, et chiffrons le travail après cet échange.

MickeyRépond en quelques secondes