CV laden

Performance

php-fpm-RSS, den Laravel dir nicht zeigt

memory_get_usage() sank am Ende des Requests. Der Host wuchs trotzdem. Copy-on-Write machte geteilte OPcache-Seiten nach Eloquent-Hydrate zu privatem RSS.

nginx lieferte eine Minute 502, nachdem der OOM-Killer einen php-fpm-Worker genommen hatte. Der Docker-Host hat ein Memory-Budget für php-fpm, Redis, MySQL, Memcached und Node. Im Laravel-Log sah php-fpm gut aus. memory_get_usage(true) am Ende eines Notes-Listenrequests lag bei etwa 12 MB. Acht Worker füllten trotzdem eine 2-GB-Box.

Es gab kein Leak im PHP-Allocator. Der Host wuchs, weil Copy-on-Write eine Seite kopiert, sobald ein Worker sie schreibt. OPcache ist nach dem Fork geteilt. Eloquent-Hydrate einer Collection mit drei Locales in JSON schreibt viele Seiten. Diese Seiten werden privates RSS und bleiben privat, bis der Worker stirbt. Nutzer sahen 502. Der Host sah RSS, das Laravel nie druckte.

Telescope und das Log waren sich einig, der Request sei klein. smem am Worker nicht.

memory_get_usage() fiel. Host-RSS nicht. Die Lücke ist Copy-on-Write.
memory_get_usage() fiel. Host-RSS nicht. Die Lücke ist Copy-on-Write.

Was die Worker hielten

Der Listenendpunkt cachte Eloquent-Models in Redis. serialize() speichert Attribute, Relationen, Klassennamen und Extra-State. Beim get baute PHP den Graphen neu. Jede Zeile trug en, ru und de, auch wenn der Request eine Sprache wollte.

Der Cache-Wert wurde eine Liste von Arrays mit vier Keys: id, title, image, size. Title ist schon der String der Request-Locale. Redis speichert dieses JSON. Der Worker-Heap hörte auf, den ganzen Model-Graphen zu klonen.

Accessors, die eine Relation anfassen, schmutzen nach dem DTO-Wechsel weiter Seiten. Eine Notizenliste lud die Kategorie für ein Badge. Dieser Hydrate kopierte weitere Seiten neben OPcache in privates RSS. Das Badge-Feld liegt jetzt im gecachten Array beim Schreiben. Der Request weckt Eloquent für das Raster nicht mehr.

RSS nach dem Fork messen, nicht memory_get_usage() am Ende des Requests.
RSS nach dem Fork messen, nicht memory_get_usage() am Ende des Requests.

Limits, die den Host wirklich begrenzen

pm.max_requests = 200, damit ein Worker stirbt, bevor privates RSS die Maschine wird. memory_limit bleibt 128M. Dieses Limit sah den Host-RSS nie, weil RSS nicht der PHP-Allocator ist. Das Docker-Memory-Limit des php-Service muss alle Worker plus Puffer fassen. Teilen Redis und MySQL dieselbe VM, zähle sie mit.

Ich messe RSS nach dem Fork, nach der ersten Liste, nach zweihundert Listen. Stimmen die ersten zwei Zahlen und die dritte ist um 100 MB gestiegen, hat das nächste Feature einen Accessor ergänzt, der eine Relation anfasst. Der N+1-Test hätte das nicht gefangen. Das RSS-Log hat es gefangen.

pm.max_children auf einer geteilten 2-GB-Box ist nicht die Zahl, die auf einem dedizierten PHP-Host gut aussieht. Acht Worker mal privates RSS, das nach Hydrate stieg, ist, wie der OOM ein Opfer wählte. Ich habe Children gesenkt, bis Redis und MySQL nach zweihundert Listen noch Platz hatten.

Was ich daraus mitnehme

Ich messe Worker-RSS nach Fork und nach N Listen, nicht memory_get_usage() am Request-Ende. 12 MB Allocator und ein wachsender Host können beide stimmen.

Eloquent im Redis-Listen-Cache und memory_limit als Host-Grenze traue ich nicht. Ein Array mit vier Keys pro Kachel cachen. Worker mit pm.max_requests recyceln.

Die Regel, die ich behalte: Docker-Memory für alle Worker plus MySQL und Redis zählen. Steigt RSS bis Request 200 um 100 MB, hat ein Accessor eine Relation geweckt. Den Worker töten, bevor die Box stirbt.

Zurück zu Notizen