Actuator / Health
Работающему приложению нужно уметь отвечать на вопрос «ты живо?» — и отвечать так,
чтобы ответ понял не человек, а оркестратор. Актуатор даёт набор диагностических
эндпоинтов под /actuator: состояние зависимостей, метрики, настройки,
таблицу маршрутов. По умолчанию он выключен.
Зачем это нужно
Проблема. Балансировщик и оркестратор решают, слать ли трафик на экземпляр
приложения. Единственное, что они умеют, — сделать HTTP-запрос и посмотреть на код
ответа. Приложение, которое отвечает 200 на любой запрос, для них живо всегда —
даже когда база недоступна и каждый реальный запрос падает.
Второй, человеческий вопрос — «что сейчас с этим экземпляром»: сколько памяти съедено, какие каналы логов включены, какие маршруты он вообще знает. Ходить за этим на сервер по SSH неудобно, а в контейнере часто и нечем.
Решение. Набор read-only эндпоинтов, отдающих JSON. Главный — /actuator/health
— возвращает не только тело, но и код ответа, пригодный для пробы. Остальные
отвечают на человеческие вопросы.
Откуда слово «актуатор»
Термин взят из Spring Boot Actuator, и Winter повторяет ту же идею: приложение само рассказывает о себе по фиксированным адресам, а не требует внешнего агента.
Никакого «управления» вопреки названию здесь нет — все эндпоинты только читают.
Изменить что-либо через них нельзя, и метод поддерживается ровно один: GET.
Включение
Актуатор включается атрибутом на классе приложения:
use Flytachi\Winter\Kernel\App\Attribute\{EnableActuator, EnableWeb};
#[EnableWeb]
#[EnableActuator]
final class Application extends WinterApplication
{
public static function main(array $argv): never
{
parent::run($argv);
}
}Без атрибута маршруты /actuator/* не регистрируются вовсе — не «отдают 403», а
просто не существуют.
Нужен `#[EnableWeb]`
Актуатор — это HTTP-маршруты, и вешать их некуда, если веб-сервер не поднят.
Приложение без #[EnableWeb] — планировщик, очередь, набор процессов — работает
headless, таблица маршрутов там не строится, и #[EnableActuator] в нём не даёт
ничего. Ошибки при этом не будет: атрибут просто останется без эффекта.
Чтобы наблюдать за фоновым приложением снаружи, у него должен быть веб-слой — хотя бы ради самого актуатора.
Аргументы атрибута:
| Аргумент | Назначение |
|---|---|
middleware |
Middleware-страж, через который проходят все эндпоинты |
indicator |
Свой класс отчёта вместо встроенного |
#[EnableActuator(middleware: InternalOnly::class)]
#[EnableActuator(indicator: MyIndicator::class)]Проверить, что включилось
curl -s localhost:8000/actuator/health | jq{ "status": "up", "components": { "db": { }, "redis": { }, "disk": { }, "memory": { } } }Ответ 404 означает, что атрибута нет на классе приложения или приложение не
перезапущено после его добавления.
Эндпоинты
| URL | Что отдаёт |
|---|---|
/actuator/health |
Общий статус и состояние компонентов: БД, кеш, диск, память |
/actuator/pools |
Загрузка пулов соединений этого воркера |
/actuator/info |
Версии PHP, ядра и проекта, режим рантайма |
/actuator/metrics |
CPU, память, диск, opcache, аптайм, текущий запрос |
/actuator/loggers |
Каналы логов, их уровни и назначение вывода |
/actuator/mappings |
Таблица зарегистрированных маршрутов |
/actuator/env |
Срез окружения — по умолчанию пустой |
/actuator без имени — то же, что /actuator/health. Все эндпоинты отвечают только
на GET.
health — состояние
{
"status": "degraded",
"components": {
"db": {
"status": "up",
"details": {
"Main\MainDbConfig": {
"status": "up", "driver": "pgsql", "latency": 1.24, "error": null
}
}
},
"redis": { "status": "up", "details": { } },
"disk": { "status": "degraded",
"details": { "usage_percent": 84.1,
"warning": "Disk usage above 80%" } },
"memory": { "status": "up", "details": { "usage_percent": 31.7 } }
}
}Общий статус выводится по худшему компоненту: есть down — весь отчёт down; нет
down, но есть degraded — degraded; иначе up.
Встроенные компоненты
| Компонент | Как проверяется | Когда degraded |
Когда down |
|---|---|---|---|
db |
Живой SELECT 1 в каждую найденную конфигурацию |
Ответ дольше 500 мс | База не ответила |
redis |
То же для конфигураций Redis — PING в каждую найденную |
Ответ дольше 500 мс | Не ответил |
disk |
Заполненность файловой системы | Свыше 80 % | Свыше 90 % |
memory |
Использование против memory_limit |
Свыше 80 % | Свыше 90 % |
Конфигурации баз и Redis находятся обходом проекта, перечислять их нигде не нужно. Проверка выполняется на каждом запросе к эндпоинту — это живой пинг, а не кешированное значение.
Проверка стоит запроса в базу
health открывает соединение и делает SELECT 1 для каждой конфигурации. Проба
раз в несколько секунд — это нормально; проба раз в секунду с десяти балансировщиков
превращается в постоянную нагрузку.
Если проверка нужна часто, ставьте пробу на более дешёвый эндпоинт, а health
опрашивайте реже.
Коды ответа
Здесь главное отличие от остальных эндпоинтов: health кладёт вердикт в код
ответа, а не только в тело.
| Статус | Код | Почему так |
|---|---|---|
up |
200 |
Всё в порядке |
degraded |
200 |
Работает хуже, но работает |
down |
503 |
Не работает |
Почему `degraded` — это 200
Соблазн отдавать на degraded ошибку велик, но он опасен. degraded означает
«медленнее обычного» или «диск заполняется» — приложение при этом обслуживает
запросы.
Если проба выведет экземпляр из ротации, нагрузка перейдёт на соседей, у которых те
же условия, — и они деградируют следом. Частичный сбой так превращается в полный.
Поэтому из ротации выводит только down.
Отсюда практическое разделение проб: liveness и readiness обе смотрят на
/actuator/health, но реагируют на код, а не на тело.
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/actuator/health"]
interval: 30s
timeout: 5s
retries: 3Флаг -f заставляет curl завершиться с ошибкой на коде 5xx — этого достаточно,
разбирать JSON не нужно.
pools — загрузка соединений
{
"status": "degraded",
"pools": {
"Main\MainDbConfig": {
"total": 10, "idle": 0, "active": 10, "maximum": 10,
"saturated": true, "source": "ppa"
},
"Main\AppRedisConfig": {
"total": 2, "idle": 2, "active": 0, "maximum": 10,
"saturated": false, "source": "redis"
}
}
}saturated означает, что все соединения розданы и следующий запрос встанет в
очередь. Числа относятся к этому воркеру: пул у каждого свой, и запрос ждёт
освобождения именно в своём.
source говорит, какой слой открыл пул: ppa — база данных, redis — Redis. Оба
отчитываются одинаково и попадают в один список, потому что ключ — имя класса
конфигурации, а оно уникально; поле нужно, чтобы читатель различал их, не зная имён
классов конкретного приложения.
Раздел показывает только установленные слои: приложение без
PPA и без Redis получит {"status": "up", "pools": {}} — пулов нет, и это не повод для тревоги.
Почему пулы вынесены из `health`
Доступность базы и заполненность пула — разные вопросы, и меняются они в разном темпе. База либо есть, либо нет; пул наполняется и опустошается в пределах секунды.
Пока они были в одном отчёте, занятое, но исправное приложение сообщало
degraded — неверный сигнал для пробы, которая решает, слать ли трафик. Теперь
health о пулах не знает ничего.
Те же цифры по всему флоту показывает php call db pool — см.
PPA.
Остальные эндпоинты
info — версия PHP и SAPI, версия ядра, режим рантайма, имя и версия пакета
проекта. То, что первым делом спрашивают при разборе инцидента:
{
"php": { "version": "8.4.3", "sapi": "cli", "zend_version": "4.4.3" },
"framework": { "name": "flytachi/winter-kernel", "version": "v4.0.4", "runtime": "Swoole" },
"project": { "name": "acme/shop", "type": "project", "version": "1.7.2", "isDev": false }
}metrics — средняя загрузка и число ядер, память процесса против лимита,
свободное место на диске, состояние opcache, аптайм хоста и параметры текущего
запроса.
loggers — каналы sys и http: действующий уровень, формат, назначение
вывода и путь к файлу, если пишется в файл. Способ проверить, что переменные
LOG_* в этом окружении применились так, как вы ожидали.
mappings — снимок таблицы маршрутов, той же, что показывает
call mapping show. Полезно, когда нужно убедиться, что
развёрнутая версия знает новый маршрут.
env — по умолчанию пустой массив. Это сделано намеренно: автоматическая
выдача окружения означала бы выдачу паролей и ключей. Наполните его сами, если
нужно, — переопределив метод в своём индикаторе.
Свои проверки
Встроенные компоненты знают только про инфраструктуру: база, кеш, диск, память. Всё, что специфично для вашего приложения, фреймворку неизвестно — доступен ли платёжный шлюз, не переполнена ли очередь, не устарела ли выгрузка курсов. Такие проверки вы добавляете сами.
Что нужно написать
Один класс с двумя методами. Никакой регистрации, никакого наследования:
<?php
namespace Main\Health;
use Flytachi\Winter\Kernel\Http\Health\HealthContributor;
use Flytachi\Winter\Kernel\Http\Health\HealthStatus;
final class QueueHealth implements HealthContributor
{
public function name(): string
{
return 'queue';
}
public function check(): HealthStatus
{
return HealthStatus::up();
}
}Этого достаточно. Обновите /actuator/health — в отчёте появился новый компонент:
{
"status": "up",
"components": {
"db": { "status": "up", "details": { } },
"redis": { "status": "up", "details": { } },
"disk": { "status": "up", "details": { } },
"memory": { "status": "up", "details": { } },
"queue": { "status": "up", "details": { } }
}
}Ключ queue — это то, что вернул name(). Значение — то, что вернул check().
HealthContributor — что это
Интерфейс, который говорит фреймворку: «этот класс — проверка, включи её в отчёт». Больше он ничего не значит и ничего не требует.
| Метод | Что вернуть | Когда вызывается |
|---|---|---|
name(): string |
Ключ компонента в JSON — 'queue', 'payments' |
Один раз за запрос |
check(): HealthStatus |
Вердикт проверки | На каждом запросе к /actuator/health |
Классы находит тот же обход проекта, который собирает контроллеры и конфигурации. Положили файл — проверка работает; удалили — исчезла из отчёта. Списка проверок нигде нет.
HealthStatus — что это
Ответ проверки. Это не строка и не массив, а маленький объект, который вы
собираете фабрикой, а не через new:
HealthStatus::up(); // всё хорошо
HealthStatus::degraded(); // работает, но хуже обычного
HealthStatus::down(); // не работаетТри фабрики — три возможных состояния, четвёртого не бывает. Конструктор закрыт намеренно: так в отчёт невозможно положить статус, которого система не понимает.
К любому из них можно приложить подробности — цепочкой withDetail():
HealthStatus::degraded()
->withDetail('depth', 24817)
->withDetail('oldest_message_age', 412);"queue": {
"status": "degraded",
"details": { "depth": 24817, "oldest_message_age": 412 }
}Ключи и значения произвольны — это ваши данные, они просто попадают в JSON как есть.
Подробности нужны человеку, который откроет эндпоинт при разборе инцидента:
down без причины сообщает, что сломалось, но не сообщает — что именно.
| Метод | Что делает |
|---|---|
HealthStatus::up() |
Создаёт ответ со статусом up |
HealthStatus::degraded() |
То же со статусом degraded |
HealthStatus::down() |
То же со статусом down |
->withDetail($key, $value) |
Добавляет поле в details, возвращает объект — вызовы можно сцеплять |
Проверка целиком
Теперь то же самое, но с настоящей логикой и зависимостью:
final class QueueHealth implements HealthContributor
{
private const int WARN_DEPTH = 10_000;
public function __construct(private QueueClient $queue) {}
public function name(): string
{
return 'queue';
}
public function check(): HealthStatus
{
if (!$this->queue->isConnected()) {
return HealthStatus::down()
->withDetail('reason', 'broker unreachable');
}
$depth = $this->queue->depth();
if ($depth > self::WARN_DEPTH) {
return HealthStatus::degraded()
->withDetail('depth', $depth)
->withDetail('threshold', self::WARN_DEPTH);
}
return HealthStatus::up()->withDetail('depth', $depth);
}
}Зависимость приходит через конструктор — атрибут не нужен, контейнер разрешает аргументы по типу. Объект создаётся заново на каждый запрос к эндпоинту, поэтому проверка всегда видит текущее состояние, а не то, что было при старте.
Как выбрать статус
Это главное решение, и ошибиться в нём легко. Ориентир простой — что должен сделать оркестратор:
| Ситуация | Статус | Почему |
|---|---|---|
| Всё работает | up |
— |
| Очередь растёт, ответы медленные, диск заполняется | degraded |
Приложение обслуживает запросы; забирать трафик нельзя |
| Обязательная зависимость недоступна, работать не может | down |
Экземпляр надо вывести из ротации |
Помните, что down даёт ответ 503, и проба уберёт экземпляр из балансировки.
Ставьте его только если приложение действительно не может обслуживать запросы.
Внешний сервис, без которого часть функций деградирует, а остальное работает, — это
degraded, а не down.
Как складывается общий статус
Каждый компонент даёт свой вердикт, общий берётся по худшему:
db: up redis: up queue: degraded → общий degraded (HTTP 200)
db: up redis: up queue: down → общий down (HTTP 503)То есть одна ваша проверка, вернувшая down, выведет весь экземпляр из
ротации. Это ровно то, для чего механизм существует, — и ровно то, о чём стоит
помнить, отмечая необязательную зависимость как критическую.
Несколько проверок
Классов может быть сколько угодно — по одному на область:
final class PaymentsHealth implements HealthContributor
{
public function __construct(private PaymentGateway $gateway) {}
public function name(): string { return 'payments'; }
public function check(): HealthStatus
{
$ms = $this->gateway->pingMs();
return $ms === null
? HealthStatus::degraded()->withDetail('reason', 'gateway timeout')
: HealthStatus::up()->withDetail('latency_ms', $ms);
}
}final class ExchangeRatesHealth implements HealthContributor
{
public function __construct(private RateRepository $rates) {}
public function name(): string { return 'rates'; }
public function check(): HealthStatus
{
$age = time() - $this->rates->lastUpdatedAt();
return $age > 3600
? HealthStatus::degraded()->withDetail('age_seconds', $age)
: HealthStatus::up()->withDetail('age_seconds', $age);
}
}Второй пример показывает частый и полезный случай: проверяется не доступность сервиса, а свежесть данных. Внешний источник может отвечать, а выгрузка при этом не обновляться сутки — и узнать об этом лучше от эндпоинта, чем от пользователей.
Замена встроенной проверки
Ключи не разделены на «свои» и «системные»: если name() вернёт db, ваш
компонент займёт место встроенного.
public function name(): string
{
return 'db'; // заменяет встроенную проверку базы
}Пригождается, когда встроенная проверка слишком дорога — она пингует каждую найденную конфигурацию, а вам достаточно одной, основной.
Чего делать не стоит
Исключение из `check()` роняет весь эндпоинт
Вызов проверки не обёрнут в try. Исключение, вылетевшее из check(), не сделает
компонент down — оно оборвёт весь ответ /actuator/health, и проба получит
ошибку вместо отчёта. Остальные компоненты при этом не будут даже опрошены.
Ловите исключения внутри и превращайте их в статус сами:
public function check(): HealthStatus
{
try {
return HealthStatus::up()->withDetail('latency_ms', $this->api->pingMs());
} catch (\Throwable $e) {
return HealthStatus::down()->withDetail('reason', $e->getMessage());
}
}Внешний вызов без таймаута — источник зависаний
check() выполняется синхронно на каждом запросе к эндпоинту. HTTP-вызов без
таймаута превращает проверку живости в то, что само зависает: проба ждёт ответа,
не получает его и считает экземпляр мёртвым — хотя приложение исправно.
Задавайте таймаут в одну-две секунды и считайте его превышение результатом проверки, а не ошибкой.
Ещё три правила, которые экономят время:
- Проверка должна быть дешёвой. Её вызывают часто. Тяжёлый агрегирующий запрос
в
check()— это тяжёлый запрос каждые несколько секунд, круглосуточно. - Не пишите в базу. Проверка только читает: она выполняется параллельно на всех экземплярах и в любом порядке.
- Не логируйте на каждом вызове. Иначе лог заполнится записями пробы, и настоящие события в нём потеряются.
Как проверить, что работает
Класс не привязан к фреймворку, поэтому проверяется обычным юнит-тестом:
$health = new QueueHealth(new FakeQueue(depth: 50_000));
self::assertSame(Status::Degraded, $health->check()->status());
self::assertSame(50_000, $health->check()->toArray()['details']['depth']);Живьём — просто откройте эндпоинт:
curl -s localhost:8000/actuator/health | jq '.components.queue'Если компонента нет в ответе — класс не попал в обход: проверьте, что он лежит под
корнем проекта (не в vendor/, storage/ или resources/) и что приложение
перезапущено.
Свой отчёт целиком
Когда нужно поменять не отдельный компонент, а формат ответа — передайте свой индикатор:
use Flytachi\Winter\Kernel\Http\Health\HealthIndicator;
final class MyIndicator extends HealthIndicator
{
public function env(): array
{
return ['APP_ENV' => env('APP_ENV'), 'TIME_ZONE' => env('TIME_ZONE')];
}
}#[EnableActuator(indicator: MyIndicator::class)]Наследование от HealthIndicator оставляет остальные эндпоинты как есть.
Реализовать HealthIndicatorInterface с нуля тоже можно, но тогда придётся дать
все семь методов: health, pools, info, metrics, env, loggers,
mappings.
Имя метода — это и есть имя эндпоинта: добавили публичный метод queues() —
появился /actuator/queues.
Защита доступа
Актуатор рассказывает об устройстве приложения довольно много. В интернет его лучше не выставлять.
use Flytachi\Winter\Kernel\Http\Stereotype\Middleware;
final class InternalOnly extends Middleware
{
public function before(HttpRequest $request, HttpResponse $response): void
{
$ip = $request->getClientIp();
if (!str_starts_with($ip, '10.') && $ip !== '127.0.0.1') {
throw new ResponseException('Not found', HttpCode::NOT_FOUND);
}
}
}#[EnableActuator(middleware: InternalOnly::class)]Middleware применяется ко всем эндпоинтам актуатора сразу. Отвечать 404
вместо 403 — сознательный приём: он не подтверждает, что путь существует.
Что видно без защиты
mappings показывает все маршруты приложения, включая административные; info —
версии, по которым подбирают известные уязвимости; metrics — состояние хоста.
Это не секреты сами по себе, но это подробная карта для того, кто ищет вход.
Закройте актуатор middleware или не публикуйте его порт наружу.
Дальше
- Состав приложения — атрибут
#[EnableActuator]среди прочих - Middleware — как пишется страж доступа
- PPA — что означают числа в
pools - Логирование — что показывает
loggers