16. Healthcheck и readiness probe

Когда врач скорой помощи осматривает пациента, он в первую очередь проверяет базовые рефлексы: есть ли дыхание и бьется ли сердце. Лишь убедившись, что пациент жив, врач выясняет: готов ли человек сейчас встать и бежать марафон.

В мире микросервисов и облачных оркестраторов (Kubernetes, Nomad, Docker Swarm) действует точно такая же медицинская логика. Оркестратор — это автоматический дежурный врач. С помощью специальных HTTP-запросов (Health Probes) он непрерывно опрашивает каждый контейнер. Если процесс впал в кому из-за взаимной блокировки горутин (deadlock), его нужно немедленно реанимировать (перезапустить). Но если процесс просто ожидает прогрева кэша или восстановления соединения с базой данных, убивать его нельзя — нужно лишь временно отвести от него поток клиентского трафика.

Понимание фундаментальной разницы между Liveness и Readiness пробами — граница, отделяющая стабильные кластеры от систем, страдающих от каскадных падений и бесконечных перезапусков.

flowchart TD
    A["Kubelet инициирует запрос"] --> B{"Тип HTTP-зонда"}
    B -->|"liveness"| C["Проверка внутреннего состояния<br>горутин, блокировок, памяти"]
    B -->|"readiness"| D["Проверка внешних зависимостей<br>БД, Redis, очереди"]
    C -->|"200 OK"| E["Процесс работает<br>Kubelet не вмешивается"]
    C -->|"5xx или Timeout"| F["Kubelet перезапускает под<br>Graceful shutdown + restart"]
    D -->|"200 OK"| G["Сервис в пуле Endpoints<br>Принимает трафик"]
    D -->|"5xx или Timeout"| H["Исключение из пула Endpoints<br>Трафик идёт на реплики"]

    classDef kube fill:#0f172a,stroke:#6366f1,stroke-width:2px,color:#f8fafc;
    classDef check fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#f8fafc;
    classDef ok fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#f8fafc;
    classDef err fill:#450a0a,stroke:#f43f5e,stroke-width:2px,color:#f8fafc;
    class A,B kube;
    class C,D check;
    class E,G ok;
    class F,H err;
  

Разделение ответственности. Liveness и Readiness

В контейнерных оркестраторах (Kubernetes, Docker Swarm, Nomad) жизненный цикл сервиса управляется через HTTP-зонды. Они сигнализируют платформе о состоянии процесса: «завис ли он» и «готов ли принимать трафик». Неправильная реализация зондов — одна из самых частых причин каскадных падений, 502-х ошибок при деплое и бесконечных рестартов подов.

В Go реализация зондов тривиальна синтаксически, но требует глубокого понимания механики оркестратора, работы сетевого стека и стоимости операций на уровне CPU и памяти.


1. Архитектура HTTP-зондов в Kubernetes

Существует три типа проб, каждый из которых выполняет строго определённую функцию:

  • Liveness (Жизнеспособность): Отвечает на вопрос «Процесс мёртв?». Если зонд падает, оркестратор принудительно перезапускает контейнер. Не должен проверять внешние зависимости (БД, кеш, очереди). Временный сетевой разрыв не должен убивать под.
  • Readiness (Готовность): Отвечает на вопрос «Могу ли я обрабатывать запросы?». Если зонд падает, оркестратор исключает под из пула Endpoints. Трафик перенаправляется на реплики. Должен проверять критические зависимости.
  • Startup (Запуск): Даёт сервису время на инициализацию (миграции, загрузка кешей, прогрев пулов соединений). Пока не ответит 200, liveness-проба не активна.

2. Идиоматичная реализация на Go

Зонды находятся на hot-path оркестратора. Они вызываются каждые 1-5 секунд на каждый под. Любая аллокация или блокировка в обработчике создаёт кумулятивный overhead на кластере.

Go
package health

import (
    "net/http"
    "sync/atomic"
)

// Probes управляет состояниями жизнеспособности и готовности.
// Использует атомарные операции для lock-free чтения на горячем пути.
type Probes struct {
    isReady atomic.Int32 // 0 = not ready, 1 = ready
}

func NewProbes() *Probes {
    p := &Probes{}
    p.isReady.Store(0) // Изначально сервис не готов
    return p
}

func (p *Probes) SetReady(ready bool) {
    var val int32
    if ready {
        val = 1
    }
    p.isReady.Store(val)
}

// LivenessHandler проверяет только базовую работоспособность процесса.
func (p *Probes) LivenessHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        w.WriteHeader(http.StatusOK)
        // Использование константного среза избегает аллокаций в куче на каждый вызов
        w.Write([]byte(`{"status":"ok"}`))
    }
}

// ReadinessHandler возвращает готовность на основе атомарного флага.
func (p *Probes) ReadinessHandler() http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        w.Header().Set("Content-Type", "application/json")
        if p.isReady.Load() == 1 {
            w.WriteHeader(http.StatusOK)
            w.Write([]byte(`{"status":"ready"}`))
        } else {
            w.WriteHeader(http.StatusServiceUnavailable)
            w.Write([]byte(`{"status":"not ready"}`))
        }
    }
}

3. Механика работы и Mechanical Sympathy

Понимание того, как зонды взаимодействуют с рантаймом Go и ядром Linux, критично для стабильности.

Сетевой стек и syscalls
Каждый зонд kubelet — это новый TCP-соединение (в старых версиях K8s) или мультиплексированный поток (HTTP/2 в новых). Для Go net/http сервера это означает:

  1. accept() syscall → получение файлового дескриптора
  2. Регистрация в netpoll (epoll/kqueue)
  3. Запуск горутины-обработчика
  4. read() → парсинг HTTP-заголовков
  5. write() → отправка ответа
  6. Закрытие соединения (если нет keep-alive)

Частые зонды (каждую секунду) на 100-подовом кластере генерируют сотни accept/close syscall в секунду. Это нагружает планировщик, увеличивает churn горутин и создает contention на уровне netpoll.

Атомарные операции vs Мьютексы
Для флага готовности atomic.Int32 предпочтительнее sync.RWMutex. atomic.Store/Load компилируется в инструкции mov с префиксами памяти (x86) или ldxr/stxr (ARM). Это lock-free операция, выполняемая за 1-2 такта CPU в User Space. sync.RWMutex при высокой конкуренции вызывает futex syscall, переводит горутины в состояние _Gwaiting и требует переключения контекста ядра. На hot-path разница достигает 10-50x.


4. Асинхронная проверка зависимостей

Readiness должен проверять БД, кеш или очереди, но никогда не должен делать это синхронно внутри HTTP-обработчика. Блокировка обработчика приведёт к таймауту пробы, исключению пода из сервиса и увеличению нагрузки на оставшиеся реплики.

Правильный паттерн — фоновый воркер, который обновляет атомарный флаг:

Go
func StartDependencyChecker(p *Probes, db *sql.DB) {
    go func() {
        ticker := time.NewTicker(5 * time.Second)
        defer ticker.Stop()

        for range ticker.C {
            ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
            err := db.PingContext(ctx)
            cancel()

            // Атомарное обновление состояния готовности
            p.SetReady(err == nil)
        }
    }()
}

5. Ловушки и антипаттерны


6. Собеседование и типичные вопросы


Итог

  1. Разделяйте liveness (процесс жив) и readiness (зависимости доступны) на уровне кода и конфигурации оркестратора.
  2. Используйте atomic примитивы для управления флагами готовности, избегая блокировок на горячем пути.
  3. Никогда не проверяйте внешние зависимости внутри liveness-обработчика.
  4. Асинхронные воркеры обновляют состояние readiness, обработчики только читают атомарный флаг.
  5. Оптимизируйте аллокации: константные JSON-ответы, отсутствие рефлексии, минимальный набор заголовков.
  6. Настройте таймауты HTTP-сервера специально для probe-портов, чтобы избежать утечки дескрипторов.

Следующая статья: 17. Метрики и базовый monitoring