Each apache2 or httpd child sits at 80, 120, sometimes 200MB of RSS, the sum of all children creeps toward total RAM, and the OOM killer starts picking off workers at peak traffic. MaxRequestWorkers is already set conservatively, but it does not matter: per-child memory is so large that any worker count high enough to serve your traffic exceeds what the machine can hold.
This is the mod_php signature. When PHP runs as an Apache module, the entire PHP runtime - interpreter, every loaded extension, the opcode cache, and whatever your framework loads per request - lives inside every Apache child process. A child that would cost 10-15MB serving static files now costs 50-100MB or more, and every concurrent connection pays that full price because mod_php forces the prefork MPM, where one connection equals one process.
This article covers why the memory math breaks, how to confirm mod_php is your problem, and when the move to PHP-FPM is worth the migration work.
What this means
Apache’s memory ceiling is one multiplication: MaxRequestWorkers x per-child RSS. The working rule is that this product should stay under 70% of total RAM, leaving room for the OS, page cache, and everything else on the box. With plain prefork serving static content at 10-50MB per child, the math is forgiving. With mod_php, it is not:
- 8GB RAM, 5GB usable for Apache, 80MB average child RSS: MaxRequestWorkers caps at about 60.
- Same box serving static files at 15MB per child: MaxRequestWorkers could be over 300.
So mod_php does two things at once: it multiplies per-child memory by 3-10x, and it forces prefork, the MPM where every connection costs a full process. You cannot switch to the threaded worker or event MPM because mod_php is not thread-safe with many common PHP extensions. You also lose HTTP/2: mod_http2 refuses to run under prefork (since Apache 2.4.27) because it requires a threaded MPM.
The compounding effect is that PHP children accumulate memory over their lifetime. PHP applications and extensions leak, opcache fragments, and per-request allocations do not fully return to the OS. With the default MaxConnectionsPerChild 0 (never recycle), a child’s RSS only grows. What starts as 60MB per child after a restart becomes 120MB three days later, and your computed MaxRequestWorkers ceiling quietly becomes an OOM guarantee.
flowchart LR
subgraph mod_php["mod_php: PHP inside Apache"]
A1[client] --> C1[httpd child + PHP runtime 60-150MB RSS]
A2[client] --> C2[httpd child + PHP runtime 60-150MB RSS]
A3[client] --> C3[httpd child + PHP runtime 60-150MB RSS]
end
subgraph fpm["PHP-FPM: PHP outside Apache"]
B1[client] --> W1[event MPM worker thread ~MBs]
B2[client] --> W1
B3[client] --> W1
W1 -->|mod_proxy_fcgi| P1[php-fpm pool: pm.max_children]
endCommon causes
| Cause | What it looks like | First thing to check |
|---|---|---|
| PHP runtime embedded per child | Baseline RSS 50-100MB+ per child even at low traffic | apachectl -M or httpd -M shows php_module; MPM is prefork |
| MaxConnectionsPerChild 0 (default) | Per-PID RSS grows monotonically over hours/days | ps RSS per PID sampled twice, hours apart |
| PHP application or extension leak | RSS growth correlates with specific URL patterns | Compare RSS of children serving different vhosts/paths |
| MaxRequestWorkers sized without memory math | AH00484 in error log plus swap usage, then OOM kills | dmesg for OOM kills; multiply MaxRequestWorkers by observed RSS |
| mod_php segfaults | “child pid NNNN exit signal Segmentation fault” in error log | grep error log for segfault; check which extension core dumps implicate |
| Graceful restart overlap | Memory doubles briefly during restarts as old and new generations coexist | Count resuming normal operations frequency in error log |
Quick checks
All read-only. Run them before changing anything.
# 1. Confirm which MPM is active and whether mod_php is loaded
apachectl -V 2>/dev/null | grep -i mpm || httpd -V | grep -i mpm
apachectl -M 2>/dev/null | grep -E 'mpm|php' || httpd -M | grep -E 'mpm|php'
# 2. Per-child RSS, sorted, then summary statistics
ps -C apache2 -o pid,rss,vsz,cmd --sort=-rss 2>/dev/null | head -20 || \
ps -C httpd -o pid,rss,vsz,cmd --sort=-rss | head -20
{ ps -C apache2 -o rss= 2>/dev/null || ps -C httpd -o rss=; } | \
awk '{sum+=$1; count++; if($1>max)max=$1} END {
printf "Children: %d\nTotal: %d MB\nAvg: %d MB\nMax: %d MB\n",
count, sum/1024, sum/count/1024, max/1024}'
# 3. The memory ceiling math
grep -rE 'MaxRequestWorkers|MaxClients|ServerLimit' /etc/apache2/ /etc/httpd/ 2>/dev/null
grep MemTotal /proc/meminfo
# 4. Has the OOM killer already been here?
dmesg -T 2>/dev/null | grep -i -E 'oom|killed process' | tail -10
# 5. mod_php segfault history
grep -iE 'segfault|segmentation' /var/log/apache2/error.log /var/log/httpd/error_log 2>/dev/null | tail -10
# 6. Current worker pressure (requires mod_status enabled and reachable from localhost)
curl -s http://localhost/server-status?auto | grep -E 'BusyWorkers|IdleWorkers'
# 7. Current MaxConnectionsPerChild setting (0 means never recycle)
grep -rE 'MaxConnectionsPerChild|MaxRequestsPerChild' /etc/apache2/ /etc/httpd/ 2>/dev/null
How to diagnose it
Confirm the stack. Step 1 above should show prefork MPM plus a
phpmodule. If you see event MPM, mod_php is not loaded and this article is not your problem.Measure real per-child cost. RSS overstates unique memory because forked children share pages with the parent (copy-on-write). For the MaxRequestWorkers math, use PSS from
/proc/<pid>/smaps_rolluporsmemif you want accuracy; use RSS if you want a conservative upper bound. Measure at peak, not right after a restart, because children grow.Run the math.
MaxRequestWorkers x max_observed_child_RSSis your worst case. If that exceeds roughly 70% of RAM, the configuration is a guaranteed OOM under full worker saturation. It is only a matter of traffic.Check for the leak pattern. Sample per-PID RSS now and again in a few hours. Monotonic growth per PID with
MaxConnectionsPerChild 0is the slow-death pattern: each request leaks a little, children grow without bound, swap climbs, OOM kills begin, and the parent respawns fresh children that also leak.Correlate growth with traffic shape. If only some children balloon, their request mix differs. Compare children that happened to serve heavy endpoints, admin panels, or image processing routes against children serving mostly cached pages. That tells you whether the leak is in one code path or systemic.
Check the segfault cadence. Under prefork, a segfault kills only that child and the parent respawns it, so isolated crashes look harmless. A steady drumbeat of mod_php segfaults is itself a signal: PHP extensions crashing inside your web server processes is an argument for isolation, not just a bug to file.
Metrics and signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
| Per-child RSS / PSS | Sets the MaxRequestWorkers ceiling | Growth trend over days; any child >2x the average |
| Total Apache RSS vs RAM | Direct OOM predictor | MaxRequestWorkers x avg RSS approaching 70% of RAM |
| MemAvailable and swap usage | Early warning before OOM kills | Any sustained swap use by Apache children |
| BusyWorkers / MaxRequestWorkers | Saturation headroom | Sustained >80%; IdleWorkers pinned at 0 |
| OOM kill events in dmesg | Confirms the ceiling was hit | Any Apache child killed |
| Segfault count in error log | mod_php instability indicator | More than occasional; any cluster |
| AH00484 (MaxRequestWorkers reached) | Worker pool exhausted | Any occurrence |
| Scoreboard G states and restart frequency | Graceful restart pile-up doubles memory | Overlapping generations during peak |
Fixes
Bound the damage on mod_php first
If you cannot migrate this week, make mod_php survivable:
Set MaxConnectionsPerChild to a finite value. Something in the 1000-10000 range forces children to recycle before leaked memory accumulates. This is a band-aid: it bounds RSS growth but does not fix the leak, and child churn costs some CPU. The default of 0 is the wrong default for any mod_php deployment.
Derive MaxRequestWorkers from memory, not traffic wishes. MaxRequestWorkers = (RAM available to Apache) / max_observed_child_RSS, targeting 70% of RAM worst case. If the resulting number cannot serve your peak, the honest options are more RAM or fewer megabytes per child, not a bigger number in the config.
Trim the per-child footprint. Disable PHP extensions the application does not use; every loaded extension costs memory in every child. Set PHP’s memory_limit sanely so a single request cannot balloon a child unboundedly.
Spread restarts out. If log rotation or config management triggers frequent graceful restarts, each restart temporarily runs two generations of children. With 100MB children, that overlap is significant. Reduce restart frequency and set GracefulShutdownTimeout so old children cannot linger indefinitely.
Move to PHP-FPM: the structural fix
PHP-FPM decouples the two things mod_php welds together. PHP runs in its own process pool with its own pm.max_children, sized against RAM independently. Apache talks to it over FastCGI via mod_proxy_fcgi, which means Apache no longer needs mod_php, which means Apache no longer needs prefork. You switch to the event MPM, where:
- Worker threads are cheap and shared; keepalive connections are held by the listener, not by workers.
- Per-connection Apache memory drops back to megabytes, so MaxRequestWorkers stops being memory-bound.
- HTTP/2 works again.
- A PHP segfault kills a PHP-FPM child, not an Apache worker. Static content and health checks keep serving even while PHP is on fire.
- PHP memory leaks are bounded by the FPM pool, and FPM has its own recycling (
pm.max_requests) to restart children after N requests.
On Debian/Ubuntu the migration looks roughly like:
# Plan for a maintenance window: this restarts Apache and drops every in-flight connection.
# Substitute your actual PHP version for <ver> (e.g. php8.2); a2* tools do not expand globs.
a2dismod mpm_prefork php<ver>
a2enmod mpm_event proxy_fcgi setenvif
a2enconf php<ver>-fpm # conf ships with the distro php-fpm package
systemctl restart apache2 php<ver>-fpm
Verify the FPM socket or port matches what proxy_fcgi is configured to use, and confirm apachectl -M shows mpm_event and no php module afterward. Keepalive behavior, worker sizing, and scoreboard interpretation all change under event; read them against the event model, not your old prefork instincts.
Two cautions. First, size pm.max_children with the same memory math: FPM children are still 30-50MB+ each, and the pool plus Apache plus everything else must fit in RAM. Second, set pm.max_requests so FPM children recycle and leaks stay bounded. Leaving it unlimited recreates the slow-death pattern inside FPM.
When to actually migrate
Migrate when any of these are true: you have had OOM kills or AH00484 events under mod_php; your memory-derived MaxRequestWorkers cannot cover peak traffic; you see recurring mod_php segfaults; you want HTTP/2 or the event MPM’s keepalive efficiency; or per-child RSS grows despite MaxConnectionsPerChild recycling. If mod_php is stable, sized correctly, and meeting traffic with headroom, migration is an improvement project rather than an incident fix, and can be scheduled accordingly.
Prevention
- Monitor per-child RSS as a trend, not a snapshot. Leaks are invisible in any single reading.
- Alert on the product, not the parts. Track
MaxRequestWorkers x avg RSSas a percentage of RAM and page before the OOM killer does it for you. - Never leave MaxConnectionsPerChild at 0 on any prefork host running embedded interpreters.
- Watch segfault counts. Under prefork they are survivable one by one, which is exactly why teams ignore them until the pattern is entrenched.
- Document the memory math next to the MaxRequestWorkers setting so the next person does not tune it against traffic alone.
- Prefer PHP-FPM for new deployments. mod_php’s operational simplicity (one service, one config) is real, but the memory model is the single biggest driver of Apache OOM risk.
How Netdata helps
- Per-process RSS for every Apache child, charted over time, so slow monotonic leak growth shows up days before the OOM kill.
- System memory correlation: Apache’s total RSS against MemAvailable and swap, so you see the ceiling approaching rather than discovering it in dmesg.
- Worker saturation from the scoreboard (BusyWorkers, IdleWorkers, state distribution) alongside memory, so you can tell “ran out of workers” from “ran out of RAM that workers would have used.”
- Error log pattern alerting for AH00484 and segfaults, turning the two loudest mod_php tells into signals instead of log trivia.
- After migration, per-second visibility into both sides: Apache worker utilization under the event MPM and PHP-FPM pool saturation, so the decoupled architecture is observable as two systems, not one opaque box.
Netdata’s Apache HTTP Server monitoring with Netdata brings these signals together with per-second metrics and ML anomaly detection.
Related guides
- How Apache HTTPD actually works in production: a mental model for operators
- Apache MaxRequestWorkers tuning: sizing the worker pool against memory
- Apache AH00484: server reached MaxRequestWorkers setting - worker pool exhausted
- Apache BusyWorkers and IdleWorkers: reading worker utilization from mod_status
- Apache keepalive consuming workers: KeepAliveTimeout, the K state, and MPM choice
- Apache 503 Service Unavailable: worker exhaustion versus proxy pool exhaustion
- Apache 502 Bad Gateway: a backend that returned an invalid response
- Apache 504 Gateway Timeout: slow backends, ProxyTimeout, and worker pile-up
- Apache backend response time: telling ‘Apache is slow’ from ’the backend is slow’
- Apache CLOSE_WAIT and TIME_WAIT: connection leaks versus normal churn
- Apache listen queue overflow: Recv-Q growth, ListenBacklog, and refused connections
- Apache HTTPD monitoring checklist: the signals every production web server needs






