Bali, Indonesia
Insights

Mencegah OOM Cascading di Shared Hosting: Konfigurasi Cgroups v2 memory.high untuk Menahan Ledakan Worker PHP-FPM

October 15, 2026
0:00
Pada klaster shared web hosting dengan ratusan tenant, lonjakan alokasi memori tak terkontrol pada worker PHP-FPM dapat memicu Linux OOM Killer mengeksekusi daemon kritis seperti database (MySQL/MariaDB). Baris terakhir pada ring buffer dmesg menampilkan pesan terminasi proses:
TEXT
[14285.892101] Out of memory: Killed process 1824 (mysqld) total-vm:18452144kB, anon-rss:8192340kB, file-rss:0kB, shmem-rss:0kB
[14285.898421] oom_reaper: reaped process 1824 (mysqld), now anon-rss:0kB, memory-backend:0kB
Linux Out-of-Memory (OOM) Killer baru saja mengeksekusi daemon database utama server yang melayani ratusan database tenant, alih-alih menembak ratusan worker PHP-FPM nakal yang sebenarnya memicu ledakan alokasi memori. Mengapa ini terjadi? Sebuah exploit script berbasis arbitrary file upload pada instalasi CMS salah satu tenant dieksekusi secara masif oleh botnet. Skrip tersebut melakukan infinite loop pengolahan citra mentah dengan modul GD/ImageMagick dan mencoba menyuntikkan payload defacement ke ribuan direktori. Masalah fundamental shared hosting konvensional terletak pada arsitektur isolasi yang hanya bersandar pada batas process-level primitif seperti direktif pm.max_children PHP-FPM dan ulimit UNIX. Ketika batasan ini gagal atau diatur terlalu longgar untuk mengakomodasi lonjakan traffic sah, kernel Linux kehilangan kendali granular atas batas konsumsi memori per tenant. Untuk memutus siklus degradasi ini, kita harus beralih ke kontrol mekanis kernel: hierarki cgroups v2 dan pemanfaatan direktif memory.high sebagai rem proporsional sebelum hard limit mengorbankan stabilitas host.
Untuk memahami mengapa database atau reverse proxy selalu menjadi korban pertama OOM Killer, kita perlu membedah kalkulasi skor penalti kernel di mm/oom_kill.c. Fungsi penentu korban adalah oom_badness():
C
unsigned long oom_badness(struct task_struct *p, unsigned long totalpages)
{
    long points;
    long adj;

    if (oom_task_origin(p))
        return ULONG_MAX;

    /* Proses yang tidak bisa di-kill (misal: init / PID 1) */
    if (p->flags & PF_KTHREAD || is_global_init(p) || (p->flags & PF_NO_SETAFFINITY))
        return 0;

    adj = (long)p->signal->oom_score_adj;
    if (adj == OOM_SCORE_ADJ_MIN)
        return 0;

    /* Titik mula penalti dihitung dari Resident Set Size (RSS) */
    points = get_mm_rss(p->mm) + get_mm_counter(p->mm, MM_SWAPENTS) +
             atomic_long_read(&p->mm->nr_ptes);

    adj *= (long)totalpages / 1000;
    points += adj;

    return points > 0 ? points : 1;
}
Kernel Linux menghitung nilai badness murni berdasarkan proporsi memori fisik (RSS), swap entries, dan page tables yang saat itu sedang dipegang oleh satu proses tunggal. Di lingkungan shared hosting, pola serangan exploit PHP-FPM hampir selalu terdistribusi ke puluhan atau ratusan subproses (fork worker). Setiap worker PHP-FPM mungkin hanya mengonsumsi 60 MB hingga 120 MB RAM. Namun, jika ada 80 worker aktif yang mengalami memory bloat secara simultan, total memori yang dihisap tenant tersebut mencapai hampir 10 GB. Ketika memori fisik host habis dan sistem memasuki status darurat, OOM Killer memindai seluruh process table. OOM Killer melihat mysqld berdiri tegak memegang buffer pool sebesar 8 GB sebagai satu PID tunggal. Sementara itu, 80 worker PHP-FPM tadi masing-masing hanya memegang ratusan megabyte. Hasilnya fatal: mysqld memiliki skor oom_badness paling tinggi di sistem, menjadikannya target paling empuk untuk dieliminasi, memicu cascading failure ke seluruh aplikasi di dalam server.
Dalam cgroups v1, administrator hanya diberikan instrumen tumpul: memory.limit_in_bytes. Di cgroups v2, model kontrol memori didesain ulang total untuk membedakan antara hard ceiling yang destruktif dan throttling brake yang adaptif.
Kontrol Cgroup v2Reclaim StrategyDampak ke ProsesRespon OOM Killer
memory.minProtected memoryKernel menolak merebut halaman anonim/fileKebal OOM jika usage di bawah limit
memory.lowBest-effort protectionSoft reclaim jika tekanan memori global tinggiDiproteksi selama cgroup lain masih bisa di-reclaim
memory.highProportional throttleDirect reclaim sinkron + sleep penalty di kernelTidak memicu OOM Killer
memory.maxHard enforcementDirect reclaim agresif sinkronOOM Killer dipanggil jika reclaim nihil
Ketika alokasi memori pada sebuah cgroup melampaui ambang batas memory.high, proses tidak langsung dihentikan atau di-kill. Sebaliknya, kernel Linux mengeksekusi fungsi mem_cgroup_handle_over_high() saat proses hendak kembali dari mode kernel ke userspace (misalnya setelah syscall mmap, brk, atau page fault handling). Kernel menghitung seberapa jauh alokasi aktual melampaui memory.high dan mengonversinya menjadi durasi penundaan (delay penalty):
C
/* Logika konseptual perhitungan penalty pada kernel Linux */
overage = usage - high;
penalty = (overage * HZ * penalty_rate) / high;
schedule_timeout_killable(penalty);
Efek penundaan ini luar biasa penting di dunia nyata:
  1. Melambatkan Eksekusi Worker: Script exploit yang boros memori mendadak mengalami degradasi eksekusi masif. Loop alokasi memori yang tadinya memakan CPU time secara instan dipaksa tidur (sleep) hingga beberapa puluh milidetik per page fault.
  2. Memberikan Jendela Waktu untuk Direct Reclaim: Sembari proses ditahan di dalam kernel, thread pembersih kswapd dan direct reclaim internal cgroup memiliki waktu yang cukup untuk membersihkan page cache yang kotor dan men-swap halaman memori anonim yang dingin ke zRAM atau swap space tanpa membuat server freeze.
  3. Backpressure ke Web Server: Karena worker PHP-FPM ter-throttle, socket backlog antara reverse proxy (Nginx) dan PHP-FPM upstream mulai terisi penuh. Nginx akan merespons request berlebih dengan HTTP 502/504 Bad Gateway alih-alih membiarkan request baru terus membanjiri pool tenant yang sakit.
Sebaliknya, jika Anda hanya mengandalkan memory.max, begitu worker mengeksekusi pemanggilan alokasi memori yang menabrak batas, direct reclaim akan langsung dijalankan secara panik. Jika direct reclaim gagal membebaskan halaman dalam sekian nanosekon, cgroup OOM Killer langsung membunuh worker PHP secara acak. Worker yang mati tiba-tiba ini sering kali meninggalkan file lock, transaksi database yang corrupt, atau state session setengah matang.
Banyak system administrator mencoba mengatasi lonjakan ini hanya lewat konfigurasi pool PHP-FPM (/etc/php-fpm.d/tenant.conf):
INI
[tenant_a]
user = tenant_a
group = tenant_a
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
php_admin_value[memory_limit] = 256M
Ada tiga kelemahan fatal pada pendekatan userspace ini:
  1. php_admin_value[memory_limit] Bukan Jaminan Fisik: Parameter ini hanya menghitung memori yang dialokasikan oleh engine Zend (emalloc()). Alokasi memori yang dilakukan oleh library compiled C seperti imagick, ekstensi parsing XML, database driver, atau library enkripsi libc malloc() sering kali lolos dari perhitungan memory_limit PHP.
  2. Eksplosi RSS Akibat CoW (Copy-on-Write) Decay: Saat worker PHP-FPM pertama kali melakukan fork dari master process, mereka berbagi memori fisik melalui Copy-on-Write. Namun, seiring berjalannya waktu dan eksekusi request exploit yang acak, shared page tersebut pecah (dirty pages) menjadi private dirty RSS di masing-masing subproses. 50 worker yang tadinya berbagi 200 MB dapat membengkak menjadi 50 x 200 MB = 10 GB dalam beberapa detik.
  3. Exploit Spawn Process: Jika celah keamanan memungkinkan penyerang menjalankan fungsi seperti shell_exec() atau proc_open(), subproses eksternal yang di-spawn sama sekali tidak tunduk pada memory_limit PHP runtime.
Solusinya: batasan memori harus dikunci di level hierarki kernel melalui kontrol cgroups v2.
Distribusi Linux modern seperti AlmaLinux 9, Rocky Linux 9, Debian 12, dan Ubuntu 22.04/24.04 telah mengaktifkan hierarki terpadu (unified hierarchy) cgroups v2 secara default. Kita akan memanfaatkan fitur native systemd-slice untuk mengelompokkan setiap pool tenant shared hosting ke dalam batas isolasi yang kedap.
Kita membuat satu slice induk untuk seluruh tenant shared hosting, kemudian setiap tenant mendapatkan sub-slice unik berdasarkan UID mereka:
TEXT
-.slice (Root)
 +-- system.slice (sshd, mysqld, nginx)
 +-- hosting.slice (Total alokasi shared hosting)
      +-- hosting-tenant1001.slice
      |    +-- php-fpm-pool-tenant1001.service
      +-- hosting-tenant1002.slice
           +-- php-fpm-pool-tenant1002.service
Unit slice induk ini berfungsi sebagai perimeter pertahanan agar seluruh tenant shared hosting digabung sekalipun tidak akan pernah bisa memakan lebih dari 75% total memori host, menyisakan ruang absolut untuk database dan daemon sistem:
SYSTEMD
[Unit]
Description=Shared Hosting Master Slice
Before=slices.target

[Slice]
# Mengamankan minimal 4 GB untuk sistem dari total 32 GB RAM host
MemoryAccounting=yes
MemoryHigh=24G
MemoryMax=26G
MemorySwapMax=4G
CPUAccounting=yes
IOAccounting=yes
Kita dapat menerapkan dynamic drop-in template untuk setiap instance sub-slice tenant. Misalkan kita menetapkan batas aman per tenant: memory.high di 1.5 GB dan memory.max di 2.0 GB:
SYSTEMD
[Slice]
MemoryAccounting=yes
# Threshold perlambatan agresif dan direct reclaim bertahap
MemoryHigh=1536M
# Batas absolut hard ceiling (OOM trigger spesifik ke slice ini saja)
MemoryMax=2048M
# Batasi konsumsi swap agar tidak melumpuhkan I/O disk NVMe
MemorySwapMax=512M
# Bobot alokasi CPU saat terjadi contention (default 100)
CPUWeight=80
# Proteksi I/O read/write writeback contention
IOWeight=80
Alih-alih menjalankan seluruh pool PHP-FPM di bawah satu daemon tunggal php-fpm.service yang raksasa, pisahkan setiap pool tenant menjadi systemd service instance tersendiri menggunakan template unit. Buat file template unit /etc/systemd/system/[email protected]:
SYSTEMD
[Unit]
Description=PHP-FPM Pool untuk Tenant %I
After=syslog.target network.target
PartOf=hosting.slice

[Service]
Type=notify
Slice=hosting-%I.slice
PIDFile=/run/php-fpm/php-fpm-%I.pid
ExecStart=/usr/sbin/php-fpm --nodaemonize --fpm-config /etc/php-fpm.d/%I.conf
ExecReload=/bin/kill -USR2 $MAINPID
PrivateTmp=true
ProtectSystem=full
ProtectHome=read-only
ReadOnlyPaths=/usr /bin /sbin
ReadWritePaths=/run /var/log/php-fpm /home/%I
Restart=on-failure
RestartSec=3s

[Install]
WantedBy=multi-user.target
Konfigurasi pool /etc/php-fpm.d/tenant1001.conf hanya perlu mengarahkan socket dan user UNIX yang bersangkutan:
INI
[tenant1001]
user = tenant1001
group = tenant1001
listen = /run/php-fpm/tenant1001.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660

pm = dynamic
pm.max_children = 40
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 1000

; Logging telemetry
access.log = /var/log/php-fpm/tenant1001.access.log
slowlog = /var/log/php-fpm/tenant1001.slow.log
request_slowlog_timeout = 5s
Aktifkan service per tenant:
BASH
systemctl daemon-reload
systemctl enable --now [email protected]
Sekarang, jika tenant1001 terkena serangan eksploitasi yang meledakkan alokasi memori hingga melampaui 1.5 GB, kernel Linux akan langsung mengerem seluruh thread/proses milik [email protected] di dalam hosting-tenant1001.slice. Jika alokasi tetap melonjak hingga 2.0 GB dan reclaim gagal, OOM killer hanya akan membunuh worker di dalam slice tersebut tanpa menyentuh tenant lain, apalagi mysqld host!
Sistem monitoring Anda tidak boleh hanya menunggu alert OOM berbunyi. Anda harus mengukur fenomena penahanan memori (memory throttling) secara real-time.
Kernel Linux mengekspos counter telemetri secara mendalam pada setiap direktori cgroups v2. Anda dapat melihat apakah sebuah tenant sedang mengalami over-high penalty:
BASH
# Periksa counter event pada slice tenant 1001
cat /sys/fs/cgroup/hosting.slice/hosting-tenant1001.slice/memory.events
Output telemetri tipikal saat mitigasi berjalan:
TEXT
low 0
high 14829
max 0
oom 0
oom_kill 0
oom_group_kill 0
Mari kita bedah artinya:
  • high 14829: Tenant ini telah menyentuh batas memory.high sebanyak 14.829 kali. Kernel telah berkali-kali menjatuhkan delay penalty dan sukses membersihkan halaman memori via direct reclaim.
  • max 0 dan oom 0: Hard limit tidak pernah tertembus, dan OOM Killer sama sekali tidak pernah diaktifkan!
Cgroups v2 mengintegrasikan subsistem Pressure Stall Information (PSI). File memory.pressure menunjukkan persentase waktu terbuang di mana proses terhambat (stalled) menunggu alokasi memori:
BASH
cat /sys/fs/cgroup/hosting.slice/hosting-tenant1001.slice/memory.pressure
Output:
TEXT
some avg10=24.50 avg60=8.12 avg300=1.05 total=8492011
full avg10=12.10 avg60=3.20 avg300=0.40 total=2109400
Jika metrik full avg10 melompat di atas 10%, artinya seluruh thread di dalam slice tersebut tidak dapat melakukan komputasi CPU karena terkunci menunggu kswapd/direct reclaim membebaskan halaman memori. Ini adalah sinyal presisi bagi tim support hosting untuk segera mengisolasi atau mengabari tenant bahwa website mereka mengalami anomali resource.
Untuk membuktikan secara empiris bahwa kernel benar-benar memperlambat worker PHP alih-alih melempar error fatal, kita bisa memasang probe eBPF pada fungsi try_to_free_mem_cgroup_pages. Simpan skrip berikut sebagai reclaim_latency.bt:
C
#!/usr/bin/env bpftrace

#include <linux/memcontrol.h>

kprobe:try_to_free_mem_cgroup_pages
&#123;
    @start[tid] = nsecs;
&#125;

kretprobe:try_to_free_mem_cgroup_pages
/@start[tid]/
&#123;
    $duration_us = (nsecs - @start[tid]) / 1000;
    @reclaim_latency_us = hist($duration_us);
    delete(@start[tid]);
&#125;

interval:s:10
&#123;
    print(@reclaim_latency_us);
    clear(@reclaim_latency_us);
&#125;
Jalankan di terminal saat Anda menyimulasikan attack payload:
BASH
bpftrace reclaim_latency.bt
Output histogram latensi direct reclaim di tingkat kernel:
TEXT
@reclaim_latency_us: 
[16, 32)               4 |@                                       |
[32, 64)              18 |@@@@@                                   |
[64, 128)            142 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[128, 256)            89 |@@@@@@@@@@@@@@@@@@@@@@@                 |
[256, 512)            31 |@@@@@@@@                                |
[512, 1024)            6 |@                                       |
[1024, 2048)           2 |                                        |
Data di atas membuktikan bahwa proses worker PHP-FPM yang membengkak ditahan di dalam kernel antara 64 hingga 256 mikrodetik per siklus reclaim. Penundaan akumulatif inilah yang secara fisik mencekik ledakan alokasi memori sebelum menyentuh batas kritis server.
Selain konfigurasi cgroup per slice, kernel Linux membutuhkan penyesuaian parameter virtual memory (vm) global agar tidak panik saat salah satu slice tenant mengalami turbulensi reclaim. Tambahkan konfigurasi berikut ke /etc/sysctl.d/99-shared-hosting-vm.conf:
INI
# Nonaktifkan overcommit memori yang ugal-ugalan
# Nilai 0 = Heuristic overcommit (default kernel)
# Untuk hosting padat, tetap gunakan 0 tetapi pantau watermarks
vm.overcommit_memory = 0

# Tingkatkan watermark scale factor untuk memulai kswapd lebih awal
# Default kernel 10 (0.1% dari RAM). Naikkan ke 50 (0.5% dari RAM)
# Ini memberi kswapd buffer lebih tebal sebelum terpaksa direct reclaim
vm.watermark_scale_factor = 50

# Kontrol agresivitas swap anonim vs page cache reclaim (default 60)
# Di shared hosting dengan SSD/NVMe cepat, pertahankan di rentang 30-40
# agar kernel lebih memprioritaskan membersihkan file cache yang aman di-evict
vm.swappiness = 30

# Cegah kswapd mengorbankan dentries dan inodes VFS terlalu agresif
# Nilai 50 menjaga metadata filesystem tetap hangat di RAM
vm.vfs_cache_pressure = 50

# Paksa OOM Killer mematikan seluruh proses dalam cgroup jika menabrak memory.max
# 1 = Panic/Kill seluruh task cgroup, mencegah zombie loop
vm.oom_dump_tasks = 1
Muat parameter baru tanpa reboot:
BASH
sysctl --system
Pastikan juga daemon kritis seperti SSH dan Database diberikan kekebalan penalti OOM secara manual melalui systemd service override:
BASH
# Untuk mysqld/mariadb
systemctl edit mysqld
Tambahkan konfigurasi berikut:
SYSTEMD
[Service]
# Lindungi mysqld dari eliminasi OOM Killer global
OOMScoreAdjust=-900
Lakukan hal yang sama untuk sshd:
SYSTEMD
[Service]
OOMScoreAdjust=-1000
Dengan menurunkan oom_score_adj ke -900 atau -1000, kernel secara eksplisit dilarang mengeksekusi kedua daemon vital ini selama krisis memori global belum mencapai titik absolut di mana tidak ada proses lain yang tersisa di muka bumi.
Ketika tenant terkena exploit, membiarkan worker terus-menerus ter-throttle di memory.high selama berjam-jam tetap membuang resource CPU cycle. Kita membutuhkan daemon ringan yang mengevaluasi metrics PSI dan merespons anomali secara elegan. Berikut skrip bash daemon produksi /usr/local/bin/slice-psi-monitor.sh yang memanfaatkan antarmuka epoll/inotify atau pooling interval untuk mendeteksi cgroup yang terjebak dalam loop reclaim intensif:
BASH
#!/usr/bin/env bash
set -euo pipefail

BASE_CGROUP="/sys/fs/cgroup/hosting.slice"
THRESHOLD_FULL_SOME=15.00 # 15% stall pressure
LOG_FILE="/var/log/hosting-remediation.log"

echo "[(date -Iseconds)] Memulai Slice PSI Monitoring Daemon..." >> "LOG_FILE"

while true; do
    for slice_path in "$BASE_CGROUP"/hosting-*.slice; do
        [ -d "$slice_path" ] || continue
        
        slice_name=(basename "slice_path")
        pressure_file="$slice_path/memory.pressure"
        events_file="$slice_path/memory.events"
        
        if [ -f "$pressure_file" ]; then
            # Ambil nilai some avg10
            some_avg10=(grep 'some' "pressure_file" | awk '&#123;print $2&#125;' | cut -d'=' -f2)
            
            # Evaluasi apakah pressure melebihi threshold aman
            if (( (echo "some_avg10 > $THRESHOLD_FULL_SOME" | bc -l) )); then
                oom_count=(grep 'high' "events_file" | awk '&#123;print $2&#125;')
                
                echo "[(date -Iseconds)] WARNING:slice_name stall avg10=some_avg10% (high_events=oom_count)" >> "$LOG_FILE"
                
                # Ambil instance systemd yang berasosiasi
                tenant_id=$&#123;slice_name#hosting-&#125;
                tenant_id=$&#123;tenant_id%.slice&#125;
                service_name="php-fpm-tenant@$&#123;tenant_id&#125;.service"
                
                # Reload graceful PHP-FPM pool untuk me-reap worker bengkak tanpa drop connection kasar
                echo "[(date -Iseconds)] ACTION: Graceful reloadservice_name" >> "$LOG_FILE"
                systemctl reload "$service_name" || true
                
                # Cooldown untuk mencegah thundering reload loop
                sleep 5
            fi
        fi
    done
    sleep 10
done
Pasang skrip ini sebagai systemd unit mandiri /etc/systemd/system/slice-psi-monitor.service:
SYSTEMD
[Unit]
Description=Automated Shared Hosting Slice PSI Remediation Daemon
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/slice-psi-monitor.sh
Restart=always
RestartSec=5s
User=root

[Install]
WantedBy=multi-user.target
Aktifkan unit monitor:
BASH
chmod +x /usr/local/bin/slice-psi-monitor.sh
systemctl daemon-reload
systemctl enable --now slice-psi-monitor.service
Dalam dunia rekayasa sistem, tidak ada solusi perak (silver bullet). Penerapan arsitektur memory.high cgroups v2 membawa serangkaian trade-off operasional yang harus dipahami oleh Principal Architect:
Saat sebuah pool tenant melampaui memory.high, kernel sengaja menunda eksekusi syscall proses tersebut. Pengunjung situs tenant yang bersangkutan akan melihat response time melonjak dari 150 ms menjadi 2.000 ms - 5.000 ms. Ini adalah intended behavior (desain yang disengaja): lebih baik situs tenant yang terinfeksi malware merespons lambat atau timeout, daripada membuat node cluster crash dan merusak service ratusan tenant lain yang tidak bersalah.
Jika nilai memory.high dan memory.max dipatok terlalu berdekatan (misalnya selisih kurang dari 64 MB), dan workload tenant terus mencoba mengalokasikan dirty memory, kernel akan menghabiskan CPU cycle dalam jumlah masif untuk mencoba membersihkan halaman memori yang sebenarnya tidak bisa dibersihkan (unreclaimable anon memory). Pastikan selalu memberi jarak aman (headroom) minimal 20% hingga 30% antara memory.high dan memory.max (contoh: high di 1536M dan max di 2048M) agar kernel memiliki ruang gerak sebelum terpaksa mengambil tindakan terminasi.
Jika database seperti MySQL dijalankan di dalam tenant slice yang sama (bukan node terpisah atau system slice), konfigurasi memory.high dapat memicu stall pada thread write engine InnoDB (khususnya page flushing). Oleh karena itu, arsitektur shared hosting modern harus selalu memisahkan database daemon ke slice sistem terlindung (system.slice) dengan reservasi memori yang dijamin oleh MemoryLow atau MemoryMin. Melalui orkestrasi cgroups v2, integrasi slice systemd, dan kontrol memory.high, kita mengubah watak brutal kernel Linux dari algojo serampangan menjadi pengatur lalu lintas memori yang tenang dan terkontrol. Serangan exploit atau memory leak di satu situs WordPress tidak akan lagi mampu merobohkan benteng shared hosting Anda.