Level 5 · Hidup-Mati Pod di OpenShift
Sesi 36: Detik-Detik Terakhir Sebuah Pod: Graceful Shutdown, SIGTERM, & preStop Hook
Bagaimana jika setiap kali tim kita deploy versi baru, ada 300 pengguna yang mendapat error 502, padahal semua pod baru sudah sehat dan semua probe hijau?
1. Bayangkan Jika
Sebuah restoran mau tutup jam 22.00. Pelayan di dalam sudah diberi tahu: "Mulai sekarang jangan terima tamu baru, selesaikan yang sedang makan, lalu pulang."
Masalahnya, papan di depan restoran masih bertuliskan BUKA. Petugas yang harus mengganti papan itu ada di ujung jalan, dan baru sampai lima menit kemudian. Selama lima menit itu, tamu terus berdatangan, mendorong pintu, dan mendapati pintunya sudah dikunci dari dalam. Mereka pulang dengan kesal.
Tidak ada yang salah dengan dapurnya. Tidak ada yang salah dengan pelayannya. Yang salah adalah urutan: pintu dikunci sebelum papan diganti.
Itulah yang terjadi pada pod di setiap rolling update, scale-down, dan node drain jika tidak ada yang mengatur detik-detik terakhirnya.
2. Apa yang Sebenarnya Terjadi
Di Sesi 35 kita membahas bagaimana pod lahir dan dinyatakan sehat lewat startup, readiness, dan liveness probe. Sesi ini membahas sisi sebaliknya: bagaimana pod mati.
Pod dihapus karena banyak alasan: oc rollout restart, rolling update image baru, HPA scale-down, oc adm drain saat node maintenance, eviction karena node kekurangan memori, atau oc delete pod manual. Apa pun pemicunya, urutannya sama:
- API server menandai pod
Terminatingdan mengisideletionTimestamp. CountdownterminationGracePeriodSeconds(default 30 detik) mulai berjalan saat ini juga. - Dua proses berjalan PARALEL, tidak berurutan:
- (a) Jalur kubelet: kubelet di node menjalankan
preStophook (jika ada). Setelah hook selesai, kubelet mengirim SIGTERM ke PID 1 di dalam container. - (b) Jalur jaringan: endpoints controller menghapus IP pod dari EndpointSlice. Perubahan ini lalu disebarkan secara asinkron ke kube-proxy / iptables di setiap node, ke OpenShift router (HAProxy) yang membaca Route, ke ingress controller lain, dan ke service mesh (Envoy sidecar) jika ada. - Jika aplikasi selesai lebih cepat, container exit dengan kode 0 dan pod hilang dengan bersih.
- Jika countdown habis, kubelet mengirim SIGKILL. Proses mati seketika dengan exit code 137, dan semua request in-flight terpotong di tengah jalan.
Kata kuncinya adalah paralel. Jalur (a) bisa selesai dalam milidetik: SIGTERM sampai, aplikasi langsung menutup listening socket. Jalur (b) butuh waktu: 1 sampai beberapa detik di cluster kecil, lebih lama di cluster besar dengan ratusan node dan ribuan endpoint. HAProxy router OpenShift juga punya interval reload sendiri.
Selama celah itu, router masih percaya pod hidup dan terus mengirim request baru ke sana. Hasilnya: 502 Bad Gateway, 503, atau connection refused. Ini adalah race condition, bukan bug aplikasi.
Timeline tanpa preStop hook
| Waktu | Jalur kubelet | Jalur jaringan | Dampak |
|---|---|---|---|
| t=0 | Pod Terminating, SIGTERM dikirim |
EndpointSlice mulai diupdate | - |
| t=0.05 | Aplikasi menutup listener | kube-proxy node A sudah update | - |
| t=0.5 | Aplikasi menyelesaikan in-flight | HAProxy router belum reload | Request baru ke pod: connection refused |
| t=1.5 | Aplikasi exit 0 | kube-proxy node C baru update | Request via node C: 502 |
| t=3 | Pod hilang | Semua komponen sudah update | Error berhenti |
Tiga detik error di setiap pod. Kalikan dengan 20 pod dalam satu rolling update, dan setiap deploy meninggalkan jejak 502 di dashboard.
Solusinya: preStop hook sebagai "jeda sopan"
preStop hook dijalankan sebelum SIGTERM. Jika isinya hanya sleep 10, aplikasi tetap melayani normal selama 10 detik, sementara jalur jaringan menyelesaikan propagasinya. Saat SIGTERM akhirnya tiba, tidak ada lagi router yang mengirim request baru.
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout-api
spec:
template:
spec:
terminationGracePeriodSeconds: 45 # sleep 10 + drain 20 + cleanup + margin
containers:
- name: app
image: registry.example.com/checkout-api:2.4.1
lifecycle:
preStop:
exec:
command: ["sleep", "10"]
# Aksi native (diperkenalkan di Kubernetes 1.29, aktif default sejak 1.30):
# sleep:
# seconds: 10
Timeline dengan preStop hook
| Waktu | Jalur kubelet | Jalur jaringan | Dampak |
|---|---|---|---|
| t=0 | Pod Terminating, preStop sleep 10 mulai |
EndpointSlice mulai diupdate | Aplikasi masih melayani |
| t=0.5 | Masih sleep | kube-proxy sebagian node update | Request lama tetap dilayani |
| t=3 | Masih sleep | HAProxy router reload, semua update | Tidak ada request baru masuk |
| t=10 | SIGTERM ke PID 1 | - | Aplikasi mulai drain |
| t=10–25 | Selesaikan in-flight, tutup pool | - | 0 error |
| t=26 | Exit 0 | - | Pod hilang bersih |
| t=45 | (batas SIGKILL, tidak tercapai) | - | - |
Perhatikan: countdown 45 detik sudah termasuk durasi preStop. Jika preStop 10 detik dan aplikasi butuh 20 detik untuk drain, grace period 30 detik (default) tidak cukup. Aplikasi akan di-SIGKILL di detik ke-30.
Rumus sizing
sleep preStop >= waktu propagasi endpoint (ukur, biasanya 5-10 detik)
sleep + drain request terlama + cleanup < terminationGracePeriodSeconds
Apa yang harus dilakukan aplikasi saat SIGTERM
- Berhenti menerima koneksi baru.
- Selesaikan request in-flight.
- Tutup koneksi keep-alive: kirim header
Connection: closepada respons berikutnya, atau tutup koneksi idle. Tanpa ini, client dan proxy yang memakai HTTP/1.1 keep-alive, HTTP/2, atau gRPC tetap "menempel" ke pod lama. - Tutup connection pool DB/Redis dengan rapi, supaya server tidak menyimpan koneksi zombie.
- Flush log dan metric buffer.
- Exit 0.
# Spring Boot application.yaml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 20s
Node.js tidak melakukan ini otomatis. Anda harus memasang process.on('SIGTERM', ...) lalu memanggil server.close(). Gunicorn punya graceful_timeout.
Jebakan PID 1
Jika Dockerfile memakai shell form, misalnya ENTRYPOINT java -jar app.jar atau command: ["sh", "-c", "java -jar app.jar"], yang menjadi PID 1 adalah sh, bukan Java. Shell tidak meneruskan SIGTERM ke child process. Aplikasi tidak pernah tahu dirinya diminta berhenti, menunggu sampai grace period habis, lalu mati oleh SIGKILL. Selalu exit 137, selalu terlambat 30 detik.
Perbaikannya: gunakan exec form (ENTRYPOINT ["java", "-jar", "app.jar"]), tambahkan exec di script (exec java -jar app.jar), atau pakai init kecil seperti tini / dumb-init.
Bagaimana dengan readiness probe?
Banyak tim mengira readiness harus dibuat gagal saat SIGTERM agar pod keluar dari Service. Sebenarnya tidak perlu untuk itu: pod yang Terminating otomatis dikeluarkan dari endpoint yang ready, apa pun hasil probe-nya. Yang tidak otomatis hilang adalah koneksi panjang yang sudah terbuka. Koneksi itulah yang harus ditutup oleh aplikasi.
3. Bagaimana Jika Kita Coba...
"Gampang, set terminationGracePeriodSeconds: 300 saja biar aman!"
Grace period panjang tidak membuat shutdown lebih rapi. Ia hanya memperpanjang batas waktu:
- Rollout jadi lambat sekali. Jika aplikasi tidak menangani SIGTERM (jebakan PID 1), setiap pod menunggu penuh 300 detik. 20 pod dengan maxUnavailable: 1 berarti rollout bisa memakan waktu sekitar 100 menit.
- Node drain tersendat. oc adm drain menunggu semua pod di node itu selesai. Upgrade cluster OpenShift yang berjalan node per node bisa molor berjam-jam.
- Autoscaler lambat melepas kapasitas. Pod scale-down bertahan dalam status Terminating dan tetap memakai resource.
- Race condition tetap ada. Tanpa preStop, SIGTERM tetap tiba di detik ke-0, dan error tetap terjadi di detik ke-0 sampai ke-3.
"Kalau begitu, tidak usah preStop. Aplikasi kita sudah handle SIGTERM dengan cepat."
Justru semakin cepat aplikasi berhenti, semakin lebar jendela error-nya, karena listener tutup sebelum router tahu. Hasilnya adalah lonjakan 502 kecil yang muncul setiap kali deploy, terlalu singkat untuk memicu alert, tetapi cukup untuk menggagalkan transaksi pengguna.
4. Nama Resminya
- Pod Termination Lifecycle: Urutan
Terminating→ preStop → SIGTERM → grace period → SIGKILL. - terminationGracePeriodSeconds: Batas waktu total (default 30 detik) sejak terminasi dimulai, termasuk preStop.
- preStop Hook: Lifecycle hook container yang berjalan sebelum SIGTERM (
exec,httpGet, atausleepnative sejak Kubernetes 1.29). - SIGTERM / SIGKILL: Sinyal permintaan berhenti yang bisa ditangani aplikasi, dan sinyal pembunuhan paksa yang tidak bisa ditangani.
- Exit Code 137: 128 + 9 (SIGKILL). Bisa berarti OOMKilled (Sesi 15) atau grace period terlewati. Cek field
reasonuntuk membedakan. - EndpointSlice Propagation Delay: Jeda antara pod dihapus dari endpoint dan semua kube-proxy / router mengetahuinya.
- Connection Draining: Menyelesaikan koneksi yang sudah ada tanpa menerima koneksi baru.
5. Di Dunia Kita
- Rolling update di OpenShift:
oc rollout restart deployment/checkout-apimematikan pod lama satu per satu. Tanpa preStop, setiap pod yang mati menyumbang beberapa detik error. Ini sering terlihat sebagai "spike 502 kecil tiap jam 14.00", yaitu jadwal deploy. - HAProxy router OpenShift: Router membaca perubahan endpoint lalu memperbarui konfigurasinya. Di cluster dengan banyak Route, update ini bisa tertinggal beberapa detik di belakang EndpointSlice.
- Beda dengan Sesi 9: Di Sesi 9, graceful drain terjadi di load balancer. LB yang mengatur kapan sebuah backend berhenti menerima trafik. Di Kubernetes, tidak ada satu LB pusat. Ada puluhan komponen (kube-proxy di setiap node, router, mesh) yang masing-masing belajar sendiri secara asinkron. Itu sebabnya pod harus "menunggu" secara aktif.
- gRPC dan HTTP/2: Satu koneksi membawa ribuan request. Jika server tidak mengirim GOAWAY atau menutup koneksi, client tetap memakai pod lama sampai pod itu benar-benar mati, lalu semua stream di koneksi itu gagal bersamaan.
- Node drain saat upgrade cluster: Semua pod di node dievict sekaligus. Jika
PodDisruptionBudgettidak ada, beberapa replika dari Deployment yang sama bisa mati bersamaan.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: checkout-api-pdb
spec:
minAvailable: 80%
selector:
matchLabels:
app: checkout-api
6. Lensa Performance Tester
Graceful shutdown hampir tidak pernah diuji, karena load test biasanya berjalan pada cluster yang diam. Ubah itu.
- Deploy-under-load test: Jalankan steady load (misal 500 RPS selama 15 menit), lalu di menit ke-5 jalankan
oc rollout restart deployment/checkout-api. Hitung jumlah 502, 503, dan connection reset per deploy. Target: 0. - Scale-down test: Biarkan HPA menurunkan replika saat beban turun di tengah test. Error yang muncul saat scale-down sama berbahayanya dengan error saat deploy.
- Node drain test:
oc adm drain <node> --ignore-daemonsets --delete-emptydir-datasaat load berjalan. Ukur error dan apakah PDB dihormati. - Exit code audit:
oc get pod <pod> -o jsonpath='{.status.containerStatuses[0].lastState.terminated}'atau lihat event. Exit 137 dengan reason selainOOMKilledberarti grace period terlewati: kemungkinan jebakan PID 1 atau drain terlalu lama. - Ukur durasi: Waktu dari SIGTERM sampai exit (dari log aplikasi), dan total durasi pod dalam status
Terminating. Jika selalu tepat 30 detik, aplikasi hampir pasti tidak menerima SIGTERM. - Ukur propagasi endpoint: Kirim request terus-menerus langsung lewat Route, hapus satu pod, dan catat kapan request terakhir mendarat di pod itu. Angka ini menjadi dasar ukuran
sleeppreStop. - Koneksi panjang: Untuk client keep-alive atau gRPC, cek apakah koneksi berpindah ke pod baru, atau semua error terkumpul di detik terakhir pod lama.
Laporan yang berguna bukan "p95 = 180 ms", tetapi "setiap deploy menghasilkan 0 error, 137 tidak pernah muncul, pod Terminating rata-rata 18 detik".
7. Pertanyaan untuk Ronde Berikutnya
Pod lama sudah pergi dengan sopan. Tetapi saat rolling update atau HPA scale-up, puluhan pod baru lahir hampir bersamaan, dan masing-masing langsung membuka connection pool ke Redis dan database. Satu pod membuka 10 koneksi. Bagaimana dengan 200 pod?
Jawabannya ada di Sesi 37: Satu Pod 10 Koneksi, 200 Pod 2.000 Koneksi.