Bali, Indonesia
Insights

Penerapan cgroups v2 memory.high dan systemd Slice untuk Isolasi Pool PHP-FPM Multi-Tenant

October 15, 2026
0:00
Secara default, alokasi memori pada PHP-FPM dengan mode dynamic process manager (pm = dynamic) hanya membatasi alokasi heap level user-space melalui direktif memory_limit di dalam file php.ini. Direktif ini dieksekusi oleh Zend Engine Memory Manager (Zend MM). Zend MM tidak memiliki visibilitas terhadap page cache kernel, alokasi memori ekstensi biner C (seperti ImageMagick, cURL, atau driver database), fragmentasi memori libc allocator (ptmalloc), atau lonjakan konsumsi memori akibat anonymous pages gabungan dari puluhan child worker yang beroperasi secara konkuren di dalam pool yang sama. Ketika beberapa tenant di-hosting dalam satu instans sistem operasi yang sama, satu tenant dengan beban alokasi memori abnormal dapat mengonsumsi seluruh memori fisik yang tersedia. Ketika alokasi global melampaui kapasitas RAM dan swap, Linux Out-Of-Memory (OOM) Killer akan aktif melalui fungsi out_of_memory() di kernel. OOM Killer menghitung nilai badness pada setiap proses dan mengeksekusi SIGKILL secara indiscriminatif terhadap proses dengan footprint memori terbesar, yang sering kali merupakan database daemon (mysqld), web server (nginx), atau worker PHP-FPM milik tenant lain yang beroperasi normal. Untuk mengeliminasi dampak noisy neighbor dan mengisolasi alokasi memori secara deterministik, arsitektur isolasi harus dipindahkan dari runtime PHP ke level kernel Linux menggunakan unified cgroup hierarchy (cgroups v2) dengan perantara systemd slice. Untuk analisis mendalam terkait dampak cascading OOM pada level cluster hosting, lihat juga arsitektur yang dibahas pada Mencegah OOM Cascading di Shared Hosting: Konfigurasi Cgroups v2 memory.high untuk Menahan Ledakan Worker PHP-FPM.
Zend Engine melacak alokasi memori menggunakan custom allocator yang meminta blok besar dari libc via mmap() atau brk(), kemudian membaginya ke dalam chunks internal berukuran 2 MB (yang dibagi lagi menjadi pages 4 KB). Parameter memory_limit hanya memeriksa alokasi internal Zend MM:
C
/* Zend/zend_alloc.c (Zend Memory Manager excerpt) */
static zend_always_inline void *zend_mm_alloc_pages(zend_mm_heap *heap, size_t pages_count, size_t alignment)
{
    size_t size = pages_count * ZEND_MM_PAGE_SIZE;
    if (UNEXPECTED(heap->real_size + size > heap->limit)) {
        if (zend_mm_gc(heap) && heap->real_size + size <= heap->limit) {
            /* Alokasi berhasil setelah garbage collection */
        } else {
            zend_error_noreturn(E_ERROR, "Allowed memory size of %zu bytes exhausted", heap->limit);
        }
    }
    /* ... pemanggilan OS allocator ... */
}
Mekanisme ini memiliki tiga kelemahan mendasar pada lingkungan multi-tenant:
  1. Ekstensi C Melompati Zend MM: Pustaka seperti libjpeg, libxml2, atau vips sering mengalokasikan memori langsung melalui malloc() bawaan libc sistem operasi atau memori mmap langsung. Alokasi ini sepenuhnya invisible bagi heap->real_size milik Zend MM.
  2. Multiplikasi Worker Pool: Jika suatu pool dikonfigurasi dengan pm.max_children = 50 dan memory_limit = 256M, footprint memori teoritis yang dapat dialokasikan oleh satu pool adalah 12,8 GB. Lonjakan lalu lintas mendadak pada satu tenant dapat melipatgandakan worker aktif hingga mencapai ambang batas fisik mesin tanpa memicu satupun error Allowed memory size exhausted di level skrip individual.
  3. Fragmentasi Memori Native: Alokasi dan dealokasi berulang terhadap objek berukuran heterogen menyebabkan heap fragmentation pada glibc. Memori yang sudah di-free() oleh PHP tidak selalu langsung dikembalikan ke kernel melalui madvise(MADV_DONTNEED) atau sbrk(), mempertahankan ukuran Resident Set Size (RSS) proses tetap tinggi.
Pada cgroups v1, isolasi memori hanya menyediakan parameter kaku seperti memory.limit_in_bytes. Ketika sebuah cgroup menyentuh batas ini, kernel langsung memicu OOM Killer lokal di dalam cgroup tersebut jika memori tidak dapat di-reclaim. Ini menimbulkan terminasi mendadak pada proses PHP-FPM tanpa adanya mekanisme graceful backpressure. Pada cgroups v2, model kontrol memori dirombak menjadi empat boundary hirarkis: memory.min, memory.low, memory.high, dan memory.max.
Boundary ParameterTipe PembatasReaksi Kernel Saat DilampauiDampak pada Worker PHP-FPM
memory.minHard ProtectionTidak ada reclaim; memori dilindungi penuhMencegah worker terkena reclaim saat load tinggi
memory.lowSoft ProtectionBest-effort reclaim; dihindari selama ada cgroup lainProteksi elastis selama sistem global stabil
memory.highSoft Limit / ThrottlingKernel memicu direct reclaim sinkron dan penundaan alokasi (throttling)Eksekusi PHP melambat; proses tidak di-kill; socket queue Nginx tertahan
memory.maxHard LimitOOM Killer aktif lokal pada cgroup jika reclaim gagalWorker PHP-FPM dihentikan paksa via SIGKILL
Perbedaan fungsional paling krusial antara memory.high dan memory.max terletak pada alur penanganan saat threshold tercapai. memory.high adalah mekanisme rate-limiting preventif. Ketika alokasi agregat dari seluruh worker di dalam satu cgroup melewati memory.high, kernel tidak menghentikan proses. Sebaliknya, kernel memaksa proses yang sedang meminta alokasi tersebut untuk mengeksekusi in-thread direct reclaim dan menambahkan sleep latency proporsional sebelum alokasi disetujui.
Ketika sebuah worker PHP-FPM melakukan alokasi memori yang memicu alokasi page frame baru (alloc_pages()), kernel mengeksekusi pemanggilan fungsi pengecekan cgroup pada subsystem memcg:
TEXT
alloc_pages()
  -> __alloc_pages_slowpath()
       -> try_charge()
            -> mem_cgroup_handle_over_high()
                 -> memory_high_reclaim()
                 -> schedule_timeout_killable()
Di dalam fungsi try_charge() pada mm/memcontrol.c, kernel menghitung apakah penambahan page ini menyebabkan cgroup melampaui nilai yang ditentukan pada memory.high. Jika terlampaui, bit penanda disetel pada struct task_struct milik thread yang bersangkutan, dan eksekusi diarahkan ke mem_cgroup_handle_over_high() sebelum thread kembali ke user-space.
C
/* mm/memcontrol.c excerpt (Linux Kernel) */
void mem_cgroup_handle_over_high(void)
{
    unsigned long penalty_jiffies;
    unsigned long overage;
    struct mem_cgroup *memcg;

    memcg = get_mem_cgroup_from_current();
    if (!memcg)
        return;

    /* Memicu direct reclaim lokal di level cgroup */
    mem_cgroup_reclaim_high(memcg, GFP_KERNEL, MEMCG_RECLAIM_SHRINKER);

    overage = mem_cgroup_over_high(memcg);
    if (!overage)
        goto out;

    /* Hitung penalty latency proporsional terhadap overage */
    penalty_jiffies = calculate_over_high_delay(memcg, overage);
    schedule_timeout_killable(penalty_jiffies);

out:
    css_put(&memcg->css);
}
Mekanisme di atas menghasilkan dua dampak fisik pada sistem:
  1. Direct Reclaim Lokal: Alih-alih mendelegasikan pembersihan page cache ke daemon latar belakang kswapd, thread worker PHP-FPM sendiri yang dipaksa membuang inactive file pages (misalnya cache file skrip PHP yang di-include) atau menulis dirty pages ke disk. Langkah ini langsung memperlambat runtime eksekusi worker tersebut.
  2. Dynamic Sleep Penalty (Throttling): Fungsi calculate_over_high_delay() menghitung selisih memori aktual terhadap batas memory.high. Makin jauh batas tersebut dilanggar, makin lama durasi schedule_timeout_killable() yang dikenakan pada thread (dapat mencapai hingga 2 detik per cycle). Worker akan berada pada status TASK_KILLABLE (tidak menggunakan CPU, tetapi thread diblokir).
Penundaan alokasi ini secara otomatis membatasi kemampuan worker pool untuk menerima koneksi baru dari web server (misalnya Nginx via Unix domain socket). Akibatnya, antrean backlog socket (listen() backlog) akan terisi penuh. Nginx kemudian akan mengembalikan response HTTP 502/504 kepada client tenant yang bermasalah tersebut, sementara memori fisik server secara global tetap terlindungi dari OOM cascade.
Di lingkungan produksi berbasis systemd (seperti Debian, Ubuntu, RHEL, atau Rocky Linux), manipulasi langsung pada filesystem /sys/fs/cgroup/ tidak disarankan karena systemd mengelola cgroup tree secara eksklusif. Setiap modifikasi manual dapat ditimpa oleh cgroup synchonization loop milik systemd. Struktur isolasi yang tepat mengandalkan unit berjenis .slice. Slice membentuk node hirarkis di cgroups v2. Setiap tenant dialokasikan ke dalam satu slice independen yang mewarisi atau mendefinisikan pembatasan resource. Hirarki cgroups v2 yang terbentuk di bawah systemd direpresentasikan sebagai berikut:
TEXT
root
|
+-- system.slice
    +-- php-fpm.service (master process runner)
    |
    +-- tenant-alpha.slice
    |   +-- [email protected]
    |       +-- php-fpm (worker 1)
    |       +-- php-fpm (worker 2)
    |       +-- php-fpm (worker N)
    |
    +-- tenant-beta.slice
        +-- [email protected]
            +-- php-fpm (worker 1)
            +-- php-fpm (worker N)
Dengan memisahkan instance ke dalam unit layanan yang berbeda ([email protected]) di dalam slice terpisah, kita memisahkan proses master PHP-FPM per tenant. Langkah ini lebih aman dibandingkan menjalankan satu proses master PHP-FPM global yang memuat banyak pool, karena master process tunggal yang berjalan di bawah satu service unit tidak dapat memisahkan worker-nya ke dalam cgroup yang berbeda secara otomatis tanpa intervensi eksternal.
Berikut implementasi konfigurasi produksi yang membatasi konsumsi memori tenant dengan parameter memory.high dan fallback memory.max.
Slice ini mendefinisikan batas memory.high sebesar 1.5 GB dan memory.max sebesar 2.0 GB. Jika tenant menyentuh 1.5 GB, direct reclaim dan penundaan alokasi terjadi. OOM Killer hanya akan dipicu jika konsumsi menembus 2.0 GB.
INI
[Unit]
Description=Resource Slice untuk Tenant Alpha
DefaultDependencies=no
Before=slices.target

[Slice]
# Aktifkan CPU, Memory, dan IO Accounting
MemoryAccounting=yes
CPUAccounting=yes
IOAccounting=yes

# cgroups v2 memory boundaries
MemoryHigh=1536M
MemoryMax=2048M

# Batasi alokasi swap agar tidak membanjiri swap device (max 512M swap per tenant)
MemorySwapMax=512M

# Cegah fork-bomb atau eksploitasi thread pool tak terbatas
TasksMax=128
Service unit ini menggunakan model instanced unit (@), memetakan tenant ke slice yang sesuai secara dinamis menggunakan variabel %I.
INI
[Unit]
Description=PHP-FPM Worker Pool untuk %I
After=network.target
PartOf=%I.slice

[Service]
Type=notify
Slice=%I.slice
PIDFile=/run/php/php8.2-fpm-%I.pid
ExecStart=/usr/sbin/php-fpm8.2 --nodaemonize --fpm-config /etc/php/8.2/fpm/pool.d/%I.conf
ExecReload=/bin/kill -USR2 $MAINPID

# Konfigurasi isolasi keamanan OS
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes

# OOM Policy: Pastikan hanya worker/master di dalam slice ini yang dihentikan jika menyentuh MemoryMax
OOMPolicy=stop
OOMScoreAdjust=500

# Restart policy jika proses crash atau terkena hard OOM
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target
Pool PHP-FPM disesuaikan untuk mendengarkan antrean socket dedicated dan mengunci alokasi runtime Zend MM.
INI
[tenant-alpha]
user = tenant-alpha
group = tenant-alpha

listen = /run/php/php-fpm-tenant-alpha.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
listen.backlog = 1024

pm = dynamic
pm.max_children = 40
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 10
pm.max_requests = 1000

; Batasi Zend MM heap per worker sebagai defense-in-depth
php_admin_value[memory_limit] = 256M

; Nonaktifkan fungsi sistem yang berpotensi memicu fork di luar kontrol cgroup
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen
Setelah file-file konfigurasi tersebut dibuat, apply perubahan unit dan nyalakan service tenant:
BASH
systemctl daemon-reload
systemctl enable --now tenant-alpha.slice
systemctl enable --now [email protected]
Verifikasi bahwa hierarki cgroups v2 telah diinisialisasi oleh kernel dengan membaca filesystem sysfs:
BASH
# Periksa apakah parameter memory.high dan memory.max terpasang dengan tepat
cat /sys/fs/cgroup/tenant-alpha.slice/memory.high
# Output: 1610612736 (1536M)

cat /sys/fs/cgroup/tenant-alpha.slice/memory.max
# Output: 2147483648 (2048M)

# Periksa proses PHP-FPM yang berada di dalam cgroup ini
cat /sys/fs/cgroup/tenant-alpha.slice/[email protected]/cgroup.procs
Untuk mengidentifikasi kapan dan seberapa sering cgroup mengalami throttling tanpa harus menunggu laporan timeout dari user, kernel menyediakan dua instrumen utama: Pressure Stall Information (PSI) dan event tracepoint memcg.
PSI menyediakan metrik loss of performance akibat saturasi CPU, Memory, dan I/O. Pada memory controller cgroups v2, PSI membaca rasio waktu di mana thread terhambat akibat alokasi memori (misalnya karena direct reclaim pada memory.high).
BASH
cat /sys/fs/cgroup/tenant-alpha.slice/memory.pressure
Output telemetri PSI:
TEXT
some avg10=4.12 avg60=1.85 avg300=0.45 total=12548902
full avg10=1.05 avg60=0.32 avg300=0.08 total=3210452
  • some: Persentase waktu ketika setidaknya satu thread pada cgroup mengalami stall memori (reclaim delay).
  • full: Persentase waktu ketika seluruh thread di dalam cgroup terblokir oleh stall memori (seluruh worker pool berhenti melayani trafik).
Jika nilai some avg10 melampaui rentang 15–20% secara konsisten, tenant tersebut sedang mengalami penundaan eksekusi yang signifikan akibat menabrak memory.high.
Script bpftrace berikut memantau eksekusi mem_cgroup_handle_over_high secara real-time, menampilkan PID worker, path cgroup, dan waktu yang dihabiskan dalam status kernel throttle:
BASH
cat << 'EOF' > trace_memcg_throttle.bt
#include <linux/memcontrol.h>

kprobe:mem_cgroup_handle_over_high
{
    $memcg = (struct mem_cgroup *)arg0;
    @start[tid] = nsecs;
}

kretprobe:mem_cgroup_handle_over_high
/@start[tid]/
{
    $duration_us = (nsecs - @start[tid]) / 1000;
    printf("COMM: %-16s PID: %-6d THROTTLE DURATION: %llu us
", 
           comm, pid, $duration_us);
    delete(@start[tid]);
}

END
{
    clear(@start);
}
EOF

bpftrace trace_memcg_throttle.bt
Contoh log eksekusi tracing saat worker mengalami lonjakan alokasi:
TEXT
Attaching 2 probes...
COMM: php-fpm8.2       PID: 142105 THROTTLE DURATION: 28412 us
COMM: php-fpm8.2       PID: 142106 THROTTLE DURATION: 45120 us
COMM: php-fpm8.2       PID: 142108 THROTTLE DURATION: 102450 us
Ketika memory.high aktif, kernel menginkrementasi counter high pada file memory.events cgroup dan TIDAK membuang stack trace ke syslog/dmesg:
BASH
cat /sys/fs/cgroup/tenant-alpha.slice/memory.events
Output:
TEXT
low 0
high 14820
max 0
oom 0
oom_kill 0
Sebaliknya, jika memory.max terlampaui dan kernel gagal melakukan reclaim terhadap anonymous pages melalui swap, OOM Killer lokal terpicu. Output dmesg akan menunjukkan jejak OOM yang terkungkung dalam slice target, bukan pada level sistem:
TEXT
[  4128.910244] php-fpm8.2 invoked oom-killer: gfp_mask=0x1100cca(GFP_HIGHUSER_MOVABLE), order=0, oom_score_adj=500
[  4128.910251] CPU: 3 PID: 142109 Comm: php-fpm8.2 Not tainted 6.1.0-21-amd64 #1 Debian 6.1.90-1
[  4128.910255] Hardware name: Supermicro SYS-E300-9D/X11SDV-4C-TLN2F, BIOS 1.4 03/12/2020
[  4128.910258] Call Trace:
[  4128.910260]  <TASK>
[  4128.910264]  dump_header+0x4a/0x200
[  4128.910271]  oom_kill_process.cold+0xb/0x10
[  4128.910275]  out_of_memory+0x228/0x560
[  4128.910281]  mem_cgroup_out_of_memory+0x13c/0x160
[  4128.910286]  try_charge_memcg+0x628/0x7b0
[  4128.910291]  mem_cgroup_charge+0xc2/0x2c0
[  4128.910296]  __handle_mm_fault+0x951/0xe90
[  4128.910302]  handle_mm_fault+0xda/0x2c0
[  4128.910307]  do_user_addr_fault+0x1b5/0x650
[  4128.910313]  exc_page_fault+0x76/0x170
[  4128.910318]  asm_exc_page_fault+0x22/0x30
[  4128.910324]  </TASK>
[  4128.910326] memory: usage 2097152kB, limit 2097152kB, failcnt 1204
[  4128.910328] swap: usage 524288kB, limit 524288kB, failcnt 32
[  4128.910330] Memory cgroup out of memory: Killed process 142109 (php-fpm8.2) total-vm:458210kB, anon-rss:210450kB, file-rss:1240kB, shmem-rss:0kB
[  4128.921402] oom_reaper: reaped process 142109 (php-fpm8.2), now anon-rss:0kB, file-rss:0kB, shmem-rss:0kB
Perhatikan baris: Memory cgroup out of memory: Killed process 142109. Ini menegaskan bahwa OOM killer beroperasi strictly di level cgroup-scoped, bukan sistem global. Nginx master, MySQL, dan pool tenant lain di luar tenant-alpha.slice tidak terganggu sama sekali.
Untuk mengukur efektivitas isolasi dan overhead latensi yang ditimbulkan saat boundary memory.high tercapai, skenario pengujian beban disimulasikan menggunakan generator beban HTTP (wrk) dengan skrip PHP yang mengeksekusi pembacaan file dataset besar ke dalam array memori.
  • Tenant Target (Tenant Alpha): 40 worker concurrency, alokasi memori sengaja dinaikkan hingga menyentuh 1.8 GB (melewati memory.high 1.5 GB, di bawah memory.max 2.0 GB).
  • Tenant Pembanding (Tenant Beta): Pool normal yang melayani endpoint API statis dengan alokasi memori rendah (< 200 MB), berjalan di slice terpisah pada server fisik yang sama.
Pengujian dijalankan dalam 3 skenario arsitektur:
  1. Baseline: Tanpa cgroups limits (hanya mengandalkan memory_limit = 512M per worker).
  2. Hard-Limit Saja: Menggunakan cgroups v2 dengan konfigurasi kaku (memory.max = 1.5G, tanpa memory.high).
  3. Dual-Boundary: Menggunakan konfigurasi bertingkat (memory.high = 1.5G, memory.max = 2.0G).
Metrik PerformaBaseline (No Cgroup Limit)Hard-Limit Only (MemoryMax=1.5G)Dual-Boundary (High=1.5G, Max=2.0G)
Global System OOM Invocations3 kali (Nginx & DB killed)0 kali0 kali
Tenant Alpha HTTP Success Rate34.2% (OOM crash)68.5% (Worker SIGKILL)98.9% (Throttled/Latent)
Tenant Alpha Latency p99142 ms (sebelum crash)2,450 ms (error burst)840 ms (controlled reclaim)
Tenant Alpha Process ResetsSeluruh pool down148 worker terminates0 worker terminates
Tenant Beta Latency p991,890 ms (terimbas OOM)12.4 ms (terisolasi)12.8 ms (terisolasi)
System PSI Memory Stall (some)48.2% (global)3.1% (lokal slice)4.2% (lokal slice)
  1. Eliminasi OOM Cascading: Kedua konfigurasi cgroups (Hard-Limit dan Dual-Boundary) berhasil melindungi Tenant Beta secara mutlak. Latensi Tenant Beta tetap stabil di kisaran ~12 ms tanpa fluktuasi, terlepas dari fakta bahwa mesin sedang mengalami saturasi memori pada cgroup Tenant Alpha.
  2. Kestabilan Koneksi vs Latensi pada Tenant Bermasalah: Pada skenario Hard-Limit Only, setiap kali batas 1.5 GB tersentuh, kernel langsung membunuh worker aktif. Klien HTTP menerima response 502 Bad Gateway karena koneksi TCP terputus sebelum worker selesai memproses request. Pada model Dual-Boundary (memory.high), response time p99 Tenant Alpha melonjak menjadi 840 ms akibat alur eksekusi mem_cgroup_handle_over_high(), namun koneksi HTTP diselesaikan tanpa error (200 OK) dan worker pool tidak perlu melakukan siklus spawning ulang yang mengonsumsi CPU time secara berlebihan.
  3. Overhead CPU Reclaim: Penerapan memory.high memindahkan beban pembersihan memori ke thread worker itu sendiri. Pada saat throttling aktif, penggunaan CPU time pada thread worker dalam konteks kernel (sys CPU utilization) akan meningkat antara 5% hingga 12% karena worker berulang kali memindai Least Recently Used (LRU) list di kernel untuk me-reclaim inactive file pages.
Arsitektur pemisahan service unit per-tenant yang diikat pada systemd slice dengan parameter memory.high memberikan kontrol deterministik terhadap isolasi memori multi-tenant. Penggunaan mekanisme throttling kernel ini memitigasi kegagalan runtime yang diakibatkan oleh keterbatasan Zend MM, serta mengeliminasi risiko degradasi performa sistem secara menyeluruh akibat OOM Killer.