How a bot flood turns into an out-of-memory event
pm.max_children limit.
If that limit was set without checking how much RAM each worker really uses, the pool can grow past what the machine has. Meanwhile MySQL is holding its own large block of memory, mostly the InnoDB buffer pool, plus per-connection buffers for every PHP worker that opened a database connection.
Linux usually lets processes reserve more memory than physically exists, a behavior called overcommit. Memory is only backed by real pages when a process touches it. So the system does not fail when PHP-FPM forks the extra workers. It fails later, when some process needs a fresh page and the kernel cannot find one.
At that point the kernel tries to reclaim memory first. It drops clean page cache, writes dirty pages back to disk, and pushes anonymous memory to swap if swap exists. This is the phase where the server feels frozen and load average climbs, because processes are stuck waiting on reclaim and disk I/O. Only when reclaim makes no real progress does the kernel call its out-of-memory handler, the OOM killer.
How the OOM killer chooses a victim
oom_score_adj, a per-process value from -1000 to 1000 that administrators or service managers can set. A value of -1000 makes a process effectively exempt. The process with the highest score receives SIGKILL.
This is the part that confuses people. The kernel scores processes one at a time, not groups. Fifty PHP-FPM workers might use far more memory together than MySQL, but each worker on its own is small. mysqld is usually the largest single process on a WordPress server, so it wins the score and gets killed. The kernel freed the most memory in one step, which is its goal, but it removed the database while the real growth was in PHP.
There is also a second flavor of OOM. If a service runs inside a cgroup with a memory limit, for example a systemd unit with a memory cap or a container, the kernel can run the same selection only inside that cgroup when it hits its limit. The rest of the server may have plenty of free memory. The log wording differs, which is how you tell the two apart. And some systems run a userspace daemon such as systemd-oomd or earlyoom that kills processes before the kernel would. Those log through their own service, not as kernel messages.
Finding the evidence in journalctl and dmesg
dmesg reads, and journald copies them into the journal. The journal is the better place to look if the server has rebooted since, as long as it stores logs on disk rather than only in RAM.
BASH
# Kernel messages from the current boot that mention the OOM killer
journalctl -k -b | grep -iE 'invoked oom-killer|out of memory|killed process|oom-kill'
# Same search on the previous boot (needs a persistent journal)
journalctl -k -b -1 | grep -iE 'invoked oom-killer|out of memory|killed process'
# Ring buffer with human-readable timestamps (lost on reboot)
dmesg -T | grep -iE 'out of memory|killed process'
# What systemd saw from the database service side
# (unit may be mysql, mysqld, or mariadb depending on the setup)
journalctl -u mysql --since '-1 day' | grep -iE 'killed|status=9|main process exited'
# Only relevant if a userspace OOM daemon is installed
journalctl -u systemd-oomd --since '-1 day'SIGKILL.
If nothing turns up in the kernel log, look at the MySQL error log instead. A real crash usually leaves its own trace there, such as a signal report or an InnoDB assertion with a stack trace, because the server gets a chance to write before it dies. SIGKILL cannot be caught, so a killed mysqld writes nothing about its death. The only sign in the MySQL log is the next startup, where InnoDB reports that it is doing crash recovery.
Reading the OOM report line by line
TEXT
# Illustrative output format (values are not from a real system)
php-fpm invoked oom-killer: gfp_mask=0x140cca(GFP_HIGHUSER_MOVABLE|__GFP_COMP), order=0, oom_score_adj=0
...
Tasks state (memory values in pages):
[ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[ 2101] 27 2101 600000 250000 2500000 0 0 mysqld
[ 3310] 1001 3310 80000 20000 400000 0 0 php-fpm
[ 3311] 1001 3311 80000 20000 400000 0 0 php-fpm
...
oom-kill:constraint=CONSTRAINT_NONE,nodemask=(null),cpuset=/,mems_allowed=0,task=mysqld,pid=2101,uid=27
Out of memory: Killed process 2101 (mysqld) total-vm:2400000kB, anon-rss:1000000kB, file-rss:0kB, shmem-rss:0kB, UID:27 pgtables:2440kB oom_score_adj:0php-fpm here simply means a PHP worker was the one asking for memory when there was none left, which already hints at where the pressure came from.
The task table is a snapshot of every candidate process at the moment of the kill. The rss column is in pages, usually 4 KiB each on x86_64, so multiply to get bytes. This table is the most useful evidence you have, because it shows how many PHP workers existed and how large they were right then.
The oom-kill: line, present on newer kernels, shows the constraint. CONSTRAINT_NONE means the whole system ran out of memory. CONSTRAINT_MEMCG means a cgroup hit its own limit, and on that kind of event the earlier lines usually say Memory cgroup out of memory rather than plain Out of memory. That tells you to look at service or container limits, not total RAM.
The final line confirms the victim and how much memory was freed. anon-rss is the memory that was actually private to the process, and it is usually the number that matters.
Tying the kill back to PHP workers and the attack
BASH
# Rough count of PHP-FPM rows in the task dump of the most recent OOM event
journalctl -k -b --no-pager | grep -E '\] +[0-9]+ +[0-9]+ .* php-fpm' | wc -l
# Current worker count and average RSS in MiB, for comparison with normal load
ps -C php-fpm -o rss= | awk '{s+=$1; n++} END {if (n) printf "%d workers, avg %.0f MiB
", n, s/n/1024}'php-fpm8.2 or a panel-specific name, so adjust the pattern to what you see in the task table. The grep counts every OOM event in the boot, so if there were several, narrow it with --since and --until around the timestamp of the kill.
Then line the timestamp up with the web server access log. If requests to xmlrpc.php or wp-login.php spike in the minutes before the OOM entry, the chain is clear: bot traffic held workers busy, PHP-FPM grew to its limit, memory ran out, and the kernel removed the largest single process. Reading those access log patterns is covered in Spotting XML-RPC Multicall Attacks in Web Server Access Logs. If the server was slow but nothing was killed, CPU Steal or an xmlrpc.php Brute-Force? Reading top and Access Logs on a VPS helps separate host contention from attack load.
This is also usually where support conversations go sideways. The customer sees MySQL is down and asks for more database memory, while the log shows the database was the victim, not the cause. Showing them the task table tends to settle it quickly.
What to change so it does not happen again
oom_score_adj to -1000, for example through OOMScoreAdjust= in a systemd override. That protects the database, but the memory problem stays. The kernel will kill PHP workers or something else next, or the server will spend a long time thrashing in reclaim before it picks anything. A small negative adjustment can be a reasonable way to tell the kernel MySQL matters more, but it is not a fix on its own.
The real fix is capping the thing that grew. As a rule of thumb, pm.max_children times the average worker RSS, plus MySQL's footprint, plus a margin for the OS and page cache, should fit in RAM. When the pool is full, new requests wait in the queue or time out, which is a much better failure than losing the database. Then reduce the traffic that fills the pool: block or restrict xmlrpc.php if nothing legitimate uses it, rate-limit login endpoints, and filter at a WAF or CDN layer where possible, so bot requests never reach PHP at all.
When MySQL disappears during an attack, start with journalctl -k before touching any config. A Killed process line naming mysqld, with a task table full of PHP-FPM workers, tells you what happened and what to fix: size the PHP pool to the memory you actually have, and keep bot traffic away from PHP in the first place.