Zum Inhalt springen
Alle Beiträge

1. Oktober 202610 Min. Lesezeit

Schnelles WordPress auf dem VPS: PHP-FPM, OPcache, Redis und HTTP/2

Vier Schichten, die WordPress auf einem VPS bei geringstem Risiko am meisten bringen: ein eigener PHP-FPM-Pool, OPcache, Redis als Objekt-Cache und Nginx mit HTTP/2. Mit Konfiguration, Begründung der Werte und Messmethode.

Schnelles WordPress auf dem VPS: PHP-FPM, OPcache, Redis und HTTP/2

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:

bash
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:

bash
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:

bash
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.

ini
; /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 = dynamic hält einige Prozesse bereit und startet unter Last weitere. ondemand spart RAM auf Servern mit vielen selten besuchten Websites, doch die erste Anfrage nach einer Pause wartet auf den Prozessstart. static ist nur sinnvoll, wenn der Server eine einzige Website bedient und Sie deren Traffic kennen.

  • pm.max_children ist 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 Wert 10 im Beispiel ist nur ein Startpunkt; berechnen Sie Ihren eigenen.

  • pm.max_requests = 500 startet einen Prozess nach 500 bearbeiteten Anfragen neu. Das ist eine Absicherung gegen Speicherlecks in Plugins.

  • request_slowlog_timeout schreibt 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:

bash
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:

bash
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.

ini
; /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=1 und revalidate_freq=60 – OPcache prüft höchstens einmal in 60 Sekunden, ob sich eine Datei geändert hat. Verlockend ist validate_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.

bash
sudo apt install redis-server

Setzen Sie in /etc/redis/redis.conf:

conf
bind 127.0.0.1 -::1
protected-mode yes
maxmemory 256mb
maxmemory-policy allkeys-lru
save ""
appendonly no
  • bind und protected-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 "" und appendonly 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:

PHP
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:

bash
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;.

nginx
# /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/*.php nach location ~ \.php$, würde ein ins Verzeichnis uploads eingeschleustes PHP-Skript ausgeführt. Deshalb stehen die Sperren oben.

  • try_files $uri =404 in der PHP-Location verhindert, dass nicht existierende Dateien an FPM weitergereicht werden.

  • expires 30d fü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, denn curl localhost verbindet sich oft über IPv6.

  • xmlrpc.php sperren 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:

bash
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_php PHP-FPM über mod_proxy_fcgi (a2enmod proxy_fcgi setenvif und a2enconf php8.4-fpm). mod_php erzwingt das MPM prefork, 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 mit prefork nur stark eingeschränkt funktioniert: eine Anfrage gleichzeitig pro Verbindung.

  • HTTP/2 aktivieren Sie mit a2enmod http2 und der Direktive Protocols h2 http/1.1 im SSL-vHost.

  • .htaccess kostet Leistung. Bei AllowOverride All sucht Apache bei jeder Anfrage im gesamten Verzeichnispfad nach .htaccess-Dateien. Wenn Sie Zugriff auf die vHost-Konfiguration haben, verschieben Sie die Regeln dorthin und setzen AllowOverride 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:

bash
ab -n 500 -c 10 -k https://example.com/

wrk erzeugt bei HTTP/1.1 mehr Last und liefert die Verteilung der Latenzen:

bash
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:

JavaScript
// 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)
}
bash
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.

Schreiben