CV laden
Zurück zu Business Cases

Speicher

PHP-FPM-Worker, die den Host auffraßen

RSS wuchs nach jedem Katalog-Request. Das Leck war Copy-on-Write, nicht ein fehlendes unset().

Am Dienstagnachmittag war der Speicher auf dem VPS leer. Swap setzte ein, der OOM-Killer nahm einen php-fpm-Worker, nginx lieferte eine Minute 502. Der Traffic war normal. Die Seite, die alle trafen, war die Katalogliste.

Zuerst habe ich nach einem Leak gesucht. Es gab keines. memory_get_usage() sank am Ende des Requests wieder. Der Host wuchs trotzdem. In dieser Lücke liegt die Geschichte: PHP gibt Request-Speicher frei, Linux gibt Seiten nicht immer zurück, und Copy-on-Write macht den geteilten OPcache privat, sobald ein String beschrieben wird.

Das Problem war kein fehlendes unset(). Die Liste hydrierte Eloquent-Graphen in den Worker, bis privater RSS einen 2-GB-Host füllte. Shop-Besucher bekamen 502. Ich brauchte dieselben vierzig Kacheln ohne drei Locales und ein Model-Graph bei jedem Request.

OPcache bleibt geteilt, bis ein Worker schreibt. Danach ist die Seite privat.
OPcache bleibt geteilt, bis ein Worker schreibt. Danach ist die Seite privat.

Was die Worker wirklich hielten

Der Katalog-Endpunkt cachte Eloquent-Collections in Redis. serialize() an einem Product-Model speichert Attribute, Relationen, Klassennamen und viel Extra-State. Beim get baute PHP diesen Graphen im Worker neu. Drei Locales lagen in einer JSON-Spalte, jede Zeile trug also en, ru und de, auch wenn der Request nur eine Sprache wollte.

Nach zweihundert Requests lag der private RSS eines Workers bei etwa 180 MB. Acht Worker füllten einen 2-GB-Host. OPcache war gesund und geteilt. Der private Heap war der Katalog.

Ich habe /proc/self/status nach dem Fork und nach der Liste verglichen. VmRSS sprang beim Hydrate, nicht beim Bootstrap. smaps zeigte anonyme private Seiten mit jedem Katalog-Hit. Geteiltes OPcache blieb flach. Das ist Copy-on-Write, kein Leak, den der PHP-Allocator meldet.

Derselbe Worker, drei Momente: nach dem Fork, nach Eloquent-Hydrate, nach einem kompakten DTO.
Derselbe Worker, drei Momente: nach dem Fork, nach Eloquent-Hydrate, nach einem kompakten DTO.

Die Änderung

Der Cache-Wert wurde eine Liste von Arrays mit vier Keys: id, slug, title, price. Title ist schon der String der Request-Locale. Memcached speichert dieses JSON. Redis sieht das Katalog-Payload nicht mehr. Queues und Sessions laufen weiter dort, dafür sitzt Redis in diesem Stack.

  • pm.max_requests = 200, damit ein Worker stirbt, bevor RSS den Host wird.
  • opcache.preload lädt LaraBoom und die Host-App beim Start, damit der erste Request nicht die halbe Codebasis kopiert.
  • memory_limit bleibt 128M. Dieses Limit hat den Host-RSS nie gefangen, weil RSS nicht der PHP-Allocator ist.

Der private RSS pro Worker lag auf derselben Liste bei etwa 70 MB. Die 502er waren weg. Die Zahl ist nicht der Punkt. Der Punkt ist, RSS nach dem Fork zu messen, nicht memory_get_usage() im Request.

Was ich daraus mitnehme

Ich vertraue memory_get_usage() nicht mehr für Host-Druck. Der Kernel sieht RSS. PHP sieht seinen Allocator. Die Zahlen divergieren, sobald ein Worker in eine geteilte Seite schreibt.

Eloquent ist ein Schreibmodell. Eine öffentliche Liste ist eine Liste von Skalaren. Das Model zu cachen ist, wie ein 2-GB-VPS bei normalem Traffic stirbt.

pm.max_requests ist ein Backstop, kein Design. Das Design ist ein kompaktes DTO in Memcached und Redis ohne den Katalog-Blob.

Zurück zu Business Cases