
🌐 Also available in: Deutsch · Français · Español
Deep Dive, part of the series around the Ansible Tour. Level: advanced Linux administration. The tour explains the roles; the deep dives explain the technology underneath them – how it works, why it is here, what exactly this setup does with it, how to run it day to day, and what else there would have been.
WordPress and Nextcloud are PHP applications, and in both pods every dynamic request ends up in a PHP-FPM worker (part 3, part 4). How many workers exist, how long one may run and how much compiled code they share decides how fast the sites are and what happens under load. This post explains the mechanics, the settings chosen here, the measurements behind them – and two defaults that capped Nextcloud without anyone noticing.
🧭 How PHP-FPM and OPcache work
Master, pool, workers
PHP-FPM (“FastCGI Process Manager”) is a master process that starts and supervises worker processes. Each worker handles exactly one request at a time, from start to finish. nginx, in the same pod, serves static files itself and passes every .php request over FastCGI to port 9000. A pool is a group of workers with its own settings; both applications use the single pool www from the official images.
How many workers
pm = static: a fixed number of workers, always running.pm = dynamic: betweenmin_spare_serversandmax_spare_serversidle workers are kept ready; new ones are started up tomax_children.pm = ondemand: workers start only when a request arrives and stop after an idle timeout.
pm.max_children is the real concurrency limit: request number max_children + 1 waits in the listen queue until a worker is free. pm.max_requests recycles a worker after that many requests, which bounds slow memory growth from leaky extensions.
Two kinds of time limits
max_execution_time is PHP’s own limit and counts only CPU time of the script on Linux – a request waiting for the database or a lock does not use it up. request_terminate_timeout is FPM’s limit on wall-clock time: when it expires, the master kills the worker. Only the second one frees a worker that hangs.
OPcache: compile once
PHP compiles every script to bytecode before running it. OPcache keeps that bytecode in shared memory, which all workers of a pool use together, so a file is compiled once per pod instead of once per request. Three settings size it: memory_consumption (the segment), interned_strings_buffer (identical strings stored once), and max_accelerated_files (the number of slots in its hash table). That last one is rounded up internally to the next number from a fixed list of primes – 10,000 becomes 16,229, 20,000 becomes 32,531. When the slots or the memory are full, further files are simply not cached and are compiled on every request.
validate_timestamps decides whether OPcache checks file modification times (every revalidate_freq seconds). Off is faster, but then a changed file stays old bytecode until FPM restarts.
APCu: a cache for the application
APCu is a second shared-memory segment that the application itself fills with data – Nextcloud stores app and configuration lookups there as its local cache. Like OPcache it is per pod, not shared between pods; anything that has to be consistent across processes and pods belongs in Redis.
🛡️ Why it is here
- It is what the official images ship.
wordpress:fpmandnextcloud:fpmboth contain PHP-FPM; nginx in front is the documented setup. - Predictable concurrency. A fixed upper limit of workers that fits the container’s memory limit is better than a server that accepts everything and then gets OOM-killed.
- Fine control. Pool size, timeouts, the status page and the slow log can be set per application, independently of the web server.
🔧 What this repository does
| Blog | Nextcloud A (larger) | Nextcloud B | |
|---|---|---|---|
| PHP | 8.4 | 8.5 | 8.5 |
pm | dynamic | dynamic | dynamic |
max_children | 12 | 30 | 20 |
| start / min spare / max spare | 4 / 2 / 8 | 8 / 4 / 16 | 6 / 3 / 12 |
max_requests | 500 | 500 | 500 |
request_terminate_timeout | 300 s | 3,600 s | 3,600 s |
| Container limit | 1 GiB, 2 cores | 6 GiB, 3 cores | 5 GiB, 2 cores |
| OPcache | 128 MB, 10,000 files, timestamps checked every 60 s | 256 MB, 20,000 files, timestamps off | same |
| APCu | – | 128 MB | 128 MB |
The pool settings live in a ConfigMap mounted as z-monitoring.conf into php-fpm.d/ – the “z” makes sure it is read last and wins over the image’s www.conf. The Nextcloud values come from host variables with defaults in the template, so a small test host and the two production servers share one template.
The default that capped Nextcloud at five
In the first version of the Nextcloud stack the pool had no settings of its own. The container therefore ran with FPM’s stock pm.max_children = 5 – regardless of its 4 GiB memory limit. Desktop sync clients, mobile apps and Collabora’s WOPI callbacks all occupy workers; the sixth simultaneous PHP request queued. That was fixed in July 2026 with a pool of 12, later 20; by October Prometheus showed peaks of 17 active workers on the larger server, so the original cap of five had been very real. The tuning pass of 16 October 2026 raised the pools to 30 and 20. The sizing rule: memory per worker times max_children, plus OPcache and APCu, must stay well below the container limit.
The bytecode cache that was too small
The Nextcloud image sets max_accelerated_files = 10000, internally 16,229 slots. Nextcloud with its apps has 15,486 PHP files on the larger server and 17,811 on the smaller one. On the smaller server, more than 1,500 files never fit into the cache and were compiled again on every request that used them. Since 16 October both servers run with 20,000 (32,531 slots) and 256 MB. The blog, with 4,452 PHP files, is far from its limit.
Timestamps off – and what that costs
On Nextcloud validate_timestamps = 0: no stat() per file and request. The price is that a changed file is not noticed until the pod restarts. Image updates restart the pod anyway, but occ upgrade and occ app:update overwrite PHP files in place. The update playbook therefore rolls the Nextcloud pod a second time after them. For this article it turned out that the opt-in app update in the main playbook lacked that second restart – updated apps would have kept running as their old code. Fixed. The blog keeps timestamps on with a 60-second interval, because WordPress updates plugins in place from its own admin page.
Hard limits and the slow log
[www]
pm.status_path = /fpm-status
slowlog = /proc/1/fd/2
request_slowlog_timeout = 5s
request_slowlog_trace_depth = 0
request_terminate_timeout = 3600s ; blog: 300srequest_terminate_timeoutwas not set before 16 October either: a request hanging on a lock or an external storage kept its worker until the pod restarted. 3,600 s on Nextcloud, because a streamed folder download can legitimately run for many minutes; 300 s on the blog.- The slow log goes to the container’s stderr and thus into the pod log. A stack trace would need
ptrace, which a container without capabilities does not have; depth 0 logs only the line “executing too slowly”. FPM still tries to attach and logs one harmlessfailed to ptraceper slow request – accepted, becauseSYS_PTRACEwould weaken the container. - The status page is not exposed through nginx at all; a
php-fpm_exportersidecar in the same pod reads it over FastCGI and serves the numbers to Prometheus (Grafana dashboard 4912).
📊 Measured
| Last 7 days | Blog | Nextcloud A | Nextcloud B |
|---|---|---|---|
| PHP requests per day | ~21,700 | ~23,600 | ~16,200 |
| Peak active workers | 8 of 12 | 13 of 30 | 9 of 20 |
max_children reached | 0 | 0 | 0 |
| Slow requests (> 5 s) | 0 | 0 | 0 |
| Worker memory (RSS / own share PSS) | ~78 / ~16 MB | 108–157 / 14–49 MB | 71–116 / 8–27 MB |
| Container memory peak | 538 MiB of 1 GiB | 763 MiB of 6 GiB | 277 MiB of 5 GiB |
| CPU periods throttled | 2.1 % | 2.2 % | 0.2 % |
Three readings. First, the RSS of a worker overstates its cost: it counts the shared OPcache and APCu pages in every worker. PSS divides shared pages among the processes that use them and is the honest number – 16 MB per blog worker, not 78. Second, the Nextcloud pools are now generous: the peak used less than half of them, and the container limits are far above the peaks. Third, the blog had one outlier: a single minute on 16 October with 8 active workers and 30 requests waiting in the queue – a burst of roughly 40 simultaneous requests. No log from that day survives (log collection started three days later), so the cause is unknown. It was one minute in a week, and since then the queue has stayed at zero.
🧰 Day-to-day administration
# FPM status as the exporter sees it (it talks FastCGI to port 9000 itself)
k3s kubectl -n nextcloud exec deploy/nextcloud -c nginx -- \
curl -s http://127.0.0.1:9253/metrics | grep -E '^phpfpm_(active|idle|listen_queue|max_children)'
# slow requests
k3s kubectl -n nextcloud logs deploy/nextcloud -c nextcloud-fpm | grep -i 'executing too slow'
# memory per worker, honestly (PSS, in MB)
for p in $(pgrep -f '^php-fpm: pool www'); do
awk '/^Pss:/{s+=$2} END{printf "%d ", s/1024}' /proc/$p/smaps_rollup; done; echo
# new PHP code after an in-place update (Nextcloud)
k3s kubectl -n nextcloud rollout restart deployment/nextcloud- Sizing workers. Measure PSS under load, multiply by the planned
max_children, add OPcache and APCu, compare with the container limit. Then watch “max_children reached” and the listen queue in Grafana; if both stay at zero, the pool is big enough. - OPcache reset.
opcache_reset()on the command line resets the CLI’s own cache, not FPM’s. For FPM the reliable reset is a restart – on Kubernetes, a rollout. - A hanging site. The exporter’s per-process metrics show how long each worker has been busy; together with the slow log that is usually faster than searching access logs.
🆘 Cheat sheet: symptom → command
The short version of this article for the moment something is wrong. Run on the host as root; <…> are placeholders.
| Symptom | Command |
|---|---|
| All workers busy, requests waiting | k3s kubectl -n nextcloud exec deploy/nextcloud -c nginx -- \ |
| “server reached pm.max_children” | k3s kubectl -n nextcloud logs deploy/nextcloud -c nextcloud-fpm | grep max_children |
| Which requests are slow? | k3s kubectl -n nextcloud logs deploy/nextcloud -c nextcloud-fpm | grep -i 'executing too slow' |
| 502 Bad Gateway | k3s kubectl -n nextcloud logs deploy/nextcloud -c nginx --tail=30 |
| How much memory does a worker really need? | for p in $(pgrep -f '^php-fpm: pool www'); do |
| Old code still running after an update | k3s kubectl -n nextcloud rollout restart deployment/nextcloud # OPcache: validate_timestamps=0 |
| The same on the blog | k3s kubectl -n wordpress logs deploy/wordpress -c wordpress-fpm --tail=30 |
🔭 What comes next
- PHP 8.5 for the blog once the official WordPress image recommends it; Nextcloud already runs 8.5.
- JIT is off. For I/O-bound web applications like these it brings little; it would be an experiment with measurements before and after, not a default.
- An alert on the listen queue – done on 21 October 2026: it mails when requests wait for a free worker for five minutes, which never happened in the 14 days measured.
⚖️ Alternatives – and why PHP-FPM
| Option | Strength | Why not here |
|---|---|---|
| Apache with mod_php | Simplest setup, one process type | PHP lives in every web server process, including those serving static files; no separate pool control. |
| FrankenPHP / RoadRunner | Long-running workers, the application stays in memory | Needs an application built for it; WordPress and Nextcloud expect a fresh state per request. |
| Swoole | Asynchronous PHP | Not compatible with these applications. |
| PHP-FPM + nginx | What the images ship, fine-grained control, well understood | Chosen. |
Nothing in this post is exotic. The two real gains – twenty-five more workers and a cache that fits the code – came from looking at what the defaults actually were, not from clever tuning. Defaults are someone else’s guess about your workload; with PHP-FPM, two of them were wrong for Nextcloud by a factor of four.


