Every PHP-FPM tuning guide gives you the same formula:
pm.max_children = (total RAM - other services) / average process size
It is not wrong. It is just that average process size is doing all the work,
and nobody tells you how to measure it on your application. So people pick a
number that sounds brave, deploy it, and find out at 3am.
What the pool actually does
A pool is a queue and a set of workers. pm.max_children is the number of
workers. Every request occupies one worker for its entire lifetime — including
the part where it is sitting on a slow database query doing nothing at all.
Set it too low and requests queue. Set it too high and the box swaps, which is far worse than queueing, because a swapping machine gets slower under the load that caused the swap.
Measuring the real process size
ps overstates it. PHP-FPM workers share a lot of memory with the master
through copy-on-write, so summing RSS across workers double-counts the shared
pages. What you want is PSS, which divides shared pages by the number of
processes sharing them:
# Proportional set size, in MB, across every php-fpm worker
grep -H '^Pss:' /proc/$(pgrep -d' -o ' php-fpm | tr ' ' '\n' | head -1)/smaps_rollup
# Or the whole pool at once
ps -o pid= -C php-fpm | while read -r pid; do
awk '/^Pss:/ {sum += $2} END {printf "%s\t%.1f MB\n", "'"$pid"'", sum/1024}' \
"/proc/$pid/smaps_rollup" 2>/dev/null
done | sort -k2 -n
Run that under real traffic, not on an idle box. An idle worker that has not yet served a request is not representative of anything.
Setting the pool
With a real number in hand, the config is unremarkable:
; /etc/php/8.3/fpm/pool.d/marcusloo.conf
[marcusloo]
user = www-data
group = www-data
listen = /run/php/php8.3-fpm-marcusloo.sock
listen.owner = www-data
listen.group = www-data
pm = static
pm.max_children = 24
pm.max_requests = 500
; Log anything slower than five seconds with a full PHP backtrace.
slowlog = /var/log/php-fpm/marcusloo.slow.log
request_slowlog_timeout = 5s
pm = static rather than dynamic is deliberate. On a box that only serves
this one application, spawning and reaping workers buys nothing and costs
latency at exactly the moment traffic arrives.
pm.max_requests = 500 recycles workers to paper over leaks in extensions you
do not control. It is a mitigation, not a fix.
Wiring nginx to it
upstream marcusloo_fpm {
server unix:/run/php/php8.3-fpm-marcusloo.sock;
}
server {
listen 443 ssl http2;
server_name marcusloo.com;
root /var/www/marcusloo.com;
index index.php;
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass marcusloo_fpm;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
# Fail fast rather than holding a worker for a minute.
fastcgi_read_timeout 30s;
}
}
The part people skip
Watch the queue, not the CPU. PHP-FPM's status page reports listen queue, and
any value above zero for a sustained period means requests are waiting for a
worker:
| Field | What it means |
|---|---|
listen queue |
Requests waiting right now |
max listen queue |
The worst it has been since start |
slow requests |
Requests past request_slowlog_timeout |
Scraping it is three lines, and worth wiring into whatever already pages you:
<?php
$status = json_decode(file_get_contents('http://127.0.0.1/fpm-status?json'), true);
if (($status['listen queue'] ?? 0) > 0) {
error_log(sprintf('fpm: %d queued, %d/%d workers busy',
$status['listen queue'], $status['active processes'], $status['total processes']));
}
Apache's equivalent, if you are on mod_proxy_fcgi rather than nginx:
<FilesMatch \.php$>
SetHandler "proxy:unix:/run/php/php8.3-fpm-marcusloo.sock|fcgi://localhost/"
</FilesMatch>
<Location "/fpm-status">
Require local
</Location>
If max listen queue is climbing and memory is fine, raise pm.max_children.
If it is climbing and memory is not fine, the answer is not the pool — it is the
slow query in slowlog.
Tuning the pool is what you do after the application is fast. It is not a substitute for the application being fast.