Skip to main content
BlogDevOps

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

Flag --memory dan --cpus Docker hanyalah file di tree cgroups v2. Memahami memory.max, cpu.max, dan OOM killer membuat limit Anda bisa diprediksi.

Matthews Wong

Software Engineer

Mei 202610 menit baca
Apa yang Sebenarnya Dilakukan cgroups v2 Saat Anda Set Limit Memory Docker

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.

Limit Itu Cuma File: Menembus Abstraksi Docker

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 window

Baris 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.

Memory: max, high, dan Tempat Tinggal OOM Killer

Cgroups v2 memberi kontrol memory beberapa knob berbeda, dan flag Docker memetakan langsung ke sana:

Flag DockerFile cgroup v2Perilaku kernel di ambang batas
--memorymemory.maxHard 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.highPlafon lunak. Proses di-throttle dan ditekan reclaim berat, tapi tidak pernah di-OOM-kill. Orchestrator memakai ini untuk degradasi anggun sebelum hard limit.
--memory-swapmemory.swap.maxMengontrol 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.

CPU: Kenapa Limit Men-throttle, Bukan Membunuh

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:

  • --cpus adalah kuota: cap bandwidth keras per periode scheduling, alat yang tepat untuk menjamin satu container berisik tidak bisa membuat VPS kelaparan.
  • --cpu-shares adalah bobot proporsional, dan hanya berarti saat rebutan. Container 2-share mendapat CPU dua kali lipat tetangganya yang 1-share saat keduanya mau semuanya; di host yang idle, keduanya jalan tanpa cap.
  • --cpuset-cpus menyematkan ke core tertentu — jarang sepadan di host kecil, sesekali berguna untuk mengisolasi proses kritis-latency dari yang lain.

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.

Apa yang Berubah dari cgroups v1 — dan Kenapa Anda Perlu Peduli

Kalau runbook atau jawaban Stack Overflow Anda berasal dari era v1, tiga perbedaan ini penting secara operasional:

  • Satu hierarki terpadu alih-alih tree terpisah per controller. Satu container adalah satu cgroup dengan semua controller terpasang, bukan lima cgroup yang bisa saling tidak setuju — debugging jadi jauh lebih waras.
  • Penegakan hierarkis bersifat ketat: cgroup anak tidak pernah bisa melebihi limit induknya. Inilah yang membuat limit bersarang — slice systemd yang membatasi seluruh Docker, dengan limit per-container di dalamnya — benar-benar bisa dipercaya.
  • File pressure stall information (PSI) — memory.pressure, cpu.pressure, io.pressure — ada per cgroup, memberi sinyal peringatan dini yang tidak pernah dimiliki v1. Tekanan memory yang naik memprediksi OOM kill sebelum terjadi.

Membuat Runtime Menghormati Limit

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" myservice

JVM 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.

Prosedur Sizing yang Bertahan di Production

Cara saya menyetel limit untuk service baru, berurutan:

  1. Jalankan tanpa limit di staging dengan beban realistis dan catat titik tertinggi dari docker stats atau Prometheus, bukan satu pengecekan sekilas.
  2. Set --memory kira-kira 1,5 kali puncak yang terobservasi, dan set heap runtime sekitar 75 persen dari limit itu.
  3. Set --cpus untuk menutup burst p99, bukan rata-rata — throttling kuota di web service menyakiti user persis di momen tersibuk.
  4. Tambahkan alert Prometheus pada container_oom_events dan counter throttling sejak hari pertama, supaya kegagalan limit mem-page Anda alih-alih mengejutkan Anda.
  5. Ukur ulang tiap kuartal. Jejak memory merayap naik di setiap bump dependency, dan limit yang pas di Januari akan OOM di Juni.

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.

Intinya

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.

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Apa yang sebenarnya terjadi saat Anda menyetel --memory=512m di Docker — apakah itu soft limit atau hard limit?

Menyetel --memory=512m menulis hard limit ke dalam file memory.max di direktori cgroup v2 container. Docker tidak menegakkan limit ini sendiri; yang melakukannya adalah kernel Linux. Saat pemakaian memory mencapai batas, kernel pertama-tama mencoba me-reclaim page cache, dan jika tidak cukup, OOM killer dipanggil dan proses Anda dimatikan dengan exit code 137.

Kenapa container kadang di-OOM-kill meski heap aplikasinya tampak normal?

Karena memory.max di cgroup v2 menghitung seluruh memory termasuk page cache, bukan hanya heap proses. Container yang membaca file besar bisa mengakumulasi cache yang bisa di-reclaim hingga mendorong total pemakaian melewati limit, sehingga OOM killer menyala meski metrik memory di level aplikasi tampak biasa saja. Memeriksa memory.stat di dalam cgroup adalah langkah pertama yang tepat saat sebuah limit tampak menyala terlalu dini.

Kenapa menabrak limit CPU men-throttle container alih-alih membunuhnya, berbeda dengan limit memory?

Memory bersifat biner — sebuah page ada atau tidak ada — sehingga melampaui memory.max memicu OOM killer. Waktu CPU adalah resource berkelanjutan yang bisa diukur lintas periode scheduling, jadi kernel cukup membuat container menunggu saat kuotanya habis. Flag --cpus menyetel kuota bandwidth dalam satuan milidetik waktu CPU yang diizinkan per jendela 100 milidetik, dan kelebihannya muncul sebagai throttling, bukan penghentian proses.

Bagaimana cara mengukur limit memory yang tepat untuk workload Node.js atau JVM agar terhindar dari OOM kill?

Pendekatan yang direkomendasikan artikel ini adalah menjalankan service tanpa limit di staging dengan beban realistis dan mencatat puncak pemakaian memory, lalu menyetel --memory sekitar 1,5 kali puncak tersebut. Heap runtime dikonfigurasi sekitar 75 persen dari limit container — untuk Node.js melalui --max-old-space-size dan untuk JVM melalui -XX:MaxRAMPercentage — agar ada ruang tersisa untuk stack thread, buffer native, dan page cache di dalam cgroup yang sama.

Bagaimana cara mendeteksi CPU throttling sebelum berdampak pada latency yang dirasakan pengguna?

File cpu.stat di dalam cgroup mengekspos counter nr_throttled dan throttled_usec, dan cAdvisor menerbitkan container_cpu_cfs_throttled_periods_total ke Prometheus. Artikel ini menyarankan untuk memasang alert pada counter throttling, bukan pada utilisasi CPU rata-rata, karena sebuah service bisa berjalan di rata-rata CPU 40 persen dan tetap mengalami latency p99 yang buruk jika sering menabrak plafon kuota saat lonjakan request.