top, yang kelihatan paling banyak makan memori justru PHP-FPM, karena worker-nya berderet puluhan. Wajar kalau muncul pertanyaan: kenapa kernel malah mematikan MySQL? Jawabannya ada di cara kernel menilai proses, dan cara itu berbeda dengan cara kita membaca top.
Apa yang terjadi saat memori benar-benar habis
out_of_memory(). Di titik inilah OOM killer bekerja.
OOM killer lalu menelusuri semua proses, memberi masing-masing skor "badness", memilih yang skornya paling tinggi, dan mengirim SIGKILL. Sinyal ini tidak bisa ditangkap atau ditunda oleh proses, jadi MySQL tidak sempat shutdown dengan rapi. InnoDB biasanya bisa recovery saat start ulang lewat redo log, tapi koneksi yang sedang berjalan langsung putus.
Satu hal yang sering terlewat: proses yang memicu OOM belum tentu proses yang dibunuh. Bisa saja worker PHP yang meminta memori terakhir, tapi korbannya MySQL, karena yang dihitung adalah skor, bukan siapa yang meminta.
Cara kernel menghitung skor korban
- RSS (resident set size), yaitu halaman memori yang sedang benar-benar ada di RAM, termasuk memori anonim, halaman file yang dipetakan, dan shared memory.
- Jumlah halaman milik proses itu yang sedang ada di swap.
- Halaman yang dipakai untuk page table proses tersebut.
oom_score_adj. Nilai ini berada di rentang -1000 sampai 1000, dan dikonversi menjadi proporsi dari total memori yang tersedia. Nilai positif membuat proses lebih mungkin dipilih, nilai negatif membuatnya lebih aman. Proses dengan nilai -1000 dilewati sama sekali.
Perhatikan apa yang tidak dihitung. Kolom VIRT di top (memori virtual yang dipesan tapi belum tentu dipakai) tidak masuk perhitungan. Lama proses berjalan, prioritas nice, atau seberapa penting proses itu bagi bisnis kamu juga tidak masuk. Kernel lama dulu memberi potongan skor untuk proses milik root, tapi di kernel yang lebih baru potongan itu sudah dihapus.
Yang paling penting: skor dihitung per proses, bukan per layanan. Kernel tidak tahu bahwa 80 proses php-fpm itu sebenarnya satu kesatuan.
Kenapa MySQL hampir selalu menang "lomba" ini
mysqld dengan skor setara 1,5 GB, dan 40 proses php-fpm dengan skor masing-masing setara 50 MB. Yang tertinggi jelas mysqld.
MySQL memang dirancang menahan memori dalam jumlah besar dalam satu proses. InnoDB buffer pool dialokasikan di dalam mysqld, ditambah buffer per koneksi dan per query. Semakin lama server berjalan dan semakin banyak data yang tersentuh, semakin penuh buffer pool, dan RSS-nya naik mendekati batas yang dikonfigurasi. Sementara itu, PHP-FPM, Apache prefork, atau Exim menyebar bebannya ke banyak proses kecil.
Ada juga efek dari swap. Kalau sebagian buffer pool MySQL sudah terdorong ke swap, RSS-nya di top terlihat lebih kecil. Tapi kernel tetap menghitung halaman di swap itu, jadi skornya tidak turun. Ini salah satu alasan kenapa MySQL tetap dipilih meskipun angka RES-nya tidak terlihat dominan.
Dari sisi support, inilah yang paling sering membingungkan pemilik situs. Mereka melihat MySQL mati lalu menyimpulkan MySQL bermasalah, padahal MySQL hanya korban dari tekanan memori total. Kalau kamu ingin mengenali tanda-tanda tekanan memori sebelum sampai ke tahap ini, ada pembahasannya di How to Spot Memory Pressure on a VPS Before the OOM Killer Hits.
Membaca jejak OOM di journalctl dan dmesg
dmesg atau, di sistem dengan systemd, lewat journalctl -k. Kalau server sempat reboot setelah kejadian, dmesg biasanya sudah kosong, jadi journal lebih bisa diandalkan asalkan journald menyimpan log secara permanen.
BASH
# Log kernel boot saat ini
journalctl -k | grep -i -E 'out of memory|oom-kill|killed process'
# Log kernel dari boot sebelumnya (kalau server sempat restart)
journalctl -k -b -1 | grep -i -E 'out of memory|oom-kill|killed process'
# Alternatif tanpa journal, dengan timestamp yang terbaca
dmesg -T | grep -i -E 'out of memory|killed process'
# Skor OOM saat ini per proses, urut dari yang paling mungkin dibunuh
for p in /proc/[0-9]*; do printf '%s %s %s
' "(catp/oom_score 2>/dev/null)" "(catp/oom_score_adj 2>/dev/null)" "(catp/comm 2>/dev/null)"; done | sort -rn | head -n 15/proc, jadi aman dijalankan di server produksi. Kolom pertama adalah skor, kolom kedua oom_score_adj, kolom ketiga nama proses. Angka skor ini sudah dinormalisasi, jadi gunakan untuk membandingkan antarproses di server yang sama, bukan antarserver dengan kernel berbeda.
Laporan OOM di log biasanya terdiri dari tiga bagian: siapa yang memicu, tabel proses, dan keputusan akhir. Contoh format output (disederhanakan, kolom dan susunannya berbeda antarversi kernel):
TEXT
php-fpm invoked oom-killer: gfp_mask=0x..., order=0, oom_score_adj=0
...
[ pid ] uid tgid total_vm rss pgtables_bytes swapents oom_score_adj name
[ 1200] 27 1200 600000 380000 1000000 20000 0 mysqld
[ 2101] 33 2101 90000 12000 200000 0 0 php-fpm
...
Out of memory: Killed process 1200 (mysqld) total-vm:2400000kB, anon-rss:1500000kB, file-rss:0kB, shmem-rss:0kB, UID:27 pgtables:1000kB oom_score_adj:0php-fpm. Tabel proses menunjukkan kondisi semua proses saat itu. Perlu dicatat, kolom rss dan swapents di tabel ini dalam satuan halaman (umumnya 4 KB), bukan kilobyte. Baris terakhir menyebutkan korbannya.
Ada dua variasi yang perlu dikenali. Kalau log menyebut Memory cgroup out of memory, artinya yang habis adalah batas memori sebuah cgroup (misalnya batas per akun atau per container), bukan RAM server secara keseluruhan. Pemilihan korbannya hanya di dalam cgroup tersebut. Selain itu, beberapa distro memakai systemd-oomd, yaitu daemon di userspace yang bisa mematikan proses lebih awal berdasarkan tekanan memori. Jejaknya ada di log unit systemd-oomd, bukan di log kernel, jadi kalau grep di atas kosong padahal MySQL mati mendadak, cek juga log itu.
Kapan oom_score_adj layak diatur
OOMScoreAdjust di unit service, bukan menulis langsung ke /proc/<pid>/oom_score_adj, karena nilai di /proc hilang setiap kali proses restart.
Cek dulu nilai yang sedang berlaku. Nama unit bisa mysql, mysqld, atau mariadb, tergantung distro dan paket:
INI
# Cek nilai saat ini: systemctl show mysql -p OOMScoreAdjust
# Buat override: systemctl edit mysql
# Isi override:
[Service]
OOMScoreAdjust=-500
# Setelah itu jalankan: systemctl restart mysql/proc/<pid>/oom_score_adj milik mysqld.
Aku pribadi lebih memilih nilai negatif moderat daripada -1000. Dengan -1000, MySQL dikecualikan total, dan kernel akan mencari korban lain. Korban berikutnya bisa saja sshd, agen monitoring, atau proses yang kamu butuhkan untuk masuk dan memperbaiki server. Kalau hampir semua proses besar dikecualikan, server bisa berakhir dalam kondisi yang jauh lebih buruk, bahkan kernel panic di beberapa konfigurasi. Nilai moderat seperti contoh di atas membuat MySQL tetap bisa dipilih kalau memang tidak ada pilihan lain.
Yang lebih penting dipahami: oom_score_adj hanya mengubah siapa yang mati, bukan mencegah memori habis. Kalau MySQL dilindungi, korban berikutnya biasanya worker PHP-FPM. Itu lebih ringan dampaknya karena master process akan membuat worker baru, tapi masalah dasarnya tetap ada. Kalau OOM terjadi berulang, yang perlu dibenahi adalah anggaran memorinya: ukuran buffer pool, jumlah maksimal worker PHP-FPM, dan batas memori per kelompok proses. Untuk yang terakhir, pendekatan dengan cgroup dibahas di Mencegah OOM Cascading di Shared Hosting: Konfigurasi Cgroups v2 memory.high untuk Menahan Ledakan Worker PHP-FPM.
Sebagai pegangan praktis: saat MySQL mati karena OOM, buka log kernel dulu dan lihat siapa pemicunya, bukan hanya siapa korbannya. Bandingkan total memori per layanan, bukan per proses, lalu hitung ulang apakah jumlah semuanya masuk akal untuk RAM yang ada. Atur OOMScoreAdjust kalau memang ingin MySQL jadi pilihan terakhir, tapi anggap itu sebagai pengaman, bukan perbaikan.