Setiap deployment menghadapi dua arah: aplikasi harus menyala dan aplikasi harus berhenti. Penghentian adalah bagian yang paling jarang dirancang — padahal docker stop, penggantian pod, dan pembaruan image mengirim sinyal yang sama ke proses Java. Tanpa penanganan, request yang setengah jalan terpotong: pesanan sudah ditagih tetapi belum tercatat, penyangga log belum tersimpan. Graceful shutdown adalah urutan penutupan yang tertata: berhenti menerima pekerjaan baru, selesaikan pekerjaan yang sedang berjalan, lalu tutup seluruh resource secara berurutan.
Sinyal di Balik docker stop #
Perintah docker stop mengirim SIGTERM ke proses nomor satu di dalam container — di sinilah exec form ENTRYPOINT dari chapter Containerizing Aplikasi Java menunjukkan fungsinya: shell form menjadikan shell proses nomor satu dan sinyal tidak diteruskan ke JVM. Docker menunggu 10 detik (bawaan, diubah lewat --time) sebelum SIGKILL. Kubernetes memakai pola yang sama: kubelet mengirim SIGTERM dengan masa tenggang bawaan 30 detik (terminationGracePeriodSeconds) sebelum SIGKILL.
| Sinyal | Dikirim oleh | Dapat ditangkap | Perilaku JVM |
|---|---|---|---|
SIGTERM | docker stop, systemctl stop | ya | shutdown hook dijalankan |
SIGINT | Ctrl+C di terminal | ya | shutdown hook dijalankan |
SIGKILL | kill -9, OOM killer kernel | tidak | proses berhenti seketika, hook tidak jalan |
Shutdown Hook di Dalam JVM #
JVM menyediakan Runtime.getRuntime().addShutdownHook(Thread): thread terdaftar dijalankan saat shutdown — dipicu System.exit(), SIGTERM/SIGINT, atau selesainya seluruh thread non-daemon. Dua aturan dari dokumentasi resmi memandu desain: urutan antar-hook tidak dijamin, jadi seluruh tahapan penutupan sebaiknya berada dalam satu hook; dan hook harus cepat karena SIGKILL menunggu di ujung grace period. Program berikut merakit pola tersebut:
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/** Graceful shutdown mini: satu shutdown hook dengan tahapan berurutan. */
public class MatangBerhenti {
/** Model kolam koneksi database yang harus ditutup di akhir hidup proses. */
static final int KONEKSI_AKTIF = 20;
/** Gerbang penerimaan request: false berarti load balancer mengalihkan trafik. */
static final AtomicBoolean MENERIMA = new AtomicBoolean(true);
/** Pekerjaan yang sedang diproses saat sinyal tiba — harus diselesaikan, dibuang. */
static final Deque<String> SEDANG_DIPROSES =
new ArrayDeque<>(List.of("pesanan P-1051", "pesanan P-1052", "pesanan P-1053"));
/** Seluruh tahap penutupan berada di SATU hook: urutan eksplisit, bukan kebetulan. */
static void matiAnggun() {
System.out.println("== Shutdown hook dijalankan JVM ==");
// Tahap 1: berhenti menerima pekerjaan baru.
MENERIMA.set(false);
System.out.println(" [tahap 1] readiness -> 503, load balancer mengalihkan trafik");
// Tahap 2: selesaikan pekerjaan yang sedang berjalan.
System.out.println(" [tahap 2] menguras " + SEDANG_DIPROSES.size() + " pekerjaan berjalan:");
while (!SEDANG_DIPROSES.isEmpty()) {
System.out.println(" selesai : " + SEDANG_DIPROSES.poll());
}
// Tahap 3: tutup resource berurutan, log disiram paling akhir.
System.out.println(" [tahap 3] kolam koneksi " + KONEKSI_AKTIF + " ditutup");
System.out.println(" [tahap 3] berkas log disiram (flush) ke disk");
System.out.println("proses berakhir dengan aman");
}
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(MatangBerhenti::matiAnggun, "hook-penutupan"));
System.out.println("== Proses berjalan normal ==");
System.out.println("menerima request baru : " + MENERIMA.get());
System.out.println("sedang diproses : " + SEDANG_DIPROSES.size() + " pekerjaan");
System.out.println();
System.out.println("operator : docker stop kasirapp (SIGTERM sampai ke JVM)");
System.exit(0);
}
}Output yang Dihasilkan:
== Proses berjalan normal ==
menerima request baru : true
sedang diproses : 3 pekerjaan
operator : docker stop kasirapp (SIGTERM sampai ke JVM)
== Shutdown hook dijalankan JVM ==
[tahap 1] readiness -> 503, load balancer mengalihkan trafik
[tahap 2] menguras 3 pekerjaan berjalan:
selesai : pesanan P-1051
selesai : pesanan P-1052
selesai : pesanan P-1053
[tahap 3] kolam koneksi 20 ditutup
[tahap 3] berkas log disiram (flush) ke disk
proses berakhir dengan amanMari kita bedah tahapannya:
- Baris 40 (
addShutdownHook): satu thread hook mendaftarkanMatangBerhenti::matiAnggun— method reference menjaga seluruh urutan dalam satu hook, bukan tersebar di beberapa hook yang urutannya tidak dijamin. - Baris 46-47 (
System.exit(0)): mensimulasikan launcher setelah menerimaSIGTERM. Panggilanexitmemicu urutan shutdown dari sumber apa pun — uji, sinyal operator, atau akhir alami aplikasi melewati pintu yang sama. - Baris 24-25 (tahap 1): gerbang
MENERIMAdimatikan lebih dulu — padanan readiness503dari chapter Health Checks — agar load balancer mengalihkan trafik saat pengurasan berjalan. - Baris 28-31 (tahap 2): lingkaran
whiledenganpoll()menguras antrean FIFO sampai kosong — model drain request sebelum resource ditutup. - Baris 33-36 (tahap 3): resource ditutup berurutan dan log disiram paling akhir; kejadian tahap 1-3 sempat tercatat sebelum proses berakhir.
SIGTERM vs SIGKILL dalam Praktik #
Beda nasib kedua sinyal paling jelas lewat percobaan nyata. Program berikut menyalakan “server” yang tidur menunggu request dengan hook tiga tahap yang sama:
public class ServerTidur {
public static void main(String[] args) throws Exception {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
System.out.println(" [tahap 1] berhenti menerima request baru");
System.out.println(" [tahap 2] selesaikan request yang sedang berjalan");
System.out.println(" [tahap 3] tutup kolam koneksi dan siram log");
}));
System.out.println("aplikasi menyala, menunggu request...");
Thread.sleep(60_000);
}
}Jalankan di latar belakang lalu kirim SIGTERM — sinyal yang sama dengan docker stop:
javac ServerTidur.java
java ServerTidur & PID=$!
sleep 1
kill -TERM $PID
wait $PID; echo "exit code: $?"Output yang Dihasilkan:
aplikasi menyala, menunggu request...
[tahap 1] berhenti menerima request baru
[tahap 2] selesaikan request yang sedang berjalan
[tahap 3] tutup kolam koneksi dan siram log
exit code: 143Ketiga tahap tercetak lengkap meskipun thread utama masih tidur — JVM menunggu hook selesai sebelum berhenti. Exit code 143 mengikuti konvensi shell: 128 + 15, nomor sinyal SIGTERM. Bandingkan dengan SIGKILL:
javac ServerTidur.java
java ServerTidur & PID=$!
sleep 1
kill -KILL $PID
wait $PID 2>/dev/null; echo "exit code: $?"Output yang Dihasilkan:
aplikasi menyala, menunggu request...
exit code: 137Tidak satu tahap pun tercetak. Inilah nasib hook ketika grace period terlampaui: SIGKILL dari docker stop yang kehabisan waktu — atau dari OOM killer kernel — menghentikan proses tanpa memberi JVM kesempatan apa pun. Exit code 137 = 128 + 9, nomor sinyal SIGKILL; angka ini di log container menandai proses yang dihentikan paksa, bukan menutup diri dengan tertib.
Batas Waktu dan Jebakan Penutupan #
Hook berjalan memakai waktu pinjaman: 10 detik milik Docker, 30 detik milik Kubernetes. Konsekuensinya, tahap pengurasan perlu batas waktu sendiri — bila tenggat tercapai, sisa pekerjaan ditolak eksplisit (tercatat di log) alih-alih dipotong diam-diam oleh SIGKILL. Tahapan juga sebaiknya idempoten, dan jangan memulai thread non-daemon baru dari dalam hook karena JVM harus menunggunya juga.
Aplikasi kita kini menyala dengan terukur (health check, metrik) dan berhenti dengan tertib (shutdown berurutan). Namun produksi tetap menyimpan kejadian tak terduga: latensi menanjak tanpa sebab, memori habis perlahan, container berhenti mendadak. Chapter berikutnya menyusun metode diagnosis sistematis untuknya.
Referensi #
- Oracle: API Runtime.addShutdownHook
- Docker: docker container stop
- Kubernetes: Pod Lifecycle — pod termination
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
MatangBerhenti.java
Unduh berkasimport java.util.ArrayDeque;
import java.util.Deque;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
/** Graceful shutdown mini: satu shutdown hook dengan tahapan berurutan. */
public class MatangBerhenti {
/** Model kolam koneksi database yang harus ditutup di akhir hidup proses. */
static final int KONEKSI_AKTIF = 20;
/** Gerbang penerimaan request: false berarti load balancer mengalihkan trafik. */
static final AtomicBoolean MENERIMA = new AtomicBoolean(true);
/** Pekerjaan yang sedang diproses saat sinyal tiba — harus diselesaikan, dibuang. */
static final Deque<String> SEDANG_DIPROSES =
new ArrayDeque<>(List.of("pesanan P-1051", "pesanan P-1052", "pesanan P-1053"));
/** Seluruh tahap penutupan berada di SATU hook: urutan eksplisit, bukan kebetulan. */
static void matiAnggun() {
System.out.println("== Shutdown hook dijalankan JVM ==");
// Tahap 1: berhenti menerima pekerjaan baru.
MENERIMA.set(false);
System.out.println(" [tahap 1] readiness -> 503, load balancer mengalihkan trafik");
// Tahap 2: selesaikan pekerjaan yang sedang berjalan.
System.out.println(" [tahap 2] menguras " + SEDANG_DIPROSES.size() + " pekerjaan berjalan:");
while (!SEDANG_DIPROSES.isEmpty()) {
System.out.println(" selesai : " + SEDANG_DIPROSES.poll());
}
// Tahap 3: tutup resource berurutan, log disiram paling akhir.
System.out.println(" [tahap 3] kolam koneksi " + KONEKSI_AKTIF + " ditutup");
System.out.println(" [tahap 3] berkas log disiram (flush) ke disk");
System.out.println("proses berakhir dengan aman");
}
public static void main(String[] args) {
Runtime.getRuntime().addShutdownHook(new Thread(MatangBerhenti::matiAnggun, "hook-penutupan"));
System.out.println("== Proses berjalan normal ==");
System.out.println("menerima request baru : " + MENERIMA.get());
System.out.println("sedang diproses : " + SEDANG_DIPROSES.size() + " pekerjaan");
System.out.println();
System.out.println("operator : docker stop kasirapp (SIGTERM sampai ke JVM)");
System.exit(0);
}
}