Aplikasi server tidak berhenti dengan menutup jendela — ia berhenti karena menerima sinyal: Ctrl+C dari operator, SIGTERM dari systemd, Docker, atau orchestrator Kubernetes saat pod dijadwalkan ulang. Pada saat sinyal itu tiba, aplikasi kita mungkin sedang memegang pool koneksi MongoDB dan satu-dua operasi tulis yang belum selesai. Graceful shutdown adalah urutan berhenti yang benar: berhenti menerima pekerjaan baru, tunggu pekerjaan yang sedang berjalan, lalu tutup client — sehingga tidak ada penulisan terpotong dan tidak ada koneksi terbengkalai. Chapter ini membuktikan dua kutubnya dengan eksekusi nyata: server yang berhenti bersih, dan proses yang menolak keluar karena lupa close().
Sinyal dan Urutan Berhenti #
Dua sinyal bisa kita tangani di Node.js lewat process.on():
| Sinyal | Sumber umum | Bisa ditangani |
|---|---|---|
SIGINT | Ctrl+C di terminal | ya |
SIGTERM | systemctl stop, docker stop, penghapusan pod | ya |
SIGKILL | kill -9, paksa dari luar | tidak — proses langsung berakhir |
Urutan pembersihan yang benar untuk aplikasi berbasis MongoDB: pertama, hentikan sumber pekerjaan baru (clearInterval, tutup HTTP server sehingga tidak ada request masuk); kedua, tunggu operasi database yang sedang berjalan tuntas; ketiga, panggil await client.close() untuk menutup seluruh pool; terakhir process.exit(0) — status keluar bersih.
Server dengan Handler yang Rapi #
File berikut meniru aplikasi server mini: sebuah pekerjaan latar mencatat pesanan tiap 300 milidetik, lalu setelah satu detik proses mengirim SIGINT ke dirinya sendiri — persis seperti yang terjadi ketika operator menekan Ctrl+C:
// server-rapi.js — graceful shutdown: berhenti menerima kerja baru, tunggu, tutup
// Jalankan: MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node server-rapi.js
const { MongoClient } = require("mongodb")
const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/?directConnection=true"
const client = new MongoClient(uri)
let timer = null
let pekerjaanBerjalan = Promise.resolve()
let pesanan = null
function catatPesanan(nomor) {
pekerjaanBerjalan = pesanan.insertOne({ nomor, pada: new Date() })
.then(() => console.log("pesanan", nomor, "tercatat"))
return pekerjaanBerjalan
}
async function mulai() {
await client.connect()
pesanan = client.db("latihan_js").collection("pesanan")
await pesanan.drop().catch(() => {})
// Pekerjaan latar: satu pesanan baru setiap 300 ms
let nomor = 0
timer = setInterval(() => catatPesanan(++nomor), 300)
console.log("aplikasi berjalan — sinyal berhenti disimulasikan 1 detik dari sekarang")
setTimeout(() => process.kill(process.pid, "SIGINT"), 1000)
}
async function matikanGracious(signal) {
console.log(`\n${signal} diterima — berhenti menerima pekerjaan baru`)
clearInterval(timer) // 1. berhenti menerima kerja baru
await pekerjaanBerjalan.catch(() => {}) // 2. tunggu operasi yang sedang berjalan
const jumlah = await pesanan.countDocuments()
console.log("pekerjaan tersimpan :", jumlah, "pesanan")
await pesanan.drop()
await client.close() // 3. tutup pool koneksi
console.log("pool koneksi ditutup : aplikasi keluar dengan bersih")
process.exit(0)
}
process.on("SIGINT", () => { matikanGracious("SIGINT") })
process.on("SIGTERM", () => { matikanGracious("SIGTERM") })
mulai().catch(err => {
console.error("GAGAL:", err.name, "-", err.message)
process.exit(1)
})MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node server-rapi.jsOutput yang Dihasilkan:
aplikasi berjalan — sinyal berhenti disimulasikan 1 detik dari sekarang
pesanan 1 tercatat
pesanan 2 tercatat
pesanan 3 tercatat
SIGINT diterima — berhenti menerima pekerjaan baru
pekerjaan tersimpan : 3 pesanan
pool koneksi ditutup : aplikasi keluar dengan bersihMari kita bedah urutannya. Variabel pekerjaanBerjalan selalu menunjuk Promise operasi terakhir — catatPesanan() menimpanya setiap kali timer berbunyi, sehingga saat sinyal tiba, handler tahu persis pekerjaan mana yang harus ditunggu. clearInterval(timer) memutus sumber pekerjaan baru lebih dulu; tanpa langkah ini, pesanan keempat bisa menyusup di antara sinyal dan penutupan. await pekerjaanBerjalan.catch(() => {}) menunggu operasi terakhir selesai sambil menelan galatnya — penutupan tidak boleh gagal hanya karena pekerjaan terakhir gagal. Barulah client.close() menutup seluruh socket pool, dan process.exit(0) mengakhiri proses dengan status bersih — terlihat dari exit=0 pada terminal.
Tanpa close(), Proses Tidak Pernah Keluar #
Kebalikannya layak dibuktikan karena sering mengejutkan: skrip yang menyelesaikan seluruh pekerjaannya tetapi tidak pernah memanggil close(). Kita jalankan dengan pembatas tiga detik agar terlihat jelas:
// tanpa-close.js — demonstrasi: tanpa close(), proses Node.js tidak pernah keluar
// Jalankan lalu amati prosesnya menggantung (hentikan paksa dengan Ctrl+C):
// MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" node tanpa-close.js
const { MongoClient } = require("mongodb")
const uri = process.env.MONGODB_URI ?? "mongodb://127.0.0.1:27017/?directConnection=true"
const client = new MongoClient(uri)
async function main() {
await client.connect()
await client.db("latihan_js").collection("kunjungan").insertOne({ pada: new Date() })
console.log("pekerjaan selesai — tetapi proses tidak pernah keluar:")
console.log("socket pool menjaga event loop tetap hidup (hentikan paksa dengan Ctrl+C)")
// tidak ada client.close() di mana pun
}
main().catch(err => {
console.error("GAGAL:", err.name, "-", err.message)
process.exit(1)
})MONGODB_URI="mongodb://127.0.0.1:27017/?directConnection=true" timeout 3 node tanpa-close.jsOutput yang Dihasilkan:
pekerjaan selesai — tetapi proses tidak pernah keluar:
socket pool menjaga event loop tetap hidup (hentikan paksa dengan Ctrl+C)Perintah timeout 3 memutus proses paksa setelah tiga detik dan melaporkan status 124 — bukti proses memang masih hidup ketika seharusnya sudah selesai. Penyebabnya ada di jantung Node.js: event loop hanya mengakhiri proses bila tidak ada pekerjaan tersisa, dan socket pool MongoDB beserta pemantauan periodiknya (heartbeat topologi) selalu dihitung sebagai pekerjaan. Pada skrip sekali jalan, lupa close() membuat proses menggantung selamanya; pada server, close() yang tidak pernah dipanggil membuat slot koneksi di sisi server terlepas hanya lewat timeout.
Pola Cleanup Handler #
Aplikasi nyata tidak hanya memegang satu client — ada HTTP server, message queue, dan file log yang juga perlu ditutup. Pola yang mudah dirawat: daftarkan setiap langkah pembersihan saat komponen dibuat, lalu jalankan semuanya berurutan terbalik saat sinyal tiba:
// mdtest: skip — ilustrasi pola umum, bukan file lengkap
const langkahPembersihan = []
function daftarkanPembersihan(fn) { langkahPembersihan.push(fn) }
// di berbagai titik aplikasi:
daftarkanPembersihan(() => server.close()) // http.Server
daftarkanPembersihan(() => client.close()) // MongoClient
async function matikan(signal) {
for (const fn of [...langkahPembersihan].reverse()) {
try { await fn() } catch { /* lanjut ke sumber daya berikutnya */ }
}
process.exit(0)
}
process.on("SIGINT", () => matikan("SIGINT"))
process.on("SIGTERM", () => matikan("SIGTERM"))Urutan terbalik memastikan sumber daya yang dibuat paling akhir ditutup paling awal — kebiasaan yang sama dengan blok finally bersarang. Setiap langkah dibungkus try/catch agar satu kegagalan tidak menghalangi sumber daya lainnya untuk ditutup; urutan SIGINT dan SIGTERM yang sama memanggil handler yang sama juga membuat aplikasi berperilaku konsisten di terminal dan di lingkungan produksi.
Rangkuman #
- Tangani
SIGINTdanSIGTERMlewatprocess.on();SIGKILLtidak bisa ditangani siapa pun. - Urutan benar: hentikan pekerjaan baru → tunggu operasi berjalan (
awaitpromise terakhir) →client.close()→process.exit(0). - Tanpa
close(), socket pool menjaga event loop tetap hidup — proses skrip menggantung, dan slot koneksi server terlepas hanya lewat timeout. - Untuk banyak sumber daya, pakai daftar pembersihan yang dijalankan terbalik dengan
try/catchper langkah.