Skip to content

Build1 publisher3 min readPublished

PHP-FPM's dynamic pool is a one-second idle-worker loop, not a capacity plan

A developer watched fpm-status in real time and reduced pm = dynamic to a single rule. Read it that way and an oversized max_children stops looking like insurance.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • The example pool config is pm = dynamic with pm.max_children = 300, pm.start_servers = 5, pm.min_spare_servers = 5, pm.max_spare_servers = 50, pm.max_requests = 1000.
  • FPM's master process runs one check roughly every second: if idle workers are fewer than min_spare_servers, fork one; if more than max_spare_servers, kill one; otherwise do nothing.
  • The author booted FPM with start_servers = 5 and immediately saw 6 workers.
  • Timeline: at t=0, 5 idle workers and no action; at t=1 one request leaves 4 idle, which is below the minimum of 5, so the master forks; at t=2 there are 1 busy plus 5 idle; at t=3 the request finishes leaving 6 idle, which is not above the maximum of 50, so nothing is killed and the pool stays at 6.
  • FPM never shrinks back to start_servers; the kill rule only fires above max_spare_servers, so between the two bounds the pool stays wherever demand pushed it, ratcheting up easily and trimming lazily.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

A developer writing on dev.to sat down with a live PHP-FPM pool and reduced `pm = dynamic` to one rule: roughly every second the master counts idle workers, forks one if there are fewer than `min_spare_servers`, kills one if there are more than `max_spare_servers`, and otherwise does nothing [2]. That matters because the loop never consults `max_children` except as a ceiling, which means the large ceiling most teams set defensively is doing far less work than they think.

The asymmetry is where the behaviour lives. Booting with `start_servers = 5` produced six workers almost immediately [3]: one request made four workers idle, which is below the minimum of five, so the master forked; when the request finished, six idle workers were still under the maximum of fifty, so nothing was killed [4]. FPM never returns to `start_servers`, because the kill rule only fires above `max_spare_servers`, and between the two bounds the pool simply stays wherever demand last pushed it [5]. Two seconds after a restart the author's status output showed 5 total, 4 idle, 1 active [6]; thirty-two seconds later it showed 6 total and 99 accepted connections [7], about 2.9 per second [23], all of it a metrics exporter polling `/fpm-status` and keeping one worker permanently busy [9]. The status request is itself served by a worker [8].

Under a k6 run with 100 virtual users, the pool sat at 50 total when quiet, which is exactly `max_spare_servers`, grew to 72 during the burst, and glided back down to 50 over roughly a minute [10]. Shrinking is deliberately slow, roughly one worker per second, so a dip followed by another spike does not trigger a fork storm [11]. Note what the ceiling did during all of this: nothing. The configured ceiling of 300 [1] is about 4.2 times the observed peak of 72 [21].

The counters say the same thing more bluntly. `max active processes` is a high-water mark of workers simultaneously busy, and comparing it to `max_children` shows how close the pool has come to the wall [17]. `max children reached` counts the occasions when every worker was busy and a new request had nowhere to go; anything above zero means users waited [18]. `listen queue` shows requests waiting for a worker right now [19]. All of these reset on restart or reload [20], which is the usual reason a team believes it has never hit the ceiling.

Then the arithmetic. Measured warm and under load, the author's Laravel app ran 51 processes at 1866 MB RSS, about 36.6 MB each [12]; fresh workers start near 14 MB and grow to roughly 37 MB, and a heavy CMS can reach 150-250+ MB per worker [13]. Applying `(RAM for PHP x 0.9) / avg process size` [14] to 7.7 GB gives about 187 children, against a configured 300 that would need about 11 GB, more than the box has [15]. The configured ceiling exceeds the RAM-derived one by about 60 percent [22]. The author is candid that his own config flunks his own math, and that he got away with it only because real peak was 72 workers [16] and therefore about 2.7 GB [24].

Worth watching in your own pools: whether `max children reached` is non-zero since the last reload, and whether the RSS you sized from was measured warm rather than at fork time [13][20].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories