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]
  end

Common causes

CauseWhat it looks likeFirst thing to check
PHP runtime embedded per childBaseline RSS 50-100MB+ per child even at low trafficapachectl -M or httpd -M shows php_module; MPM is prefork
MaxConnectionsPerChild 0 (default)Per-PID RSS grows monotonically over hours/daysps RSS per PID sampled twice, hours apart
PHP application or extension leakRSS growth correlates with specific URL patternsCompare RSS of children serving different vhosts/paths
MaxRequestWorkers sized without memory mathAH00484 in error log plus swap usage, then OOM killsdmesg for OOM kills; multiply MaxRequestWorkers by observed RSS
mod_php segfaults“child pid NNNN exit signal Segmentation fault” in error loggrep error log for segfault; check which extension core dumps implicate
Graceful restart overlapMemory doubles briefly during restarts as old and new generations coexistCount 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

  1. Confirm the stack. Step 1 above should show prefork MPM plus a php module. If you see event MPM, mod_php is not loaded and this article is not your problem.

  2. 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_rollup or smem if you want accuracy; use RSS if you want a conservative upper bound. Measure at peak, not right after a restart, because children grow.

  3. Run the math. MaxRequestWorkers x max_observed_child_RSS is 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.

  4. Check for the leak pattern. Sample per-PID RSS now and again in a few hours. Monotonic growth per PID with MaxConnectionsPerChild 0 is 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.

  5. 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.

  6. 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

SignalWhy it mattersWarning sign
Per-child RSS / PSSSets the MaxRequestWorkers ceilingGrowth trend over days; any child >2x the average
Total Apache RSS vs RAMDirect OOM predictorMaxRequestWorkers x avg RSS approaching 70% of RAM
MemAvailable and swap usageEarly warning before OOM killsAny sustained swap use by Apache children
BusyWorkers / MaxRequestWorkersSaturation headroomSustained >80%; IdleWorkers pinned at 0
OOM kill events in dmesgConfirms the ceiling was hitAny Apache child killed
Segfault count in error logmod_php instability indicatorMore than occasional; any cluster
AH00484 (MaxRequestWorkers reached)Worker pool exhaustedAny occurrence
Scoreboard G states and restart frequencyGraceful restart pile-up doubles memoryOverlapping 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 RSS as 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.