
Running many independent WordPress installations behind one OpenResty/Nginx layer, one PHP-FPM container, and one MySQL instance creates a distinctive failure mode: the server may have abundant free memory while CPU utilization and load average climb until every site becomes slow.
This article documents a production-style investigation of that pattern. The important lesson is not a single tuning value. It is the method: identify which layer is doing work, measure the work by site and request, find the application behavior multiplying it, reduce concurrency amplification, and verify the result with fresh statistics.
1. The Initial Symptom
A typical environment has ample free RAM, a generously sized InnoDB buffer pool, a large PHP-FPM pool, and many independent WordPress sites. Despite the available memory, load average and CPU utilization remain high, with the PHP container appearing to consume most of the CPU.
This is a common point at which administrators make the wrong first move. Increasing memory, increasing pm.max_children, or increasing MySQL caches may make the system less stable because none of those actions answers the key question:
Is the server short of memory, waiting for storage, executing expensive code, or admitting too much concurrent work?
High load is not synonymous with high CPU usage. Linux load includes runnable tasks and tasks blocked in uninterruptible sleep, often storage I/O. Establish the resource bottleneck before tuning either MySQL or PHP.
2. Classify the Bottleneck First
Start with operating-system evidence:
uptime
nproc
vmstat 1 10
iostat -x 1 10
docker stats
Interpret the important signals as follows:
| Signal | Likely meaning |
|---|---|
vmstat runnable queue r remains above the CPU core count | CPU scheduling pressure |
us is high | User-space work, commonly PHP or MySQL query execution |
sy is high | Kernel, networking, container, or syscall overhead |
wa and iostat await are high | Storage latency or queueing |
b is high while CPUs remain partly idle | Tasks blocked on I/O |
si or so is non-zero | Active swapping |
PHP container CPU dominates docker stats | PHP request execution requires investigation |
Inspect PHP and MySQL at thread level when necessary:
top -H -p "$(pidof php-fpm)"
pidstat -t -p "$(pidof php-fpm)" 1
top -H -p "$(pidof mysqld)"
pidstat -u -w -p "$(pidof mysqld)" 1 10
Do not treat a Docker CPU limit as a performance fix. It is useful for isolation, but it only limits the damage after expensive requests have already entered PHP.
3. Prove Whether MySQL Is the Root Cause or a Victim
WordPress requests often spend a large part of their lifetime waiting on MySQL. Conversely, inefficient WordPress plugin behavior can make both PHP and MySQL busy. The layers must be correlated rather than investigated independently.
3.1 Check active database concurrency
SHOW GLOBAL STATUS WHERE Variable_name IN (
'Threads_connected',
'Threads_running',
'Max_used_connections',
'Connections'
);
SHOW FULL PROCESSLIST;
Threads_running is more useful than the number of connected or sleeping sessions. If it remains well above the available CPU core count, queries are competing for CPU or other shared resources. Raising max_connections does not resolve this; it permits a larger overload event.
Check locks separately:
SELECT * FROM sys.innodb_lock_waits;
SELECT
trx_id,
trx_mysql_thread_id,
trx_started,
trx_state,
trx_rows_locked,
trx_rows_modified,
LEFT(trx_query, 300) AS trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
3.2 Use statement digests, not isolated slow-query anecdotes
The slow query log finds individually slow statements. Performance Schema digests also reveal inexpensive statements executed millions of times and queries whose aggregate cost dominates the server.
SELECT
SCHEMA_NAME,
DIGEST_TEXT,
COUNT_STAR,
ROUND(SUM_TIMER_WAIT / 1000000000000, 2) AS total_seconds,
ROUND(AVG_TIMER_WAIT / 1000000000, 2) AS avg_ms,
SUM_ROWS_EXAMINED,
SUM_ROWS_SENT,
SUM_CREATED_TMP_DISK_TABLES,
SUM_SORT_ROWS
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME IS NOT NULL
ORDER BY SUM_TIMER_WAIT DESC
LIMIT 30;
Read the digest table in two dimensions:
- High aggregate time: statements that consume the most database capacity overall.
- High execution count or rows examined: individually fast statements whose frequency or scan volume makes them expensive.
A query does not need to appear in the slow query log to be a major resource consumer. For example, a 5 ms query executed ten million times consumes far more capacity than an isolated five-second administration query. Likewise, a lookup that returns one row but examines hundreds of thousands usually indicates an unsuitable access path.
When a suspicious digest is found, identify the affected schema, capture the parameterized query shape, inspect the table and indexes, and test the execution plan:
SHOW CREATE TABLE target_table\G
SHOW INDEX FROM target_table;
EXPLAIN ANALYZE
SELECT selected_columns
FROM target_table
WHERE indexed_candidate = 'sample value'
LIMIT 1;
Use EXPLAIN instead of EXPLAIN ANALYZE if executing the statement could be unsafe or excessively expensive. Optimize from actual column types, selectivity, and query patterns rather than adding every filtered column to one large index.
3.3 Read buffer-pool statistics correctly
Collect the relevant status counters:
SHOW GLOBAL STATUS WHERE Variable_name IN (
'Innodb_buffer_pool_pages_dirty',
'Innodb_buffer_pool_pages_free',
'Innodb_buffer_pool_read_requests',
'Innodb_buffer_pool_reads',
'Innodb_buffer_pool_wait_free',
'Innodb_log_waits'
);
With the usual 16 KiB InnoDB page size, free pages can be converted into approximate unused capacity. Physical reads should be compared with logical read requests, while dirty pages, buffer-pool waits, and redo-log waits reveal different forms of pressure.
The buffer-pool hit rate can be estimated as:
1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests
If the hit rate is already extremely high and many pages remain free, adding more buffer-pool memory will not fix CPU-heavy scans. Data can be fully cached while MySQL still compares a large number of rows on every call. Conversely, do not use hit rate alone: examine waits, working-set size, query plans, and storage latency together.
4. Fix the Application Multiplier Before Tuning Around It
Once an expensive query or request pattern is identified, determine which WordPress component produces it. The source may be core behavior, a theme, a plugin, a scheduled task, an AJAX endpoint, a REST endpoint, or hostile traffic repeatedly invoking valid application code.
Choose the least complex durable correction:
- Remove or disable a feature that has no business value.
- Reduce how often it runs through caching, batching, or scheduling.
- Fix its query shape or add a selective index after plan analysis.
- Move long-running work out of web requests and into controlled background jobs.
- Rate-limit or block abusive callers before they enter PHP.
Avoid tuning infrastructure merely to preserve wasteful behavior. A query reduced from 500 ms to 50 ms is still expensive if an unnecessary endpoint calls it millions of times.
For shared fixes across independently installed sites, a carefully maintained must-use plugin can enforce common policies from each site’s wp-content/mu-plugins/ directory. Use this only for behavior that is truly universal, document it, and test it against plugin and WordPress upgrades.
Proxy identity is another common multiplier. Applications should prefer a trusted, normalized client address and must not blindly trust client-supplied X-Forwarded-For. Misconfigured proxy chains can duplicate addresses, defeat per-IP controls, and create inconsistent application keys.
5. Why Excessive PHP-FPM Concurrency Amplifies Incidents
Consider a PHP-FPM pool configured as:
pm = static
pm.max_children = 400
In static mode, FPM maintains exactly pm.max_children workers. The settings pm.start_servers, pm.min_spare_servers, and pm.max_spare_servers do not apply.
A very large static pool is not automatically capable of the same number of useful concurrent requests. When requests are CPU-heavy or generate expensive SQL, excessive concurrency creates:
- excessive runnable processes and context switching;
- contention for CPU caches and memory bandwidth;
- a burst of simultaneous MySQL work;
- increased tail latency;
- a feedback loop in which slow requests occupy workers longer and admit more competing work.
Nginx queueing a short burst is often healthier than allowing hundreds of WordPress requests to execute simultaneously.
An illustrative dynamic-pool starting point for a large server is:
[www]
listen = 0.0.0.0:9000
listen.backlog = 1024
pm = dynamic
pm.max_children = 100
pm.start_servers = 16
pm.min_spare_servers = 8
pm.max_spare_servers = 24
pm.max_requests = 500
pm.status_path = /status
slowlog = /var/log/php/fpm.slow.log
request_slowlog_timeout = 3s
request_terminate_timeout = 60s
This is a starting point, not a universal formula. Choose the final limit from three constraints:
- Worker memory: measure RSS and preferably proportional set size under real traffic.
- CPU capacity: determine where additional concurrency stops increasing throughput.
- Backend capacity: ensure PHP cannot create more simultaneous database or external API work than those services can sustain.
Useful worker-memory measurements include:
ps --no-headers -o rss -C php-fpm | awk '
{sum += $1; count++}
END {
if (count) {
printf "workers=%d avg=%.2fMB total=%.2fMB\n", \
count, sum/count/1024, sum/1024
}
}'
pm.max_requests periodically replaces workers and limits long-term growth caused by extensions or plugin code. request_terminate_timeout prevents a pathological request from occupying a worker indefinitely, but long-running imports and maintenance tasks may require a separate pool or CLI worker rather than a globally larger timeout.
6. Validate and Tune OPcache with Evidence
Inspect OPcache with opcache_get_status(false) and focus on used and free bytecode memory, interned-string capacity, cached script count, restart counters, and JIT utilization. Common warning signs include little free memory, a completely full interned-strings buffer, or a large JIT allocation with negligible use.
The configuration was changed to provide ample bytecode and interned-string capacity and to disable JIT:
[opcache]
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 4096
opcache.interned_strings_buffer = 256
opcache.max_accelerated_files = 100000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 60
opcache.save_comments = 1
opcache.jit = 0
opcache.jit_buffer_size = 0
These values are examples, not defaults for every host. Size the bytecode memory, interned-strings buffer, and accelerated-file table from measured usage, then leave reasonable growth capacity. A healthy steady state has free space, no recurring OOM or hash restarts, low wasted memory, and enough cached-key capacity for all active sites.
A modest overall hit rate immediately after restart is not evidence of failure. A host with many sites must compile a large number of unique scripts during cache warm-up. The correct test is the incremental hit rate over a stable interval:
incremental hit rate = delta(hits) / (delta(hits) + delta(misses))
Do not keep enlarging OPcache once it has comfortable free space and no restart events. At that point, request behavior and concurrency are the more important CPU targets.
7. Add One PHP-FPM Access Log for Every Site in the Pool
Nginx access logs and PHP-FPM access logs are independent. A per-site Nginx access_log does not override the FPM pool’s access.log.
Add the following once to the shared [www] pool:
access.log = /var/log/php/fpm.access.log
access.format = "%t host=%{HTTP_HOST}e client=%R method=%m uri=%{REQUEST_URI}e script=%f status=%s duration=%{milliseconds}dms memory=%{megabytes}MMB cpu=%C%%"
Every request reaching this pool will be written to the same file, and host= identifies the site.
Ensure the common FastCGI configuration supplies the required values:
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTP_HOST $host;
fastcgi_param REQUEST_URI $request_uri;
fastcgi_param REMOTE_ADDR $remote_addr;
fastcgi_param HTTP_X_REAL_IP $remote_addr;
fastcgi_param HTTP_X_FORWARDED_FOR $http_x_forwarded_for;
After real_ip_header has accepted a header only from trusted proxy addresses, $remote_addr should represent the normalized client IP. Avoid repeatedly appending the same address at every proxy layer.
Validate before reloading:
nginx -t
php-fpm -tt
In containers, verify the FPM SAPI configuration rather than assuming CLI php -i reads the same files.
8. Rank URLs by Estimated CPU Time, Not by Summed Percentages
The FPM %C field is the percentage of CPU used by a request during its lifetime. Simply adding percentages across requests is dimensionally wrong: a 100% request lasting 20 ms costs far less CPU than a 40% request lasting 5 seconds.
Estimate CPU time per request as:
CPU milliseconds = duration milliseconds x CPU percent / 100
The following parser ranks host + URI by estimated cumulative CPU time. It expects the exact key-value log format shown above:
awk '
{
host = uri = "";
duration_ms = cpu_pct = 0;
for (i = 1; i <= NF; i++) {
if ($i ~ /^host=/) {
host = substr($i, 6);
} else if ($i ~ /^uri=/) {
uri = substr($i, 5);
} else if ($i ~ /^duration=/) {
value = substr($i, 10);
sub(/ms$/, "", value);
duration_ms = value + 0;
} else if ($i ~ /^cpu=/) {
value = substr($i, 5);
sub(/%$/, "", value);
cpu_pct = value + 0;
}
}
if (host != "" && uri != "") {
key = host " " uri;
cpu_ms[key] += duration_ms * cpu_pct / 100;
wall_ms[key] += duration_ms;
requests[key]++;
}
}
END {
for (key in requests) {
printf "%.0f\t%d\t%.1f\t%.0f\t%s\n", \
cpu_ms[key], \
requests[key], \
cpu_ms[key] / requests[key], \
wall_ms[key] / requests[key], \
key;
}
}' /var/log/php/fpm.access.log \
| sort -t $'\t' -k1,1nr \
| head -50
The columns are:
estimated_cpu_ms requests avg_cpu_ms avg_wall_ms host URI
Use a similar aggregation by host to find the sites consuming the most PHP CPU:
awk '
{
host = ""; duration_ms = cpu_pct = 0;
for (i = 1; i <= NF; i++) {
if ($i ~ /^host=/) host = substr($i, 6);
else if ($i ~ /^duration=/) {
v = substr($i, 10); sub(/ms$/, "", v); duration_ms = v + 0;
} else if ($i ~ /^cpu=/) {
v = substr($i, 5); sub(/%$/, "", v); cpu_pct = v + 0;
}
}
if (host != "") {
cpu_ms[host] += duration_ms * cpu_pct / 100;
requests[host]++;
}
}
END {
for (host in requests)
printf "%.0f\t%d\t%.1f\t%s\n", \
cpu_ms[host], requests[host], cpu_ms[host]/requests[host], host;
}' /var/log/php/fpm.access.log \
| sort -t $'\t' -k1,1nr \
| head -30
These are estimates based on FPM’s reported average CPU percentage, but they are much more meaningful than sorting single requests or summing percentages.
Normalize query strings when appropriate. Otherwise tracking parameters and search terms may split one endpoint into thousands of keys. Conversely, keep query strings when a particular parameter is suspected of triggering expensive behavior.
9. Correlate Expensive URLs with Slow Stacks
The FPM access log answers which host and URI consumed resources. The FPM slow log helps answer which PHP code path was active:
slowlog = /var/log/php/fpm.slow.log
request_slowlog_timeout = 3s
Inspect it with:
tail -200 /var/log/php/fpm.slow.log
Look for plugin and theme paths in backtraces. Common expensive WordPress endpoints include:
/wp-cron.php;/wp-admin/admin-ajax.php;/xmlrpc.php;- REST endpoints under
/wp-json/; - search, filtering, and uncached product pages;
- form submission, search, filtering, and reporting endpoints.
Slow wall time does not always mean high CPU. A request with low estimated CPU time but long duration is probably waiting on MySQL, storage, DNS, an external API, or a lock. A request with both high CPU time and high wall time is a stronger candidate for expensive PHP execution.
10. Reduce How Often Requests Reach PHP
FPM tuning controls the blast radius; it does not eliminate unnecessary executions. For mostly anonymous WordPress sites, the most effective CPU optimization is often full-page caching at Nginx, OpenResty, or the CDN.
The desired path is:
Client -> CDN or FastCGI cache hit -> response
rather than:
Client -> Nginx -> PHP-FPM -> WordPress bootstrap -> plugins -> MySQL
Also consider:
- disabling visitor-triggered WP-Cron and scheduling it from the system;
- staggering cron execution across many sites instead of starting all jobs on the same minute;
- auditing Action Scheduler backlogs and high-frequency hooks;
- using persistent object caching where it measurably reduces repeat database work;
- rate-limiting abusive endpoints before PHP;
- applying bot controls at Cloudflare or Nginx, with carefully maintained allowlists;
- disabling or replacing plugins whose per-request work has little business value.
In the database digests, Action Scheduler lookups were individually fast but executed more than 160 million times. High-frequency cheap calls may not lead the aggregate-time table, yet they reveal a large background-work volume worthy of separate investigation.
11. Logging and Proxy Configuration Pitfalls
Nginx and FPM logs do not override each other
An access_log inside a server block overrides inherited Nginx access logs at the http level for that server, unless multiple destinations are explicitly declared. It does not affect PHP-FPM’s access.log.
Keeping per-site Nginx logs plus one shared FPM log is a useful arrangement:
| Log | Primary use |
|---|---|
| Per-site Nginx access log | Traffic, bots, status codes, total and upstream time |
| Shared FPM access log | PHP duration, memory, estimated CPU, executing script |
| Shared FPM slow log | PHP stack samples for slow requests |
proxy_set_header does not configure FastCGI
Headers declared with proxy_set_header apply to proxy_pass. They do not automatically become FastCGI parameters. Values required by PHP must be supplied with fastcgi_param, preferably in a common include used by all sites.
Rotate the shared FPM log
A global log for many sites can grow rapidly. Configure rotation in the host or container logging system. A simple logrotate policy might use daily rotation, compression, seven retained files, and a safe FPM reopen signal. copytruncate is convenient but can lose a small number of lines and imposes copying overhead on large files; signaling FPM to reopen the log is preferable when supported by the deployment.
12. A Reliable Verification Loop
Performance Schema and OPcache counters are cumulative. Old values remain after a fix and can make successful changes look ineffective.
Use a controlled before-and-after process:
- Record OS, FPM, MySQL, and OPcache baselines.
- Make one logically related change set.
- Validate configuration syntax.
- Restart or reload only the required service.
- Reset the relevant performance summary or record counter deltas.
- Observe through a representative traffic interval.
- Compare request rate, CPU time, latency, error rate, FPM queueing, and database work.
To reset only MySQL statement digest statistics:
TRUNCATE TABLE performance_schema.events_statements_summary_by_digest;
This does not delete application data. After waiting through a representative traffic interval, query the digest table again and compare the new aggregate time, execution count, rows examined, temporary tables, and sort work with the baseline.
For PHP-FPM, monitor:
listen queue
max listen queue
active processes
idle processes
max active processes
max children reached
slow requests
Do not automatically raise pm.max_children when max children reached increases. If CPUs are already saturated, more workers usually increase contention. First determine whether queueing comes from insufficient capacity or slow and unnecessary requests.
13. Recommended Incident Order of Operations
For this class of multi-site WordPress incident, the following order minimizes guesswork:
- Classify CPU, I/O, swapping, or lock pressure with OS metrics.
- Identify the busiest container and processes.
- Inspect MySQL active threads, locks, and aggregate statement digests.
- Fix, cache, reschedule, rate-limit, or disable the dominant application behavior.
- Reduce excessive PHP-FPM concurrency to a measured starting point.
- Verify OPcache capacity and disable unhelpful JIT for WordPress workloads.
- Add one structured FPM access log for the shared pool.
- Rank sites and URLs by estimated CPU time, request volume, and wall time.
- Correlate expensive requests with FPM slow-log stacks.
- Add page caching, staggered cron, endpoint rate limits, and component-specific fixes.
- Reset or delta the counters and verify the improvement under real traffic.
Conclusion
In this class of incident, insufficient RAM is often not the central problem. High-frequency application work, excessive PHP concurrency, and weak request-level attribution combine to make every layer appear overloaded at once.
The durable solution combined application, database, runtime, and observability changes:
- remove unnecessary work and optimize the remaining query and request paths;
- prevent PHP concurrency from overwhelming CPU and MySQL;
- size OPcache from measured usage and interpret post-restart misses as warm-up;
- log every FPM request with host, URI, duration, memory, and CPU percentage;
- rank URLs using estimated CPU time rather than summed percentages;
- keep anonymous requests out of PHP through caching and edge controls;
- verify every change with fresh or delta-based counters.
That method scales beyond WordPress. Whenever a shared application runtime hosts many tenants, aggregate cost and concurrency amplification matter more than isolated slow requests—and observability must preserve the tenant and endpoint dimensions needed to find them.
Leave a Reply
You must be logged in to post a comment.