Nginx bei EGPHP

Die Eingangstür, richtig gebaut.

Nginx entscheidet, was Ihre Website erreicht: welcher Server und welcher PHP-Pool antworten, was aus dem Cache kommt und was schon an der Tür abgewiesen wird. So richtet unser Team das ein – mit der Konfiguration als Beleg.

Läuft auf und gebaut mit
  • NGINX
  • PHP
  • Redis

Eine Anfrage, von der Tür bis zu Ihrem Code

Die meisten Anfragen brauchen PHP überhaupt nicht. Jede Station unten kann selbst antworten, und nur, was übrig bleibt, geht weiter zur nächsten.

  1. Die Verbindung

    Verschlüsselt mit TLS 1.3, und eine einzige Verbindung überträgt jede Datei der Seite über HTTP/2 oder HTTP/3.

  2. Limits

    Zu viele Anfragen von einer Adresse, zu viele offene Verbindungen oder ein Request-Body, der größer ist, als die Website annimmt – die Antwort darauf kommt von hier.

    429 oder 413: hier ist Schluss
  3. Gesperrte Pfade

    Versteckte Dateien, Backups und jedes Skript in einem Upload-Ordner werden abgewiesen, bevor PHP davon erfährt.

    403 oder 404: hier ist Schluss
  4. Statische Dateien

    Bilder, Styles und Skripte gehen direkt von der Festplatte hinaus, mit langen Cache-Laufzeiten.

    Ausgeliefert: hier ist Schluss
  5. Seiten-Cache

    Ein nicht angemeldeter Besucher erhält eine fertige Seite aus dem Speicher.

    Cache-Treffer: hier ist Schluss
  6. Der Load Balancer

    Wählt den Server und den PHP-Pool, die noch Platz für die Anfrage haben.

  7. Der eigene Pool des Kontos

    PHP läuft als eigener Benutzer des Kontos, in dessen eigenen Ordnern und mit dessen eigener php.ini.

Lastverteilung zwischen Servern

Reicht ein Server nicht aus, steht Nginx vor mehreren und verteilt den Verkehr. Die Methode entscheidet, wer die nächste Anfrage übernimmt.

Ein Beispiel: Adressen und Werte dienen nur der Veranschaulichung

Der Standard. Jeder Server übernimmt der Reihe nach die nächste Anfrage: richtig, wenn die Server gleichwertig sind und Anfragen etwa gleich viel kosten.

Die nächste Anfrage geht an den Server mit den wenigsten offenen Verbindungen: richtig, wenn manche Anfragen lange dauern, etwa Berichte, Exporte und Uploads.

Die Adresse des Besuchers bestimmt den Server, sodass derselbe Besucher immer wieder beim selben landet. Für Anwendungen, die Sessions auf der lokalen Festplatte ablegen; noch besser verlegen wir die Sessions nach Redis, dann entfällt die Notwendigkeit.

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
Health-Checks mit echtem Verkehr: Nach drei fehlgeschlagenen Versuchen innerhalb von dreißig Sekunden bleibt ein Server dreißig Sekunden außen vor und wird dann erneut versucht.
backup
Ein Reserveserver, der nur dann Verkehr erhält, wenn alle anderen Server nicht verfügbar sind.
proxy_next_upstream
Eine Anfrage, die auf einen Fehler oder ein Timeout stößt, wird am nächsten Server erneut versucht. Eine Anfrage, die Daten ändert, wird nicht zweimal gesendet – eine Bestellung wird also nie doppelt aufgegeben.
keepalive
Die Verbindungen zu den Servern bleiben zwischen den Anfragen offen, statt für jede einzelne neu aufgebaut zu werden.
weight
Ein größerer Server kann einen größeren Anteil der Anfragen erhalten.
down
Ein Server wird per Reload für Wartungsarbeiten herausgenommen, während die anderen den Verkehr tragen.

Lastverteilung zwischen PHP-Pools

Dieselbe Idee innerhalb eines einzelnen Servers. Hinter einer Website kann mehr als ein PHP-FPM-Pool stehen: So kann ein Pool neu starten, während der andere antwortet, und langsame Arbeit nimmt dem Shop nie die Worker weg, die er braucht.

Nginx ~ \.php$^~ /admin/
shop-a.sock Shop, Pool A Antwortet Antwortet und übernimmt die Anfragen von B
shop-b.sock Shop, Pool B Antwortet Startet mit einer neuen Einstellung neu: Kein Besucher wartet darauf
shop-admin.sock Verwaltung und Berichte Eigene Worker, getrennt vom Shop
sites/example.com.confEin Beispiel
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;
    }
}
  • Zwei Pools, eine Website

    Beide Pools führen denselben Code unter demselben Konto aus. Eine neue PHP-Einstellung erreicht erst den einen Pool, dann den anderen, und die Besucher werden die ganze Zeit über bedient.

  • Failover

    fastcgi_next_upstream leitet eine Anfrage an den anderen Pool weiter, wenn der Socket des ersten die Verbindung verweigert oder nicht rechtzeitig antwortet. Eine Anfrage, die Daten ändert, wird nicht zweimal gesendet.

  • Eine eigene Spur für langsame Arbeit

    Der Adminbereich, Exporte und Berichte erhalten einen eigenen Pool, sodass ein stundenlanger Bericht nicht die Worker belegen kann, die ein Kunde zum Bezahlen braucht.

Die Regeln an der Tür

Der Schutz beginnt, bevor PHP eine einzige Zeile ausführt. Diese Regeln kommen in jeden Server-Block, den wir schreiben, abgestimmt auf die jeweilige Website.

sites/example.com.confEin Beispiel: Die Werte werden je Website festgelegt
# 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. Anfrage- und Verbindungslimits

    Jede Adresse erhält eine gleichmäßige Rate mit Spielraum für einen kurzen Burst; darüber hinaus antwortet Nginx mit 429, und PHP wird gar nicht erst geweckt. Die Login-Seite bekommt ein eigenes, deutlich strengeres Limit, das dem Erraten von Passwörtern die Luft nimmt.

  2. Body-Größe und Timeouts

    Ein Upload, der größer ist, als die Website akzeptiert, wird sofort abgewiesen, und ein Client, der seine Anfrage sehr langsam sendet, wird getrennt, statt eine Verbindung offen zu halten.

  3. Versteckte Dateien und Überbleibsel

    Alles, dessen Name mit einem Punkt beginnt, etwa .git oder .env, sowie Sicherungskopien von Dateien wird nie ausgeliefert. Einzige Ausnahme ist der Ordner, den Zertifikatsprüfungen verwenden.

  4. Uploads führen nie PHP aus

    Ein Ordner, der Dateien von Besuchern annimmt, liefert sie als Dateien aus. Ein dort abgelegtes Skript wird abgewiesen, egal wie es heißt.

  5. Nur eine PHP-Datei, die existiert

    try_files $uri =404 reicht eine Anfrage nur dann an PHP weiter, wenn genau diese Datei auf der Festplatte liegt. Das schließt die klassische Falle, die unten dargestellt ist.

  6. Header und TLS

    HSTS hält Browser auf HTTPS, die übrigen Header verhindern, dass der Browser den Inhaltstyp einer Seite errät oder eine fremde Website sie in einen Frame einbettet, und der Server verrät seine Version nicht. Nur TLS 1.2 und 1.3.

    Wie wir den Server selbst absichern

Die Falle, die eine einzige fehlende Zeile öffnet

/uploads/avatar.jpgEine echte Datei: ein Bild mit darin verstecktem PHP-Code /shell.phpExistiert nicht
Ohne die Regel

Bei einem alten oder nachlässigen Setup geht PHP den Pfad rückwärts entlang, findet das Bild und führt den Code darin aus.

Mit der Regel

Ein 404, und nichts wird ausgeführt. cgi.fix_pathinfo=0 in PHP und security.limit_extensions im Pool sind das zweite und dritte Schloss an derselben Tür.

Jedes Konto ist ein ummauerter Raum

Auf einem Server, den sich mehrere Kunden teilen, darf eine Website niemals eine andere lesen können. Wir bauen diese Mauer in Schichten, damit keine einzelne Einstellung sie allein trägt.

shopKonto
Linux-Benutzer
shop
PHP-Pool
/run/php/shop.sock
Erlaubte Ordner
/home/shop
Einstellungen
pool.d/shop.conf
clinicKonto
Linux-Benutzer
clinic
PHP-Pool
/run/php/clinic.sock
Erlaubte Ordner
/home/clinic
Einstellungen
pool.d/clinic.conf
blogKonto
Linux-Benutzer
blog
PHP-Pool
/run/php/blog.sock
Erlaubte Ordner
/home/blog
Einstellungen
pool.d/blog.conf
Der Code des Shops greift nach den Dateien der Klinik file_get_contents('/home/clinic/public_html/config.php') Permission deniedopen_basedir restriction in effect
  1. Ein eigener Linux-Benutzer

    Jede Website läuft als eigener Benutzer, und jeder Home-Ordner ist für andere Konten gesperrt. Das ist die Mauer, die selbst dann hält, wenn man PHP ganz außen vor lässt.

  2. Ein eigener PHP-FPM-Pool

    Ein Pool pro Konto, erreichbar nur über seinen eigenen Socket. Eine ausgelastete oder defekte Website verbraucht ihre eigenen Worker, nicht die der Nachbarn.

  3. open_basedir und include_path

    PHP darf Dateien nur in den eigenen Ordnern des Kontos öffnen und sucht auch nur dort nach eingebundenen Dateien. Ein Skript, das einen Pfad außerhalb davon anfordert, wird abgewiesen, bevor die Festplatte berührt wird.

  4. Eigene temporäre Dateien und Sessions

    Laufende Uploads und Session-Dateien bleiben innerhalb des Kontos, nicht in einem gemeinsamen /tmp, in dem jede Website die angemeldeten Sessions einer anderen lesen könnte.

  5. Keine Funktionen, die die Website nicht braucht

    open_basedir ist keine Sandbox: Es hält kein Programm auf, das aus PHP heraus gestartet wird. Deshalb werden die Funktionen, die Shell-Befehle ausführen, pro Konto abgeschaltet, es sei denn, die Website braucht eine davon wirklich.

php/8.3/fpm/pool.d/shop.confEin Beispiel
[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, pro Konto

Jedes Konto hat seine eigenen Einstellungen, eingetragen in seinen eigenen Pool. Manche legen wir fest, manche darf die Website ändern, und der Unterschied ist ein einziges Wort.

php_admin_value: fest

Für das Konto festgelegt. Weder ini_set() noch eine .user.ini-Datei kann das ändern.

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

php_value: ein Startwert

Unser Standardwert für das Konto. Die Website darf ihn erhöhen oder senken, soweit PHP das erlaubt.

upload_max_filesize = 32M
post_max_size = 32M
max_input_vars = 3000

.user.ini: Sache der Website

Eine Datei im Ordner der Website für die Einstellungen, die sie ändern darf. PHP liest sie standardmäßig alle fünf Minuten neu ein.

upload_max_filesize = 64M
post_max_size = 64M
display_errors = Off

Ein Beispiel: Die Werte werden je Website festgelegt

Grenzen, die zusammenpassen müssen

Upload-Größe
upload_max_filesizePHP≤ post_max_sizePHP≤ client_max_body_sizeNginx

Erlaubt Nginx weniger, wird der Upload mit 413 abgewiesen, bevor PHP ihn sieht; erlaubt PHP weniger, kommt die Datei an und wird verworfen.

Zeit
max_execution_timePHP< request_terminate_timeoutPHP-FPM≤ fastcgi_read_timeoutNginx

PHP sollte sich zuerst selbst beenden, mit einem Fehler, den es protokollieren kann; der Pool beendet, was PHP nicht stoppen konnte; und Nginx wartet lange genug, um keinen 504 zu zeigen, während PHP noch arbeitet.

Cache, in Schichten

Jede Schicht hält etwas anderes vor, und eine Anfrage geht nur so tief wie nötig. Wechseln Sie zwischen den beiden Besuchern, um zu sehen, wo jeder endet.

  1. BrowserStyles, Skripte und Bilder mit einer Version im Namen, ein Jahr lang aufbewahrt.
    Hat die Dateien schonHat die Dateien schon
  2. Nginx-Seiten-CacheFertige Seiten, aufbewahrt für einige Sekunden bis einige Minuten.
    Treffer: Die Seite wird von hier beantwortetÜbersprungen: Ein Login- oder Warenkorb-Cookie macht die Seite persönlich
  3. OPcachePHP, einmal kompiliert und im Speicher gehalten.
    Nicht erreichtDer Code läuft, ohne erneut gelesen oder kompiliert zu werden
  4. RedisWiederkehrende Datenbankantworten und Sessions.
    Nicht erreichtDie meisten Antworten kommen aus dem Speicher
  5. DatenbankDie maßgebliche Quelle der Daten.
    Nicht erreichtNur nach dem gefragt, was Redis nicht hatte
conf.d/cache.confEin Beispiel: Die Werte werden je Website festgelegt
# 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;
Der Schlüssel
Schema, Methode, Host und die vollständige Adresse, damit zwei verschiedene Seiten nie denselben Eintrag teilen.
Die Umgehung
Ein Login oder ein Warenkorb umgeht den Cache in beide Richtungen: Die Seite wird weder aus ihm ausgeliefert noch in ihm gespeichert. Mit POST gesendete Formulare werden nie gecacht.
Alte Kopie während der Aktualisierung
Läuft ein Eintrag ab, aktualisiert ihn eine Anfrage im Hintergrund, während alle anderen weiterhin die vorherige Kopie erhalten. Schlägt PHP fehl, wird die letzte funktionierende Seite ausgeliefert.
Eine Regel, die oft übersehen wird
Ein add_header innerhalb eines location-Blocks ersetzt jeden weiter oben gesetzten Header. Deshalb halten wir die Sicherheits-Header in einem Snippet und binden es überall dort ein, wo Header hinzugefügt werden.

Tempo und der Rest einer guten Konfiguration

Die Einstellungen, die jede Website auf dem Server schneller machen, und die Gewohnheiten, mit denen eine Änderung sicher bleibt.

Eine Änderung, eingespielt, ohne dass etwas abreißt
$ 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

Die neue Konfiguration wird zuerst getestet; schlägt der Test fehl, ändert sich nichts. Beim Reload starten neue Worker damit, während die alten die Anfragen abschließen, die sie gerade bearbeiten – so wird kein Besucher abgeschnitten.

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

Jede Logzeile hält die Gesamtzeit fest, die Zeit, die PHP gebraucht hat, und was der Cache getan hat. So wird eine langsame Seite Nginx, PHP oder dem Netzwerk zugeordnet, statt dass man rät.

http2 on; listen 443 quic;
Eine Verbindung trägt die ganze Seite; HTTP/3 kommt hinzu, wo der Nginx-Build und das Netzwerk es zulassen.
gzip · brotli
Text wird komprimiert, bevor er hinausgeht, mit Brotli, wo das Modul einkompiliert ist. Bilder und Videos werden nicht doppelt komprimiert.
sendfile · tcp_nopush
Der Kernel schickt Dateien selbst von der Festplatte ins Netzwerk, in vollen Paketen.
open_file_cache
Nginx merkt sich, welche Dateien existieren und wo sie liegen, statt bei jeder Anfrage die Festplatte zu fragen.
worker_processes auto
Ein Worker pro Prozessorkern, jeder mit vielen Verbindungen in einer einzigen Ereignisschleife.
Cache-Control: immutable
Dateien mit einer Version im Namen werden ein Jahr lang gecacht und nie wieder geprüft.

Was hinter der Eingangstür sitzt

Nach der Aufgabe jedes Teils gruppiert, alles in der Nginx-Konfiguration festgelegt.

Verbindungen

  • Verschlüsselte VerbindungenTLS 1.3 verschlüsselt die Verbindung, und HTTP/2 trägt alle Dateien einer Seite über eine Verbindung.
  • Raten- und GrößenlimitsZu viele Anfragen, zu viele offene Verbindungen oder ein zu großer Body werden schon an der Tür beantwortet.

Auslieferung

  • Statische DateienBilder, Stile und Skripte gehen direkt von der Platte, mit langen Cache-Zeiten.
  • Seiten-CacheEin nicht angemeldeter Besucher erhält eine fertige Seite aus dem Speicher.
  • LastverteilungAnfragen gehen an den Server und den PHP-Pool, die Platz dafür haben.

Schutz

  • Gesperrte PfadeVersteckte Dateien, Backups und Skripte in Upload-Ordnern werden abgewiesen, bevor PHP davon erfährt.
  • Sicherheits-HeaderIn einem Snippet gehalten und überall eingebunden, wo Header gesetzt werden.
  • Abgeschottete KontenJedes Konto führt PHP als eigener Benutzer in den eigenen Ordnern aus.

Die Konfiguration ändern, ohne einen Besucher zu verlieren

Nginx übernimmt eine neue Konfiguration im laufenden Betrieb. So beschreibt die eigene Dokumentation den Ablauf.

  1. Die Datei prüfen

    Der Befehl nginx -t liest die gesamte Konfiguration und bricht beim ersten Fehler ab, bevor irgendetwas darauf läuft.

  2. Das Reload-Signal senden

    Der Master-Prozess erhält das Reload-Signal und prüft die neue Konfiguration. Schlägt das fehl, rollt er zurück und die alte Konfiguration bedient weiter.

  3. Neue Worker starten

    Besteht die Prüfung, startet der Master neue Worker-Prozesse mit der neuen Konfiguration.

  4. Alte Worker machen fertig

    Alte Worker nehmen keine neuen Verbindungen mehr an und beenden die Anfragen, die sie schon halten.

  5. Alte Worker beenden sich

    Ist der letzte Besucher bedient, fahren sie herunter, und es bleiben nur Worker mit der neuen Konfiguration.

Nginx-Dokumentation: nginx steuern

Bringen Sie uns die Website. Wir bauen ihre Eingangstür.

Sagen Sie uns, wo sie heute läuft und was sie braucht. Wir sehen uns zuerst die aktuelle Einrichtung an, schlagen dann die Nginx- und PHP-Konfiguration vor und erstellen nach diesem Gespräch ein Angebot.

MickeyAntwortet in Sekunden