Di lingkungan orkestrasi seperti Kubernetes, load balancer dan orchestrator butuh sinyal cepat untuk menentukan apakah sebuah pod layak menerima traffic atau perlu direstart. Tanpa health check, pod yang hang atau kehilangan koneksi database tetap menerima request hingga timeout. Kubernetes membedakan dua probe: liveness (apakah process hidup?) dan readiness (apakah siap menerima traffic?). Liveness yang DOWN memicu restart; readiness yang DOWN hanya menghentikan traffic tanpa restart. Pada chapter ini kita akan membangun health endpoint dengan tiga HealthIndicator (database, cache, disk), mendemokan skenario DOWN dan DEGRADED, serta menjelaskan kontrak HTTP yang diharapkan Kubernetes.

Liveness vs Readiness vs Startup #

Tiga probe Kubernetes dengan tujuan berbeda:

ProbeTujuanSaat DOWNHTTP path umum
LivenessProcess hidup?Restart pod/health/live
ReadinessBisa menerima traffic?Stop traffic (no restart)/health/ready
StartupBoot selesai?Tetap start, tidak ada traffic/health/startup

Startup probe berguna untuk aplikasi dengan cold start lambat (mis. Spring Boot dengan banyak bean) — Kubernetes tidak mengulang restart karena liveness timeout.

Demo Health Endpoint #

Cuplikan berikut mengimplementasikan endpoint /health, /health/live, dan /health/ready dengan agregasi worst-status:

kotlin
// health_checks.kt — Demo health check endpoint liveness vs readiness

// === Status kesehatan (urutan ordinal: UP < DEGRADED < DOWN) ===
enum class HealthStatus { UP, DEGRADED, DOWN }

data class HealthResponse(
    val status: HealthStatus,
    val checks: Map<String, ComponentHealth>,
    val uptimeMs: Long
)

data class ComponentHealth(
    val status: HealthStatus,
    val details: Map<String, Any>
)

// === Komponen yang diperiksa ===
interface HealthIndicator {
    val name: String
    fun check(): ComponentHealth
}

class DatabaseHealthIndicator(
    private val isConnected: () -> Boolean,
    private val poolUsage: () -> Int
) : HealthIndicator {
    override val name = "database"
    override fun check(): ComponentHealth {
        val connected = isConnected()
        val usage = poolUsage()
        val status = when {
            !connected -> HealthStatus.DOWN
            usage > 80 -> HealthStatus.DEGRADED
            else -> HealthStatus.UP
        }
        return ComponentHealth(
            status = status,
            details = mapOf("connected" to connected, "poolUsagePercent" to usage)
        )
    }
}

class CacheHealthIndicator(
    private val hitRate: () -> Double
) : HealthIndicator {
    override val name = "cache"
    override fun check(): ComponentHealth {
        val rate = hitRate()
        val status = when {
            rate < 0.5 -> HealthStatus.DEGRADED
            rate < 0.1 -> HealthStatus.DOWN
            else -> HealthStatus.UP
        }
        return ComponentHealth(
            status = status,
            details = mapOf("hitRate" to rate)
        )
    }
}

class DiskSpaceHealthIndicator(
    private val freeBytes: () -> Long,
    private val threshold: Long = 1L * 1024 * 1024 * 1024 // 1 GB
) : HealthIndicator {
    override val name = "disk"
    override fun check(): ComponentHealth {
        val free = freeBytes()
        val status = if (free < threshold) HealthStatus.DOWN else HealthStatus.UP
        return ComponentHealth(
            status = status,
            details = mapOf("freeBytes" to free, "thresholdBytes" to threshold)
        )
    }
}

// === Agregator health: worst-status wins ===
class HealthAggregator(private val indicators: List<HealthIndicator>) {
    // Uptime frozen untuk output deterministik (demo)
    private val uptimeMs: Long = 86_400_000L

    fun aggregate(): HealthResponse {
        val checks = indicators.associate { it.name to it.check() }
        // Worst status = ordinal tertinggi (UP < DEGRADED < DOWN)
        val worst = checks.values.map { it.status }.maxByOrNull { it.ordinal }
            ?: HealthStatus.UP
        return HealthResponse(
            status = worst,
            checks = checks,
            uptimeMs = uptimeMs
        )
    }

    // Liveness: hanya cek process hidup
    fun liveness(): HealthResponse {
        return HealthResponse(
            status = HealthStatus.UP,
            checks = emptyMap(),
            uptimeMs = uptimeMs
        )
    }

    // Readiness: cek bisa menerima traffic
    fun readiness(): HealthResponse {
        // Sama dengan aggregate tetapi biasanya subset indikator
        return aggregate()
    }
}

// === HTTP-like handler ===
class HealthEndpoint(private val aggregator: HealthAggregator) {
    // GET /health/live
    fun liveness(): Pair<Int, String> {
        val resp = aggregator.liveness()
        return if (resp.status == HealthStatus.UP) {
            200 to """{"status":"UP"}"""
        } else {
            503 to """{"status":"DOWN"}"""
        }
    }

    // GET /health/ready
    fun readiness(): Pair<Int, String> {
        val resp = aggregator.readiness()
        val status = resp.status.name
        return if (resp.status == HealthStatus.UP) {
            200 to """{"status":"$status","checks":${resp.checks.size}}"""
        } else {
            503 to """{"status":"$status","checks":${resp.checks.size}}"""
        }
    }

    // GET /health (full)
    fun full(): Pair<Int, String> {
        val resp = aggregator.aggregate()
        val sb = StringBuilder()
        sb.append("""{"status":"${resp.status.name}","uptimeMs":${resp.uptimeMs},"checks":{""")
        val parts = resp.checks.entries.map { (name, ch) ->
            val detailsStr = ch.details.entries.joinToString(",") { (k, v) -> "\"$k\":$v" }
            "\"$name\":{\"status\":\"${ch.status.name}\",\"details\":{$detailsStr}}"
        }
        sb.append(parts.joinToString(","))
        sb.append("}}")
        val code = if (resp.status == HealthStatus.UP) 200 else 503
        return code to sb.toString()
    }
}

fun main() {
    println("=== Health Checks ===")
    println()
    println("Endpoint /health untuk liveness dan readiness; Kubernetes probes.")
    println()

    // === 1. Setup indikator dengan state mock ===
    println("=== 1. Komponen Health Indicator ===")
    var dbConnected = true
    var dbPoolUsage = 45
    var cacheHitRate = 0.85
    var diskFree = 5L * 1024 * 1024 * 1024 // 5 GB

    val indicators = listOf(
        DatabaseHealthIndicator({ dbConnected }, { dbPoolUsage }),
        CacheHealthIndicator({ cacheHitRate }),
        DiskSpaceHealthIndicator({ diskFree })
    )
    val aggregator = HealthAggregator(indicators)
    val endpoint = HealthEndpoint(aggregator)

    println("  database, cache, disk -> 3 indicators")
    println()

    // === 2. Skenario sehat ===
    println("=== 2. Skenario Sehat (UP) ===")
    val (code1, body1) = endpoint.full()
    println("  GET /health -> $code1")
    println("  Body: $body1")
    println()

    val (codeLive, bodyLive) = endpoint.liveness()
    println("  GET /health/live -> $codeLive")
    println("  Body: $bodyLive")
    println()

    val (codeReady, bodyReady) = endpoint.readiness()
    println("  GET /health/ready -> $codeReady")
    println("  Body: $bodyReady")
    println()

    // === 3. DB down -> readiness DOWN ===
    println("=== 3. Skenario DB Down ===")
    dbConnected = false
    val (code2, body2) = endpoint.readiness()
    println("  GET /health/ready -> $code2")
    println("  Body: $body2")
    val (code2Full, body2Full) = endpoint.full()
    println("  GET /health -> $code2Full")
    println("  Body: $body2Full")
    println()

    // === 4. DB up but pool jenuh -> DEGRADED ===
    println("=== 4. Skenario DB Up, Pool 85% ===")
    dbConnected = true
    dbPoolUsage = 85
    val (code3, body3) = endpoint.readiness()
    println("  GET /health/ready -> $code3")
    println("  Body: $body3")
    println()

    // === 5. Disk full -> DOWN ===
    println("=== 5. Skenario Disk < 1 GB ===")
    dbPoolUsage = 30
    diskFree = 500L * 1024 * 1024 // 500 MB
    val (code4, body4) = endpoint.readiness()
    println("  GET /health/ready -> $code4")
    println("  Body: $body4")
    println()

    // === 6. Liveness vs Readiness ===
    println("=== 6. Liveness vs Readiness ===")
    val compare = listOf(
        Triple("Liveness", "Apakah process hidup?", "Restart pod bila DOWN"),
        Triple("Readiness", "Apakah bisa menerima traffic?", "Stop traffic ke pod, no restart"),
        Triple("Startup", "Apakah sudah siap setelah boot?", "Bedakan dari readiness untuk aplikasi startup lambat")
    )
    compare.forEach { (probe, purpose, action) ->
        println("  " + probe.padEnd(12) + " | " + purpose.padEnd(38) + " | " + action)
    }
    println()

    // === 7. Aturan praktis ===
    println("=== 7. Aturan Praktis ===")
    val rules = listOf(
        "1. Pisahkan endpoint /health/live dan /health/ready",
        "2. Liveness hanya cek process (loop), tidak cek eksternal",
        "3. Readiness cek DB, cache, downstream service",
        "4. HTTP 200 = UP, 503 = DOWN/DEGRADED",
        "5. Hindari false negative: timeout indikator < probe timeout",
        "6. Jangan restart karena downstream down; readiness cukup stop traffic",
        "7. Sertakan detail diagnosis untuk debugging"
    )
    rules.forEach { println("  " + it) }
}

Output yang Dihasilkan:

text
=== Health Checks ===

Endpoint /health untuk liveness dan readiness; Kubernetes probes.

=== 1. Komponen Health Indicator ===
  database, cache, disk -> 3 indicators

=== 2. Skenario Sehat (UP) ===
  GET /health -> 200
  Body: {"status":"UP","uptimeMs":86400000,"checks":{"database":{"status":"UP","details":{"connected":true,"poolUsagePercent":45}},"cache":{"status":"UP","details":{"hitRate":0.85}},"disk":{"status":"UP","details":{"freeBytes":5368709120,"thresholdBytes":1073741824}}}}

  GET /health/live -> 200
  Body: {"status":"UP"}

  GET /health/ready -> 200
  Body: {"status":"UP","checks":3}

=== 3. Skenario DB Down ===
  GET /health/ready -> 503
  Body: {"status":"DOWN","checks":3}
  GET /health -> 503
  Body: {"status":"DOWN","uptimeMs":86400000,"checks":{"database":{"status":"DOWN","details":{"connected":false,"poolUsagePercent":45}},"cache":{"status":"UP","details":{"hitRate":0.85}},"disk":{"status":"UP","details":{"freeBytes":5368709120,"thresholdBytes":1073741824}}}}

=== 4. Skenario DB Up, Pool 85% ===
  GET /health/ready -> 503
  Body: {"status":"DEGRADED","checks":3}

=== 5. Skenario Disk < 1 GB ===
  GET /health/ready -> 503
  Body: {"status":"DOWN","checks":3}

=== 6. Liveness vs Readiness ===
  Liveness     | Apakah process hidup?                  | Restart pod bila DOWN
  Readiness    | Apakah bisa menerima traffic?          | Stop traffic ke pod, no restart
  Startup      | Apakah sudah siap setelah boot?        | Bedakan dari readiness untuk aplikasi startup lambat

=== 7. Aturan Praktis ===
  1. Pisahkan endpoint /health/live dan /health/ready
  2. Liveness hanya cek process (loop), tidak cek eksternal
  3. Readiness cek DB, cache, downstream service
  4. HTTP 200 = UP, 503 = DOWN/DEGRADED
  5. Hindari false negative: timeout indikator < probe timeout
  6. Jangan restart karena downstream down; readiness cukup stop traffic
  7. Sertakan detail diagnosis untuk debugging

Mari kita bedah hasil di atas:

  1. Skenario Sehat: ketiga indikator UP. GET /health mengembalikan 200 dengan detail tiap komponen. GET /health/live dan /health/ready juga 200.
  2. DB Down: indikator database DOWN, sementara cache dan disk UP. Worst-status aggregation mengembalikan DOWN. GET /health/ready 503. GET /health 503 dengan body yang menampilkan komponen mana yang DOWN. Detail connected: false membantu debugging.
  3. DB Up, Pool 85%: indikator database DEGRADED (poolUsage > 80 tetapi masih connected). Status agregat DEGRADED, 503. Traffic diarahkan ke pod lain yang pool-nya sehat.
  4. Disk < 1 GB: indikator disk DOWN (free < threshold). 503. Pod tidak restart (karena readiness, bukan liveness), tetapi tidak menerima traffic.
  5. Liveness tetap 200 di semua skenario — liveness hanya cek process hidup, bukan dependensi eksternal. Restart pod karena DB down adalah kesalahan umum yang memperburuk situasi.

Kontrak HTTP Health Check #

Standar de facto: 200 OK untuk UP, 503 Service Unavailable untuk DOWN atau DEGRADED. Kubernetes membaca status code — body JSON untuk debugging manusia.

yaml k8s-probes.yaml
apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          livenessProbe:
            httpGet:
              path: /health/live
              port: 8080
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /health/ready
              port: 8080
            periodSeconds: 5
            failureThreshold: 2
          startupProbe:
            httpGet:
              path: /health/startup
              port: 8080
            periodSeconds: 10
            failureThreshold: 30  # 5 menit untuk startup lambat

Spring Boot Actuator menyediakan /actuator/health out-of-the-box. Ktor membutuhkan plugin StatusPages dan implementasi manual. Quarkus dan Micronaut punya annotation @Health untuk deklarasi indikator.

Jebakan Umum #

  1. Timeout indikator > probe timeout: bila indikator DB butuh 30 detik dan probe timeout 5 detik, probe akan gagal. Atur timeout indikator < probe timeout.
  2. Caching hasil check: bila indikator cek DB setiap request, health endpoint bisa jadi瓶颈. Cache hasil 1-5 detik.
  3. Tidak ada detail: body {"status":"DOWN"} tanpa info penyebab. Sertakan checks field untuk diagnosis.
  4. Liveness terhubung ke downstream: liveness harus independen dari dependensi.

Referensi Lanjutan #

  • Spring Boot — dokumentasi Spring Boot Actuator untuk endpoint health check

Selanjutnya #

Health check memberi sinyal cepat, tetapi untuk melihat tren performance (latency p99, request rate, error rate) kita butuh metrics. Bab berikutnya membahas metrics dan monitoring via Micrometer + Prometheus — topik pada chapter Metrics dan Monitoring.