Tuning guides talk about throughput. Nobody pages you about throughput. They page you about symptoms, and the useful skill is mapping a symptom back to a cause before you spend money on hardware.
Three failure modes account for most of what I find on inherited servers. Each has a distinct signature.
The 502 nobody can reproduce
Server has 8GB. PHP-FPM is set to 100 workers. Each worker uses 60MB under load. That's 6GB of PHP, plus MariaDB, plus Nginx, plus the OS.
Under normal traffic you never approach 100 workers, so it looks fine for months. Then a marketing email goes out, concurrency spikes, and the kernel runs out of memory. The OOM killer picks a process and terminates it, usually the biggest one, which is a PHP-FPM worker holding an in-flight request.
User gets a 502. The application log has nothing, because the process died before it could write anything. Nginx logs recv() failed (104: Connection reset by peer). Ten minutes later everything looks normal.
sudo dmesg -T | grep -i "killed process"
sudo journalctl -k | grep -i oom
Hits there mean you don't have a mystery. You have a pm.max_children value nobody checked against real memory.
The site that degrades all day and resets overnight
TTFB is 180ms at 8am. By 4pm it's 900ms. Nobody deployed. Overnight it's fast again because something restarted PHP-FPM.
That's OPcache running out of room. When the cache fills, it stops caching new scripts or wipes and rebuilds, and every miss pays full parse-and-compile again. It degrades gradually, which is why it goes unnoticed for months.
The counters are oom_restarts and hash_restarts from opcache_get_status().
Here's the part that trips people up. OPcache state is per SAPI. Run that function from the CLI and you're reading the CLI cache, which is empty, separate, and tells you nothing about your site. You have to ask through PHP-FPM.
<?php
// drop in webroot, lock to your IP, delete when done
$allowed = ['203.0.113.42'];
if (!in_array($_SERVER['REMOTE_ADDR'], $allowed, true)) {
http_response_code(404);
exit;
}
$s = opcache_get_status(false);
$hits = $s['opcache_statistics']['hits'];
$miss = $s['opcache_statistics']['misses'];
echo 'hit rate: ', round($hits / max(1, $hits + $miss) * 100, 2), "%\n";
echo 'cached files: ', $s['opcache_statistics']['num_cached_scripts'],
' / ', $s['opcache_statistics']['max_cached_keys'], "\n";
echo 'oom restarts: ', $s['opcache_statistics']['oom_restarts'], "\n";
echo 'hash restarts: ', $s['opcache_statistics']['hash_restarts'], "\n";
echo 'cache full: ', var_export($s['cache_full'], true), "\n";
Behind Cloudflare or a load balancer, check what REMOTE_ADDR actually contains first, or that guard is an open door. If you'd rather not touch the webroot, cachetool talks to the FPM socket directly.
The default opcache.max_accelerated_files is 10,000. A CI4 app with modest deps is around 1,500 to 3,000 files, fine. Laravel with a normal dependency tree runs 10,000 to 15,000. Nextcloud's own health check tells admins to set it above 80,000. Count yours with find . -name "*.php" | wc -l before assuming.
One app taking down five others
A default install creates a single pool, www.conf, shared by every site on the box. So when your least important app starts waiting on a slow third-party API, its requests occupy workers from the shared pool.
Those workers aren't available to anything else. Your customer portal queues behind a lead-capture form calling an unresponsive CRM endpoint.
One pool per app:
; /etc/php/8.5/fpm/pool.d/crm.conf
[crm]
user = www-data
group = www-data
listen = /run/php/php8.5-fpm-crm.sock
listen.owner = www-data
listen.group = www-data
pm = dynamic
pm.max_children = 20
pm.start_servers = 5
pm.min_spare_servers = 3
pm.max_spare_servers = 8
pm.max_requests = 500
pm.status_path = /fpm-status
slowlog = /var/log/php8.5-fpm-crm-slow.log
request_slowlog_timeout = 5s
php_admin_value[memory_limit] = 256M
php_admin_value[error_log] = /var/log/php8.5-fpm-crm-error.log
Memory ceiling per app, separate logs, independent restarts. pm.max_requests recycles workers so slow leaks in older code get contained instead of accumulating until the OOM killer steps in.
Measure per-worker memory, don't guess it
The pm.max_children formula everyone quotes needs a number most people invent. Measure it against warm production workers:
$ ps -ylC php-fpm8.5 --sort:rss | awk 'NR>1 {print $8/1024" MB"}' | tail -5
58.4 MB
61.2 MB
63.9 MB
71.5 MB
88.1 MB
Check the process name with ps -e | grep fpm first, since it's php-fpm8.5 on Debian and Ubuntu and plain php-fpm elsewhere.
Size for your heavy path, not your average, and leave headroom. If the math says 50, set 40. Being conservative costs you a slightly longer queue. Being aggressive costs you the OOM killer.
A read-only hour
| Check | How | Act when |
|---|---|---|
| OOM kills | `dmesg -T \ | grep -i "killed process"` |
| CPU steal |
vmstat 1 5, st column |
Above 5% sustained |
| OPcache | localhost script through FPM, not CLI | Hit rate under 95%, restarts non-zero |
| File count | `find . -name "*.php" \ | wc -l` |
| Worker memory | ps -ylC php-fpm8.5 --sort:rss |
Exceeds what max_children assumed |
| Pool isolation | ls /etc/php/8.5/fpm/pool.d/ |
Only www.conf, multiple sites |
Nothing there changes anything or restarts anything. You end the hour knowing whether you have a configuration problem or a capacity problem, which is the distinction that decides whether you're making a config change or a permanent increase to your monthly bill.
Longer version with the status page setup, Nginx stub_status, FastCGI caching, and the cache bypass rule that burns people: Nginx and PHP-FPM Tuning for Mid-Market Infrastructure
Top comments (0)