Die Standardeinstellungen von PHP und Webserver sind so gewählt, dass sie überall laufen, nicht dass sie auf einem bestimmten Server schnell laufen. Bei WordPress oder einem WooCommerce-Shop auf einem VPS beginne ich mit vier Schichten, die bei geringstem Risiko am meisten bringen: einem PHP-FPM-Pool, OPcache, Objekt-Cache in Redis und Nginx mit HTTP/2. Bei jeder Direktive schreibe ich dazu, woher ihr Wert stammt und wie Sie prüfen, ob die Änderung etwas gebracht hat.
Die Beispiele beziehen sich auf Debian 13 (PHP 8.4, Nginx 1.26) und Ubuntu 24.04 LTS (PHP 8.3, Nginx 1.24). Unter Ubuntu 24.04 ersetzen Sie in Pfaden und Dienstnamen 8.4 durch 8.3. Die Unterschiede bei Apache finden Sie in einem eigenen Abschnitt.
Regel Nummer eins: erst messen
Ohne Ausgangsmessung wissen Sie nicht, ob eine Optimierung geholfen hat oder es nur „schneller zu sein scheint“. Bevor Sie etwas ändern, notieren Sie die Antwortzeiten für einige typische Adressen: Startseite, Beitrag, Kategorie und – falls Sie einen Shop haben – Produktseite.
Das einfachste Werkzeug ist curl mit dem Parameter -w:
curl -so /dev/null -w 'DNS: %{time_namelookup}s | TCP: %{time_connect}s | TLS: %{time_appconnect}s | TTFB: %{time_starttransfer}s | Total: %{time_total}s | HTTP/%{http_version}\n' https://example.com/
Am wichtigsten ist die TTFB (time_starttransfer) – die Zeit bis zum ersten Byte der Antwort. An ihr sieht man die Arbeit von PHP und Datenbank. Eine einzelne Messung sagt wenig, wiederholen Sie sie daher mehrere Dutzend Mal:
for i in $(seq 1 20); do
curl -so /dev/null -w '%{time_starttransfer}\n' https://example.com/
done | sort -n | awk '{a[NR]=$1} END {print "mediana TTFB:", a[int((NR+1)/2)] "s"}'
Messen Sie von einem anderen Rechner als dem Server und als nicht angemeldeter Besucher (angemeldete Nutzer umgehen meist den Cache). Speichern Sie die Ergebnisse – am Ende brauchen Sie sie wieder.
Schritt 1: PHP-FPM mit eigenem Pool
PHP-FPM ist ein Prozessmanager, der einsatzbereite PHP-Prozesse vorhält. Nginx reicht ihnen die Anfragen über FastCGI weiter. Installieren Sie die Pakete:
sudo apt install php-fpm php-mysql php-redis php-curl php-xml php-mbstring php-zip php-gd php-intl
Statt den Standard-Pool www zu bearbeiten, legen Sie für jede Website einen eigenen Pool an, der als eigener Systembenutzer läuft. Ein Einbruch in eine Website gibt dann keinen Zugriff auf die Dateien einer anderen.
; /etc/php/8.4/fpm/pool.d/example.conf
[example]
user = example
group = example
listen = /run/php/php8.4-fpm-example.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500
request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php8.4-fpm-example-slow.log
pm.status_path = /fpm-status
ping.path = /fpm-ping
php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M
Die wichtigsten Entscheidungen:
pm = dynamichält einige Prozesse bereit und startet unter Last weitere.ondemandspart RAM auf Servern mit vielen selten besuchten Websites, doch die erste Anfrage nach einer Pause wartet auf den Prozessstart.staticist nur sinnvoll, wenn der Server eine einzige Website bedient und Sie deren Traffic kennen.pm.max_childrenist die Obergrenze paralleler Prozesse – die wichtigste Zahl in der ganzen Datei. Zu klein bedeutet eine Warteschlange von Anfragen, zu groß erschöpft den RAM und führt zu Swapping, was deutlich schlimmer ist. Der Wert10im Beispiel ist nur ein Startpunkt; berechnen Sie Ihren eigenen.pm.max_requests = 500startet einen Prozess nach 500 bearbeiteten Anfragen neu. Das ist eine Absicherung gegen Speicherlecks in Plugins.request_slowlog_timeoutschreibt den Stack-Trace jeder Anfrage mit, die länger als 5 Sekunden dauert. So erfahren Sie, welches Plugin die Website ausbremst.
Wie berechnet man pm.max_children? Starten Sie die Website mit dem Standard-Pool, klicken Sie einige aufwendigere Unterseiten durch und prüfen Sie den durchschnittlichen Speicherverbrauch pro Prozess:
ps --no-headers -o rss -C php-fpm8.4 | awk '{s+=$1; n++} END {printf "%.1f MB na proces\n", s/n/1024}'
Anschließend gilt: pm.max_children = (für PHP verfügbarer RAM) / (durchschnittlicher RSS eines Prozesses). „Für PHP verfügbarer RAM“ ist der Gesamtspeicher abzüglich MySQL/MariaDB, Redis, Nginx, System und Reserve. Das Ergebnis runde ich ab, denn ein zu kleines Limit bedeutet eine Warteschlange, ein zu großes Swapping.
Prüfen Sie die Konfiguration und laden Sie den Dienst neu:
sudo php-fpm8.4 -t && sudo systemctl reload php8.4-fpm
Der Pool-Status (pm.status_path) zeigt unter anderem max children reached. Steigt dieser Wert, ist der Pool zu klein oder PHP arbeitet zu langsam.
Schritt 2: OPcache
PHP muss bei jeder Anfrage die Quelldateien parsen und kompilieren. WordPress mit einem Dutzend Plugins sind Tausende Dateien. OPcache hält den kompilierten Bytecode im gemeinsamen Speicher, sodass die Kompilierung nur einmal stattfindet. In den Paketen von Debian und Ubuntu ist OPcache aktiviert, aber mit Standardlimits, die bei größeren Installationen oft zu klein sind.
; /etc/php/8.4/fpm/conf.d/99-opcache-tuning.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
memory_consumption(MB) – Speicher für den Bytecode. Ist er voll, beginnt OPcache Dateien zu verwerfen oder startet sich neu.interned_strings_buffer(MB) – Speicher für wiederkehrende Zeichenketten (Namen von Klassen, Funktionen, Array-Schlüsseln). Der Standardwert ist bei WordPress mit Plugins schnell gefüllt.max_accelerated_files– Limit der Dateianzahl. PHP rundet es auf die nächste Primzahl aus einer internen Liste auf.validate_timestamps=1undrevalidate_freq=60– OPcache prüft höchstens einmal in 60 Sekunden, ob sich eine Datei geändert hat. Verlockend istvalidate_timestamps=0, doch dann sieht PHP keine Dateiänderungen bis zum Neuladen von PHP-FPM. Bei WordPress, das Plugins selbst aus dem Dashboard aktualisiert, ist das ein Rezept für merkwürdige Fehler nach Updates. Schalten Sie die Validierung nur ab, wenn Sie Code ausschließlich aus der CI ausrollen und FPM bei jedem Deployment neu laden.
JIT (verfügbar seit PHP 8.0) lassen Sie ausgeschaltet – in einem typischen WordPress geht die Zeit hauptsächlich für Datenbankabfragen und I/O-Operationen drauf, nicht für Berechnungen.
Falle: OPcache in CLI und in FPM sind zwei getrennte Welten. php -i | grep opcache zeigt die CLI-Einstellungen. Den Zustand des FPM-Caches prüfen Sie mit einem über den Browser aufgerufenen Skript (z. B. opcache_get_status(false)), das Sie direkt nach der Prüfung vom Server löschen.
Schritt 3: Redis als Objekt-Cache
WordPress hat einen eingebauten Objekt-Cache (WP_Object_Cache), der standardmäßig aber nur während einer einzelnen Anfrage lebt. Mit Redis werden Abfrageergebnisse, Optionen und Transients zwischen den Anfragen geteilt, sodass die Datenbank weniger zu tun hat. Am meisten profitieren WooCommerce-Shops, Foren und das Administrationsbackend.
sudo apt install redis-server
Setzen Sie in /etc/redis/redis.conf:
bind 127.0.0.1 -::1
protected-mode yes
maxmemory 256mb
maxmemory-policy allkeys-lru
save ""
appendonly no
bindundprotected-mode– Redis lauscht nur lokal. Ein ins Internet exponierter Redis ohne Passwort ist der klassische Weg, einen Server zu übernehmen.maxmemory+allkeys-lru– ist das Limit erreicht, entfernt Redis die am längsten nicht genutzten Schlüssel, statt Schreibzugriffe abzulehnen.save ""undappendonly no– es ist ein Cache und keine Datenbank. Nach einem Neustart baut WordPress ihn selbst wieder auf, und Sie sparen Festplattenzugriffe.
Nach dem Neustart (sudo systemctl restart redis-server) fügen Sie in wp-config.php oberhalb des Kommentars /* That's all, stop editing! */ Folgendes ein:
define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_PREFIX', 'example.com:' );
define( 'WP_REDIS_TIMEOUT', 1 );
define( 'WP_REDIS_READ_TIMEOUT', 1 );
Das Präfix ist Pflicht, wenn auf einem Redis mehrere Websites laufen – ohne es würden sich die Installationen gegenseitig die Schlüssel überschreiben. Die Verbindung selbst übernimmt das Drop-in object-cache.php; am bequemsten installieren Sie es mit dem Plugin Redis Object Cache und aktivieren es per WP-CLI:
wp plugin install redis-cache --activate
wp redis enable
wp redis status
Der Status sollte Connected anzeigen. Ob der Cache arbeitet, prüfen Sie mit redis-cli info stats (Felder keyspace_hits und keyspace_misses) oder im Query Monitor.
Schritt 4: Nginx und HTTP/2
HTTP/2 überträgt mehrere Dateien parallel über eine einzige TLS-Verbindung. In Nginx aktivieren Sie es ab Version 1.25.1 mit der eigenen Direktive http2 on;. In älteren Versionen, z. B. 1.24 unter Ubuntu 24.04, ergänzen Sie http2 bei listen: listen 443 ssl http2;.
# /etc/nginx/sites-available/example.com.conf
server {
listen 80;
listen [::]:80;
server_name example.com www.example.com;
return 301 https://example.com$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
http2 on; # Nginx >= 1.25.1; ältere: listen 443 ssl http2;
server_name example.com;
root /var/www/example.com;
index index.php;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:10m; # Wiederaufnahme von TLS-Sitzungen ohne vollständigen Handshake
ssl_session_timeout 1d;
client_max_body_size 64m; # muss zu upload_max_filesize in PHP passen
gzip on;
gzip_vary on;
gzip_comp_level 5;
gzip_min_length 1024;
gzip_types text/css application/javascript application/json image/svg+xml application/xml text/xml;
location / {
try_files $uri $uri/ /index.php?$args;
}
# Regex-Regeln werden der Reihe nach geprüft – Sperren müssen vor \.php$ stehen
location = /xmlrpc.php { deny all; }
location ~ /\.(?!well-known) { deny all; }
location ~* ^/wp-content/uploads/.*\.php$ { deny all; }
location ~* \.(?:css|js|mjs|woff2?|svg|png|jpe?g|gif|webp|avif|ico)$ {
expires 30d;
access_log off;
try_files $uri =404;
}
location ~ ^/(fpm-status|fpm-ping)$ {
allow 127.0.0.1;
allow ::1;
deny all;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.4-fpm-example.sock;
}
location ~ \.php$ {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_pass unix:/run/php/php8.4-fpm-example.sock;
fastcgi_buffer_size 32k;
fastcgi_buffers 16 16k;
fastcgi_read_timeout 60s;
}
}
Ein paar Punkte, die erwähnenswert sind:
Die Reihenfolge der Regex-Regeln ist entscheidend. Nginx prüft Locations mit regulären Ausdrücken der Reihe nach und wählt die erste passende. Stünde die Sperre für
/wp-content/uploads/*.phpnachlocation ~ \.php$, würde ein ins Verzeichnis uploads eingeschleustes PHP-Skript ausgeführt. Deshalb stehen die Sperren oben.try_files $uri =404in der PHP-Location verhindert, dass nicht existierende Dateien an FPM weitergereicht werden.expires 30dfür statische Ressourcen ist unbedenklich, denn WordPress hängt an die CSS- und JS-Adressen den Parameter?ver=an, der sich nach einem Update ändert.Der FPM-Status ist nur von localhost erreichbar. Denken Sie an
allow ::1, denncurl localhostverbindet sich oft über IPv6.xmlrpc.phpsperren Sie, wenn Sie weder die mobile App noch Jetpack verwenden.
Prüfen Sie die Konfiguration, laden Sie den Server neu und verifizieren Sie HTTP/2:
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/ | head -1 # oczekiwane: HTTP/2 200
curl -s -D - -o /dev/null --compressed https://example.com/ | grep -i content-encoding
Unterschiede bei Apache
Wenn Sie bei Apache bleiben, gelten dieselben Grundsätze, nur die Mechanismen sind andere:
Verwenden Sie statt
mod_phpPHP-FPM übermod_proxy_fcgi(a2enmod proxy_fcgi setenvifunda2enconf php8.4-fpm).mod_phperzwingt das MPMprefork, bei dem jeder Apache-Prozess das komplette PHP lädt – sogar beim Ausliefern eines Bildes.Wechseln Sie zum MPM
event(a2dismod mpm_prefork,a2enmod mpm_event). Die Apache-Dokumentation weist darauf hin, dass HTTP/2 mitpreforknur stark eingeschränkt funktioniert: eine Anfrage gleichzeitig pro Verbindung.HTTP/2 aktivieren Sie mit
a2enmod http2und der DirektiveProtocols h2 http/1.1im SSL-vHost..htaccesskostet Leistung. BeiAllowOverride Allsucht Apache bei jeder Anfrage im gesamten Verzeichnispfad nach.htaccess-Dateien. Wenn Sie Zugriff auf die vHost-Konfiguration haben, verschieben Sie die Regeln dorthin und setzenAllowOverride None.OPcache, FPM-Pool und Redis konfigurieren Sie genauso wie oben.
Messung nach den Änderungen
Wiederholen Sie die curl-Messungen vom Anfang und prüfen Sie dann das Verhalten unter Last. Testen Sie Ihren eigenen Server, nicht den eines anderen, und führen Sie keine Lasttests auf Shared Hosting durch.
ab (aus dem Paket apache2-utils) ist am einfachsten, unterstützt aber nur HTTP/1.0 – gut für einen schnellen Vorher-Nachher-Vergleich der PHP-Schicht:
ab -n 500 -c 10 -k https://example.com/
wrk erzeugt bei HTTP/1.1 mehr Last und liefert die Verteilung der Latenzen:
wrk -t2 -c20 -d30s --latency https://example.com/
k6 erlaubt es, ein Szenario in JavaScript zu schreiben, Akzeptanzschwellen zu setzen und über HTTP/2 zu testen:
// k6-wordpress.js
import http from 'k6/http'
import { check, sleep } from 'k6'
const BASE = __ENV.BASE_URL || 'https://example.com'
export const options = {
stages: [
{ duration: '30s', target: 10 }, // Aufwärmphase auf 10 virtuelle Nutzer
{ duration: '1m', target: 10 }, // konstante Last
{ duration: '15s', target: 0 },
],
thresholds: {
http_req_failed: ['rate<0.01'], // weniger als 1 % Fehler
http_req_duration: ['p(95)<800'], // 95 % der Antworten unter 800 ms
},
}
export default function () {
const res = http.get(`${BASE}/`)
check(res, {
'status 200': (r) => r.status === 200,
'HTTP/2': (r) => r.proto === 'HTTP/2.0',
})
sleep(1)
}
BASE_URL=https://example.com k6 run k6-wordpress.js
Query Monitor (Plugin) zeigt das Innenleben einer einzelnen Anfrage: Anzahl und Dauer der SQL-Abfragen, die langsamsten Abfragen samt dem Plugin, das sie ausgelöst hat, Treffer im Objekt-Cache und externe HTTP-Anfragen. Aktivieren Sie es nur für die Dauer der Diagnose.
Die Ergebnisse halte ich in einer einfachen Tabelle fest, immer für dieselben Adressen und zu einer ähnlichen Tageszeit:
Phase | Median-TTFB | p95 (k6) | SQL-Abfragen |
|---|---|---|---|
Ausgangszustand | … | … | … |
+ PHP-FPM und OPcache | … | … | … |
+ Redis | … | … | … |
+ HTTP/2 | … | … | … |
Ändern Sie jeweils nur eine Schicht und messen Sie nach jeder Änderung.
Wie es weitergeht
Keine dieser Änderungen ist für sich genommen spektakulär. Erst zusammen – ein PHP-FPM-Pool mit berechnetem pm.max_children, OPcache mit ausreichend Speicher, Redis zur Entlastung der Datenbank und Nginx mit HTTP/2 – sorgen sie dafür, dass der Server bei normalem Traffic nicht mehr der Flaschenhals ist. Der nächste Schritt ist das Caching ganzer Seiten, z. B. mit fastcgi_cache in Nginx, doch zuerst lohnen sich eine Ausgangsmessung und diese vier Schichten. Wenn ich den Server Ihres Shops oder Ihrer Website durchsehen soll, schauen Sie auf die Seiten WooCommerce-Onlineshops und Webseiten.
