~/blog/the-php-configuration-inside-your-container-matters-more-than-you-think
Before Adding PHP-FPM Workers, Find Out What’s Actually Slow
Learn how to diagnose and fix PHP-FPM performance bottlenecks under real production traffic instead of blindly scaling replicas. This guide covers checking FPM status, enabling slow logs, inspecting container resources, and sizing workers based on evidence.
- Published
- Reading time
- 9 min
Everything works fine in staging. Then production traffic arrives, PHP-FPM workers fill up, requests start queueing, and response times get worse.
The first thing that often comes to mind is adding more replicas/workers. That can help, but it depends on what's causing the problem. If requests spend most of their time waiting for a slow database query or an API that takes four seconds to respond, adding replicas/workers means more PHP processes waiting for the same thing. It can also put more pressure on the database.
I'd start by finding out what's keeping the workers busy before changing their number.
Start with PHP-FPM status
Expose the FPM status page through an internal endpoint. PHP-FPM uses FastCGI rather than HTTP, so you'll usually need nginx, Caddy, or another frontend to proxy the status path.
You can then query it with:
curl -fsS http://127.0.0.1:8081/fpm-statusMake sure this endpoint is only accessible to trusted internal systems. It exposes request and resource information, so it shouldn't be available publicly.
When looking at the output, pay attention to three things:
- Listen queue: Requests waiting for a free worker. If this number keeps increasing, incoming requests are arriving faster than the pool can handle them.
- Active and idle processes: If every worker is active and there are no idle workers, the pool is currently busy handling requests.
- Max children reached: This tells you whether the pool has hit its worker limit at some point since PHP-FPM started. It doesn't necessarily mean the pool is saturated right now.
The status output helps confirm that requests are waiting, but it won't tell you what those requests are waiting for. A worker might be executing PHP code, waiting for MySQL, or blocked on a call to a third-party API.
You'll need a bit more information to narrow that down.
Enable the slow log
PHP-FPM's slow log is a useful starting point when requests take longer than expected. Add these settings to the relevant pool configuration:
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/slow.logChoose a timeout that makes sense for your application. If you're aiming for a p95 latency of 800 ms, waiting five seconds before logging a request won't help you investigate every slow request. On the other hand, if several seconds is normal for some operations, a five-second threshold might produce too much noise.
Check that the log directory exists and that PHP-FPM has permission to write to it.
When a request exceeds the configured threshold, PHP-FPM writes a backtrace to the slow log. This gives you an idea of where PHP was executing when the request was flagged.
It's useful, but don't treat it as a complete performance profile. The backtrace won't necessarily tell you why a database query is slow or why an external service is taking so long to respond.
For those cases, compare the slow-log entries with your application logs, APM traces, and database metrics from the same period. That's usually where you'll find more useful clues about the actual bottleneck.
Check the container, not just the app
Sometimes the application looks fine, but the container is running out of memory or spending time being throttled by CPU limits. Before changing the FPM worker count, check what Docker reports about the container's resources and restart history.
Start with:
docker stats <container-name>This gives you a quick view of CPU and memory usage. If either is close to its limit, you'll want to investigate further.
You can also check whether the container has been killed by the OOM killer, its last exit code, and how many times it has restarted:
docker inspect <container-name> \
--format 'OOMKilled={{.State.OOMKilled}} ExitCode={{.State.ExitCode}} RestartCount={{.RestartCount}}'Next, check whether the container exposes the cgroup v2 hierarchy at the standard mount point:
docker exec <container-name> sh -c \
'test -f /sys/fs/cgroup/cgroup.controllers && echo "cgroup v2 detected"'If the file exists, check the memory and CPU statistics available at that location:
docker exec <container-name> sh -c '
for f in memory.current memory.max memory.events cpu.stat; do
echo "== $f =="
cat "/sys/fs/cgroup/$f" 2>/dev/null || echo "unavailable"
done
'A few values are worth keeping an eye on:
memory.currentshows the memory usage reported by the cgroup.memory.maxshows its memory limit, ormaxif no limit is set at that level.memory.eventscontains counters for events such ashigh,max,oom, andoom_kill. Take a reading before and after a load test to see whether any of them increase. An increase inhighcan indicate memory pressure even if the hard memory limit hasn't been reached.cpu.statincludesnr_throttledandthrottled_usec. If these increase during a load test, CPU throttling may be limiting how quickly PHP can process requests.
Don't assume cgroup v2 is unavailable just because cgroup.controllers is missing. The container might use cgroup v1, or the cgroup filesystem might be mounted somewhere else.
To investigate further, run the following on the Docker host:
pid=$(docker inspect -f '{{.State.Pid}}' <container-name>)
cat "/proc/$pid/cgroup"The output helps you find the container's cgroup membership. From there, you can check the relevant files on the host, keeping in mind that the exact paths depend on the host's cgroup layout and mount configuration.
Check what the workers are waiting for
Container metrics are only part of the picture. It's also worth looking at the services your PHP application depends on.
For the database, check query latency, active connections, lock waits, and whether the connection pool is getting exhausted. Check Redis latency and the duration of external HTTP requests as well.
The important thing here is that workers can be fully occupied without using much CPU. If a request spends most of its time waiting for a database or an external API, that worker is still busy from PHP-FPM's point of view.
Consider a setup with eight replicas and 20 workers per replica. Across those pools, you could have up to 160 requests being processed concurrently.
That doesn't mean you'll get 160 completed requests per second, and it doesn't necessarily mean you'll have 160 database connections. Both depend on how the application handles each request.
What it does mean is that increasing the number of replicas can increase the number of concurrent requests reaching your dependencies. If the database is already struggling, that extra concurrency can make the situation worse rather than improve throughput.
Use the symptoms to narrow it down
The metrics won't always give you a definite answer on their own, but they can help you decide where to investigate first.
| What you're seeing | What I'd check first |
|---|---|
| Listen queue keeps rising and every worker is busy | Slow log, application traces, database and API timings |
| High CPU usage and increasing latency | PHP profiling, CPU limits and throttling |
| Workers are busy but CPU usage is low | Database waits, Redis and external HTTP calls |
| Memory usage is close to the container limit | Memory per worker, OOM events and restart history |
| Database latency increases with traffic | Slow queries, locks and connection pools |
| CPU throttling counters increase during a load test | CPU limits, worker count and request concurrency |
These are starting points, not proof of a particular problem. You'll still need to compare the metrics and logs to understand what's happening.
An example: all workers busy, CPU usage low
Imagine running a load test and seeing the following:
- The FPM listen queue increases from 0 to 18.
- All 20 workers are active.
- CPU usage remains relatively low.
- The slow log repeatedly shows requests inside database-call paths.
- Database query latency increases during the same test.
My first step wouldn't be to increase pm.max_children.
More workers would let additional requests reach the database at the same time. If the database is already struggling, that could increase the load without helping requests finish any faster.
I'd compare application traces with database metrics from the same period and look for slow queries, missing indexes, lock contention, or connection-pool waits.
Then I'd fix one specific issue and run the same load test again.
If database latency drops and the FPM queue starts draining under comparable traffic, that's evidence that database waits were a major part of the problem. If the queue is still growing, there's more to investigate.
You don't need to assume the first thing you find explains everything. Measure the result and go from there.
Test one change at a time
After making a change, run the same representative workload and compare the results with your previous test.
I'd look at:
- Throughput
- p95 and p99 latency
- Error rate
- CPU and memory usage
- Active FPM workers and listen queue
- Database and external API latency
For example, suppose your application calls a supplier API and client telemetry shows a p95 response time of 3.9 seconds. Your Guzzle client is configured like this:
$response = $httpClient->request('GET', $supplierUrl, [
'connect_timeout' => 1.0,
'timeout' => 2.0,
]);If those timings represent comparable end-to-end request durations, the two-second timeout will cut off some requests that would otherwise take longer to complete.
Before changing the timeout, check how often requests actually time out and how those failures affect the application.
In Guzzle, connect_timeout limits the time allowed to establish a connection, while timeout limits the total request duration. Check that the handler you're using supports both options.
Set the timeout based on how long your application can reasonably wait for the dependency. If the supplier API is consistently slow, increasing the timeout might keep workers occupied for longer without improving the user experience.
Depending on the use case, caching the response, using a fallback, moving the work to a queue, or adding a circuit breaker might be a better approach. Retries can help with transient failures, but only retry operations that are safe to repeat, and make sure the retries fit within the overall request deadline.
Work out a reasonable worker limit
pm.max_children controls how many requests a single PHP-FPM pool can handle concurrently. The right value depends on both the memory available to the container and the application's memory usage under load.
A rough starting point is:
worker budget =
(container memory limit - baseline usage - safety reserve)
/ memory per additional workerFor example, say your container has a 1,024 MiB memory limit. The application and other baseline processes use 200 MiB, and you want to keep another 200 MiB in reserve.
If each additional worker consumes roughly 40 MiB of incremental container memory under representative load, the calculation is:
(1024 - 200 - 200) / 40 ≈ 15 workersThat gives you an initial estimate of 15 workers, not a number I'd deploy without testing.
Measure memory usage with realistic requests, including requests that allocate more memory than usual. Account for shared memory such as OPcache without counting it multiple times, and leave enough room for temporary increases in usage.
Then run a load test with the proposed worker limit and check whether it handles the expected traffic without pushing the container close to its memory limit.
One more thing: make sure you're editing the configuration that PHP-FPM actually uses. The CLI and FPM can load different configuration files, so checking one doesn't automatically tell you what the other is using.
For a Compose service named php using the standard configuration path, you can test and dump the FPM configuration with:
docker compose exec php \
php-fpm -tt -y /usr/local/etc/php-fpm.confAdjust the service name and path for your setup. A successful syntax check tells you that the configuration parses, but it doesn't prove that the running FPM process started with that file.
Before increasing the worker count
When PHP-FPM starts queueing requests, I wouldn't jump straight to adding workers or replicas. First, check the FPM status, look at the slow log, and compare what you're seeing with the container and dependency metrics.
Once you have a likely cause, make one change and run the same load test again. That gives you a way to see whether the change actually helped.
Adding workers makes sense when the application needs more concurrency and the container, database, and other dependencies have enough capacity to handle it. If workers are mostly waiting on a slow dependency, increasing their number might just move the problem somewhere else.
The point is to get more requests through successfully without pushing another part of the system beyond its limits.