PHP-FPM workers that ate the host
RSS grew after every catalog request. The leak was copy-on-write, not a missing unset().
The VPS ran out of memory on a Tuesday afternoon. Swap started, then the OOM killer took a php-fpm worker, then nginx returned 502 for a minute. Traffic was ordinary. The catalog listing was the page everyone hit.
I looked for a leak first. There was none. Memory_get_usage() at the end of the request went back down. The host still grew. That gap is the whole story: PHP frees request memory, Linux does not always give the pages back, and copy-on-write turns a shared OPcache into private RSS the moment a string is written.
The problem was not a missing unset(). The listing hydrated Eloquent graphs into the worker until private RSS filled a 2 GB host. Shop visitors got 502. I needed the same forty tiles without copying three locales and a model graph on every request.
What the workers actually held
The catalog endpoint cached Eloquent collections in Redis. serialize() on a Product model stores attributes, relations, class names, and a pile of extra state. On get, PHP rebuilt that graph in the worker. Three locales lived in a JSON column, so each row carried en, ru, and de even when the request asked for one language.
After two hundred requests the private RSS of a single worker sat near 180 MB. Eight workers filled a 2 GB host. OPcache was healthy and shared. The private heap was the catalog.
I compared /proc/self/status after fork and after the listing. VmRSS jumped on hydrate, not on bootstrap. smaps showed anonymous private pages growing with each catalog hit. Shared OPcache stayed flat. That is copy-on-write, not a PHP leak the allocator would report.
The change
The cache value became a list of arrays with four keys: id, slug, title, price. Title is already the string for the request locale. Memcached stores that JSON. Redis no longer sees the catalog payload. It still runs queues and sessions, which is what it is good at in this stack.
- pm.max_requests = 200, so a worker dies before RSS becomes the host.
- opcache.preload loads LaraBoom and the host App on start, so the first request does not copy half the codebase.
- memory_limit stays at 128M. That limit never caught the host RSS, because RSS is not the PHP allocator.
Private RSS per worker settled around 70 MB on the same listing. The 502s stopped. The interesting part is not the number. The interesting part is measuring RSS after fork, not memory_get_usage() inside the request.
What I took from this
I stopped trusting memory_get_usage() for host pressure. The kernel sees RSS. PHP sees its allocator. Those numbers diverge as soon as a worker writes into a shared page.
Eloquent is a write model. A public listing is a list of scalars. Caching the model is how a 2 GB VPS dies on ordinary traffic.
pm.max_requests is a backstop, not a design. The design is a compact DTO in Memcached and Redis off the catalog blob.
