Shopware 6 Hosting optimieren: PHP-FPM, OPcache und Redis richtig konfigurieren

• von Tobias Schäfer • 7 min read

Die Shopware-Doku sagt dir, dass opcache.max_accelerated_files mindestens 20000 sein soll. Wie viel Speicher OPcache bekommen soll, sagt sie nicht. Zur PHP-FPM-Poolgröße steht dort gar nichts. Diese Lücken füllen die meisten Setups mit Defaults oder kopierten Werten aus einem WordPress-Tutorial.

Dieser Artikel setzt da an, wo der Hosting-Artikel aufhört. Dort ging es um die Wahl des Servers, hier um die Einstellungen danach. Alle Beispiele gehen von einem einzelnen Root-Server mit 16 GB RAM aus, Shopware 6.7, PHP 8.3 und nginx.

PHP-FPM: Poolgröße aus dem RAM rechnen

pm.max_children ist die wichtigste Zahl auf dem ganzen Server. Zu niedrig, und Besucher warten in der Schlange, obwohl RAM frei ist. Zu hoch, und eine Lastspitze schickt den OOM-Killer los, der dann gern MariaDB erwischt.

Die Rechnung ist simpel: RAM, der für PHP übrig bleibt, geteilt durch den Speicher pro Worker. Bei einem 16-GB-Server mit allem auf einer Maschine sieht das Budget so aus: etwa 1 GB für Betriebssystem und nginx, 4 GB InnoDB-Buffer-Pool für MariaDB, 1 GB für Redis. Bleiben rund 10 GB für PHP-FPM. Läuft OpenSearch auf derselben Maschine, ziehst du dessen Heap noch ab.

Die 4 GB für MariaDB sind kein Naturgesetz. Laut MariaDB-Doku soll der Buffer-Pool den aktiven Datenbestand fassen, und ein paar Gigabyte sind ein guter Start. Wie groß deine Shop-Datenbank ist, zeigt diese Abfrage:

SELECT ROUND(SUM(data_length + index_length) / 1024 / 1024) AS mb
FROM information_schema.tables
WHERE table_schema = 'shopware'; -- Name deiner Shop-Datenbank

Ist sie deutlich kleiner als 4 GB, gibst du den Rest an PHP. Ist sie größer, bekommt MariaDB mehr und PHP weniger.

Den Speicher pro Worker misst du auf dem laufenden Server, statt ihn zu schätzen:

ps --no-headers -o rss -C php-fpm | awk '{sum+=$1} END {print sum/NR/1024 " MB"}'

Der Wert hängt stark an den installierten Plugins. Rechne für den ersten Entwurf mit 80 MB und ersetz die Zahl durch deine Messung, sobald der Shop Traffic hat. Ein häufiger Fehler ist, stattdessen durch memory_limit zu teilen. Mit 512 MB kommst du bei 10 GB auf 20 Worker, und der Pool ist bei der ersten Lastspitze voll. memory_limit ist die Obergrenze für einzelne Ausreißer, nicht der Normalverbrauch. Große Importe laufen ohnehin über die Message Queue auf der CLI, nicht über FPM.

Mit 10240 MB, 10 % Puffer und 80 MB pro Worker liefert der PHP-FPM Pool-Rechner diese Werte:

; /etc/php/8.3/fpm/pool.d/www.conf
[www]
pm = dynamic
pm.max_children = 115
pm.start_servers = 29
pm.min_spare_servers = 29
pm.max_spare_servers = 58
pm.max_requests = 500
pm.status_path = /fpm-status

pm.max_requests = 500 startet jeden Worker nach 500 Requests neu. Das begrenzt den Schaden, wenn ein Plugin Speicher verliert. Die Statusseite gibst du in nginx nur für 127.0.0.1 frei. Dort steht der Zähler max children reached, und der ist nach einer Woche Betrieb die ehrlichste Antwort auf die Frage, ob 115 passt. Steht er auf 0, ist der Wert eine Obergrenze, die nie erreicht wird, und das ist in Ordnung. Steigt er, findest du im FPM-Log die Warnung server reached pm.max_children setting. Dann prüfst du, ob wirklich RAM frei ist, bevor du erhöhst.

OPcache: drei Werte und ein Fehler

; /etc/php/8.3/fpm/conf.d/99-shopware.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=20
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
zend.assertions=-1
realpath_cache_size=4096K
realpath_cache_ttl=3600

opcache.memory_consumption steht in PHP standardmäßig auf 128 MB. Für Shopware ist das zu knapp, denn neben vendor/ landet auch der kompilierte Symfony-Container aus var/cache/ im OPcache. 256 MB sind auch das, was der Shopware-Installer empfiehlt. Das offizielle Docker-Image von Shopware setzt dieselben Werte wie oben, inklusive validate_timestamps=0. Ob 256 MB reichen, zeigt cachetool mit cachetool opcache:status. Steht Cache full auf Yes, landen neue Dateien nicht mehr im Cache. Oom restarts zählt, wie oft OPcache sich deshalb komplett geleert hat. Ist einer der beiden Werte auffällig, gehst du auf 512.

Bei opcache.max_accelerated_files rundet PHP intern auf die nächste Primzahl aus einer festen Liste auf, aus 20000 werden 32531 Slots. Zähl einmal nach, was du tatsächlich hast:

find vendor var/cache custom -name '*.php' | wc -l

Liegst du über 32531, setz den Wert auf 60000.

Der eigentliche Fehler steckt in opcache.validate_timestamps. Der PHP-Default ist 1. Dann prüft PHP bei jeder Datei regelmäßig, ob sie sich geändert hat, standardmäßig alle zwei Sekunden. Auf einem Entwicklungsrechner ist das richtig. Auf einem Live-Shop mit Zehntausenden Dateien sind das Dateisystemzugriffe, die keinen Zweck haben, weil sich der Code nur beim Deployment ändert.

Mit validate_timestamps=0 musst du dafür an genau einer Stelle aufpassen: Nach jedem Deployment liefert PHP so lange den alten Code aus, bis du OPcache leerst. Also gehört systemctl reload php8.3-fpm oder cachetool opcache:reset als letzter Schritt in jedes Deployment-Skript. Installierst du ein Plugin über den Admin, leert Shopware den OPcache selbst. Ein bin/console plugin:install auf der Kommandozeile erreicht dagegen nur den OPcache der CLI, nicht den von FPM. Danach brauchst du den Reload also auch.

opcache.preload lasse ich weg. Die Doku verspricht 2–5 % Gewinn, dafür braucht jeder Cache-Clear einen FPM-Neustart und der Extension Manager funktioniert nicht mehr. Für die meisten Shops ist das kein guter Tausch.

Redis: zwei Instanzen statt einer

Die Shopware-Doku teilt Redis-Daten in drei Klassen ein. Caches lassen sich jederzeit neu aufbauen. Sessions sind wichtig, verlieren aber mit der Zeit an Wert. Warenkörbe und Nummernkreise lassen sich nicht wiederherstellen: Ist der Zähler für Bestellnummern weg, vergibt Shopware Nummern doppelt.

Eine einzige Redis-Instanz für alles passt deshalb nicht. Läuft der Cache voll, verdrängt er dort im Zweifel Warenkörbe. Und Persistenz müsstest du für die ganze Instanz einschalten, obwohl nur ein kleiner Teil der Daten sie braucht. Für einen Ein-Server-Shop reichen zwei Instanzen, eine flüchtige für den Cache und eine persistente für alles andere:

# /etc/redis/redis-cache.conf
port 6379
maxmemory 512mb
maxmemory-policy volatile-lru
save ""
appendonly no

# /etc/redis/redis-persistent.conf
port 6380
maxmemory 512mb
maxmemory-policy volatile-lru
appendonly yes

Die Doku empfiehlt für Sessions eigentlich eine dritte Instanz mit allkeys-lru. Bei einem Server mit einem Shop lege ich sie in die persistente Instanz, nur in eine eigene Datenbank. Das geht, weil Sessions und Warenkörbe in Redis eine Ablaufzeit haben, Nummernkreise aber nicht. Wird der Speicher knapp, wirft volatile-lru also zuerst alte Sessions und verwaiste Warenkörbe. Die Zähler für Bestellnummern fasst es nie an.

Die Verbindungen stehen in der .env:

REDIS_CACHE_URL=redis://127.0.0.1:6379/0
REDIS_PERSISTENT_URL=redis://127.0.0.1:6380/0?persistent=1
REDIS_SESSION_URL=redis://127.0.0.1:6380/1

?persistent=1 heißt Connection-Pooling, nicht Datenpersistenz. Seit 6.6.8 definierst du Redis-Verbindungen einmal mit Namen und verweist dann darauf. Die alten redis_url- und dsn-Schlüssel sind in 6.7 deprecated und fallen mit 6.8 weg:

# config/packages/shopware.yaml
shopware:
    redis:
        connections:
            ephemeral:
                dsn: '%env(REDIS_CACHE_URL)%'
            persistent:
                dsn: '%env(REDIS_PERSISTENT_URL)%'
    cart:
        storage:
            type: redis
            config:
                connection: persistent
    number_range:
        increment_storage: redis
        config:
            connection: persistent
    cache:
        invalidation:
            delay_options:
                storage: redis
                connection: ephemeral
    increment:
        user_activity:
            type: array

user_activity merkt sich, welche Admin-Module ein Nutzer zuletzt geöffnet hat. Jede Änderung sperrt dafür kurz die Tabelle, und laufen mehrere Queue-Worker parallel, bremst das laut Doku die Worker aus. Brauchst du die Übersicht über die genutzten Module nicht, empfiehlt die Doku array.

Cache und Sessions laufen über Symfony:

# config/packages/framework.yaml
framework:
    cache:
        app: cache.adapter.redis_tag_aware
        system: cache.adapter.redis_tag_aware
        default_redis_provider: '%env(REDIS_CACHE_URL)%'
    session:
        handler_id: '%env(REDIS_SESSION_URL)%'

Stellst du einen laufenden Shop um, liegen Warenkörbe und Nummernkreise noch in der Datenbank. Ohne Migration verlieren Kunden ihren Warenkorb, und die Bestellnummern fangen in Redis von vorn an. Die Reihenfolge ist deshalb: Wartungsmodus an, Konfiguration ausrollen, dann migrieren:

bin/console sales-channel:maintenance:enable --all
# neue Konfiguration ausrollen, Cache leeren
bin/console number-range:migrate mysql redis
bin/console cart:migrate sql redis://127.0.0.1:6380/0
bin/console sales-channel:maintenance:disable --all

Der Befehl für die Nummernkreise warnt selbst, dass er unter Last doppelte Nummern erzeugen kann. Den Wartungsmodus lässt du also nicht weg.

Zwei Dinge noch. Die Redis-Cache-Instanz kann trotz maxmemory voll laufen, weil Cache-Tags ohne Ablaufzeit liegen bleiben. Die Doku nennt dafür FroshTools oder den shopware-redis-cli-helper zum regelmäßigen Aufräumen. Und die PHP-Extension: php --ri redis | grep Version zeigt, welche php-redis-Version installiert ist. Ab 6.7.7.0 empfiehlt Shopware 6.1 oder neuer, Ubuntu 24.04 liefert 5.3.7. Composer prüft das nicht, du musst also selbst nachsehen. Mehr dazu im Upgrade-Artikel 6.6 auf 6.7.

Nach einer Woche schaust du auf vier Werte: max children reached in der FPM-Statusseite, Cache full und Oom restarts im OPcache-Status und used_memory gegen maxmemory in beiden Redis-Instanzen. Steht überall noch Luft, passt das Setup.

Ähnliche Artikel