Case Study: When the OOM Killer Kills PHP — The Classic Memory Configuration Mistake
12 min read

Case Study: When the OOM Killer Kills PHP — The Classic Memory Configuration Mistake

A PHP process dying suddenly without a fatal error in the application log is one of the most confusing symptoms an engineer can encounter. The server looks fine, CPU is low, no exception is caught, but requests suddenly cut off just like that. The cause is almost always the same: the OOM Killer at the kernel level executing the PHP process because the system ran out of memory. This article dissects a real case study — a 4GB RAM server, a 2GB PHP memory_limit, and 20 PHP-FPM workers — to show how this combination of seemingly reasonable numbers is actually a time bomb.

The Real Case Study

The server configuration that became the source of the problem looks like this:

ParameterValue
Total server RAM4 GB
PHP memory_limit2 GB
PHP-FPM pm.max_children20
SwapMinimal / none

Symptoms appearing in production:

  • PHP processes dying suddenly, requests cut off without a clear response to the client.
  • No fatal errors or exceptions in the PHP application log.
  • dmesg shows the OOM Killer active, with the PHP process as the victim.
  • Monitoring logs show PHP memory usage reaching about 3GB before the process was killed.

The initial hypothesis during investigation: several PHP processes simultaneously consuming large memory, and when total usage approached the system RAM limit, the kernel chose the process with the largest consumption as the victim. This hypothesis proved correct — but the root cause wasn’t a bug, rather a failure to understand one basic concept: how PHP’s memory_limit actually works in the context of many workers.


How PHP-FPM Memory Actually Works

PHP-FPM (FastCGI Process Manager) doesn’t run one big process serving all requests. It runs one master process managing a set of independent worker processes. Each worker is a separate operating system process, with its own memory space.

flowchart TD
    A[Nginx / Apache] -->|FastCGI request| B[PHP-FPM Master Process]
    B --> C[Worker 1]
    B --> D[Worker 2]
    B --> E[Worker 3]
    B --> F[Worker ... N]
    C -.->|memory_limit per process| G[(Independent Memory Space)]
    D -.->|memory_limit per process| H[(Independent Memory Space)]
    E -.->|memory_limit per process| I[(Independent Memory Space)]

Each worker handles one request at a time, and each worker has its own memory allocation completely unshared with other workers. This is the root of a classic, very common mistake, especially in older configurations:

COMMON MISUNDERSTANDING:
  "memory_limit = 2GB means PHP as a whole
   may only use 2GB of memory."

THE ACTUAL FACT:
  memory_limit is a limit PER WORKER / PER REQUEST,
  not a global limit for the entire PHP-FPM process.
  • memory_limit is never an aggregate limit for the entire PHP-FPM process.
  • If there are 20 workers and each is given a 2GB memory_limit, the kernel potentially faces requests of up to 40GB of memory simultaneously — far exceeding the 4GB of physical RAM available.
  • Linux allows memory overcommit by default, so PHP-FPM can start and run normally for a long time before some heavy requests happen simultaneously and trigger the OOM Killer.

Anatomy of the Mistake: The Ignored Memory Math

This kind of configuration mistake is rarely visible at the start because under normal conditions, most PHP requests only use a few dozen megabytes of memory. The problem appears when several heavier requests — large queries, data loops, file parsing — happen at the same time, and the max_children × memory_limit combination turns out far exceeding the available physical RAM.

The rough formula:

Maximum Potential Allocation = pm.max_children × memory_limit

This case study:
  20 workers × 2GB = 40GB potential memory allocation

Server physical RAM: 4GB

Over-provisioning ratio: 10x the available RAM

The following table shows how the max_children and memory_limit combination affects the risk level on a server with the same RAM:

max_childrenmemory_limitPotential AllocationServer RAMStatus
202 GB40 GB4 GBVery risky
20512 MB10 GB4 GBStill risky
10256 MB2.5 GB4 GBRelatively safe
12256 MB3 GB4 GBSafe with overhead

This “potential allocation” number isn’t a guarantee the system will crash immediately — as long as not all workers use maximum memory simultaneously, the system can run without problems for a long time. But this is what makes this mistake dangerous: it doesn’t fail consistently. It fails randomly, usually when traffic is at its highest, making it hard to reproduce in a staging environment.

  • Setting a large memory_limit feels “safe” because errors rarely appear directly — until several heavy requests happen to coincide.
  • The higher pm.max_children, the more workers can use memory simultaneously when traffic rises, not just more requests that can be served.

How the OOM Killer Works

When the Linux kernel detects the system is almost out of memory and can’t do further reclaiming (cleaning caches, swapping, etc.), the Out-of-Memory Killer mechanism activates to prevent the system from truly freezing or crashing completely.

flowchart TD
    A[System almost out of memory] --> B{Can memory still be reclaimed?}
    B -- Yes: from cache/buffer --> C[Reclaim cache, continue normally]
    B -- Can't reclaim anymore --> D[OOM Killer activated]
    D --> E[Compute each process's oom_score]
    E --> F[Pick the process with the highest score]
    F --> G[Send SIGKILL to that process]
    G --> H[Process memory freed]
    H --> I[System stable again]

The kernel computes an oom_score for every running process, considering several factors:

  • The amount of memory the process is currently using (the most dominant factor).
  • Process privilege — root-owned processes tend to be given lower scores so they aren’t easily victimized.
  • Process lifetime and several additional kernel heuristics.

The process with the highest oom_score — usually the one using the most memory — is chosen as the “cheapest candidate to sacrifice”, then the kernel sends SIGKILL to that process. This signal can’t be caught or delayed by the application, unlike SIGTERM which still gives a chance for cleanup.

In this case study, the PHP-FPM worker process currently handling the heavy request with the largest memory usage was the clearest candidate, so that’s the process the kernel executed.

The OOM Killer isn’t a feature exclusive to PHP. This mechanism works at the kernel level and can target any process — Node.js, Java, Python, even databases — if that process has the highest memory score when the system runs out of resources.

Reading the Signals in the Kernel Log

Because the OOM Killer works at the kernel level, not the application level, PHP never gets a chance to write an error to its own log. The request being processed is cut off just like that, without a stack trace, without an exception. This is why this case often looks “mysterious” — engineers search the application log when the answer is in the kernel log.

How to verify the OOM Killer suspicion:

# Check the OOM Killer history in the kernel ring buffer
dmesg | grep -i oom

# Alternative on systems with a systemd journal
journalctl -k | grep -i "out of memory"

# Check which process recently died and its PID
dmesg -T | grep -i "killed process"

The output that usually appears contains information like the name of the executed process, PID, and the total memory the process was using right before being killed — this is the number used to confirm which process was the victim and how much memory it was using.

The memory pattern usually seen before a kill isn’t a sudden spike, but a gradual increase while the request is processed — caused by requests that are intrinsically heavy (processing large datasets, generating files), not the classic memory leak in the sense of an application failing to free memory between requests.


Why It Dies at Around 3GB, Not Exactly at 4GB?

One of the confusing things in this case: why does the OOM Killer activate when PHP memory usage only reaches about 3GB, even though the server’s total RAM is 4GB? The answer is because that 4GB of RAM isn’t entirely available to PHP.

Memory ConsumerNotes
Kernel & operating systemBasic overhead that always exists
Page cacheUsed by the OS for caching file I/O
Other services (Nginx, systemd, cron, etc.)Running in parallel with PHP-FPM
PHP-FPM workersThe remaining memory truly available

As a practical rule, the danger zone usually starts to appear when memory usage reaches 70–80% of total physical RAM. On a 4GB server, that means around 2.8GB–3.2GB — aligned with the ~3GB recorded in this case study before the PHP process was killed.


Factors Worsening the Situation

The disproportionate memory_limit and max_children configuration is the root problem, but several other factors accelerate the arrival of the OOM condition:

High concurrency. Several heavy requests happening to run simultaneously are far more dangerous than one heavy request running alone, because memory usage is cumulative across active workers.

Too many PHP workers relative to RAM. A pm.max_children set without accounting for the memory budget is usually based on the assumption “the more workers, the more requests can be served” — without considering that every additional worker is an additional potential memory load.

Intrinsically heavy PHP requests. Several operation types indeed need large memory:

Operations prone to high memory:
  ✗ CSV / Excel export with large datasets
  ✗ PDF generation from complex content
  ✗ Image processing (resize, format conversion)
  ✗ Database queries without pagination loading the entire result into memory

Minimal or no swap. Without swap, the kernel loses one buffer layer of time before having to make an extreme decision. The OOM Killer becomes far more aggressive because there’s no extra room to hold temporary memory surges.

Memory fragmentation. PHP running native extensions (image processing, PDF generation, etc.) can cause memory fragmentation making allocations fail faster than estimated from total usage alone.


The Fix: Calculating the Memory Budget Correctly

The solution for this case isn’t just “increase the server RAM” — although that could help — but consciously calculating the memory budget based on the resources truly available.

The basic formula:

(pm.max_children × memory_limit) + system overhead  <  Total RAM

For the 4GB server in this case study, a far safer configuration looks like this:

; ANTI-PATTERN: disproportionate to 4GB RAM
; memory_limit = 2G
; pm.max_children = 20
; Potential allocation: 40GB on a 4GB RAM server

; CORRECT: memory budget consciously calculated against physical RAM
[www]
pm = dynamic
pm.max_children = 12
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 6

; php.ini or pool-level php_admin_value
php_admin_value[memory_limit] = 256M

With a 256MB memory_limit and 12 max_children, the maximum potential allocation is around 3GB — still leaving room for the operating system overhead, page cache, and other services on the 4GB server.

  • Lowering memory_limit to a much smaller number (256MB, sometimes even lower for simple CRUD applications) isn’t a step backward — it’s an old best practice often forgotten in modern configurations.
  • A small memory_limit actually helps find bugs faster: requests using unreasonable memory will fail with a clear PHP fatal error, not pile up silently until the OOM Killer steps in.

Separating Load: Queues for Heavy Requests

One mistake often accompanying cases like this is running heavy operations — large dataset exports, PDF generation, image processing — directly on the same PHP-FPM web workers serving normal traffic. Web workers should ideally be optimized for fast requests with small memory, not for jobs needing long time and large memory.

flowchart LR
    A[Client] -->|Export request| B[Nginx]
    B --> C[PHP-FPM Web Worker]
    C -->|Push job to queue| D[(Redis / RabbitMQ / SQS)]
    C -->|Fast response: job accepted| A
    D --> E[Dedicated Background Job Worker]
    E -->|Larger memory limit, controlled concurrency| F[(Result: export files, PDFs, etc.)]

By separating heavy load into dedicated queue workers:

  • Web workers stay light and fast to respond, with a small memory_limit safe from OOM risk.
  • Queue workers can be given a larger memory_limit and far more controlled concurrency, usually only a few parallel processes.
  • Heavy job failures don’t take down the capacity to serve normal web traffic.

Common queue technologies for this pattern include Redis (with libraries like Laravel Queue or Symfony Messenger), RabbitMQ, or Amazon SQS for those already in the AWS ecosystem.


Proactive Monitoring Before OOM Happens Again

Preventing a similar case from recurring requires visibility into memory usage per worker, not just total server memory.

# See each process's memory usage, sorted from the largest
ps aux --sort -rss | head -n 20

# Filter specifically for PHP-FPM processes
ps aux --sort -rss | grep php-fpm

PHP-FPM also provides a built-in status page that can be enabled to monitor the number of active processes, idle processes, and requests being processed:

; in the PHP-FPM pool configuration
pm.status_path = /status

For long-term visibility, APM (Application Performance Monitoring) tools like New Relic or Datadog can track memory trends per request and alert before conditions approach the danger zone, far earlier than waiting for dmesg to report the next OOM Killer victim.


When Swap Helps, and When It Doesn’t

Enabling swap on a server with limited RAM is often considered controversial because swap is fundamentally far slower than RAM. But in the context of preventing the OOM Killer, swap’s function here isn’t for performance — it’s to give observation time before the kernel makes an extreme decision.

  • Too much swap on a server with a slow disk can make the system feel “hung” (thrashing) instead of crashing fast — sometimes this is harder to diagnose than the OOM Killer acting directly.
  • Moderate swap (for example 1–2GB on a 4GB RAM server) is enough to provide a buffer without trapping the system in prolonged thrashing.

Swap isn’t a replacement for a correctly calculated memory budget — it’s just an additional safety net, not the main solution.


Summary

  • PHP’s memory_limit is a limit per worker / per request, not a global limit for the entire PHP-FPM process.
  • The maximum potential allocation is calculated from pm.max_children × memory_limit — if this number far exceeds physical RAM, the system is just “lucky” not to have crashed, not truly safe.
  • The OOM Killer works at the kernel level by computing each process’s oom_score and executing SIGKILL on the process with the highest score — usually the process with the largest memory consumption.
  • Because it works at the kernel level, the OOM Killer leaves no trace in the PHP application log — always check dmesg | grep -i oom when encountering processes dying without a fatal error.
  • The danger zone usually starts appearing at 70–80% of used RAM, not exactly at 100%, because the OS and other services also use memory.
  • Lower memory_limit to a realistic number (for example 256MB) and adjust pm.max_children so the maximum potential allocation stays below the available physical RAM.
  • Separate heavy requests (exports, PDFs, image processing) into dedicated queue workers with controlled concurrency, don’t run them on regular web workers.
  • Moderate swap helps give observation time, but isn’t a replacement for a consciously calculated memory budget.

Portfolio