Stack toko kini menyala dengan urutan yang terbaca baris demi baris — tetapi siklus hidup tidak berhenti di momen menyala. Suatu saat stack harus dimatikan: untuk deploy versi baru, mengganti konfigurasi, atau sekadar menutup laptop di penghujung hari. Setiap kali kita menjalankan docker compose stop, Compose mengirim sinyal SIGTERM ke proses PID 1 di dalam setiap container, lalu memasang pengatur waktu sepuluh detik sebelum mengirim SIGKILL yang tak bisa ditolak. Aplikasi yang tidak pernah belajar membaca sinyal itu akan mati dengan koneksi pool yang terpotong dan permintaan yang masih setengah diproses — curl yang sedang menunggu jawaban bisa menggantung atau menerima kegagalan di tengah jalan. Chapter ini membedah alur penghentian container, mempraktikkan dua nasib aplikasi yang berbeda di hadapan SIGTERM, lalu melengkapi compose.yaml dengan tenggang waktu yang manusiawi.

Alur Penghentian: SIGTERM, Tenggang Waktu, SIGKILL #

Mekanisme di balik docker compose stop sama dengan docker stop yang kita kenal dari section dasar CLI, hanya diterapkan ke banyak service sekaligus. Alurnya tiga babak: sinyal SIGTERM dikirim ke PID 1 container; daemon menunggu dengan masa tenggang bawaan 10 detik; bila proses belum keluar saat tenggang habis, SIGKILL menyusul dan container berhenti seketika. Bukan kebetulan bahwa proses yang dihentikan paksa meninggalkan jejak berupa exit code 137 — konvensi 128 + 9 yang kita hafal dari chapter Memahami Exit Code. Tiga angka berikut adalah kosakata lengkap penghentian:

Exit codeAsal-usulArti bagi kita
0Aplikasi keluar atas keputusannya sendiri setelah drainShutdown yang sopan — inilah target kita
143128 + 15 — proses berhenti karena SIGTERMWajar dan bukan galat: sinyal sampai dan diberlakukan tanpa penanganan
137128 + 9 — proses dibunuh SIGKILLTenggang habis: ada pekerjaan yang terpotong di tengah

Baris 143 layak digarisbawahi. Melihat Exited (143) setelah docker compose stop bukan berarti aplikasi gagal — sinyal memang terkirim dan proses memang berhenti karenanya; docker stop pun menganggap hasil itu sukses. Yang hilang hanyalah kesopanan di sisi aplikasi: ia tidak diberi kesempatan menutup koneksi database, menuntaskan permintaan yang sedang digarap, dan memerah log terakhirnya. Selama masa tenggang itulah aplikasi yang sehat seharusnya menunaikan urutan pembersihan berikut: berhenti menerima permintaan baru, menyelesaikan permintaan yang sedang berjalan, menutup koneksi ke dependency dengan rapi, lalu keluar dengan kode 0. Urutan inilah yang disebut graceful shutdown — berhenti dengan pamit, bukan dilerengkan dari jurang.

Aplikasi yang Mengabaikan Sinyal #

Mari kita saksikan dulu nasib aplikasi yang belum memasang penangan sinyal. Tambahkan satu endpoint yang sengaja lambat di server.js — laporan tiga detik yang meniru pekerjaan nyata:

js
  if (req.url === "/laporan") {
    await new Promise((selesai) => setTimeout(selesai, 3000));
    const { rows } = await pool.query("SELECT COUNT(*) AS jumlah FROM produk");
    res.writeHead(200, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ produk: rows[0].jumlah }));
    return;
  }

Endpoint di atas diakses lewat proxy sebagai /api/laporan — awalan /api/ diganti menjadi / oleh proxy_pass, persis aturan yang kita bedah di chapter Reverse Proxy. Bangun ulang image lalu nyalakan stack, dan buka dua terminal. Terminal pertama mengalirkan lalu lintas tanpa henti:

bash
while sleep 1; do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:8080/api/laporan; done

Output yang Dihasilkan:

text
200
200
200

Sementara lalu lintas mengalir, hentikan backend dari terminal kedua:

bash
docker compose stop backend

Output yang Dihasilkan:

text
[+] Stopping 1/1
 ✔ Container toko-backend-1  Stopped   0.9s

Container berhenti dalam sekejap — dan tepat di situlah masalahnya. Terminal pertama melanjutkan ceritanya:

text
200
200
200
502
502

Mari kita telaah rangkaian kejadian di atas:

  1. Stopped 0.9s adalah alarm, bukan prestasi — proses Node tidak memasang penangan SIGTERM, sehingga sinyal berlaku dengan disposition bawaan: proses dihentikan saat sinyal diterima. Permintaan laporan yang sedang digarap mati di tengah jalan.
  2. 502 yang seketika — permintaan yang terputus membuat nginx melihat upstream menutup koneksi sebelum respons selesai, lalu menjawab 502 Bad Gateway kepada klien. Tanpa perantara proxy, klien bahkan bisa menggantung menunggu jawaban yang tak pernah tiba.
  3. Pool terpotong tanpa pamit — sepuluh koneksi pool yang terbuka putus serentak; PostgreSQL mencatatnya sebagai klien yang menghilang:
bash
docker compose logs db

Output yang Dihasilkan (dipotong):

text
toko-db-1  | 2026-10-14 10:02:41.512 UTC [38] LOG:  unexpected EOF on client connection

Baris unexpected EOF on client connection adalah sidang jari kematian yang kasar: server database mendapati lawan bicaranya menghilang tanpa mengirim pesan penutupan koneksi. Keadaan akhirnya terbaca jelas di docker compose ps -a: backend tercatat Exited (143) — bukan galat, tetapi juga bukan perpisahan yang layak.

Penangkap Sinyal di Kode Aplikasi #

Sekarang kita lengkapi server.js dengan upacara penutupan yang layak. Penangkap sinyal dipasang setelah server mulai mendengarkan port 3000:

js
process.on("SIGTERM", () => {
  console.log("SIGTERM diterima — menutup pintu");
  server.closeIdleConnections();
  server.close(async () => {
    console.log("permintaan tersisa selesai — menutup pool");
    await pool.end();
    console.log("pool db tertutup — keluar");
    process.exit(0);
  });
});

Mari kita telaah langkah demi langkah upacara di atas:

  1. process.on("SIGTERM", ...) — begitu penangan terpasang, Node tidak lagi membiarkan sinyal mematikan proses begitu diterima; kendali penuh berpindah ke fungsi kita, dan jam sepuluh detik daemon mulai berdetak di latar.
  2. server.closeIdleConnections() — koneksi HTTP yang menganggur ditutup segera; tanpa baris ini, satu koneksi idle yang lupa pergi cukup membuat close() menunggu selamanya.
  3. server.close(callback) — listener berhenti menerima koneksi baru, sedangkan permintaan yang sedang digarap dibiarkan selesai; callback hanya terpanggil setelah semua koneksi aktif benar-benar kosong.
  4. pool.end() — seluruh anggota pool ditutup dengan jabat tangan PostgreSQL yang benar: driver mengirim pesan termination, server menutup sesinya dengan rapi, dan tidak ada lagi unexpected EOF di log db.
  5. process.exit(0) — proses keluar atas keputusannya sendiri dengan kode 0; inilah yang membuat docker compose ps -a kelak menampilkan Exited (0), bukan 143.

Nyalakan ulang backend, ulangi lalu lintas di terminal pertama, lalu hentikan sekali lagi:

bash
docker compose stop backend

Output yang Dihasilkan:

text
[+] Stopping 1/1
 ✔ Container toko-backend-1  Stopped   4.2s

Durasi berubah dari 0,9 detik menjadi 4,2 detik — dan perubahan itu justru kabar baik. Mari kita telaah bukti-buktinya:

  1. 4.2s adalah durasi drain yang sesungguhnya — laporan tiga detik yang sedang digarap dibiarkan selesai, ditambah waktu menutup pool; aplikasi keluar atas kemauannya jauh sebelum tenggang habis.
  2. 200 terakhir tiga detik setelah stop — terminal pertama kini mencatat 200 200 200 200 502: baris 200 paling akhir adalah permintaan yang sedang diproses ketika SIGTERM tiba, dituntaskan sampai respons terkirim; hanya permintaan baru setelah pintu tertutup yang dijawab 502 oleh proxy.
  3. Log backend mencatat tiga babak upacara — SIGTERM diterima, permintaan tersisa selesai, pool db tertutup; sementara log db tidak lagi mencatat satu pun unexpected EOF.
  4. Exited (0) menutup verifikasi — penghentian yang sopan tuntas dengan angka paling sopan yang dimiliki Unix.

stop_grace_period: Tenggang yang Diatur per Service #

Masa tenggang sepuluh detik adalah pilihan bawaan, bukan hukum alam. Drain pada toko kita memakan sekitar empat detik, tetapi worker yang harus menguras antrean panjang atau aplikasi dengan ratusan koneksi bisa membutuhkan lebih. Compose menyediakan kunci stop_grace_period per service:

yaml
  backend:
    build: ./api
    image: toko-api:dev
    stop_grace_period: 20s

Mari kita telaah perilaku kunci tersebut:

  1. Penghitungan mulai saat SIGTERM terkirim — daemon memberi backend jendela 20 detik penuh untuk menuntaskan drain; SIGKILL baru menyusul bila jendela itu terlampaui.
  2. Angkanya harus melampaui anggaran drain di kode — tenggang 10 detik dengan drain 15 detik berarti SIGKILL selalu datang lebih dulu; seluruh penangan sinyal menjadi sia-sia. Naikkan tenggang bila lalu lintas puncakmu membuat drain kerap panjang — 20 detik adalah pilihan yang wajar untuk shutdown yang berat.
  3. Nilainya berupa durasi bebas — 30s, 1m, hingga 1m30s semuanya sah; kuncinya padanan dari --timeout pada docker stop.
  4. Tenggang bisa diatur ulang per pemanggilan — docker compose stop -t 30 backend memberi tenggang 30 detik khusus untuk penghentian itu, tanpa menyentuh compose.yaml; berguna untuk maintenance yang memang butuh ruang lebih.

Sinyal Harus Sampai: Exec Form Wajib #

Seluruh bangunan di atas bertumpu pada satu asumsi: SIGTERM benar-benar tiba di tangan aplikasi. Sinyal dikirim ke PID 1 — dan di sinilah bentuk penulisan CMD di Dockerfile menentukan nasibnya. Dockerfile api kita sejak chapter Application dan Database memakai bentuk exec:

dockerfile
CMD ["node", "server.js"]

Bentuk itu membuat Node langsung menjadi PID 1, sehingga sinyal berhenti mendarat tepat di tangan proses yang memasang penangan. Bandingkan dengan bentuk shell yang kerap kita jumpai di image sepele:

dockerfile
CMD node server.js

Posisi PID 1 bisa diverifikasi langsung di dalam container yang sedang berjalan:

bash
docker compose exec backend ps

Output yang Dihasilkan:

text
PID   USER     TIME  COMMAND
    1 node     0:00 node server.js
    7 node     0:00 ps

Baris pertama adalah kabar yang kita cari: aplikasi — bukan shell — menduduki kursi PID 1. Sebagai catatan penutup bagian ini: untuk aplikasi warisan yang sama sekali tidak bisa diajar menangkap sinyal, opsi --init pada docker run memasang proses init ringkas yang meneruskan sinyal dan memungut anak yatim — pola yang kita bahas pada chapter Container Init Process.

Stop Serentak dan Urutan yang Kita Atur Sendiri #

Start dibimbing rantai depends_on yang bertingkat; penghentian tidak mendapat perlakuan setimpal. docker compose stop mengirim SIGTERM kepada seluruh service dalam satu gelombang — tidak ada penunggal “backend selesai drain dulu” sebelum daemon menyentuh db. Hentikan seluruh stack yang sedang berjalan untuk menyaksikannya:

bash
docker compose stop

Output yang Dihasilkan:

text
[+] Stopping 5/5
 ✔ Container toko-proxy-1     Stopped   1.1s
 ✔ Container toko-frontend-1  Stopped   0.8s
 ✔ Container toko-backend-1   Stopped   4.2s
 ✔ Container toko-cache-1     Stopped   0.6s
 ✔ Container toko-db-1        Stopped   2.3s

Mari kita telaah apa yang sebenarnya terjadi pada keluaran tersebut:

  1. Lima sinyal dilepas serentak — urutan baris hanyalah urutan selesai, bukan urutan penyentuhan; proxy dan db menerima SIGTERM pada saat yang sama.
  2. Tiap durasi menceritakan drain masing-masing — backend 4,2 detik menuntaskan laporan yang sedang digarap; db 2,3 detik menutup sesi-sesi koneksinya; cache 0,6 detik karena Redis memang sigap berpisah.
  3. Konsekuensi desainnya eksplisit — backend yang sedang drain bisa kehilangan db di tengah pool.end() bila db kebetulan selesai lebih dulu; aplikasi harus tetap tangguh terhadap tetangga yang menghilang, persis semangat retry dari chapter Service Discovery yang bekerja dua arah.

Bila kita memang butuh urutan penghentian yang pasti — trafik berhenti masuk dulu, database mati paling akhir — urutan itu kita susun sendiri lewat pemanggilan eksplisit beruntun: docker compose stop proxy, lalu docker compose stop backend frontend, lalu cache, dan db terakhir. Sedangkan untuk sekadar “semua berhenti dan bersih”, docker compose down menuntaskannya dalam satu perintah: menghentikan seluruh service, menghapus container dan network, sambil membiarkan volume tetap utuh kecuali kita menuliskan -v. Perintah-perintah lengkap siklus hidup stack ini sudah kita rangkum di chapter Menjalankan dan Mengelola Stack.

Healthcheck Saat Stack Ditutup #

Satu perhatian terakhir: probe tidak berhenti hanya karena stack sedang ditutup. Selama container masih hidup, healthcheck terus berjalan sesuai intervalnya — dan selama masa drain, pintu /healthz memang sudah ditutup oleh server.close(). Akibatnya docker compose ps -a bisa sempat menampilkan backend dengan status (unhealthy) di detik-detik menjelang kematiannya. Status itu bukan diagnosis penyakit, melainkan konsekuensi wajar dari drain: aplikasi sedang menolak permintaan baru dengan sengaja. Baca konteksnya sebelum panik — unhealthy di tengah operasi stop yang sukses berbeda maknanya dari unhealthy di tengah siang yang sepi; pembedaan konteks semacam ini adalah keahlian diagnosa yang kita latih sepanjang section Observability dan Troubleshooting nanti.

Tabel Elemen Graceful Shutdown #

Seluruh perlengkapan berhenti yang sopan kini terkumpul dalam satu daftar periksa:

ElemenPeranWujud pada backend toko
Penangkap sinyalAplikasi mengambil kendali atas kematiannya sendiriprocess.on("SIGTERM", ...)
Exec formSinyal tiba di aplikasi, bukan berhenti di shellCMD ["node", "server.js"]
Drain permintaanPermintaan yang sedang digarap dituntaskanserver.close() setelah closeIdleConnections()
Penutupan koneksiDependency diberi tahu dengan sopan, bukan ditinggalkanpool.end() lalu process.exit(0)
Tenggang memadaiRuang waktu untuk seluruh upacarastop_grace_period: 20s

Menyala dengan urutan dan berhenti dengan sopan — dua ujung siklus hidup toko kini sama-sama kita pegang. Seluruh pola sepanjang section ini, dari reverse proxy sampai penghentian yang rapi, tinggal dirangkai menjadi satu kesatuan yang bisa dipakai sehari-hari: satu repositori berisi stack development lengkap dengan hot reload, data contoh, dan alat bantu debug. Chapter penutup section, Local Development Stack, merakit semuanya.

Referensi #