php-fpm max_children warning: find the cause in the logs before adding workers

The php-fpm max_children warning is a sign that every PHP worker process is busy handling requests, so new requests have gone into the queue. On a WordPress server running Nginx, Apache and php-fpm where this warning had been piling up for weeks, I used the logs to single out the requests that filled the workers, and solved it with an nginx rate limit instead of raising the setting.
In the weekly check the warning was logged 27–187 times a day, yet visitors got zero 502 or 504 errors over the same four days. What filled the workers was not visitors but a bot hammering admin URLs and a scanner requesting paths that do not exist.
1️⃣ php-fpm max_children & seems busy
When the warning piles up to 66 a day, it is easy to read that as 66 times the workers ran out. The php-fpm log mixes two different warnings, and most are early warnings logged before the limit is reached.
[09-Oct-2026 00:16:18] WARNING: [pool www] seems busy (you may need to increase pm.start_servers, or pm.min/max_spare_servers), spawning 8 children, there are 0 idle, and 15 total children
[09-Oct-2026 13:02:05] WARNING: [pool www] server reached pm.max_children setting (26), consider raising it[seems busy] means there are 0 idle workers and a new process is being spawned; with process management set to dynamic, even a brief rise in requests logs it. [server reached pm.max_children] is the moment the limit of 26 was hit and no more could be spawned, and from then on new requests wait.
It is the difference between calling in one more waiter and leaving guests standing outside because every seat is taken.
Counting one day by type gave 58 seems busy and 6 max_children. The check routine added both and reported 66, but the limit was actually reached six times.
grep '09-Oct-2026' /var/log/php-fpm/error.log | grep 'pool www' | grep -c 'seems busy'58grep '09-Oct-2026' /var/log/php-fpm/error.log | grep 'max_children' | cut -c2-2109-Oct-2026 13:02:05
09-Oct-2026 13:52:10
09-Oct-2026 18:09:20
09-Oct-2026 18:42:17
09-Oct-2026 18:46:07
09-Oct-2026 19:12:44The six times printed by the second command become the reference for tracing the cause; the next step uses only these times.
2️⃣ Why raising max_children is not the answer
Raising the limit of 26 may reduce the number of warnings, but it does not change which requests filled the workers.
The usual calculation divides available memory by one worker's average memory. On the measured server, 26 workers used 131MB on average and 214MB at most, 3.4GB in total, and 4.1GB of the 15.7GB of memory was available, so on paper 30 or more additional workers could be spawned.
ps -o rss= -u www -C php-fpm | awk '{s+=$1; n++; if($1>m) m=$1} END{print n, s/n/1024, m/1024, s/1024}'26 130.579 214.27 3395.05free -m total used free shared buff/cache available
Mem: 15783 11658 397 340 4383 4124
Swap: 8191 1615 6576nproc4The four values in the first output are worker count, average memory (MB), maximum memory (MB) and total (MB); the available column of the free output is the available memory.
With 4 CPU cores, more workers do not increase the requests handled at once; they only lengthen the time each request takes. While visitors get zero 502 or 504 errors, raising the limit gains nothing and simply gives more processes to the bots and scanners that were occupying the workers.
Before raising the limit, check whether the requests occupying workers at the warning times were visitors or not; the next section lays out how to check that in access.log.
3️⃣ Tracing the cause in access.log: time, IP, path, status code
Pick one warning time and the answer is in the access.log for that 1–2 minutes. The order is: extract the lines by time, tally by IP, then check the paths and status codes of the top IPs.
grep '09/Oct/2026:18:09' /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head 115 140.245.x.x
61 187.15.x.x
17 3.36.x.x
8 91.239.x.x
4 79.127.x.x
3 109.248.x.x
3 109.248.x.x
3 109.248.x.x
2 79.127.x.x
2 40.77.x.xgrep '09/Oct/2026:18:09' /var/log/nginx/access.log | awk '{print $7, $9}' | sort | uniq -c | sort -rn | head 55 / 200
13 /wp-login.php 404
13 /wp-admin/plugins.php 302
13 /wp-admin/index.php 302
13 /wp-admin/edit.php 302
12 /wp-admin/profile.php 302
4 / 499
3 /xmlrpc.php 302
2 /xmlrpc.php 499
2 /wp-content/themes/kinsta/images/duos/small/wheelchair.svg 404At 18:09 on October 9, 286 requests were logged in one minute; one IP accounted for 115 of them, the paths were admin URLs such as /wp-admin/profile.php, and every status code was 302.
The status code tells you whether PHP was reached. A 200 for cached HTML served directly by nginx, and 444 or 400, never touch PHP, while 302, 404 and an uncached 200 each use a worker. When a logged-out client requests an admin URL, WordPress responds with a 302 to the login page, and that one 302 is one PHP execution.
If one server hosts two or more sites, the global access.log records lines from both. At first I thought this bot was attacking the first site, but comparing with each site's own log showed that 22,390 of the 22,391 requests were for the second site.
4️⃣ Three kinds of requests that filled the workers: bot wp-admin hits, scanner 404s, cache preload
Applying the same order to every time a warning was logged showed three kinds of requests filling the workers.
The first was a bot repeatedly requesting admin URLs. One IP sent 22,391 requests in three hours; 9,944 of them were to four paths including /wp-admin/profile.php, and since each logged-out 302 is one PHP execution, it used a worker 9,944 times.
The second was a vulnerability scanner requesting paths that do not exist. Even with Googlebot in the User-Agent it is not Googlebot: the IP that sent 14,672 requests a day was a Google Cloud virtual server whose reverse DNS ends in googleusercontent.com, while Googlebot ends in googlebot.com. The same IP alternated between bingbot, Applebot, GPTBot and ClaudeBot.
Requests for files like .env are cut off by nginx with a 444 in 0.04 seconds, but ordinary paths not covered by the block rules, such as /userfiles or /secrets.yml, make WordPress generate a 404 page. That 404 page costs 266KB and 1.4–2.9 seconds, the same as generating a normal post, and one IP requested it 4,069 times a day.
The third was the server itself. Saving a post or translation clears the cache and WP Rocket's preload requests every URL again, which came to 8,489 requests a day and up to 60 a minute. Rank Math's index status checks also used a worker 754 times a day via Action Scheduler. Both concentrated on working hours and caused no visitor errors, so I left them out of the fix.
In the simulator below you can see at which layer each request ends and whether it uses a PHP worker.
| Request | Stops at | Response | Workers |
|---|---|---|---|
| Bot wp-admin | WordPress | 302 (to the login page) | Uses 1 |
| Scanner .env-type | nginx block rule | 444 (0.04 s) | Not used |
| Scanner ordinary fake path | WordPress | 404 page (266KB · 1.4–2.9 s) | Uses 1 |
| Cache preload | WordPress | 200 · saved as cache file | Uses 1 |
| Visit to a cached post | Cache file | 200 (nginx answers directly) | Not used |
| Visit to an uncached post | WordPress | 200 · saved as cache file | Uses 1 |
5️⃣ nginx rate limiting: limiting logged-out requests and restoring the real IP
I chose rate limiting over blocking. Block a path and the scanner switches paths; block an IP and a different IP shows up the next day. Only logged-out requests that reach PHP are limited, to 30 per minute and 60 at once per IP, and the excess gets a 429.
For a server behind a CDN, this setting alone is not enough. The IP nginx sees is the CDN edge address, so every visitor coming through that edge is lumped into one IP; you first need to register the edge IP list with set_real_ip_from to restore the real IP from X-Forwarded-For. Requests with a login cookie are exempt, but nothing is exempted by User-Agent.
# http context
real_ip_header X-Forwarded-For;
real_ip_recursive on;
set_real_ip_from 203.0.113.0/24; # CDN edge list
map $http_cookie $php_anon_key {
default $binary_remote_addr;
~*wordpress_logged_in '';
}
limit_req_zone $php_anon_key zone=php_anon:10m rate=30r/m;
# location that passes to PHP
location @apache {
limit_req zone=php_anon burst=60 nodelay;
limit_req_status 429;
proxy_pass http://127.0.0.1:8080;
}Cached pages are answered inside nginx by try_files and never reach this location, and admin-ajax.php is a separate location, so it is not included in the limit.
Test with concurrent requests. Sequential requests never exceed 30 a minute because one 404 takes 1.5 seconds, so they never hit the limit; sending 100 requests 20 at a time gives 67 404s and 33 429s, confirming the limit works.
seq 1 100 | xargs -P 20 -I{} curl -s -o /dev/null -w '%{http_code}\n' https://example.com/zz-{} | sort | uniq -cIf the output counts 429s, the limit is working. If you exempted the server's own IP, lift the exemption briefly while testing or run it from another network.
6️⃣ Before and after
The limit on logged-out admin URLs went live on the evening of October 9, and the limit on requests reaching PHP on the evening of the 11th. Admin URL 302s fell from 9,996 on the 9th to 11 on the 10th and 1 on the 11th, and the IP that sent 22,391 requests in three hours has sent none since the next day.
For the scanner the limit has only just been applied, so there is no full day of numbers yet; the judgment will come from how far the number of 404 pages generated by WordPress (2,563 on the 9th, 2,612 on the 10th, 1,630 on the 11th) drops in next week's check. Visitor 502 and 504 errors were 0 both before and after.
These numbers were tallied directly from the logs of a WordPress server running Nginx, Apache and php-fpm, and the judgment was based not on the number of warnings but on visitor errors and requests reaching PHP.
🔢 FAQ & Recommended Content
Cache
Website Cache & Server-Side Cache
Explains layer by layer how cached requests avoid PHP.
Security
wp-config.php Security Vulnerabilities
How to protect the config file that scanners target with permissions and server rules.
Linux
15 categories of Linux commands
A table sorting commands into 15 categories, including the grep and awk used to tally the logs.
ℹ️ Affiliate Disclosure
This site's content contains affiliate links. When a visitor buys a product or service through one of them, the site receives a commission from the seller. The amount the buyer pays(it goes down during event discounts ↓)does not go up. Posted prices, discounts, and stock reflect the time of writing and may differ, so confirm with the seller before buying. Products are chosen and reviewed by our own standards, and commissions do not affect the order or content of reviews.