Продвинутое

Actuator / Health

Работающему приложению нужно уметь отвечать на вопрос «ты живо?» — и отвечать так, чтобы ответ понял не человек, а оркестратор. Актуатор даёт набор диагностических эндпоинтов под /actuator: состояние зависимостей, метрики, настройки, таблицу маршрутов. По умолчанию он выключен.

Включение #[EnableActuator]Путь /actuator/*Метод только GET

Зачем это нужно

Проблема. Балансировщик и оркестратор решают, слать ли трафик на экземпляр приложения. Единственное, что они умеют, — сделать HTTP-запрос и посмотреть на код ответа. Приложение, которое отвечает 200 на любой запрос, для них живо всегда — даже когда база недоступна и каждый реальный запрос падает.

Второй, человеческий вопрос — «что сейчас с этим экземпляром»: сколько памяти съедено, какие каналы логов включены, какие маршруты он вообще знает. Ходить за этим на сервер по SSH неудобно, а в контейнере часто и нечем.

Решение. Набор read-only эндпоинтов, отдающих JSON. Главный — /actuator/health — возвращает не только тело, но и код ответа, пригодный для пробы. Остальные отвечают на человеческие вопросы.

Откуда слово «актуатор»

Термин взят из Spring Boot Actuator, и Winter повторяет ту же идею: приложение само рассказывает о себе по фиксированным адресам, а не требует внешнего агента.

Никакого «управления» вопреки названию здесь нет — все эндпоинты только читают. Изменить что-либо через них нельзя, и метод поддерживается ровно один: GET.

Включение

Актуатор включается атрибутом на классе приложения:

bootstrap.php
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 Свой класс отчёта вместо встроенного
php
#[EnableActuator(middleware: InternalOnly::class)]
#[EnableActuator(indicator: MyIndicator::class)]

Проверить, что включилось

bash
curl -s localhost:8000/actuator/health | jq
json
{ "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 — состояние

json
{
"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, но есть degradeddegraded; иначе 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, но реагируют на код, а не на тело.

docker-compose.yml
healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8000/actuator/health"]
interval: 30s
timeout: 5s
retries: 3

Флаг -f заставляет curl завершиться с ошибкой на коде 5xx — этого достаточно, разбирать JSON не нужно.

pools — загрузка соединений

json
{
"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, версия ядра, режим рантайма, имя и версия пакета проекта. То, что первым делом спрашивают при разборе инцидента:

json
{
"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 — по умолчанию пустой массив. Это сделано намеренно: автоматическая выдача окружения означала бы выдачу паролей и ключей. Наполните его сами, если нужно, — переопределив метод в своём индикаторе.

Свои проверки

Встроенные компоненты знают только про инфраструктуру: база, кеш, диск, память. Всё, что специфично для вашего приложения, фреймворку неизвестно — доступен ли платёжный шлюз, не переполнена ли очередь, не устарела ли выгрузка курсов. Такие проверки вы добавляете сами.

Что нужно написать

Один класс с двумя методами. Никакой регистрации, никакого наследования:

main/Health/QueueHealth.php
<?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 — в отчёте появился новый компонент:

json
{
"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:

php
HealthStatus::up();          // всё хорошо
HealthStatus::degraded();    // работает, но хуже обычного
HealthStatus::down();        // не работает

Три фабрики — три возможных состояния, четвёртого не бывает. Конструктор закрыт намеренно: так в отчёт невозможно положить статус, которого система не понимает.

К любому из них можно приложить подробности — цепочкой withDetail():

php
HealthStatus::degraded()
  ->withDetail('depth', 24817)
  ->withDetail('oldest_message_age', 412);
json
"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, возвращает объект — вызовы можно сцеплять

Проверка целиком

Теперь то же самое, но с настоящей логикой и зависимостью:

main/Health/QueueHealth.php
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.

Как складывается общий статус

Каждый компонент даёт свой вердикт, общий берётся по худшему:

text
db: up   redis: up   queue: degraded   →  общий degraded  (HTTP 200)
db: up   redis: up   queue: down       →  общий down      (HTTP 503)

То есть одна ваша проверка, вернувшая down, выведет весь экземпляр из ротации. Это ровно то, для чего механизм существует, — и ровно то, о чём стоит помнить, отмечая необязательную зависимость как критическую.

Несколько проверок

Классов может быть сколько угодно — по одному на область:

php
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);
  }
}
php
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, ваш компонент займёт место встроенного.

php
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() — это тяжёлый запрос каждые несколько секунд, круглосуточно.
  • Не пишите в базу. Проверка только читает: она выполняется параллельно на всех экземплярах и в любом порядке.
  • Не логируйте на каждом вызове. Иначе лог заполнится записями пробы, и настоящие события в нём потеряются.

Как проверить, что работает

Класс не привязан к фреймворку, поэтому проверяется обычным юнит-тестом:

php
$health = new QueueHealth(new FakeQueue(depth: 50_000));

self::assertSame(Status::Degraded, $health->check()->status());
self::assertSame(50_000, $health->check()->toArray()['details']['depth']);

Живьём — просто откройте эндпоинт:

bash
curl -s localhost:8000/actuator/health | jq '.components.queue'

Если компонента нет в ответе — класс не попал в обход: проверьте, что он лежит под корнем проекта (не в vendor/, storage/ или resources/) и что приложение перезапущено.

Свой отчёт целиком

Когда нужно поменять не отдельный компонент, а формат ответа — передайте свой индикатор:

php
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')];
  }
}
php
#[EnableActuator(indicator: MyIndicator::class)]

Наследование от HealthIndicator оставляет остальные эндпоинты как есть. Реализовать HealthIndicatorInterface с нуля тоже можно, но тогда придётся дать все семь методов: health, pools, info, metrics, env, loggers, mappings.

Имя метода — это и есть имя эндпоинта: добавили публичный метод queues() — появился /actuator/queues.

Защита доступа

Актуатор рассказывает об устройстве приложения довольно много. В интернет его лучше не выставлять.

main/Middleware/InternalOnly.php
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);
      }
  }
}
php
#[EnableActuator(middleware: InternalOnly::class)]

Middleware применяется ко всем эндпоинтам актуатора сразу. Отвечать 404 вместо 403 — сознательный приём: он не подтверждает, что путь существует.

Что видно без защиты

mappings показывает все маршруты приложения, включая административные; info — версии, по которым подбирают известные уязвимости; metrics — состояние хоста. Это не секреты сами по себе, но это подробная карта для того, кто ищет вход.

Закройте актуатор middleware или не публикуйте его порт наружу.

Дальше