PHP 8.1.14, Alpine containers, eight queue:work processes per host, each capped at memory_limit=512M by a supervisor config nobody had touched in two years. The symptom: workers would vanish every six to nine hours, supervisor restarted them, and the only trace was a single line in the log — Allowed memory size of 536870912 bytes exhausted. No stack trace worth reading, always inside a different job class. Payloads were modest: JSON blobs of 20–80KB, maybe 300 jobs a minute at peak.
My first assumption was a leak in a job. I added a memory_get_usage(true) probe at the top and bottom of the worker loop and dumped it to stderr every 500 jobs. The numbers climbed in a staircase: ~2MB per 500 jobs, but only on hosts where the image-resize queue was active. On the plain default queue, memory was flat.
The resize job used Imagick. Nothing exotic — open a JPEG, thumbnail it, write to S3. But it never called ->clear() or ->destroy() on the Imagick object, and neither did the shared ImageService it delegated to. PHP’s refcounting should have freed those objects when the job function returned, except Imagick holds a resource that cycles through its own internal handle table, and the objects were being stored in a static $cache array inside ImageService — a memoization someone added for “performance” and forgot about. Every distinct source path stayed resident forever.
What I did, in order:
// before: unbounded static cache
private static array $cache = [];
// after: bounded, and we release the handle
private static array $cache = [];
private const MAX_CACHE = 64;
public function load(string $path): \Imagick {
if (isset(self::$cache[$path])) {
return self::$cache[$path];
}
if (count(self::$cache) >= self::MAX_CACHE) {
$old = array_key_first(self::$cache);
self::$cache[$old]->clear();
self::$cache[$old]->destroy();
unset(self::$cache[$old]);
}
return self::$cache[$path] = new \Imagick($path);
}
And in the job’s finally block, $img->clear(); $img->destroy();. I also dropped --max-jobs from 0 to 1000 so workers recycle on a schedule regardless, which is a blunt instrument but a cheap backstop.
The mechanism matters more than the patch. PHP frees memory when refcounts hit zero, but an unbounded static array is a root — those objects are reachable from the class, so they never hit zero. memory_get_usage(true) also reports arena size, not live objects, so it lags and looks noisier than memory_get_usage(false); I should have used the latter for the leak signal and the former only for fragmentation. The cost of the fix: cache hits on repeated paths drop, and we re-decode the same image occasionally. At 300 jobs/min that’s negligible; at 30,000 it would not be, and I’d want a real LRU with byte accounting instead.
Where this breaks down: if your leak is in a C extension holding memory outside PHP’s allocator (I’ve seen this with old gd builds and with curl handles not closed), memory_get_usage will stay flat while RSS climbs. Watch memory_get_usage(false) and the container’s RSS together. Also, --max-jobs recycling masks leaks rather than fixing them — fine as a backstop, dangerous as the only defense, because your restart window becomes your MTBF and nobody notices until a deploy doubles job volume.
What’s your signal that a long-running worker is drifting — do you trust per-job probes, or do you watch RSS from outside the process?


0 条评论
还没有评论,写下第一条。