-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Added Health Check Module for Ktor #5498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Subhanshu20135
wants to merge
6
commits into
ktorio:main
Choose a base branch
from
The-Developer-Diaries:feature/health-check
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
d86a330
Added Health Check Module for Ktor
Subhanshu20135 adc233a
Added README for Health Check module
Subhanshu20135 34b12a3
Update ktor-server/ktor-server-plugins/ktor-server-health-check/commo…
Subhanshu20135 742f75c
Update ktor-server/ktor-server-plugins/ktor-server-health-check/commo…
Subhanshu20135 ef5ec22
Improve exception handling in HealthCheck and add POST route test
Subhanshu20135 fe974f9
Improve error messages for failed health checks and fix documentation…
Subhanshu20135 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
115 changes: 115 additions & 0 deletions
115
ktor-server/ktor-server-plugins/ktor-server-health-check/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,115 @@ | ||
| # ktor-server-health-check | ||
|
|
||
| A Ktor server plugin that provides health check endpoints for Kubernetes readiness and liveness probes (and any other orchestration system that polls HTTP health endpoints). | ||
|
|
||
| ## Motivation | ||
|
|
||
| Every production Ktor application deployed to Kubernetes needs health and readiness endpoints. Currently, developers write these manually every time. This plugin provides a declarative DSL - similar to Spring Actuator, Micronaut Health, or Quarkus SmallRye Health - so that health endpoints are consistent, correct, and require minimal boilerplate. | ||
|
|
||
| ## Installation | ||
|
|
||
| Add the dependency to your `build.gradle.kts`: | ||
|
|
||
| ```kotlin | ||
| dependencies { | ||
| implementation("io.ktor:ktor-server-health-check:$ktor_version") | ||
| } | ||
| ``` | ||
|
|
||
| ## Quick Start | ||
|
|
||
| ```kotlin | ||
| import io.ktor.server.application.* | ||
| import io.ktor.server.plugins.healthcheck.* | ||
|
|
||
| fun Application.module() { | ||
| install(HealthCheck) { | ||
| readiness("/ready") { | ||
| check("database") { dataSource.connection.isValid(1) } | ||
| check("redis") { redisClient.ping(); true } | ||
| } | ||
| liveness("/health") { | ||
| check("memory") { Runtime.getRuntime().freeMemory() > 10_000_000 } | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## DSL Reference | ||
|
|
||
| ### `readiness(path) { ... }` | ||
|
|
||
| Configures a readiness endpoint. Readiness checks determine whether the application is ready to serve traffic. Failed readiness checks cause Kubernetes to stop routing requests to the pod. | ||
|
|
||
| ### `liveness(path) { ... }` | ||
|
|
||
| Configures a liveness endpoint. Liveness checks determine whether the application is still running. Failed liveness checks cause Kubernetes to restart the container. | ||
|
|
||
| ### `check(name) { ... }` | ||
|
|
||
| Adds a named health check within a readiness or liveness block. The lambda should return `true` for healthy, `false` for unhealthy. If the lambda throws an exception, the check is treated as unhealthy and the exception message is included in the response. | ||
|
|
||
| ## Response Format | ||
|
|
||
| The plugin responds with `application/json` and uses standard HTTP status codes: | ||
|
|
||
| | Condition | HTTP Status | `status` field | | ||
| |---|---|---| | ||
| | All checks pass | 200 OK | `UP` | | ||
| | Any check fails | 503 Service Unavailable | `DOWN` | | ||
|
|
||
| ### All healthy | ||
|
|
||
| ```json | ||
| { | ||
| "status": "UP", | ||
| "checks": [ | ||
| { "name": "database", "status": "UP" }, | ||
| { "name": "redis", "status": "UP" } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| ### One or more unhealthy | ||
|
|
||
| ```json | ||
| { | ||
| "status": "DOWN", | ||
| "checks": [ | ||
| { "name": "database", "status": "UP" }, | ||
| { "name": "redis", "status": "DOWN", "error": "Connection refused" } | ||
| ] | ||
| } | ||
| ``` | ||
|
|
||
| ## Kubernetes Configuration | ||
|
|
||
| ```yaml | ||
| apiVersion: apps/v1 | ||
| kind: Deployment | ||
| spec: | ||
| template: | ||
| spec: | ||
| containers: | ||
| - name: app | ||
| livenessProbe: | ||
| httpGet: | ||
| path: /health | ||
| port: 8080 | ||
| initialDelaySeconds: 10 | ||
| periodSeconds: 15 | ||
| readinessProbe: | ||
| httpGet: | ||
| path: /ready | ||
| port: 8080 | ||
| initialDelaySeconds: 5 | ||
| periodSeconds: 10 | ||
| ``` | ||
|
|
||
| ## Design Decisions | ||
|
|
||
| - **Concurrent checks**: All checks within an endpoint run concurrently via `coroutineScope` + `async`, minimizing probe latency when multiple I/O-bound checks are configured. | ||
| - **No external dependencies**: JSON responses are built with `StringBuilder` - no serialization library required. The plugin does not require `ContentNegotiation`. | ||
| - **`onCall` interception**: The plugin intercepts at the call level (before routing), matching configured paths directly. This means health endpoints work even without installing `Routing`. | ||
| - **GET-only**: Only `GET` requests are handled, matching the HTTP method used by Kubernetes probes and load balancers. | ||
| - **Multiplatform**: The plugin is implemented in `commonMain` and works on all Ktor server targets (JVM, Native, JS). |
9 changes: 9 additions & 0 deletions
9
ktor-server/ktor-server-plugins/ktor-server-health-check/build.gradle.kts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| /* | ||
| * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. | ||
| */ | ||
|
|
||
| description = "Ktor server health check and readiness/liveness endpoints plugin" | ||
|
|
||
| plugins { | ||
| id("ktorbuild.project.server-plugin") | ||
| } |
128 changes: 128 additions & 0 deletions
128
...ins/ktor-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheck.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| /* | ||
| * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. | ||
| */ | ||
|
|
||
| package io.ktor.server.plugins.healthcheck | ||
|
|
||
| import io.ktor.http.* | ||
| import io.ktor.server.application.* | ||
| import io.ktor.server.request.* | ||
| import io.ktor.server.response.* | ||
| import kotlinx.coroutines.* | ||
|
|
||
| /** | ||
| * A plugin that provides health check endpoints for Kubernetes readiness and liveness probes. | ||
| * | ||
| * Intercepts GET requests matching configured paths and responds with a JSON health status. | ||
| * All checks within an endpoint run concurrently. The overall status is UP only when every | ||
| * individual check reports healthy. | ||
| * | ||
| * Response format: | ||
| * ```json | ||
| * { | ||
| * "status": "UP", | ||
| * "checks": [ | ||
| * { "name": "database", "status": "UP" }, | ||
| * { "name": "redis", "status": "DOWN", "error": "Connection refused" } | ||
| * ] | ||
| * } | ||
| * ``` | ||
| * | ||
| * - HTTP 200 when all checks pass (status: UP) | ||
| * - HTTP 503 when any check fails (status: DOWN) | ||
| * | ||
| * Example: | ||
| * ```kotlin | ||
| * install(HealthCheck) { | ||
| * readiness("/ready") { | ||
| * check("database") { dataSource.connection.isValid(1) } | ||
| * check("redis") { redisClient.ping(); true } | ||
| * } | ||
| * liveness("/health") { | ||
| * check("memory") { Runtime.getRuntime().freeMemory() > threshold } | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| public val HealthCheck: ApplicationPlugin<HealthCheckConfig> = | ||
| createApplicationPlugin("HealthCheck", ::HealthCheckConfig) { | ||
| val endpoints = pluginConfig.endpoints.toList() | ||
|
|
||
| onCall { call -> | ||
| if (call.response.isCommitted) return@onCall | ||
| if (call.request.httpMethod != HttpMethod.Get) return@onCall | ||
|
|
||
| val path = call.request.path() | ||
| val endpoint = endpoints.find { it.path == path } ?: return@onCall | ||
|
|
||
| val results = coroutineScope { | ||
| endpoint.checks.map { namedCheck -> | ||
| async { evaluateCheck(namedCheck) } | ||
| }.awaitAll() | ||
| } | ||
|
|
||
| val overallUp = results.all { it.status == CheckStatus.UP } | ||
| val httpStatus = if (overallUp) HttpStatusCode.OK else HttpStatusCode.ServiceUnavailable | ||
|
|
||
| call.respondText( | ||
| buildHealthResponse(overallUp, results), | ||
| ContentType.Application.Json, | ||
| httpStatus | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private suspend fun evaluateCheck(namedCheck: NamedCheck): CheckResult = | ||
| try { | ||
| val healthy = namedCheck.check() | ||
| CheckResult(namedCheck.name, if (healthy) CheckStatus.UP else CheckStatus.DOWN) | ||
| } catch (cause: CancellationException) { | ||
| throw cause | ||
| } catch (cause: Exception) { | ||
| CheckResult(namedCheck.name, CheckStatus.DOWN, cause.message ?: "Health Check Failed") | ||
| } | ||
|
|
||
| internal enum class CheckStatus { UP, DOWN } | ||
|
|
||
| internal class CheckResult(val name: String, val status: CheckStatus, val error: String? = null) | ||
|
|
||
| private fun buildHealthResponse(overallUp: Boolean, results: List<CheckResult>): String = buildString { | ||
| append("{\"status\":\"") | ||
| append(if (overallUp) "UP" else "DOWN") | ||
| append("\",\"checks\":[") | ||
| results.forEachIndexed { index, result -> | ||
| if (index > 0) append(',') | ||
| append("{\"name\":\"") | ||
| append(result.name.escapeJson()) | ||
| append("\",\"status\":\"") | ||
| append(result.status.name) | ||
| append('"') | ||
| if (result.error != null) { | ||
| append(",\"error\":\"") | ||
| append(result.error.escapeJson()) | ||
| append('"') | ||
| } | ||
| append('}') | ||
| } | ||
| append("]}") | ||
| } | ||
|
|
||
| private fun String.escapeJson(): String = buildString(length) { | ||
| for (ch in this@escapeJson) { | ||
| when (ch) { | ||
| '\\' -> append("\\\\") | ||
| '"' -> append("\\\"") | ||
| '\b' -> append("\\b") | ||
| '\u000C' -> append("\\f") | ||
| '\n' -> append("\\n") | ||
| '\r' -> append("\\r") | ||
| '\t' -> append("\\t") | ||
| else -> if (ch.code < 0x20) { | ||
| append("\\u") | ||
| append(ch.code.toString(16).padStart(4, '0')) | ||
| } else { | ||
| append(ch) | ||
| } | ||
| } | ||
| } | ||
| } |
92 changes: 92 additions & 0 deletions
92
...or-server-health-check/common/src/io/ktor/server/plugins/healthcheck/HealthCheckConfig.kt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| /* | ||
| * Copyright 2014-2025 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. | ||
| */ | ||
|
|
||
| package io.ktor.server.plugins.healthcheck | ||
|
|
||
| import io.ktor.server.application.* | ||
| import io.ktor.utils.io.* | ||
|
|
||
| /** | ||
| * Configuration for the [HealthCheck] plugin. | ||
| * | ||
| * Use [readiness] and [liveness] to define health check endpoints with named checks. | ||
| * | ||
| * Example: | ||
| * ```kotlin | ||
| * install(HealthCheck) { | ||
| * readiness("/ready") { | ||
| * check("database") { dataSource.connection.isValid(1) } | ||
| * } | ||
| * liveness("/health") { | ||
| * check("heartbeat") { true } | ||
| * } | ||
| * } | ||
| * ``` | ||
| */ | ||
| @KtorDsl | ||
| public class HealthCheckConfig { | ||
| internal val endpoints = mutableListOf<HealthCheckEndpoint>() | ||
|
|
||
| private fun addEndpoint(path: String, block: HealthCheckBuilder.() -> Unit) { | ||
| val normalizedPath = path.ensureLeadingSlash() | ||
| require(endpoints.none { it.path == normalizedPath }) { | ||
| "Health check endpoint path '$normalizedPath' is already configured" | ||
| } | ||
| endpoints += HealthCheckEndpoint(normalizedPath, HealthCheckBuilder().apply(block).checks.toList()) | ||
| } | ||
|
|
||
| /** | ||
| * Configures a readiness endpoint at the given [path]. | ||
| * | ||
| * Readiness checks determine whether the application is ready to serve requests. | ||
| * Failed readiness checks prevent Kubernetes from routing traffic to the pod. | ||
| * | ||
| * @param path the URL path for the readiness endpoint (e.g., "/ready") | ||
| * @param block builder for adding individual health checks | ||
| */ | ||
| public fun readiness(path: String, block: HealthCheckBuilder.() -> Unit) { | ||
| addEndpoint(path, block) | ||
| } | ||
|
|
||
| /** | ||
| * Configures a liveness endpoint at the given [path]. | ||
| * | ||
| * Liveness checks determine whether the application is still running. | ||
| * Failed liveness checks cause Kubernetes to restart the container. | ||
| * | ||
| * @param path the URL path for the liveness endpoint (e.g., "/health") | ||
| * @param block builder for adding individual health checks | ||
| */ | ||
| public fun liveness(path: String, block: HealthCheckBuilder.() -> Unit) { | ||
| addEndpoint(path, block) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Builder for configuring individual health checks within a readiness or liveness endpoint. | ||
| */ | ||
| @KtorDsl | ||
| public class HealthCheckBuilder { | ||
| internal val checks = mutableListOf<NamedCheck>() | ||
|
|
||
| /** | ||
| * Adds a named health check. | ||
| * | ||
| * The [block] should return `true` if the component is healthy, `false` if unhealthy. | ||
| * If [block] throws an exception, the check is treated as unhealthy and the exception | ||
| * message is included in the JSON response. | ||
| * | ||
| * @param name a human-readable identifier for this check (e.g., "database", "redis") | ||
| * @param block a suspend function returning `true` for healthy, `false` for unhealthy | ||
| */ | ||
| public fun check(name: String, block: suspend () -> Boolean) { | ||
| checks += NamedCheck(name, block) | ||
| } | ||
| } | ||
|
|
||
| internal class NamedCheck(val name: String, val check: suspend () -> Boolean) | ||
|
|
||
| internal class HealthCheckEndpoint(val path: String, val checks: List<NamedCheck>) | ||
|
|
||
| private fun String.ensureLeadingSlash(): String = if (startsWith("/")) this else "/$this" | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the module's
/api/signatures for this new public DSL.This file introduces public API (
HealthCheckConfig,HealthCheckBuilder), andHealthCheck.ktadds the publicHealthCheckplugin entry point, but the PR doesn't include the new module's ABI dump under/api/. That leaves the public surface untracked and will usually fail the legacy ABI checks.As per coding guidelines: "All public/protected API changes must be tracked in
/api/directories within modules using ABI validation" and "Run./gradlew :module-name:updateLegacyAbito update ABI signature files after public/protected API changes".🤖 Prompt for AI Agents