Dua chapter pertama section ini membatasi dua angka besar — detik CPU dan byte memori — dan keduanya punya penegak yang jelas di kernel. Kelas kecelakaan ketiga bekerja dengan satuan yang jauh lebih sederhana: jumlah. Budi pernah merasakannya di server lama kampus: sebuah skrip shell tua yang dipindahkan ke container mengandung bug rekursif — sebuah fungsi yang memanggil dirinya sendiri dua kali — dan dalam hitungan detik jumlah prosesnya berlipat dua terus-menerus: 2, 4, 8, 16, ribuan. Tabel proses host penuh, upaya login SSH baru pun gagal karena sshd tidak sanggup lagi melakukan fork, dan mesin akhirnya harus di-restart secara kasar. Satu baris kode yang salah ternyata cukup untuk menggantung satu mesin penuh — kecuali kernel menghitung slot proses per container dan menutup kran sebelum meledak. Chapter ini memasang dua pagar dari dua zaman yang berbeda: --pids-limit, pembatas modern berbasis cgroups yang menghitung task per container, dan --ulimit, pewaris mekanisme batas klasik Unix yang berlaku untuk setiap proses di dalamnya.

Setiap Proses dan Thread Makan Satu Slot #

Kernel Linux mencatat setiap task — proses dan thread sama-sama dihitung sebagai task — di tabel prosesnya, dan setiap task menempati satu slot berikut struktur datanya. Inilah yang dilaporkan kolom PIDS pada docker stats: toko-proxy-1 menampung 3 (master nginx beserta dua worker), toko-backend-1 menampung 11 (satu proses node beserta thread pool-nya), sebagaimana kita bedah di chapter Docker Stats. Karena thread ikut dihitung, aplikasi yang kelihatannya “satu proses” bisa diam-diam menghuni ratusan slot — JVM adalah warga paling terkenal di kategori ini: thread-threadnya untuk GC, JIT compiler, dan pool pekerja mudah menembus ratusan.

Fork bomb memanfaatkan mekanisme yang sama ini dari sisi gelapnya. Bomb paling terkenal di dunia Unix bahkan hanya satu baris, dan kita akan membedahnya karakter demi karakter sebelum berani menyentuhnya:

bash
:(){ :|:& };:

Mari kita telaah anatomi baris maut itu:

  1. : adalah nama fungsi — karakter titik dua sah dipakai sebagai identifier di shell, dan pilihan ini membuat bomb tampak seperti wajah smiley yang tidak berbahaya.
  2. (){ ... } mendefinisikan tubuh fungsi — fungsi bernama : berisi perintah di dalam kurung kurawal.
  3. :|: adalah tubuhnya — fungsi : memanggil dirinya sendiri dua kali, dan tanda pipa menghubungkan keduanya: keluaran panggilan pertama dialirkan ke panggilan kedua, yang memaksa keduanya hidup bersamaan.
  4. & menjalankan di background — sehingga pemanggil tidak menunggu anak-anaknya selesai.
  5. ;: menyalakan sumbunya — titik koma menutup definisi fungsi, lalu : terakhir memanggil fungsi itu satu kali; dari titik ini populasi berlipat dua pada setiap generasi dan mesin tenggelam dalam hitungan detik.

–pids-limit sebagai Pagar pids.max #

--pids-limit menuliskan angka maksimum task ke berkas cgroup pids.max milik container. Begitu jumlah task menyentuh angka itu, kernel menolak setiap fork dan clone baru dari dalam container — proses yang sudah ada tetap hidup, tetapi populasi berhenti bertambah tepat di pagar. Secara bawaan, docker run tidak memasang angka ini, sehingga container lahir dengan sebatas apa yang sanggup ditampung host; pembatasan bisa datang dari lapisan lain seperti batas unit systemd yang memungkung daemon, tetapi pagar milik container sendiri harus kita pasang sendiri.

Fork Bomb di Dalam Kandang #

Sekarang kita undang bomb itu ke container yang sudah dipagari 100 slot. Kita pakai image python:3.12-slim yang membawa bash — shell yang pesan galatnya paling mudah dikenali:

bash
$ docker run -d --name bom-percobaan --pids-limit 100 python:3.12-slim bash -c ':(){ :|:& };:'

Output yang Dihasilkan:

text
9b4c2e7a1d3f4a5b6c7d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b
bash
$ sleep 3 && docker logs --tail 4 bom-percobaan

Output yang Dihasilkan:

text
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
bash: fork: retry: Resource temporarily unavailable
bash
$ docker stats --no-stream bom-percobaan

Output yang Dihasilkan:

text
CONTAINER ID   NAME            CPU %     MEM USAGE / LIMIT    MEM %     NET I/O    BLOCK I/O   PIDS
9b4c2e7a1d3f   bom-percobaan   2.1%      14.9MiB / 7.63GiB    0.20%     0B / 0B    0B / 0B     100

Mari kita telaah rangkaian bukti bahwa pagar itu bekerja:

  1. Baris fork: retry: Resource temporarily unavailable adalah suara bomb yang tercekik — bash mencoba mem-fork anak baru, kernel menolak karena kuota task cgroup telah penuh, dan bash mencoba ulang dengan jeda; deretan ini lalu berhenti dengan sendirinya karena populasi tidak bisa bertambah lagi.
  2. Kolom PIDS terhenti tepat di 100 — angka pagar yang kita pasang sendiri; populasi bomb mati aritmetika di titik itu, jauh sebelum sempat menggantung apa pun.
  3. CPU hanya 2.1% dan memori cuma 14.9 MiB — seratus task shell yang menganggur adalah beban yang sangat kecil; inilah bedanya bomb yang dipagari dengan bomb yang lepas: yang satu adalah insiden, yang lain adalah insiden keamanan.
  4. Host tetap sepenuhnya responsif — kita masih bebas menjalankan docker stats, membuka terminal baru, dan membersihkan panggung tanpa perlu reboot apa pun.

Berapa Angka yang Tepat #

Angka pagar mengikuti tabiat aplikasi. Container ringan — proxy nginx dengan tiga prosesnya, worker shell sederhana, sidecar pembantu — hidup nyaman di bawah 100. Aplikasi berbasis thread pool butuh ruang yang jauh lebih longgar: JVM dengan ratusan thread dan PostgreSQL yang menumbuhkan satu proses backend per koneksi adalah dua warga yang paling mudah salah pagari — untuk keduanya, angka 512 ke atas adalah titik mulai yang wajar, dan untuk database sebaiknya dihitung dari max_connections yang dikonfigurasi. Angka yang terlalu kecil tidak membunuh container secara langsung; aplikasinya mulai gagal membuat thread baru dengan pesan semacam unable to create new native thread — galat yang menjengkelkan karena jarang terjadi saat pengujian normal.

Ulimit Batas Klasik Unix untuk Setiap Proses #

Pagar kedua berasal dari zaman sebelum container: ulimit, mekanisme setrlimit warisan Unix yang membatasi sumber daya setiap proses dan diwariskan ke seluruh anaknya. Docker mengeksposnya lewat flag --ulimit dengan bentuk nama=nilai atau nama=soft:hard, dan dua nilai dalam satu nama itulah yang paling sering membingungkan: nilai soft adalah batas yang sedang aktif dipakai kernel — aplikasi boleh menaikkannya sendiri kapan saja sampai sebesar nilai hard; nilai hard adalah plafon yang tidak bisa dinaikkan lagi tanpa keistimewaan khusus. Karena itu kebiasaan yang sehat justru memisahkan keduanya: soft yang cukup untuk hari normal, hard yang tinggi untuk momen-momen puncak.

Kita pasang tiga ulimit sekaligus pada satu container uji:

bash
$ docker run -d --name batas-file --ulimit nofile=1024:65536 --ulimit nproc=512 --ulimit core=0 alpine:3.20 sleep 600

Output yang Dihasilkan:

text
5a7e31c9b2d4a6f8c0e2b4d6f8a0c2e4b6d8f0a2c4e6b8d0f2a4c6e8b0d2f4a6
bash
$ docker exec batas-file sh -c 'ulimit -n; ulimit -Hn; ulimit -c'

Output yang Dihasilkan:

text
1024
65536
0

Mari kita telaah keluaran dari dalam container itu:

  1. ulimit -n membaca 1024 — batas soft jumlah file descriptor yang sedang berlaku untuk proses shell; setiap soket jaringan, berkas terbuka, dan pipe memakan satu deskriptor, dan aplikasi yang membuka lebih dari 1024 koneksi bersamaan akan menemui galat too many open files justru karena angka ini.
  2. ulimit -Hn membaca 65536 — plafon kerasnya; aplikasi boleh menaikkan batas aktifnya sampai ke angka ini saat beban memuncak.
  3. ulimit -c membaca 0 — batas ukuran berkas core dump; nol berarti kernel tidak menulis berkas core sama sekali saat proses mati karena sinyal. Untuk container pilihan ini lazim: jejak kematian sudah terbaca lengkap lewat exit code dan log sebagaimana kita praktikkan di chapter Membaca Container Exit, tanpa perlu berkas core raksasa memenuhi disk host.

Daftar nama ulimit yang paling sering dipakai di dunia container dirangkum di tabel berikut:

NamaYang dibatasiContoh nilai
nofilejumlah file descriptor per proses1024:65536
nprocjumlah proses per pengguna di dalam container512
coreukuran berkas core dump; 0 mematikannya0
memlockmemori yang boleh dikunci di RAM (mlock)64m
sigpendingantrean sinyal tertunda per proses8192

Gotcha Dockerfile Tidak Bisa Membekukan Ulimit #

Jebakan yang paling sering menjaring pemula: mencoba mengatur ulimit lewat Dockerfile. Dockerfile tidak memiliki instruksi untuk ulimit, dan baris RUN ulimit -n 4096 di dalamnya hanya berlaku untuk langkah build itu sendiri — ulimit adalah sifat proses, bukan berkas, sehingga nilainya tidak ikut terbungkus ke layer image dan tidak terbawa ke container yang berjalan. Nilai yang benar-benar dipakai container datang dari rantai berikut: bawaan daemon (yang bisa diatur global lewat kunci default-ulimits di daemon.json), lalu ditimpa oleh flag --ulimit pada saat docker run. Runtime selalu menang — itulah aturan urutannya, dan menyadari hal ini menghemat berjam-jam debugging mengapa angka di Dockerfile “tidak mau nempel”.

Jejak di docker inspect #

Kedua pagar meninggalkan jejak di metadata container:

bash
$ docker inspect --format '{{.HostConfig.PidsLimit}}' bom-percobaan

Output yang Dihasilkan:

text
100
bash
$ docker inspect --format '{{.HostConfig.Ulimits}}' batas-file

Output yang Dihasilkan:

text
[{nofile 1024 65536} {nproc 512 512} {core 0 0}]

Mari kita telaah bentuk datanya:

  1. 100 — pagar task tersimpan sebagai bilangan sederhana di PidsLimit, persis angka yang kita tulis di flag.
  2. Daftar ulimit tercetak sebagai rangkaian {nama soft hard} — nofile membawa pasangan 1024 dan 65536; nproc yang kita tulis satu angka menjadi 512 512 (soft sama dengan hard); core menjadi 0 0.
  3. Urutan menyalin urutan flag — daftar di inspect mengikuti urutan penulisan --ulimit saat container diciptakan, sehingga bisa dipakai untuk memverifikasi konfigurasi tanpa membuka ulang berkas deploy.

Kita tutup panggung dengan membersihkan kedua bekas latihan sekaligus:

bash
$ docker rm -f bom-percobaan batas-file

Output yang Dihasilkan:

text
bom-percobaan
batas-file

PIDs Limit versus Ulimit Dua Lapis Berbeda #

Kedua pagar di atas sering dicampuradukkan karena sama-sama “membatasi proses”, padahal keduanya bekerja di lapisan yang berbeda dan saling melengkapi:

Aspek--pids-limit--ulimit
Apa yang dibatasitotal task (proses dan thread) seluruh containersumber daya per proses atau per pengguna di dalam container
Penegakcgroup pids.max — kernel menghitung agregat seluruh containersetrlimit yang melekat pada tiap proses dan diwarisi anaknya
Sasaran khasfork bomb, kebocoran thread yang terus bertambahkehabisan file descriptor, core dump raksasa
Skalaper containerper proses

PIDs limit menahan ledakan jumlah; ulimit menahan rakusnya setiap individu. Sebuah fork bomb dihentikan oleh pagar pertama jauh sebelum pagar kedua berkesempatan bicara, sementara aplikasi satu proses yang membocorkan file descriptor hanya bisa ditampung pagar kedua — dua lapis, dua musuh, dan container produksi yang serius memasang keduanya.

CPU ditunda, memori dibunuh, dan sekarang jumlah proses pun ditolak — tiga resource telah menemukan pagarnya, dan semuanya menghitung hal yang bisa dihitung kernel saat container berjalan. Tapi ada satu jalur yang setiap container lintasi puluhan ribu kali per detik dan sama sekali belum menyentuh angka: piringan. Setiap log baris yang ditulis, setiap layer yang dibaca, setiap checkpoint database yang ditanam semuanya berbaris di antrean perangkat blok yang sama, dan satu container rakus cukup untuk membuat seluruh barisan menunggu. Chapter berikutnya, IO dan Block Limit, memasang rem di jalur itu dengan empat flag --device-* — termasuk demo dd yang sama besarnya selesai dalam 0,2 detik tanpa rem dan 20 detik dengan rem 10 MB per detik.

Referensi #