Пробы
Пробы позволяют проверять жизнеспособность (liveness) и готовность (readiness) приложения через системный HTTP-порт.
Их обычно используют оркестраторы и балансировщики нагрузки, чтобы понять, можно ли отправлять приложению запросы и нужно ли перезапускать его экземпляр.
Наличие двух отдельных проб помогает отличать временную неспособность принимать трафик от состояния, когда сам процесс следует считать неисправным.
Пробами занимается системный HTTP-сервер, который по умолчанию слушает порт 8085 — отдельно от публичного сервера на порту 8080.
Тот же системный сервер отдаёт метрики, поэтому оркестратору достаточно доступа всего к одному дополнительному порту.
Обе конечные точки проб всегда присутствуют на системном сервере, даже если приложение не регистрирует ни одной собственной пробы соответствующего вида — в этом случае конечная точка просто сообщает об успехе.
Если нужен пошаговый разбор перед справочным описанием, смотрите Пробы с Kora и Наблюдаемость.
Подключение¶
Интерфейсы LivenessProbe и ReadinessProbe находятся в артефакте io.koraframework:common,
который приходит транзитивно вместе с модулем HTTP-сервера, поэтому для добавления пробы не нужна никакая дополнительная зависимость.
Конечные точки, которые их отдают, предоставляет UndertowSystemHttpServerModule.
UndertowPublicHttpServerModule наследует этот модуль, поэтому приложение с публичными контроллерами получает обе пробы автоматически:
Зависимость build.gradle:
Модуль:
Зависимость build.gradle.kts:
Модуль:
Приложению, которому нужны только системные конечные точки — воркеру или консьюмеру без публичного API — достаточно подключить UndertowSystemHttpServerModule, обе пробы при этом останутся на месте.
Жизнеспособность¶
Эта проба показывает, что приложение живо и его не нужно перезапускать.
Пример конфигурации пути системного HTTP-сервера, описанной в классе SystemHttpServerConfig (указано значение по умолчанию):
Чтобы создать собственную пробу жизнеспособности, зарегистрируйте компонент, реализующий интерфейс LivenessProbe:
В случае успеха проба должна возвращать null, а при ошибке — LivenessProbeFailure с описанием проблемы.
LivenessProbeFailure — это запись, единственное поле message которой становится телом ответа 503:
Метод probe() объявлен с throws Exception, поэтому реализация может напрямую вызывать API, бросающие проверяемые исключения.
Выброшенное исключение трактуется как сбой — точные коды статуса и тела ответа смотрите в разделе Ответ.
import io.koraframework.common.annotation.Component;
import io.koraframework.common.liveness.LivenessProbe;
import io.koraframework.common.liveness.LivenessProbeFailure;
@Component
public final class ApplicationHealthProbe implements LivenessProbe {
@Override
public LivenessProbeFailure probe() {
return null;
}
}
Возвращаемое значение размечено в Java аннотацией JSpecify @Nullable, поэтому реализация на Kotlin обязана объявлять результат как LivenessProbeFailure?.
Готовность¶
Эта проба показывает, что приложение готово принимать рабочую нагрузку.
Пример конфигурации пути системного HTTP-сервера, описанной в классе SystemHttpServerConfig (указано значение по умолчанию):
Чтобы создать собственную пробу готовности, зарегистрируйте компонент, реализующий интерфейс ReadinessProbe:
В случае успеха проба должна возвращать null, а при ошибке — ReadinessProbeFailure с описанием проблемы.
ReadinessProbeFailure — это запись, единственное поле message которой становится телом ответа 503:
Как и в случае с LivenessProbe, метод probe() объявлен с throws Exception, и выброшенное исключение трактуется как сбой.
import io.koraframework.common.annotation.Component;
import io.koraframework.common.readiness.ReadinessProbe;
import io.koraframework.common.readiness.ReadinessProbeFailure;
import java.time.Duration;
import java.time.Instant;
@Component
public final class CustomReadinessProbe implements ReadinessProbe {
private static final Duration WARMUP_PERIOD = Duration.ofMillis(500);
private final Instant startedAt = Instant.now();
@Override
public ReadinessProbeFailure probe() {
var readyAt = startedAt.plus(WARMUP_PERIOD);
if (Instant.now().isBefore(readyAt)) {
return new ReadinessProbeFailure("Service is warming up");
}
return null;
}
}
import io.koraframework.common.annotation.Component
import io.koraframework.common.readiness.ReadinessProbe
import io.koraframework.common.readiness.ReadinessProbeFailure
import java.time.Duration
import java.time.Instant
@Component
class CustomReadinessProbe : ReadinessProbe {
private val startedAt = Instant.now()
override fun probe(): ReadinessProbeFailure? {
val readyAt = startedAt.plus(Duration.ofMillis(500))
return if (Instant.now().isBefore(readyAt)) {
ReadinessProbeFailure("Service is warming up")
} else {
null
}
}
}
Несколько проб¶
Kora автоматически собирает каждый зарегистрированный компонент, реализующий LivenessProbe (или ReadinessProbe) —
связывать их между собой вручную не нужно, и проба не обязана быть корневым компонентом.
Каждая конечная точка выполняет все пробы своего вида на отдельных виртуальных потоках и агрегирует результат:
- Конечная точка возвращает
200 OKтолько тогда, когда успешны все пробы этого вида. - Единственная неуспешная проба заставляет всю конечную точку вернуть
503. Если неуспешных проб несколько, телом ответа становится сообщение первой из них в порядке регистрации. - Если ни одна проба этого вида не зарегистрирована, конечная точка возвращает
200 OK— системный сервер всегда предоставляет оба пути.
Пробы внедряются в конечную точку не готовыми компонентами, а обещаниями (promise) — именно это позволяет системному серверу отвечать раньше, чем будет построен весь контейнер.
Пока контейнер не создал компонент пробы, конечная точка отвечает 503 с телом Probe is not ready yet, а не молча пропускает эту пробу.
Это позволяет разбить независимые условия готовности или жизнеспособности на несколько небольших узконаправленных компонентов-проб.
import io.koraframework.common.annotation.Component;
import io.koraframework.common.readiness.ReadinessProbe;
import io.koraframework.common.readiness.ReadinessProbeFailure;
@Component
public final class ComponentReadinessProbe implements ReadinessProbe { //(1)!
private final SomeComponent component;
public ComponentReadinessProbe(SomeComponent component) {
this.component = component;
}
@Override
public ReadinessProbeFailure probe() {
if (component.isInitialized()) { //(2)!
return null;
}
return new ReadinessProbeFailure("SomeComponent is not initialized yet");
}
}
- Компонентов
ReadinessProbeможет быть сколько угодно; конечная точка завершается неуспехом, если неуспешен хотя бы один из них - Проверяйте состояние внутреннего компонента, а не внешней зависимости
import io.koraframework.common.annotation.Component
import io.koraframework.common.readiness.ReadinessProbe
import io.koraframework.common.readiness.ReadinessProbeFailure
@Component
class ComponentReadinessProbe(
private val component: SomeComponent
) : ReadinessProbe { //(1)!
override fun probe(): ReadinessProbeFailure? {
return if (component.isInitialized) { //(2)!
null
} else {
ReadinessProbeFailure("SomeComponent is not initialized yet")
}
}
}
- Компонентов
ReadinessProbeможет быть сколько угодно; конечная точка завершается неуспехом, если неуспешен хотя бы один из них - Проверяйте состояние внутреннего компонента, а не внешней зависимости
Встроенные пробы¶
Часть компонентов Kora сама реализует ReadinessProbe, поэтому они агрегируются вместе с вашими пробами без какой-либо дополнительной настройки:
| Компонент | Модуль | Сообщает not ready, когда |
|---|---|---|
UndertowHttpServer |
io.koraframework:http-server-undertow |
публичный HTTP-сервер ещё не начал слушать порт либо уже начал останавливаться |
GrpcServer |
io.koraframework:grpc-server |
gRPC-сервер ещё не запущен либо останавливается |
JdbcDataSource |
io.koraframework:database-jdbc |
включён jdbc.readinessProbe, и соединение из пула не проходит проверку |
JobExecutorReadinessProbe |
io.koraframework.experimental:camunda-engine-bpmn |
JobExecutor Camunda не активен |
Благодаря им ответ 200 на GET /system/readiness — надёжный признак того, что приложение поднялось.
Именно поэтому ждать стоит эту конечную точку, а не строку в логе: формулировки стартовых сообщений Kora не являются контрактом.
Слушатель самого системного сервера намеренно не входит в эту агрегацию: он зарегистрирован под отдельным тегом, поэтому конечные точки проб продолжают отвечать, пока публичный сервер и остальной контейнер ещё запускаются.
При плавной остановке публичный сервер переводит свою пробу готовности в состояние сбоя в самом начале освобождения,
ещё до того, как выждет httpServer.shutdownWait (по умолчанию: 30s) для запросов в обработке.
Балансировщик, опрашивающий готовность, поэтому успевает вывести экземпляр из ротации до того, как соединения начнут отвергаться.
Ответ¶
Каждая конечная точка пробы обслуживается системным HTTP-сервером, принимает GET и возвращает тело text/plain;charset=utf-8 вместе с кодом статуса:
| Статус | Тело | Значение |
|---|---|---|
200 OK |
OK |
все зарегистрированные пробы вернули null либо ни одна проба этого вида не зарегистрирована |
503 Service Unavailable |
message возвращённого LivenessProbeFailure / ReadinessProbeFailure |
как минимум одна проба сообщила о сбое |
503 Service Unavailable |
Probe failed: <error> |
проба выбросила исключение; выброшенное исключение трактуется как сбой |
503 Service Unavailable |
Probe is not ready yet |
компонент пробы ещё не создан в контейнере зависимостей |
408 Request Timeout |
Probe failed: timeout |
выполнение пробы не завершилось за 30 секунд |
500 Internal Server Error |
сообщение об ошибке | конечная точка вообще не смогла выполнить пробы, например задача пробы не была принята на исполнение |
Конечная точка отвечает, как только становится известен агрегированный результат; поэтому проверки здоровья оркестратора и балансировщика нагрузки могут ориентироваться либо на код статуса, либо на текстовое тело ответа.
Для путей проб маршрутизируется только GET, поэтому на любой другой метод роутер вернёт стандартный 405 Method Not Allowed с заголовком Allow.
Тестирование¶
Проба — это обычный компонент, поэтому внутрипроцессный тест @KoraAppTest может внедрить её и вызвать probe() напрямую,
не обращаясь к HTTP-конечной точке:
import io.koraframework.test.extension.junit5.KoraAppTest;
import io.koraframework.test.extension.junit5.TestComponent;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertNull;
@KoraAppTest(Application.class)
class ProbeTests {
@TestComponent
private ApplicationHealthProbe livenessProbe;
@TestComponent
private CustomReadinessProbe readinessProbe;
@Test
void probesEventuallyReportHealthyState() throws Exception {
assertNull(livenessProbe.probe()); //(1)!
for (int i = 0; i < 10; i++) {
if (readinessProbe.probe() == null) { //(2)!
return;
}
Thread.sleep(100L);
}
assertNull(readinessProbe.probe());
}
}
nullозначает, что проба успешна- Готовность с периодом прогрева становится успешной лишь спустя время, поэтому её опрашивают, а не проверяют однократно
import io.koraframework.test.extension.junit5.KoraAppTest
import io.koraframework.test.extension.junit5.TestComponent
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
@KoraAppTest(Application::class)
class ProbeTests {
@TestComponent
lateinit var livenessProbe: ApplicationHealthProbe
@TestComponent
lateinit var readinessProbe: CustomReadinessProbe
@Test
fun probesEventuallyReportHealthyState() {
assertNull(livenessProbe.probe()) //(1)!
for (i in 0 until 10) {
if (readinessProbe.probe() == null) { //(2)!
return
}
Thread.sleep(100L)
}
assertNull(readinessProbe.probe())
}
}
nullозначает, что проба успешна- Готовность с периодом прогрева становится успешной лишь спустя время, поэтому её опрашивают, а не проверяют однократно
В чёрном ящике конечная точка готовности служит признаком старта контейнера. Ждите именно её, а не строку в логе — формулировки стартовых сообщений Kora не являются контрактом и меняются между версиями:
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.containers.wait.strategy.Wait;
import java.net.URI;
import java.time.Duration;
final class AppContainer extends GenericContainer<AppContainer> {
AppContainer() {
super("my-application:latest");
withExposedPorts(8080, 8085); //(1)!
withStartupTimeout(Duration.ofSeconds(30));
waitingFor(Wait.forHttp("/system/readiness").forPort(8085).forStatusCode(200)); //(2)!
}
URI getURI() {
return URI.create("http://" + getHost() + ":" + getMappedPort(8080));
}
URI getSystemURI() { //(3)!
return URI.create("http://" + getHost() + ":" + getMappedPort(8085));
}
}
- Пробрасывать нужно и публичный, и системный порт
- Пробу готовности опрашивают на системном порту, а не на публичном
- Удобно для проверок
/system/liveness,/system/readinessи/metricsвнутри теста
import org.testcontainers.containers.GenericContainer
import org.testcontainers.containers.wait.strategy.Wait
import java.net.URI
import java.time.Duration
class AppContainer : GenericContainer<AppContainer>("my-application:latest") {
init {
withExposedPorts(8080, 8085) //(1)!
withStartupTimeout(Duration.ofSeconds(30))
waitingFor(Wait.forHttp("/system/readiness").forPort(8085).forStatusCode(200)) //(2)!
}
fun getURI(): URI = URI.create("http://$host:${getMappedPort(8080)}")
fun getSystemURI(): URI = URI.create("http://$host:${getMappedPort(8085)}") //(3)!
}
- Пробрасывать нужно и публичный, и системный порт
- Пробу готовности опрашивают на системном порту, а не на публичном
- Удобно для проверок
/system/liveness,/system/readinessи/metricsвнутри теста
Рекомендации¶
Рекомендация
Не рекомендуется делать пробы, которые напрямую проверяют внешние зависимости: базы данных, очереди или другие сервисы.
Временная недоступность внешней зависимости не должна автоматически приводить к перезапуску приложения. Для таких случаев используйте шаблон CircuitBreaker.
Проба должна отражать состояние самого приложения, а не систем, с которыми оно взаимодействует.
Хорошие примеры — это ReadinessProbe, которая возвращает ошибку, пока сервис прогревается,
или та, что проверяет, завершил ли внутренний компонент свою инициализацию.
Каждая проба выполняется на собственном виртуальном потоке, поэтому тело пробы может блокироваться, не задерживая системный HTTP-сервер.
При этом вся конечная точка по-прежнему ограничена тайм-аутом в 30 секунд, по истечении которого она отвечает 408, поэтому держите логику пробы быстрой и избегайте длительной или неограниченной работы.
Проба вызывается на каждый запрос к её конечной точке, и несколько запросов могут выполняться одновременно, поэтому реализация должна быть дешёвой, без побочных эффектов и безопасной для параллельного вызова.
Всё дорогое кэшируйте или вычисляйте заранее в самом компоненте, а probe() пусть только читает уже известное состояние.