Apa yang Sebenarnya Dilakukan cgroups v2 Saat Anda Set Limit Memory Docker

Foto oleh Rémy

Foto oleh Rémy
Pertama kali salah satu container saya di-OOM-kill di production, log-nya tidak bercerita apa-apa. Aplikasinya lenyap begitu saja, Docker me-restart-nya, dan satu-satunya bukti adalah sebaris di dmesg tentang kernel yang mengorbankan sebuah proses. Saya menyetel --memory=512m dengan keyakinan itu cuma saran sopan untuk Docker. Ternyata bukan. Itu adalah hard limit yang ditegakkan kernel dan ditulis ke sebuah file, dan mekanisme penegakannya — control groups v2 — layak dipahami sebelum ia mengajari Anda dengan cara yang menyakitkan.
Artikel ini membongkar apa yang sebenarnya terjadi di host Linux modern saat Anda menyetel limit memory dan CPU Docker: file cgroup mana yang ditulis, apa yang dilakukan kernel di tiap ambang, kenapa limit CPU men-throttle sementara limit memory membunuh, dan cara mengukur limit untuk workload Node.js dan JVM supaya runtime dan kernel berhenti saling bertarung. Semua di sini berlaku untuk distro terkini yang menjalankan cgroups v2 — default sejak sekitar 2021 di Ubuntu, Debian, dan Fedora.
Control group adalah mekanisme kernel untuk mempartisi resource — waktu CPU, memory, IO — di antara pohon proses. Docker tidak mengimplementasikan pembatasan resource sendiri; saat Anda memberikan --memory atau --cpus, daemon membuat cgroup untuk container Anda dan menulis angka-angka Anda ke file interface terkait. Sisanya dikerjakan kernel.
Anda bisa menonton ini terjadi. Jalankan container dengan limit dan baca cgroup-nya langsung:
# every docker run flag becomes a file in the cgroup tree
docker run -d --name api --memory=512m --cpus=1.5 myapp:latest
CID=$(docker inspect -f '{{.Id}}' api)
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/memory.max
# 536870912 <- your --memory=512m, in bytes
cat /sys/fs/cgroup/system.slice/docker-$CID.scope/cpu.max
# 150000 100000 <- your --cpus=1.5: 150ms of CPU per 100ms windowBaris cpu.max itu adalah mental model paling berguna di seluruh topik ini: --cpus=1.5 berarti proses-proses container boleh mengonsumsi paling banyak 150 milidetik waktu CPU di setiap jendela 100 milidetik, dijumlahkan dari semua core. Tidak ada penyematan core di sini — ini kuota bandwidth yang ditegakkan scheduler, itulah kenapa efeknya muncul sebagai throttling, bukan sebagai CPU yang hilang.
Cgroups v2 memberi kontrol memory beberapa knob berbeda, dan flag Docker memetakan langsung ke sana:
| Flag Docker | File cgroup v2 | Perilaku kernel di ambang batas |
|---|---|---|
| --memory | memory.max | Hard cap. Kernel mula-mula mencoba me-reclaim page; kalau pemakaian tidak bisa diturunkan, OOM killer dipanggil di dalam cgroup dan proses Anda mati dengan exit code 137. |
| (tanpa flag langsung) | memory.high | Plafon lunak. Proses di-throttle dan ditekan reclaim berat, tapi tidak pernah di-OOM-kill. Orchestrator memakai ini untuk degradasi anggun sebelum hard limit. |
| --memory-swap | memory.swap.max | Mengontrol swap di atas RAM. Menyetel --memory-swap sama dengan --memory mematikan swap untuk container itu — biasanya yang Anda mau untuk service yang sensitif latency. |
Subtletas krusialnya: memory.max menghitung page cache, bukan hanya heap proses Anda. Container yang membaca file besar bisa menunjukkan pemakaian memory tinggi yang sebenarnya cache yang bisa di-reclaim, dan sebaliknya aplikasi Anda bisa di-OOM-kill saat heap-nya tampak sehat karena anonymous memory plus cache melewati garis bersama-sama. Saat sebuah limit tampak menyala terlalu dini, periksa memory.stat di dalam cgroup sebelum menyalahkan aplikasi Anda.
Exit code 137 tanpa error aplikasi adalah tanda tangan OOM killer. Konfirmasi dengan docker inspect — OOMKilled true — dan tahan godaan --oom-kill-disable. Mematikan si pembunuh pada container yang di-cap memory tidak membebaskan memory; ia malah men-deadlock container dalam reclaim permanen alih-alih me-restart-nya dengan bersih.
Memory tidak bisa dinegosiasikan — sebuah page ada atau tidak ada — jadi kernel membunuh. CPU dibagi per waktu, jadi kernel cukup membuat Anda menunggu. Tiga flag Docker mencakup ruang praktisnya:
Throttling itu terlihat dan terukur: cpu.stat di dalam cgroup melaporkan counter nr_throttled dan throttled_usec. Sebuah web service bisa duduk di rata-rata CPU 40 persen dan tetap punya latency p99 yang buruk karena ia menabrak plafon kuotanya di setiap lonjakan request dan menghabiskan sisa tiap periode dalam keadaan beku.
Aturan praktis dari dashboard saya sendiri: untuk service sensitif latency, pasang alert pada counter throttling, bukan pada pemakaian CPU. cAdvisor mengekspos container_cpu_cfs_throttled_periods_total ke Prometheus; throttling berkelanjutan di atas beberapa persen periode berarti kuotanya terlalu ketat meski utilisasi rata-rata tampak nyaman.
Kalau runbook atau jawaban Stack Overflow Anda berasal dari era v1, tiga perbedaan ini penting secara operasional:
Kernel menegakkan cap, tapi runtime Anda mengalokasi berdasarkan asumsi. Runtime lama membaca memory host dan mengukur heap-nya dari sana — JVM di host 32 GB di dalam container 1 GB akan dengan senang hati merencanakan heap multi-gigabyte, lalu mati di 137. Perbaikannya adalah memberi tahu runtime kebenarannya:
# Node.js: heap limit must fit inside memory.max
docker run -d --memory=512m \
-e NODE_OPTIONS="--max-old-space-size=384" myapp
# JVM: let it read the cgroup instead of guessing
docker run -d --memory=1g \
-e JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75" myserviceJVM modern (10+) sudah container-aware secara default dan membaca cgroup, tapi persentasenya tetap layak disetel eksplisit: celah antara limit heap dan memory.max harus menampung stack thread, metaspace atau buffer native, dan page cache. Default saya 75 persen dari limit container untuk max-old-space-size milik Node maupun MaxRAMPercentage milik JVM, baru dipersempit setelah profiling.
Cara saya menyetel limit untuk service baru, berurutan:
Di Docker Swarm mekanika kernel yang sama berlaku lewat blok deploy.resources: limits menjadi cap cgroup yang dijelaskan di sini, dan reservations menggerakkan keputusan penempatan scheduler. Menyetel reservation dengan jujur adalah yang mencegah Swarm menjejalkan tiga service rakus memory ke satu node 4 GB lalu membiarkan cgroups mewasiti pertarungannya.
Flag resource Docker berhenti misterius begitu Anda melihatnya apa adanya: angka di file interface cgroup v2, ditegakkan oleh kernel dengan aturan yang sangat bisa diprediksi. Memory melewati memory.max dan sesuatu mati; CPU melewati cpu.max dan sesuatu menunggu. Ukur limit dari pengukuran, buat runtime Anda sepakat dengan kernel soal berapa banyak memory yang ada, dan pasang alert pada OOM kill dan throttling alih-alih pemakaian mentah. Container tanpa limit bukan berarti murah hati — ia hanya menunda negosiasi ke momen terburuk yang mungkin.
Sumber dan bacaan lanjutan