Spawned children
Spawned children
A PHP-FPM worker is not always the whole cost of a request. A worker that runs
exec('ffmpeg …'), resizes an image, or renders a PDF starts a separate
process, a separate pid, with its own resident memory that the worker's own
RSS does not include and the status page knows nothing about.
For a tool that divides a memory budget, that gap is where an OOM comes from. The
budget it sizes against (a cgroup limit, or the machine) is charged for
everything, children included. Sizing each worker at only its own RSS prices
the pool as if the ffmpeg were free, hands out a pm.max_children that fits on
paper, and blows the limit the moment the workers start transcoding.
So the children are measured, and (because measuring them has a blind spot) declared as well.
Two numbers per worker
Every scrape reads each worker's memory twice:
- its own RSS, the worker process alone; and
- its subtree RSS, the worker plus every process descended from it.
The gap between them is what the children cost. A plain web worker has none: its subtree equals its own RSS. A worker mid-transcode has a 600 MiB one.
The subtree is read from a single snapshot of the process table taken once per round, so every pool is measured against the same instant and a host with forty pools pays for the walk once, not forty times.
What a snapshot cannot catch
A scrape lands every thirty seconds; an ffmpeg lives for two. Most of the time a transcode starts and finishes entirely between two scrapes, and the subtree walk never sees it. Point-in-time sampling systematically under-counts short-lived children, which is exactly the memory that causes the spike.
That is what the cgroup is for.
The cgroup is the ground truth
Where the master runs under a cgroup (a container, or a systemd service on a VM), the kernel maintains a high-water mark of the memory that cgroup has used: every process in it, children included, continuously, not sampled. It is the number the OOM killer actually enforces against, and it catches the two-second ffmpeg the scrape missed.
fpm-tune reads it (memory.peak on a modern kernel, memory.max_usage_in_bytes
on cgroup v1, the largest memory.current seen otherwise) and treats it as the
truth about what the pool really reached. On a bare VM or dedicated server with
no cgroup, there is nothing to read, and there the per-worker subtree
measurement stands on its own, which is why both exist.
Declaring the workload
Measurement solves the steady state. It does nothing for the first run: a freshly started media pool has no baseline, has been seen spawning nothing yet, and would be sized as if it never will, right up until the first transcode arrives and the host with it.
So a pool can say what it is, up front:
; in the pool's own php-fpm config
env[FPM_TUNE_WORKLOAD] = subprocess-heavy
or globally, for a host where most pools are alike:
fpm-tune serve --apply --workload subprocess-heavy
The classes are:
| Workload | What it means | Reserved for children |
|---|---|---|
web |
Workers serve requests in PHP and spawn nothing. The default. | none |
bursty |
A child now and then (an occasional PDF or image resize). | a little |
subprocess-heavy |
A child on most requests (transcoding, image processing). | a lot |
The per-pool marker wins over the global default, so a mostly-web host with one
transcode pool sets --workload web (or nothing) and annotates the one pool.
media and children are accepted as aliases for subprocess-heavy.
The declaration is only a floor. It keeps the pool safe before it has been
measured; once real children have been observed, the measurement takes over. And
a pool wrongly marked web that is caught spawning something is reserved for
anyway, because being wrong about "web" the unsafe way is an OOM.
How it changes the plan
The child memory is folded into each worker's cost, not held back as a
separate pool of memory. A worker that also runs a 150 MiB child costs
150 MiB more, and the allocator sizes pm.max_children by dividing the
budget by that per-worker cost, exactly as it already does for a worker's own
memory.
That per-worker figure is deliberately amortised: it is the high-water of a
scrape's total child memory divided by the pool's worker count, specifically the
larger of the workers alive in that scrape and the pool's concurrency peak, so a
scrape that happens to catch a quiet ondemand pool with only its two busy workers
alive does not record a whole worker's child as the per-worker cost. A pool where
two of eight workers were each running a 600 MiB ffmpeg records
150 MiB per worker, not 600. So multiplying it back by the worker count
reserves the 1.2 GiB that was really there, not the 4.8 GiB that never
was. For a subprocess-heavy pool, where every worker has a child, the amortised
figure is the full child size. The larger of the workload's guess and this
measurement wins.
Making it a per-worker cost rather than a host-wide reserve is what makes it
safe. The allocator already guarantees it never commits more than the budget, so
folding children into the per-worker cost means that guarantee now covers
children too, whatever the allocator does with the budget. A subprocess-heavy
pool simply gets fewer workers; it never causes the plan to fail. (An earlier
design held the child memory back as a single host-wide reserve; a review found
that one over-declared pool could then zero the budget for every pool on the
host, and that redistribution could hand a pool more workers than its reserve
covered. The per-worker model has neither problem.)
What you will see
In --recommend output, a pool that spawns children carries the split:
; reserved for spawned children: 1.8GiB (folded into each worker's cost, sized to the workers planned)
;
; transcode: measured 90MiB
; measured per worker: median 60.0MiB, p95 90.0MiB, p99 95.0MiB, worst 95.0MiB (240 readings)
; plus ~150.0MiB of children per worker (folded into the sizing; worst single worker+children seen 690.0MiB)
On /metrics:
fpm_tune_pool_subtree_rss_bytes{pool}: the worst single worker's whole footprintfpm_tune_pool_child_rss_bytes{pool}: the child memory folded into each worker's costfpm_tune_cgroup_memory_bytes{state="current|peak"}: the cgroup's own usage, for cross-checkingfpm_tune_budget_bytes{state="reserved_children"}: what the plan committed to children in total
A pool whose child_rss_bytes climbs while its worker_rss_bytes sits flat is a
pool doing more subprocess work than its workers show, and the one to give a
workload declaration if it does not have one.
What it does not catch
The subtree walk is a point-in-time sample. A child that lived and died between
two scrapes is missed, and a child whose worker was recycled (at
pm.max_requests) has reparented away from any worker by the time the next
scrape lands. Where the master runs under a cgroup, the cgroup's own high-water
(reported above) catches those, and is worth watching against the budget. On a
host with no cgroup, and no workload declaration, subtree measurement is
best-effort: declare the workload on a pool you know shells out, and its floor
holds regardless of what the sampling happens to catch.