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.
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.
-
Die Verbindung
Verschlüsselt mit TLS 1.3, und eine einzige Verbindung überträgt jede Datei der Seite über HTTP/2 oder HTTP/3.
-
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 -
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 -
Statische Dateien
Bilder, Styles und Skripte gehen direkt von der Festplatte hinaus, mit langen Cache-Laufzeiten.
Ausgeliefert: hier ist Schluss -
Seiten-Cache
Ein nicht angemeldeter Besucher erhält eine fertige Seite aus dem Speicher.
Cache-Treffer: hier ist Schluss -
Der Load Balancer
Wählt den Server und den PHP-Pool, die noch Platz für die Anfrage haben.
-
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.
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.
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.
~ \.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
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_upstreamleitet 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.
- Wie wir einen PHP-FPM-Pool dimensionieren PHP bei EGPHP
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.
# 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;}
-
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.
-
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.
-
Versteckte Dateien und Überbleibsel
Alles, dessen Name mit einem Punkt beginnt, etwa
.gitoder.env, sowie Sicherungskopien von Dateien wird nie ausgeliefert. Einzige Ausnahme ist der Ordner, den Zertifikatsprüfungen verwenden. -
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.
-
Nur eine PHP-Datei, die existiert
try_files $uri =404reicht 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. -
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
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.
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
file_get_contents('/home/clinic/public_html/config.php')
Permission deniedopen_basedir restriction in effect
-
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.
-
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.
-
open_basedirundinclude_pathPHP 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.
-
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. -
Keine Funktionen, die die Website nicht braucht
open_basedirist 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.
[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_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.
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.
-
BrowserStyles, Skripte und Bilder mit einer Version im Namen, ein Jahr lang aufbewahrt.Hat die Dateien schonHat die Dateien schon
-
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
-
OPcachePHP, einmal kompiliert und im Speicher gehalten.Nicht erreichtDer Code läuft, ohne erneut gelesen oder kompiliert zu werden
-
RedisWiederkehrende Datenbankantworten und Sessions.Nicht erreichtDie meisten Antworten kommen aus dem Speicher
-
DatenbankDie maßgebliche Quelle der Daten.Nicht erreichtNur nach dem gefragt, was Redis nicht hatte
# 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
POSTgesendete 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_headerinnerhalb 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.
$ 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.
- Die Datei prüfen
Der Befehl nginx -t liest die gesamte Konfiguration und bricht beim ersten Fehler ab, bevor irgendetwas darauf läuft.
- 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.
- Neue Worker starten
Besteht die Prüfung, startet der Master neue Worker-Prozesse mit der neuen Konfiguration.
- Alte Worker machen fertig
Alte Worker nehmen keine neuen Verbindungen mehr an und beenden die Anfragen, die sie schon halten.
- Alte Worker beenden sich
Ist der letzte Besucher bedient, fahren sie herunter, und es bleiben nur Worker mit der neuen Konfiguration.

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.