Saat container restart, Kubernetes mengirim sinyal SIGTERM ke proses dan menunggu hingga terminationGracePeriodSeconds (default 30 detik) sebelum mengirim SIGKILL. Bila aplikasi tidak menangani SIGTERM, request yang sedang diproses terputus di tengah jalan, koneksi database tidak ditutup dengan benar, dan job background meninggalkan data tidak konsisten. Pada chapter ini kita akan mempraktikkan graceful shutdown di Kotlin, mendemokan skenario shutdown saat ada request in-flight, dan menutup dengan urutan penutupan resource yang direkomendasikan.

Kontrak Sinyal #

Kubernetes dan sistem operasi modern menggunakan sinyal untuk menghentikan proses:

text Jenis sinyal dan perilakunya
SIGTERM (15)  -> Trigger graceful shutdown (default Kubernetes)
SIGINT  (2)   -> Ctrl-C, sama dengan SIGTERM
SIGKILL (9)   -> Tidak bisa ditangkap, proses langsung berhenti

JVM menerjemahkan SIGTERM dan SIGINT menjadi pemanggilan shutdown hook — thread yang didaftarkan lewat Runtime.getRuntime().addShutdownHook(Thread { ... }). Hook harus cepat (idealnya < 10 detik) agar tidak melebihi terminationGracePeriodSeconds.

Menyelesaikan Request In-Flight #

Saat shutdown diminta, kita perlu memberi tahu load balancer untuk berhenti mengirim request baru, lalu menunggu request yang sedang diproses selesai. Cuplikan berikut mensimulasikan server yang menangani shutdown dengan benar:

kotlin
// graceful_shutdown.kt
// Mendemonstrasikan konsep graceful shutdown:
// - Menerima sinyal SIGTERM / SIGINT
// - Menyelesaikan request yang sedang in-flight
// - Menutup resource (DB pool, koneksi, file)
// - Membatalkan job background dengan aman
//
// Menggunakan ExecutorService dan non-blocking check shutdown.

import java.util.concurrent.ConcurrentLinkedQueue
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger

data class Request(val id: Int, val durasiMs: Long)

class Worker(
    private val antrian: ConcurrentLinkedQueue<Request>,
    private val sedangBerjalan: AtomicInteger,
    private val shutdownDiminta: AtomicBoolean
) {
    fun proses(req: Request): String {
        sedangBerjalan.incrementAndGet()
        try {
            // Simulasi pemrosesan — pecah jadi potongan agar bisa cek shutdown
            var total = 0L
            while (total < req.durasiMs) {
                if (shutdownDiminta.get()) {
                    return "Request #${req.id} dibatalkan saat ${total}ms (shutdown)"
                }
                Thread.sleep(10)
                total += 10
            }
            return "Request #${req.id} selesai dalam ${req.durasiMs}ms"
        } finally {
            sedangBerjalan.decrementAndGet()
        }
    }
}

class Server {
    private val antrian = ConcurrentLinkedQueue<Request>()
    private val sedangBerjalan = AtomicInteger(0)
    private val shutdownDiminta = AtomicBoolean(false)
    private val executor: ExecutorService = Executors.newFixedThreadPool(2)

    fun submit(req: Request) = antrian.add(req)

    fun mulai() {
        repeat(2) { workerId ->
            executor.submit {
                val worker = Worker(antrian, sedangBerjalan, shutdownDiminta)
                while (!shutdownDiminta.get() || antrian.isNotEmpty()) {
                    val req = antrian.poll()
                    if (req != null) {
                        val hasil = worker.proses(req)
                        println("  [worker-$workerId] $hasil")
                    }
                }
            }
        }
    }

    fun shutdown(timeoutDetik: Long): Boolean {
        println("  Shutdown diminta, menunggu ${antrian.size} antrian + $sedangBerjalan in-flight")
        shutdownDiminta.set(true)
        executor.shutdown()
        return executor.awaitTermination(timeoutDetik, TimeUnit.SECONDS)
    }
}

class ResourceLeak(private val nama: String) : AutoCloseable {
    init { println("  [resource] $nama dibuka") }
    override fun close() { println("  [resource] $nama ditutup") }
}

fun main() {
    println("=== Graceful Shutdown ===")
    println()

    println("--- 1. Skenario normal (tidak ada shutdown mendadak) ---")
    val server = Server()
    server.mulai()
    listOf(
        Request(1, 50),
        Request(2, 30),
        Request(3, 20)
    ).forEach { server.submit(it) }
    Thread.sleep(150)
    val sukses = server.shutdown(5)
    println("  Shutdown berhasil dalam timeout: $sukses")

    println()
    println("--- 2. Skenario shutdown saat ada in-flight ---")
    val server2 = Server()
    server2.mulai()
    listOf(
        Request(10, 200),
        Request(11, 200),
        Request(12, 200),
        Request(13, 200),
        Request(14, 200)
    ).forEach { server2.submit(it) }
    Thread.sleep(50)  // biarkan beberapa mulai
    val sukses2 = server2.shutdown(3)
    println("  Shutdown berhasil dalam timeout: $sukses2")

    println()
    println("--- 3. Resource cleanup via use { } ---")
    val resource = listOf("DBPool", "RedisConn", "KafkaProducer")
    resource.forEach { nama ->
        ResourceLeak(nama).use { /* pemakaian */ }
    }

    println()
    println("--- 4. Urutan shutdown yang direkomendasikan ---")
    val urutan = listOf(
        "1. Hentikan listener port (no new request masuk)",
        "2. Beri tahu load balancer (health/live = DOWN)",
        "3. Tunggu in-flight request selesai (atau timeout)",
        "4. Batalkan job background (coroutine scope cancel)",
        "5. Tutup connection pool (DB, Redis, Kafka)",
        "6. Tutup file handle dan log appender",
        "7. Exit JVM dengan code 0"
    )
    urutan.forEach { println("  $it") }

    println()
    println("--- 5. Kontrak SIGTERM ---")
    val kontrak = listOf(
        "SIGTERM (15) -> trigger graceful shutdown (default Kubernetes)",
        "SIGINT (2)   -> Ctrl-C, sama dengan SIGTERM",
        "SIGKILL (9)  -> tidak bisa ditangkap, proses langsung mati",
        "terminationGracePeriodSeconds (default 30s) -> Kubernetes tunggu"
    )
    kontrak.forEach { println("  $it") }

    println()
    println("--- 6. Sinyal handling di Kotlin/JVM ---")
    println("  Runtime.getRuntime().addShutdownHook(Thread { ... })")
    println("  - Dipanggil saat JVM menerima SIGTERM/SIGINT")
    println("  - Tidak dipanggil saat SIGKILL atau System.exit(halt)")
    println("  - Hook harus cepat (<10s) agar tidak timeout")

    println()
    println("--- 7. Jebakan umum ---")
    val jebakan = listOf(
        "1. Tidak ada health/live DOWN -> load balancer masih kirim request",
        "2. Shutdown hook terlalu lama -> Kubernetes SIGKILL",
        "3. Tidak ada timeout -> thread stuck, proses hang",
        "4. Membuka resource baru saat shutdown -> deadlock",
        "5. Hanya menutup sebagian resource -> connection leak"
    )
    jebakan.forEach { println("  $it") }
}

Output yang Dihasilkan:

text
=== Graceful Shutdown ===

--- 1. Skenario normal (tidak ada shutdown mendadak) ---
  [worker-1] Request #2 selesai dalam 30ms
  [worker-0] Request #1 selesai dalam 50ms
  [worker-1] Request #3 selesai dalam 20ms
  Shutdown diminta, menunggu 0 antrian + 0 in-flight
  Shutdown berhasil dalam timeout: true

--- 2. Skenario shutdown saat ada in-flight ---
  Shutdown diminta, menunggu 3 antrian + 2 in-flight
  [worker-1] Request #11 dibatalkan saat 50ms (shutdown)
  [worker-1] Request #12 dibatalkan saat 0ms (shutdown)
  [worker-1] Request #13 dibatalkan saat 0ms (shutdown)
  [worker-1] Request #14 dibatalkan saat 0ms (shutdown)
  [worker-0] Request #10 dibatalkan saat 50ms (shutdown)
  Shutdown berhasil dalam timeout: true

--- 3. Resource cleanup via use { } ---
  [resource] DBPool dibuka
  [resource] DBPool ditutup
  [resource] RedisConn dibuka
  [resource] RedisConn ditutup
  [resource] KafkaProducer dibuka
  [resource] KafkaProducer ditutup

--- 4. Urutan shutdown yang direkomendasikan ---
  1. Hentikan listener port (no new request masuk)
  2. Beri tahu load balancer (health/live = DOWN)
  3. Tunggu in-flight request selesai (atau timeout)
  4. Batalkan job background (coroutine scope cancel)
  5. Tutup connection pool (DB, Redis, Kafka)
  6. Tutup file handle dan log appender
  7. Exit JVM dengan code 0

--- 5. Kontrak SIGTERM ---
  SIGTERM (15) -> trigger graceful shutdown (default Kubernetes)
  SIGINT (2)   -> Ctrl-C, sama dengan SIGTERM
  SIGKILL (9)  -> tidak bisa ditangkap, proses langsung mati
  terminationGracePeriodSeconds (default 30s) -> Kubernetes tunggu

--- 6. Sinyal handling di Kotlin/JVM ---
  Runtime.getRuntime().addShutdownHook(Thread { ... })
  - Dipanggil saat JVM menerima SIGTERM/SIGINT
  - Tidak dipanggil saat SIGKILL atau System.exit(halt)
  - Hook harus cepat (<10s) agar tidak timeout

--- 7. Jebakan umum ---
  1. Tidak ada health/live DOWN -> load balancer masih kirim request
  2. Shutdown hook terlalu lama -> Kubernetes SIGKILL
  3. Tidak ada timeout -> thread stuck, proses hang
  4. Membuka resource baru saat shutdown -> deadlock
  5. Hanya menutup sebagian resource -> connection leak

Pada cuplikan di atas, shutdownDiminta adalah AtomicBoolean yang dibaca oleh worker setiap iterasi. Saat flag true, worker yang sedang memproses request akan membatalkan pemrosesan dan mengembalikan pesan “dibatalkan”. executor.awaitTermination(timeoutDetik, ...) memblokir pemanggil hingga semua thread selesai atau timeout tercapai — return value true berarti shutdown sukses dalam batas waktu.

Urutan Shutdown yang Direkomendasikan #

Urutan penutupan resource sangat penting untuk menghindari deadlock dan kebocoran koneksi:

  1. Hentikan listener port — tidak ada request baru yang masuk.
  2. Beri tahu load balancer — set health/live endpoint ke DOWN agar traffic dialihkan.
  3. Tunggu in-flight request selesai — atau timeout (mis. 30 detik).
  4. Batalkan job background — cancel coroutine scope atau shutdown ExecutorService.
  5. Tutup connection pool — database, Redis, Kafka producer/consumer.
  6. Tutup file handle dan log appender — flush() log sebelum benar-benar exit.
  7. Exit JVM dengan code 0 — exit code bukan nol dianggap crash oleh orchestrator.

Langkah 1–3 harus cepat (< 5 detik) agar tidak menghabiskan terminationGracePeriodSeconds. Langkah 4–6 boleh lebih lama, tetapi total tetap harus kurang dari batas waktu.

Resource Cleanup dengan use { } #

Kotlin menyediakan extension function use { } pada semua Closeable untuk memastikan resource ditutup bahkan bila exception terjadi. Pattern ini adalah basis untuk graceful shutdown resource:

kotlin Pola use untuk resource
FileReader("data.txt").use { reader ->
    // pemakaian reader
}  // otomatis close di akhir blok, bahkan bila ada exception

Untuk resource yang berumur sepanjang aplikasi (mis. connection pool), daftarkan ke shutdown hook:

kotlin Shutdown hook untuk connection pool
Runtime.getRuntime().addShutdownHook(Thread {
    connectionPool.close()
    kafkaProducer.close(timeout = 10, TimeUnit.SECONDS)
    logAppender.stop()
})

Jebakan Umum #

Beberapa kesalahan yang sering muncul saat implementasi graceful shutdown:

  1. Tidak ada health/live DOWN — load balancer masih mengirim request setelah shutdown diminta, request baru tersebut akan terputus di tengah.
  2. Shutdown hook terlalu lama — hook yang butuh 30 detik akan menyebabkan Kubernetes mengirim SIGKILL sebelum selesai.
  3. Tidak ada timeout — awaitTermination tanpa timeout bisa menggantung selamanya bila ada thread yang tidak pernah selesai.
  4. Membuka resource baru saat shutdown — membuka koneksi baru saat shutdown bisa menyebabkan deadlock.
  5. Hanya menutup sebagian resource — koneksi DB ditutup tetapi koneksi Redis tidak, menyebabkan connection leak.

Referensi Lanjutan #

  • Spring Boot — dokumentasi Spring Boot untuk graceful shutdown dan lifecycle hook

Selanjutnya #

Bila shutdown tidak berjalan mulus atau ada masalah lain di produksi, kita perlu alat untuk mendiagnosis. Chapter berikutnya membahas production troubleshooting — thread dump, heap snapshot, dan deteksi deadlock.