Aplikasi yang berjalan di container mudah terasa seperti kotak tertutup: prosesnya hidup di namespace sendiri, filesystemnya tersusun dari layer image, dan tidak ada terminal yang bisa kita buka begitu saja. Keyakinan itu hanya bertahan sampai kegagalan pertama tiba, karena Docker sejak awal membangun jendela-jendela kecil pada setiap kotak — logs mengalirkan apa yang dikatakan aplikasi, exec membuka pintu ke dalam, dan inspect membocorkan arsip keadaan container tanpa meminta aplikasinya bangun. Debugging container pada dasarnya adalah seni mengetuk jendela yang tepat untuk gejala yang tepat. Chapter ini menelusuri stack toko — proxy, backend Node.js yang mendengarkan port 3000, dan PostgreSQL dengan kredensial toko/rahasia123 — saat empat kecelakaan klasik datang berturut-turut: aplikasi yang mati, berkas yang hilang, memori yang habis, dan bug logika yang hanya muncul di jalur tertentu.

Kasus 1: Aplikasi Mati Saat Permintaan Pertama #

Kasus pembuka datang dari rutinitas paling biasa. Budi menarik perubahan terbaru Siti — fitur diskon yang membaca parameter diskon pada rute /produk.json — lalu menyinkronkan stack dengan kebiasaan up -d --build dari Chapter Image, Build, dan Pull. Stack menyala tanpa keluhan, tetapi beberapa detik kemudian curl dari browser Siti menggantung tanpa jawaban, dan inilah yang dilihat Budi:

bash
$ docker compose ps

Output yang Dihasilkan:

text
NAME             IMAGE               COMMAND                  SERVICE   CREATED          STATUS                    PORTS
toko-proxy-1     nginx:1.29-alpine   "/docker-entrypoint.…"  proxy     About an hour ago Up About an hour           0.0.0.0:8080->80/tcp, [::]:8080->80/tcp
toko-frontend-1  nginx:1.29-alpine   "/docker-entrypoint.…"  frontend  About an hour ago Up About an hour           80/tcp
toko-backend-1   toko-api:dev        "docker-entrypoint.s…"  backend   2 minutes ago    Exited (1) 2 minutes ago  3000/tcp
toko-db-1        postgres:18-alpine  "docker-entrypoint.s…"  db        About an hour ago Up About an hour (healthy) 5432/tcp

Baris backend adalah satu-satunya yang tidak berdiri — Exited (1), kode kegagalan aplikasi dari konvensi yang kita bedah pada Chapter Memahami Exit Code. Jendela pertama yang layak diketuk adalah log:

bash
$ docker compose logs backend

Output yang Dihasilkan (dipotong):

text
toko-backend-1  | toko-api mendengarkan di port 3000
toko-backend-1  | TypeError: Cannot read properties of undefined (reading 'harga')
toko-backend-1  |     at hitungDiskon (/app/server.js:42:34)
toko-backend-1  |     at /app/server.js:61:21
toko-backend-1  |     at Layer.handle (/app/node_modules/express/lib/router/layer.js:158:17)
toko-backend-1  |     at next (/app/node_modules/express/lib/router/route.js:135:11)
toko-backend-1  |     ...
toko-backend-1  | Node.js v22.20.0

Mari kita telaah jejak yang ditinggalkan log tersebut:

  1. Aplikasi sempat sehat selama dua baris pertama. Startup line tercetak normal, galat baru muncul ketika permintaan pertama dengan parameter diskon masuk — pola khas bug yang hidup di jalur handler, bukan di jalur startup.
  2. Stack trace menyebut alamat persisnya. Fungsi hitungDiskon pada /app/server.js baris 42 mencoba membaca properti harga dari nilai undefined; Siti lupa memvalidasi daftar kosong yang dikirimkan toko kecil pertama yang belum punya produk terdaftar.
  3. Node.js v22.20.0 di baris terakhir menandai proses yang tumbang. Galat tidak tertangani pada handler Express meruntuhkan seluruh proses, container keluar dengan kode 1, dan karena tidak ada restart policy pada backend, ia tinggal Exited selamanya — kombinasi perilaku yang sudah kita saksikan pada Chapter depends_on dan Startup Order.
  4. Log tetap terbaca setelah container mati. Driver json-file menyimpan riwayat keluaran proses pada berkas di host, sehingga penyelidikan tidak kehilangan bukti hanya karena korban sudah tiada — perilaku yang kita bedah pada Chapter Melihat Log Container.

Perbaikannya satu baris pengaman: hitungDiskon mengembalikan nilai tanpa diskon bila daftar produk kosong. Karena kode backend hidup di dalam image — toko memang belum memakai bind mount source code — perbaikan menuntut satu kali sinkronisasi:

bash
$ docker compose up -d --build

Output yang Dihasilkan (dipotong):

text
[+] Building 8.3s (14/14) FINISHED
 ...
 => => naming to docker.io/library/toko-api:dev                          0.0s
[+] Running 4/4
 ✔ Container toko-db-1      Healthy
 ✔ Container toko-migrate-1 Exited (0)
 ✔ Container toko-backend-1 Recreated
 ✔ Container toko-backend-1 Started
bash
$ curl "localhost:8080/api/produk.json?diskon=10"

Output yang Dihasilkan (dipotong):

text
[{"id":1,"nama":"Kopi Arabika Gayo","harga":76500},{"id":2,"nama":"Teh Melati Putih","harga":108000},...]

Kasus pertama selesai dengan pola kerja tiga langkah yang akan berulang sepanjang chapter: lihat status di ps, dengar ceritanya di logs, lalu perbaiki dan sinkronkan. Delapan puluh persen debugging container memang hanyalah dua langkah pertama.

Kasus 2: Berkas Konfigurasi yang Tidak Pernah Sampai #

Kasus kedua lebih diam-diam. Beberapa hari setelah fitur diskon, tim toko memindahkan pengaturan diskon default dan zona waktu ke berkas config/pengaturan.json yang dibaca backend saat setiap permintaan diskon tiba — dan berkas itu dipasang lewat bind mount, pola yang kita bangun pada Chapter Bind Mount Source Code. Setelah up -d, backend hidup, tetapi setiap permintaan diskon dijawab galat server. Log memperlihatkan keluhan yang panjang dan berulang:

bash
$ docker compose logs --tail=5 backend

Output yang Dihasilkan:

text
toko-backend-1  | galat membaca pengaturan: Error: ENOENT: no such file or directory, open '/app/config/pengaturan.json'
toko-backend-1  | galat membaca pengaturan: Error: ENOENT: no such file or directory, open '/app/config/pengaturan.json'
toko-backend-1  | galat membaca pengaturan: Error: ENOENT: no such file or directory, open '/app/config/pengaturan.json'

Berkas itu ada di host — Budi baru saja menuliskannya — jadi dugaan pertama jatuh pada mount yang tidak bekerja. Jendela kedua yang layak diketuk adalah mata kita sendiri di dalam container:

bash
$ docker compose exec backend ls -la /app/config

Output yang Dihasilkan:

text
total 8
drwxr-xr-x    2 root     root          4096 Oct 15 09:12 .
drwxr-xr-x    1 root     root          4096 Oct 15 09:12 ..

Direktori /app/config ada tetapi kosong melompong — bahkan pengaturan.json yang seharusnya menembus bind mount tidak pernah tiba. Perhatikan pula pemiliknya: root:root, padahal Budi menulis berkas sebagai user biasa di laptopnya. Pemeriksaan berikutnya dilakukan dari sisi host:

bash
$ ls

Output yang Dihasilkan:

text
api  compose.yaml  config  frontend-html  konfig  nginx.conf

Mari kita telaah bukti-bukti yang barusan terkumpul:

  1. Folder config dan konfig berdiri berdampingan di host. Entri bind mount pada compose.yaml tertulis ./konfig:/app/config — satu huruf salah — sementara berkas pengaturan yang Budi tulis berada di folder config yang benar.
  2. Docker diam-diam melahirkan folder typo itu. Bentuk pendek bind mount membuat direktori sumber yang tidak ada saat container diciptakan, perilaku yang sudah kita saksikan sebagai peringatan pada Chapter Bind Mount — folder konfig lahir kosong dan dimiliki root, persis seperti isi /app/config yang kita lihat.
  3. Mount bekerja; isinya yang salah. Path target memang ter-mount, tetapi yang termount adalah folder kosong hasil typo, bukan folder berisi pengaturan.json — mount yang “berhasil” membuat galat ini tidak pernah bersuara.
  4. Inilah wajah klasik kesalahan bind mount: tidak ada pesan galat dari Docker. Aplikasi dibiarkan membaca direktori kosong; diagnosis hanya bisa datang dari membandingkan isi host dengan isi container, dan exec ls adalah mata yang paling murah.

Perbaikannya menegakkan ejaan yang benar pada compose.yaml:

yaml
  backend:
    volumes:
      - ./config:/app/config

Budi menghapus folder konfig yang lahir dari kecelakaan, menjalankan docker compose up -d agar container dibuat ulang dengan mount yang benar, lalu memastikan berkasnya sungguh tiba:

bash
$ docker compose exec backend cat /app/config/pengaturan.json

Output yang Dihasilkan:

text
{"diskonDefault":10,"zonaWaktu":"Asia/Jakarta"}

Kasus 3: Kematian Tanpa Pesan Perpisahan #

Kasus ketiga tidak meninggalkan pesan sama sekali. Laporan rekap bulanan baru di backend memuat seluruh tabel pesanan ke memori sebelum menghitung totalnya, dan setiap kali Siti membuka halaman rekap, backend mati beberapa menit kemudian — tanpa satu baris galat di log. Kolom STATUS pada korban menyimpan petunjuk pertama:

bash
$ docker compose ps -a backend

Output yang Dihasilkan:

text
NAME             IMAGE        COMMAND                  SERVICE   CREATED        STATUS                    PORTS
toko-backend-1   toko-api:dev "docker-entrypoint.s…"  backend   8 minutes ago  Exited (137) 3 minutes ago 3000/tcp

Angka 137 adalah kode yang sudah kita hafal dari Chapter Memahami Exit Code: 128 + 9, proses dijatuhkan SIGKILL. Pelaku SIGKILL ada dua — tangan manusia lewat docker kill, atau OOM killer milik kernel — dan kompos yang membedakannya tersimpan rapi di inspect:

bash
$ docker inspect --format '{{.State.OOMKilled}} {{.State.ExitCode}}' toko-backend-1

Output yang Dihasilkan:

text
true 137

Nilai true pada field .State.OOMKilled adalah pengakuan resmi: kernel membunuh proses backend karena cgroup-nya kehabisan memori. Pagar yang membuatnya mungkin adalah keputusan tim minggu lalu — mem_limit: 256m pada service backend di compose.yaml, untuk melindungi laptop bersama dari aplikasi yang kebablasan. Siti menyalakan ulang backend lalu memantau kamera resource sambil membuka halaman rekap sekali lagi:

bash
$ docker stats --no-stream

Output yang Dihasilkan:

text
CONTAINER ID   NAME             CPU %     MEM USAGE / LIMIT     MEM %     NET I/O       BLOCK I/O      PIDS
1c9f8e2b7a4d   toko-backend-1   38.15%    254.1MiB / 256MiB     99.26%    3.4MB / 0B    118MB / 0B     11
6b2d4f8a1e3c   toko-db-1        2.03%     41.2MiB / 7.63GiB     0.54%     1.2MB / 12MB  88.3MB / 0B     9

Mari kita telaah ketiga bukti yang mengunci kasus ini:

  1. 254.1MiB / 256MiB dengan MEM % 99.26. Aplikasi memburu langit-langit cgroup-nya bar demi bar selama rekap berjalan; beberapa saat setelah potret ini diambil, angka itu menyentuh 256MiB dan kernel menjatuhkan SIGKILL — drama yang persis kita provocate secara sengaja pada Chapter CPU dan Memory Limit.
  2. Tidak ada log karena tidak ada aplikasi yang sempat bicara. SIGKILL tidak bisa ditangkap proses mana pun; backend tidak diberi kesempatan menutup koneksi apalagi mencetak pesan perpisahan — sifat sinyal yang kita bahas tuntas pada chapter yang sama.
  3. Dua jalan perbaikan yang berbeda kelas. Jangka pendek: menaikkan mem_limit pada compose.yaml. Jangka panjang: memperbaiki cara kerja rekap agar tidak memuat seluruh tabel sekaligus. Mekanisme lengkap memori, swap, dan dampaknya pada keputusan angka adalah materi Chapter Memory Limit dan Swap di Section Manajemen Resource — sekarang kita cukup tahu bahwa diagnosis OOM berdiri di atas tiga perintah: ps untuk kode, inspect untuk pengakuan, stats untuk rekaman kejahatan.

Kasus 4: Turun ke Dalam Container #

Kasus keempat adalah misteri klasik yang paling menyebalkan: backend hidup, database sehat, tetapi jawaban dari proxy tersendat-sendat, dan dugaan jatuh ke koneksi antar container. Untuk kasus seperti ini, tidak ada pengganti bagi mata kita sendiri di dalam kotak — pintunya dibuka lewat sesi shell interaktif:

bash
$ docker compose exec backend sh

Output yang Dihasilkan (dipotong):

text
/app $ env
DATABASE_URL=postgres://toko:rahasia123@db:5432/toko
NODE_VERSION=22.20.0
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOME=/home/node
/app $ ps aux
PID   USER     TIME  COMMAND
    1 node      0:01 node server.js
   47 node      0:00 ps aux
/app $ nc -zv db 5432
db (172.19.0.2:5432) open
/app $ wget -qO- localhost:3000/produk.json
[{"id":1,"nama":"Kopi Arabika Gayo","harga":85000},{"id":2,"nama":"Teh Melati Putih","harga":120000},{"id":3,"nama":"Gula Aren Cair","harga":45000}]
/app $ exit

Mari kita telaah empat pemeriksaan dalam sesi tersebut:

  1. env membuktikan apa yang benar-benar diterima proses. DATABASE_URL menunjuk nama service db — bukan localhost — sesuai peringatan kebiasaan yang kita paku sejak Chapter Port dan Network; nilai NODE_VERSION mengonfirmasi basis image yang dipakai container.
  2. ps aux memperlihatkan isi rumah. Persis satu proses hidup: node server.js sebagai PID 1, tanpa shell perantara — bentuk exec form yang membuat sinyal sampai ke aplikasi, dan pengingat bahwa docker compose top dari Chapter Menjalankan dan Mengelola Stack menampilkan tabel yang sama dari luar.
  3. nc -zv db 5432 menguji jalur yang dipakai aplikasi. Koneksi TCP ke port PostgreSQL ditempuh dari DALAM container, melalui network toko_backend-net, dengan resolusi nama oleh embedded DNS di 127.0.0.11 — jalur yang sama persis dengan yang dilalui pustaka pg; jawaban open berarti jalur tersebut sehat, sehingga dugaan konektivitas gugur. Inilah latihan inti Chapter Komunikasi Antar Container, dikerjakan dari dalam korban.
  4. wget -qO- localhost:3000/produk.json memeriksa aplikasi dari balik pintu. Permintaan tidak melewati proxy di port host 8080 melainkan mengetuk port 3000 langsung dari dalam container; jawaban JSON yang utuh membebaskan backend dari tuduhan. Kesendatan ternyata berasal dari proxy yang keliru menyimpan alamat backend lama — satu docker compose restart proxy menuntasknya.

Kotak Perkakas Sementara #

Kasus keempat berjalan mulus karena image node:22-alpine membawa busybox beserta nc dan wget-nya. Namun image yang benar-benar ramping — distroless, atau varian scratch dari Chapter Multi-Stage Build — tidak membawa shell sama sekali, dan docker compose exec backend sh akan gugur sebelum dimulai. Jalan keluarnya bukan menjejalkan alat ke dalam image aplikasi, melainkan mendatangkan kotak perkakas ke network yang sama:

bash
$ docker run --rm -it --network toko_backend-net alpine:3.20 sh

Output yang Dihasilkan (dipotong):

text
/ # apk add curl
fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/main/x86_64/APKINDEX.tar.gz
...
OK: 9 MiB in 7 packages
/ # curl -s http://backend:3000/produk.json | head -c 120
[{"id":1,"nama":"Kopi Arabika Gayo","harga":85000},{"id":2,"nama":"Teh Melati Putih","harga":120000},{"id":3,"n
/ # exit

Mari kita telaah pola container perkakas tersebut:

  1. --network toko_backend-net meletakkan alat satu hop dari seluruh service. Container alat menjadi anggota network yang sama dengan backend dan db, sehingga nama service resolve dan setiap port internal terjangkau — nama network ini mengikuti pola <project>_<network> yang kita kenal sejak Section Docker Compose.
  2. apk add memasang hanya untuk container ini. Alat terpasang di writable layer container yang akan dibuang --rm; image alpine:3.20 dan image aplikasi tidak tersentuh sama sekali — kebalikan dari memasang curl ke Dockerfile demi satu sesi diagnosa.
  3. Netshoot membungkus pola ini menjadi satu image. Pihak ketiga mengemas puluhan peralatan diagnosa jaringan — dig, tcpdump, mtr, iperf — dalam image bernama netshoot, yang sudah pernah kita panggil sebagai service ber-profil debug pada Chapter Profiles; memasukkannya ke network project memberi seluruh kotak perkakas tanpa satu paket pun menempel pada aplikasi.

Menempelkan Debugger Node #

Empat kasus di atas semuanya terdiagnosis dari luar. Untuk bug logika yang hanya muncul pada jalur tertentu — syarat yang tidak terpenuhi, urutan yang tidak terduga — alat yang tepat adalah debugger sungguhan yang menempel ke proses Node. Node.js membuka protokol inspectornya lewat satu flag, dan port 9229 yang kita EXPOSE pada target dev kini menemukan pemakaiannya. Budi menukar perintah service web sementara pada compose.override.yaml miliknya:

yaml
services:
  web:
    command: node --inspect=0.0.0.0:9229 src/server.js
    ports:
      - "9229:9229"

Dua kunci itu bekerja di atas perilaku penggabungan berkas override yang kita bedah pada Chapter Compose Override dan Environment: command mengganti total perintah bawaan image, sementara ports digabung sebagai union — 9229 diterbitkan di samping 8080 tanpa saling menyingkirkan. Dari sisi editor, VS Code memasang debugger-nya lewat konfigurasi attach pada launch.json:

json
{
  "type": "node",
  "request": "attach",
  "name": "katalog-web di container",
  "address": "localhost",
  "port": 9229,
  "localRoot": "${workspaceFolder}",
  "remoteRoot": "/app"
}

Sesi debug kemudian berjalan seperti debugging lokal biasa: breakpoint dipasang pada src/server.js, permintaan dikirim, dan eksekusi proses di dalam container berhenti tepat di baris yang ditandai — localRoot dan remoteRoot yang memetakan folder host ke /app membuat baris breakpoint jatuh ke tempat yang benar. Hot reload tidak harus dikorbankan: nodemon --inspect=0.0.0.0:9229 src/server.js menggabungkan watcher dan inspector dalam satu perintah. Setelah sesi selesai, kedua kunci tambahan itu dibuang dari override dan stack kembali ke kebiasaan normalnya.

Peta Gejala ke Alat #

Empat kasus hari ini cukup untuk menyusun peta lengkap. Tabel berikut memasangkan gejala yang paling sering dijumpai dengan jendela pertama yang layak diketuk:

Gejala yang terlihatAlat pertamaDibedah tuntas di
Container Restarting atau Exiteddocker compose ps lalu docker compose logsMemahami Exit Code
Berkas yang diharapkan tidak adadocker compose exec dengan ls dan catBind Mount Source Code
Exited (137) tanpa satu baris logdocker inspect .State.OOMKilled dan docker statsMemory Limit dan Swap
Koneksi antar service tersendatnc -zv dari dalam container, kotak perkakas, netshootKomunikasi Antar Container
Bug logika yang tak terlihat di lognode --inspect dengan VS Code attachchapter ini

Seluruh perangkat ini punya satu kesamaan yang tidak nyaman: semuanya bekerja setelah kegagalan terjadi. Kita memburu galat yang sudah berumur, membaca jejak yang sudah dingin, menambal kode yang sudah lulus ke tangan pengguna development lain. Pergeseran sikap yang menutup section ini adalah bergerak sebelum kegagalan lahir — menjalankan test secara rutin di dalam container, di lingkungan yang identik dengan produksi, sehingga bug ditemukan saat masih murah. Unit test lewat docker compose run, integration test dengan database sekali pakai di atas tmpfs, dan exit code yang menjadi kontrak bagi pipeline — itulah agenda Chapter Menjalankan Test di Container.

Referensi #