Linux-Performance-Tuning für Webserver (Nginx/PHP-FPM)
$
/etc
Performance-Tuning · Nginx · PHP-FPM · Linux
Linux-Performance-Tuning für Webserver (Nginx/PHP-FPM)
vom Kernel-Limit bis zum optimalen pm.max_children

Ein überlasteter Webserver liegt selten allein am Anwendungscode, sondern häufig an unpassenden Betriebssystem-Limits und falsch dimensionierten PHP-FPM-Pools, die unter echter Last kollabieren. Dieser Artikel zeigt praxisnah, wie Nginx-Worker-Prozesse, Datei-Deskriptor-Limits, der TCP-Backlog und pm.max_children anhand des tatsächlich verfügbaren Arbeitsspeichers korrekt dimensioniert werden, und wie sich jede einzelne Änderung mit echten Benchmark-Messwerten belegen lässt, statt sich auf reine Vermutungen zu verlassen.

15 Min. Lesezeit worker_connections · ulimit · somaxconn · pm.max_children Nginx 1.24+ · PHP-FPM 8.2+ · systemd · Ubuntu · Debian

1. Warum Performance-Tuning am Betriebssystem ansetzt

Viele Tuning-Anleitungen springen direkt zu Opcode-Caches, Query-Optimierung oder CDN-Konfiguration. Auf einem stark ausgelasteten Webserver stößt das System aber oft schon früher an Grenzen: an der Zahl gleichzeitig offener Datei-Deskriptoren, an der Größe der TCP-Accept-Queue oder an der Anzahl gleichzeitig laufender PHP-FPM-Worker. Diese Betriebssystem-Limits sind standardmäßig auf konservative Werte für Desktop-Systeme oder kleine Testumgebungen eingestellt, nicht auf einen Produktionsserver mit tausenden gleichzeitigen Verbindungen. Wer nur die Anwendung optimiert, ohne die darunterliegende Schicht anzupassen, stößt trotzdem auf harte Grenzen, sobald die Last steigt.

Der Nginx-plus-PHP-FPM-Stack besteht aus mehreren Schichten mit jeweils eigenen Limits, die zusammenspielen müssen: Nginx nimmt TCP-Verbindungen entgegen und verwaltet Worker-Prozesse mit eigenen Verbindungslimits, der Kernel begrenzt die Warteschlange für eingehende Verbindungen und die Zahl offener Datei-Deskriptoren pro Prozess, und PHP-FPM verwaltet einen eigenen Pool von Worker-Prozessen, die jeweils eine feste Menge Arbeitsspeicher belegen. Ein Engpass in nur einer dieser Schichten bremst die gesamte Kette aus, unabhängig davon, wie gut die übrigen Schichten konfiguriert sind. Die folgenden Abschnitte gehen die Kette systematisch von unten nach oben durch.

2. Nginx-Worker-Prozesse und Verbindungslimits

Nginx verarbeitet Verbindungen über eine feste Anzahl Worker-Prozesse, die jeweils bis zu worker_connections gleichzeitige Verbindungen bedienen können. Die theoretische Obergrenze gleichzeitiger Clients ergibt sich aus worker_processes × worker_connections, wobei jede Verbindung sowohl beim Client als auch beim Upstream einen Datei-Deskriptor benötigt, wenn Nginx als Reverse Proxy vor PHP-FPM arbeitet. Der Wert worker_processes auto setzt die Anzahl Worker auf die Zahl der verfügbaren CPU-Kerne, was für die meisten Workloads die sinnvolle Grundeinstellung ist, da mehr Worker als Kerne nur zusätzlichen Kontextwechsel-Overhead erzeugen, ohne den Durchsatz zu erhöhen.

Der Standardwert von worker_connections liegt bei den meisten Distributionen bei 512 oder 1024, was für einen stark frequentierten Shop schnell zum Flaschenhals wird. Wichtig dabei: Nginx kann diesen Wert nur bis zur Grenze erhöhen, die der zugrunde liegende Betriebssystem-Limit für offene Datei-Deskriptoren erlaubt, gesteuert über worker_rlimit_nofile. Wird dieser Wert höher gesetzt als das tatsächliche Systemlimit, meldet Nginx beim Neuladen der Konfiguration einen Fehler oder ignoriert den Wert stillschweigend, je nach Distribution und systemd-Konfiguration.


# /etc/nginx/nginx.conf: worker sizing for a busy reverse proxy
worker_processes auto;
worker_rlimit_nofile 65536;

events {
    worker_connections 8192;
    multi_accept on;
    use epoll;
}

# Test config, then reload without dropping active connections
sudo nginx -t && sudo systemctl reload nginx

3. Datei-Deskriptor-Limits korrekt setzen

Jede TCP-Verbindung, jede geöffnete Log-Datei und jede Verbindung zu PHP-FPM über einen Unix-Socket oder TCP-Port verbraucht einen Datei-Deskriptor. Der Standardwert von 1024 offenen Dateien pro Prozess, wie ihn ulimit -n auf den meisten Systemen anzeigt, reicht für einen Produktionswebserver unter Last nicht annähernd aus. Wird das Limit erreicht, quittiert der Kernel neue Verbindungsversuche mit der Fehlermeldung Too many open files, die in den Nginx- oder PHP-FPM-Error-Logs auftaucht, oft erst unter Lastspitzen und damit zum ungünstigsten Zeitpunkt.

Ein häufiger Fehler ist, das Limit ausschließlich in /etc/security/limits.conf zu erhöhen. Diese Datei wirkt nur für Login-Shells über PAM, nicht für Dienste, die systemd direkt startet. Für systemd-verwaltete Dienste wie Nginx und PHP-FPM muss das Limit stattdessen über LimitNOFILE in der Service-Unit oder einem Override-Fragment gesetzt werden. Das tatsächlich wirksame Limit eines laufenden Prozesses lässt sich jederzeit über cat /proc/PID/limits überprüfen, was der zuverlässigste Weg ist, um Konfigurationsfehler zwischen limits.conf, systemd und Anwendungskonfiguration aufzudecken, bevor sie in Produktion zum Problem werden.


# Raise the file descriptor limit for a systemd-managed service
sudo systemctl edit nginx
# Add inside the editor:
#   [Service]
#   LimitNOFILE=65536

sudo systemctl edit php8.3-fpm
# Add inside the editor:
#   [Service]
#   LimitNOFILE=65536

sudo systemctl daemon-reload
sudo systemctl restart nginx php8.3-fpm

# Verify the limit actually applied to the running process
cat /proc/"$(pgrep -o nginx)"/limits | grep 'Max open files'

4. TCP-Backlog und Netzwerk-Stack tunen

Bevor eine Verbindung überhaupt bei Nginx als Anwendung ankommt, durchläuft sie die Accept-Queue des Kernels. Deren maximale Größe wird über den Kernel-Parameter net.core.somaxconn begrenzt, der auf vielen Distributionen standardmäßig nur 128 beträgt, ein Wert aus einer Zeit deutlich geringerer Verbindungszahlen. Bei einem plötzlichen Anstieg gleichzeitiger Verbindungen, etwa durch eine Marketing-Kampagne oder einen viralen Social-Media-Post, füllt sich diese Queue, und weitere Verbindungsversuche werden vom Kernel verworfen, noch bevor Nginx überhaupt reagieren kann. Aus Sicht des Clients sieht das wie ein Timeout oder eine Connection-Refused-Meldung aus, obwohl der Server selbst gar nicht überlastet ist.

Der in der listen-Direktive von Nginx gesetzte backlog-Parameter darf den Kernel-Wert von somaxconn nicht überschreiten, sonst wird er auf den Kernel-Wert gekappt, ohne dass eine Warnung erscheint. Ergänzend begrenzt net.ipv4.tcp_max_syn_backlog die Warteschlange für halb offene Verbindungen während des TCP-Handshakes, relevant vor allem bei hoher Neuverbindungsrate. Beide Werte müssen gemeinsam angepasst werden, da eine isolierte Erhöhung nur eines Parameters die Engstelle lediglich verschiebt, statt sie zu beseitigen.


# /etc/sysctl.d/99-webserver-network.conf
# Accept queue depth: must be >= nginx "listen ... backlog=N"
net.core.somaxconn = 65535

# Half-open connection queue during the TCP handshake
net.ipv4.tcp_max_syn_backlog = 8192

# Incoming packet queue at the NIC driver level under high PPS
net.core.netdev_max_backlog = 16384

# Reuse TIME_WAIT sockets for new outgoing connections (safe for IPv4)
net.ipv4.tcp_tw_reuse = 1

# Apply without a reboot
sudo sysctl --system

5. PHP-FPM Prozess-Manager-Modi verstehen

PHP-FPM kennt drei Prozess-Manager-Modi, die grundverschieden mit Arbeitsspeicher und Antwortzeit umgehen. Im Modus static startet PHP-FPM sofort genau pm.max_children Worker-Prozesse und hält sie dauerhaft am Leben, unabhängig von der aktuellen Last. Das liefert vorhersagbaren Speicherverbrauch und keine Verzögerung durch Prozess-Spawning, verschwendet aber RAM in Zeiten geringer Last, wenn nicht alle Worker gebraucht werden. Im Modus dynamic pendelt die Anzahl laufender Worker zwischen pm.min_spare_servers und pm.max_spare_servers, gesteuert über pm.start_servers als Startwert, was einen guten Kompromiss zwischen Speicherverbrauch und Reaktionsfähigkeit darstellt.

Der Modus ondemand startet Worker ausschließlich bei eingehenden Requests und beendet sie nach pm.process_idle_timeout wieder, was auf Servern mit vielen selten genutzten Pools, etwa in Shared-Hosting-Umgebungen mit dutzenden Kunden-Domains, Arbeitsspeicher spart. Für einen einzelnen, dauerhaft stark frequentierten Shop bringt ondemand jedoch spürbare Latenz beim ersten Request nach einer Ruhephase, da jeder neue Worker-Prozess erst gestartet und initialisiert werden muss. Für produktive Nginx-plus-PHP-FPM-Setups mit konstanter Grundlast ist dynamic meist die richtige Wahl, static eignet sich für Systeme mit bekannter, gleichmäßiger Spitzenlast und reichlich freiem RAM.

6. pm.max_children anhand des verfügbaren RAM berechnen

Der häufigste Fehler bei PHP-FPM-Konfiguration ist, pm.max_children nach Gefühl oder aus einem Tutorial-Beispiel zu übernehmen, statt ihn aus dem tatsächlich verfügbaren Arbeitsspeicher zu berechnen. Wird der Wert zu hoch gesetzt, kann der Server unter Last mehr PHP-FPM-Worker starten, als der Arbeitsspeicher verkraftet, was den Out-of-Memory-Killer aktiviert und wahllos Prozesse beendet, oft inklusive der Datenbank oder Nginx selbst. Wird der Wert zu niedrig gesetzt, geraten eingehende Requests in die Warteschlange, sobald alle Worker beschäftigt sind, was sich als steigende Antwortzeiten und im Nginx-Log als upstream timed out zeigt.

Die korrekte Formel lautet: verfügbares RAM abzüglich Reserve für Betriebssystem, Datenbank und andere Dienste, geteilt durch den durchschnittlichen Speicherverbrauch eines einzelnen PHP-FPM-Worker-Prozesses. Der durchschnittliche RSS-Wert je Worker lässt sich unter Last mit ps --no-headers -o rss -C php-fpm8.3 ermitteln, wichtig ist dabei, den Durchschnitt unter realer Last zu messen, nicht direkt nach dem Start, da der Speicherverbrauch mit dem Laden von Opcache und Autoloader-Klassen ansteigt.


# Measure average RSS per worker under real load, not right after startup
ps --no-headers -o rss -C php-fpm8.3 | \
  awk '{sum+=$1; n++} END {printf "avg %.1f MB across %d workers\n", sum/n/1024, n}'

# Example calculation: 8 GB RAM server, 2 GB reserved for OS/MySQL/Redis
# average worker size ~55 MB -> (8192 - 2048) / 55 =~ 111
echo $(( (8192 - 2048) / 55 ))

; /etc/php/8.3/fpm/pool.d/www.conf
; Sized for the 8 GB example above with a safety margin below the
; theoretical ceiling of 111 workers
pm = dynamic
pm.max_children = 100
pm.start_servers = 20
pm.min_spare_servers = 10
pm.max_spare_servers = 30
pm.max_requests = 1000

; Recycle workers periodically to bound memory leaks from extensions
pm.process_idle_timeout = 10s

; Expose a status endpoint for monitoring (see section 7)
pm.status_path = /fpm-status

7. Baseline messen, bevor man etwas ändert

Tuning ohne Messung ist Raten. Bevor auch nur ein einziger Parameter angefasst wird, braucht es eine belastbare Baseline: aktueller Durchsatz in Requests pro Sekunde, Antwortzeit-Perzentile p95 und p99, Fehlerrate unter Last und der Auslastungsgrad der PHP-FPM-Worker. Der Nginx-Statusmodul-Endpunkt stub_status liefert aktive Verbindungen und die Zahl wartender Requests, der PHP-FPM-Statusendpunkt zeigt aktive, idle und die maximal erreichte Anzahl gleichzeitig aktiver Prozesse.

Für den eigentlichen Lasttest eignet sich wrk besser als das ältere ab, weil es mit mehreren Threads parallel arbeitet und Latenz-Histogramme statt nur Durchschnittswerten liefert, die bei ungleichmäßig verteilten Antwortzeiten deutlich aussagekräftiger sind. Wichtig ist, gegen eine realistische Seite zu testen, etwa eine typische Produktdetailseite mit Datenbankzugriff, nicht gegen eine statische Testseite, die PHP-FPM kaum belastet und daher ein völlig falsches Bild vom Verhalten unter echter Last liefert.


# Nginx status endpoint (requires ngx_http_stub_status_module)
curl -s http://127.0.0.1/nginx-status
#   Active connections: 842
#   server accepts handled requests
#    189234 189234 512310
#   Reading: 3 Writing: 121 Waiting: 718

# PHP-FPM status endpoint (pm.status_path from section 6)
curl -s "http://127.0.0.1/fpm-status?json"

# Realistic load test against a real product page, 4 threads, 200 connections, 60s
wrk -t4 -c200 -d60s --latency https://shop.example.com/product/example-sku

8. Nach der Änderung messen und validieren

Nach jeder Konfigurationsänderung wird exakt derselbe Lasttest mit denselben Parametern wiederholt, denn nur so lassen sich Vorher- und Nachher-Werte fair vergleichen. Wichtig ist, jeweils nur einen Parameter zwischen zwei Testläufen zu ändern, sonst lässt sich am Ende nicht mehr feststellen, welche Anpassung tatsächlich für die Verbesserung verantwortlich war. Parallel dazu lohnt ein Blick in journalctl -u php8.3-fpm auf die Warnung server reached pm.max_children, consider raising it, ein klares Signal, dass der Pool trotz Anpassung noch immer unterdimensioniert ist.

Alle Konfigurationsdateien gehören unter Versionskontrolle, damit eine Änderung, die sich negativ auswirkt, mit git diff nachvollzogen und mit git checkout zuverlässig zurückgerollt werden kann, gefolgt von einem sauberen systemctl reload. Wer ohne Versionskontrolle arbeitet, verliert bei mehreren aufeinanderfolgenden Anpassungen schnell den Überblick, welcher Zustand tatsächlich der zuletzt funktionierende war. Die Kombination aus reproduzierbarem Lasttest, striktem Ein-Parameter-pro-Testlauf-Prinzip und versionierter Konfiguration macht Performance-Tuning nachvollziehbar statt zum Ratespiel.


{
  "test": "product-page-load",
  "tool": "wrk -t4 -c200 -d60s",
  "before": {
    "requests_per_sec": 412,
    "latency_p95_ms": 890,
    "latency_p99_ms": 2140,
    "errors": 37,
    "fpm_max_children_reached": true
  },
  "after": {
    "change": "pm.max_children 24 -> 100, somaxconn 128 -> 65535",
    "requests_per_sec": 1350,
    "latency_p95_ms": 145,
    "latency_p99_ms": 310,
    "errors": 0,
    "fpm_max_children_reached": false
  }
}

9. Tuning-Parameter im direkten Vergleich

Die einzelnen Stellschrauben aus den vorigen Abschnitten wirken erst im Zusammenspiel richtig. Die folgende Tabelle stellt typische Standardwerte den produktionsgerechten Einstellungen gegenüber und zeigt, welchen konkreten Effekt jede Anpassung hat.

Parameter Unsicher / Standard Produktionsgerecht Effekt
worker_connections 512 (Distributions-Default) 8192, an worker_rlimit_nofile gekoppelt Mehr gleichzeitige Verbindungen pro Worker
Datei-Deskriptor-Limit Nur limits.conf angepasst LimitNOFILE in systemd-Override Limit wirkt tatsächlich beim Dienst
net.core.somaxconn 128 65535 Keine verworfenen Verbindungen bei Lastspitzen
pm.max_children Aus Tutorial übernommen Aus RAM / durchschnittlichem RSS berechnet Kein OOM-Kill, keine Queue-Wartezeit
Tuning-Vorgehen Mehrere Werte gleichzeitig raten Ein Parameter pro Testlauf, mit Baseline Ursache und Wirkung eindeutig zuordenbar

Ohne reproduzierbare Messung bleibt jede Behauptung, eine Änderung habe geholfen, unbelegt. Wer die Reihenfolge Baseline, eine Änderung, erneuter Test konsequent einhält, erhält belastbare Zahlen statt Bauchgefühl, und kann Tuning-Entscheidungen im Team nachvollziehbar begründen.

Mironsoft

Server-Tuning, Kernel-Konfiguration und Performance-Monitoring

Webserver, die auch bei Lastspitzen nicht in die Knie gehen?

Wir analysieren Nginx- und PHP-FPM-Konfiguration, dimensionieren pm.max_children anhand des tatsächlichen Speicherbedarfs und richten Monitoring ein, das Engpässe zeigt, bevor Kunden sie bemerken.

Performance-Audit

Nginx-, PHP-FPM- und Kernel-Limits systematisch mit Baseline-Messung prüfen

Pool-Sizing

pm.max_children und Prozess-Manager-Modus passend zum verfügbaren RAM konfigurieren

Monitoring-Setup

stub_status, FPM-Status und Lasttests in ein belastbares Monitoring integrieren

10. Zusammenfassung

Ein Nginx-plus-PHP-FPM-Stack unter Last besteht aus mehreren Schichten mit eigenen Limits, die zusammenspielen müssen. worker_connections und worker_rlimit_nofile begrenzen, wie viele Verbindungen Nginx gleichzeitig annehmen kann, das systemweite Datei-Deskriptor-Limit muss dafür über LimitNOFILE in systemd gesetzt werden, nicht nur in limits.conf. net.core.somaxconn und net.ipv4.tcp_max_syn_backlog bestimmen, wie viele Verbindungen der Kernel puffern kann, bevor er neue Verbindungsversuche verwirft. pm.max_children muss aus dem tatsächlich verfügbaren RAM und dem gemessenen durchschnittlichen Speicherverbrauch pro Worker berechnet werden, nicht aus einem Tutorial-Beispiel übernommen werden.

Der entscheidende Grundsatz über alle Parameter hinweg: erst eine belastbare Baseline mit wrk, stub_status und dem PHP-FPM-Statusendpunkt messen, dann genau einen Parameter ändern, danach denselben Test wiederholen und die Werte vergleichen. Nur dieses Vorgehen macht nachvollziehbar, welche Änderung tatsächlich zur Verbesserung geführt hat, statt mehrere Anpassungen gleichzeitig vorzunehmen und am Ende zu raten, welche davon geholfen hat. Versionierte Konfigurationsdateien erlauben zudem, jede Änderung bei Bedarf sauber zurückzurollen.

Linux-Performance-Tuning für Webserver: Das Wichtigste auf einen Blick

Nginx-Worker

worker_processes auto und worker_connections an worker_rlimit_nofile koppeln, sonst wird der Wert stillschweigend gekappt.

Datei-Deskriptoren

Für systemd-Dienste zählt LimitNOFILE in der Unit, nicht /etc/security/limits.conf. Mit /proc/PID/limits prüfen.

TCP-Backlog

net.core.somaxconn und der Nginx-backlog-Parameter müssen zusammenpassen, sonst werden Verbindungen unter Last verworfen.

pm.max_children

Aus verfügbarem RAM und gemessenem RSS pro Worker berechnen, mit wrk und dem FPM-Statusendpunkt vor und nach jeder Änderung validieren.

11. FAQ: Linux-Performance-Tuning für Webserver

1Warum reicht Code-Optimierung allein nicht aus?
Der Stack stößt oft zuerst an Betriebssystem-Limits wie Datei-Deskriptoren, TCP-Backlog oder Worker-Anzahl, bevor Code-Optimierungen überhaupt greifen können.
2Wie berechnet man worker_connections richtig?
worker_processes mal worker_connections ergibt die Obergrenze, begrenzt durch worker_rlimit_nofile, sonst wird der Wert gekappt.
3Warum wirkt limits.conf nicht bei systemd-Diensten?
limits.conf gilt nur für PAM-Login-Shells. systemd-Dienste brauchen LimitNOFILE direkt in der Unit oder per systemctl edit.
4Was bewirkt net.core.somaxconn?
Begrenzt die Kernel-Accept-Queue. Der Nginx-backlog-Parameter darf diesen Wert nicht überschreiten, sonst wird er automatisch gekappt.
5Wie berechne ich pm.max_children korrekt?
Verfügbares RAM minus Reserve geteilt durch den unter Last gemessenen durchschnittlichen RSS-Wert pro Worker, nicht den Wert direkt nach dem Start.
6static, dynamic oder ondemand: was ist der Unterschied?
static hält immer max_children Worker am Leben, dynamic pendelt zwischen spare-Werten, ondemand startet nur bei Bedarf. Für Shops meist dynamic.
7Was bedeutet server reached pm.max_children?
Der Pool ist an der Obergrenze, weitere Requests warten. Signal, dass max_children zu niedrig ist oder mehr RAM nötig wäre.
8ab oder wrk für Lasttests?
wrk arbeitet multi-threaded und liefert Latenz-Histogramme, deutlich aussagekräftiger als das ältere, single-threaded ab.
9Warum Parameter einzeln statt gleichzeitig ändern?
Nur so lässt sich eindeutig zuordnen, welche Änderung tatsächlich für eine Verbesserung oder Verschlechterung verantwortlich war.
10Wie prüfe ich das tatsächliche Datei-Deskriptor-Limit?
Mit cat /proc/PID/limits das wirksame Limit eines laufenden Prozesses einsehen, unabhängig von limits.conf oder systemd-Konfiguration.