Содержание урока · 15 разделов
- grpc health check: протокол grpc.health.v1 одним сервисом
- Внутри server.go: statusMap, Check и NotFound
- SetServingStatus, Shutdown и Resume: ручной режим сервера
- Watch: поток статусов вместо опроса
- Клиентская проверка руками: Check с дедлайном и кодами
- healthCheckConfig: когда каналу здоровье важнее пикера
- Kubernetes: liveness, readiness и gRPC-пробы
- grpc-health-probe: exec-проба для старых кластеров
- Кто спрашивает здоровье и что делает с ответом
- Готовность против живучести: семантика, которую легко перепутать
- Чек-лист: health checking protocol
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
grpc health check: протокол grpc.health.v1 одним сервисом#
У каждого распределённого приложения рано или поздно появляется метод Ping, и почти каждый автор изобретает его заново: кто-то возвращает пустой ответ, кто-то — версию сборки, кто-то — время аптайма. Проблема самодельного пинга не в коде, а в договорённости: балансировщик, оркестратор и мониторы говорят на разных языках, и каждому приходится объяснять отдельно. Поэтому health protocol встроили в сам gRPC как обычный сервис: пакет grpc.health.v1, и весь он умещается в два сообщения и три метода. Док health-checking формулирует задачу без предисловий: «Health checks are used to probe whether the server is able to handle rpcs» — health-проверка отвечает на вопрос, способен ли сервер обрабатывать RPC вообще, а не «всё ли в порядке с миром»; и сразу проговаривает, что «unhealthy» — тоже валидный ответ: сервер вправе ответить отказом, «because it is not ready to take requests, it is shutting down or some other reason» — не готов принимать запросы, останавливается или что-то ещё. Почему проверка — именно gRPC-сервис, а не отдельный TCP-порт или HTTP-endpoint? У этого выбора три причины, названные в том же документе. Первая: «since it is a GRPC service itself, doing a health check is in the same format as a normal rpc» — проверка едет по тому же транспорту, что и бизнес-вызовы, проходит через те же TLS-учётные данные и межсетевые экраны, и успех проверки действительно означает «RPC по этому соединению ходят». Вторая: «rich semantics such as per-service health status» — один процесс может хостить несколько сервисов, и протокол позволяет спросить здоровье каждого по имени. Третья — проверке доступны обычные коды статусов и дедлайны: клиент может сказать «не успел ответить — считаю нездоровым». Сам proto маленький: HealthCheckRequest с единственным полем service, HealthCheckResponse с полем status и перечислением ServingStatus из четырёх значений — UNKNOWN, SERVING, NOT_SERVING и SERVICE_UNKNOWN, про который в комментарии сказано «Used only by the Watch method» — он существует только для Watch и никогда не возвращается через Check. В файле из репозитория grpc написано, где живут канонические определения: «The canonical version of this proto can be found at» grpc-proto — там же добавился метод List, и в свежих генерациях grpc-go он уже есть; о нём чуть ниже.
Сервер: health.NewServer рядом с OrderService
GO// cmd/server/main.go — health становится вторым сервисом процесса.
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatal(err)
}
// health.NewServer возвращает реализацию Health-сервиса: внутри
// карта статусов statusMap, и в ней уже лежит "" -> SERVING —
// сервер целиком объявлен здоровым до всяких ваших слов.
healthSrv := health.NewServer()
// Порядок тот же, что в четвёртом уроке: регистрации до Serve.
s := grpc.NewServer()
pb.RegisterOrderServiceServer(s, server.NewOrderServiceServer())
grpc_health_v1.RegisterHealthServer(s, healthSrv)
// Отдельно объявляем готовность бизнес-сервиса: пустое имя и
// имя OrderService — два независимых ключа в statusMap.
healthSrv.SetServingStatus("shop.v1.OrderService",
healthpb.HealthCheckResponse_SERVING)
// Kubernetes при выкатке шлёт SIGTERM — превращаем его в мягкий останов.
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGTERM)
<-sig
// Сначала снимаем health: readiness-пробы начнут получать
// NOT_SERVING, и Pod выведут из балансировки трафика.
healthSrv.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
healthSrv.SetServingStatus("shop.v1.OrderService",
healthpb.HealthCheckResponse_NOT_SERVING)
// Даём балансировщикам пару секунд заметить снятие, затем
// останавливаем приём новых вызовов и дослуживаем активные.
time.Sleep(5 * time.Second)
s.GracefulStop()
}()
log.Printf("listening on %s", lis.Addr())
if err := s.Serve(lis); err != nil {
log.Fatal(err)
}Три строки, на которые смотреть. Первая: health.NewServer() — вы не реализуете интерфейс HealthServer вручную, пакет health уже сделал это; его Server встраивает UnimplementedHealthServer, поэтому незаполненные методы не паникуют, а отвечают Unimplemented. Вторая: grpc_health_v1.RegisterHealthServer — обычная регистрация второго сервиса, с генерируемым дескриптором ServiceName «grpc.health.v1.Health»; четвёртый урок обещал, что health регистрируют рядом с бизнес-сервисом, — вот это место. Третья: SetServingStatus — имена в карте независимы: статус OrderService и статус пустого имени меняются по отдельности, и это ключ к разделению readiness и liveness ниже.
Внутри server.go: statusMap, Check и NotFound#
Реализация пакета health умещается в меньше двухсот строк, и почти вся логика — это работа с картой. Док пакета объявляет двойную роль сразу: «Package health provides a service that exposes server's health» — сервис здоровья, а тот же импорт включает поддержку клиентских проверок (о второй половине — ниже, в блоке про healthCheckConfig). Структура Server хранит mutex, флаг shutdown, карту статусов statusMap map[string]HealthCheckResponse_ServingStatus и карту подписчиков updates для Watch. Конструктор NewServer заполняет statusMap единственной записью: пустая строка соответствует серверу целиком и по умолчанию равна SERVING — так выполнено правило из комментария proto: «If the caller does not specify a service name, the server should respond with its overall health status». Док health-checking требует того же: «The server should register all the services manually and set the individual status, including an empty service name and its status» — сервер обязан регистрировать все сервисы вручную, включая пустое имя, и следить за их статусами сам; ничего не переключается автоматически, потому что только автору приложения известно, что значит «готов обрабатывать заказы». Для пустого имени правило сформулировано отдельным предложением: «The server should use an empty string as the key for server's overall health status» — пустая строка это здоровье всего процесса, и клиент, которому не интересен конкретный сервис, спрашивает именно её. Check — unary-метод и выглядит скучно: взять RLock, найти service в карте, вернуть статус; незнакомое имя даёт ошибку status.Error(codes.NotFound, «unknown service»). Это контракт протокола, а не прихоть реализации: «If the service name is not registered, the server returns a NOT_FOUND GRPC status» — и в комментарии proto то же самое: незнакомый сервис — это NOT_FOUND, а не NOT_SERVING и не UNKNOWN. Сопоставление имён точное: «The server can just do exact matching of the service name without support of any kind of wildcard matching» — никаких масок и префиксов из коробки; спросить «все сервисы shop.v1.*» не выйдет. Из этого следует невидимая глазу деталь: статус пустого имени НЕ производная от статусов зарегистрированных сервисов. Вы сняли OrderService в NOT_SERVING — Check со service «» по-прежнему вернёт SERVING, пока вы сами не сменили и его. В коде выше мы меняем оба имени руками именно поэтому.
Проверка: незнакомый сервис в Check
На сервере регистрировался только «shop.v1.OrderService». Клиент вызвал Check с Service: «shop.v1.BillingService». Что вернёт health.NewServer?
SetServingStatus, Shutdown и Resume: ручной режим сервера#
В health-сервере нет ни таймеров, ни наблюдателей за зависимостями: ничего не меняется само. Единственный способ обновить статус — SetServingStatus(service, status), и док это подчёркивает: «The server should register all the services manually and set the individual status». Ручной режим выглядит разочаровывающе примитивно, но это осознанный дизайн: определение «готов ли сервис» требует знания предметной области — миграции применены, кэш прогрет, пул соединений набран, — и ни одна библиотека этого не угадает. Что происходит при остановке процесса? Метод Shutdown делает две вещи разом: переводит ВСЕ записи карты, включая пустое имя, в NOT_SERVING и ставит флаг shutdown, после которого статусы замораживаются. Док-комментарий честен: «Shutdown sets all serving status to NOT_SERVING, and configures the server to ignore all future status changes» — все статусы в NOT_SERVING, и все будущие изменения игнорируются. Игнорирование видно в логах: попытка после Shutdown вписать новый статус приведёт к записи вида «health: status changing for … is ignored because health service is shutdown», статус останется прежним. Обратная операция Resume зеркальна: «Resume sets all serving status to SERVING, and configures the server to accept all future status changes» — всё обратно в SERVING и снова принимать изменения; она нужна редко, но снимает вопрос «а можно ли оживить health после Shutdown» — можно, но только парой Resume, а не одиночным SetServingStatus. Отдельный метод List вернул бы всю карту разом: «List provides a non-atomic snapshot of the health of all the available services» — несогласованный снимок всех сервисов; если записей становится слишком много, сервер ответит RESOURCE_EXHAUSTED, как и обещает proto: «The server may respond with a RESOURCE_EXHAUSTED error if too many services exist». Порог виден в исходнике: константа maxAllowedServices = 100, и при превышении List вернёт ResourceExhausted с сообщением «server health list exceeds maximum capacity» и числом. Сотня сервисов на один процесс — приличный потолок; микросервису с двумя-тремя границами хватает с запасом.
Что вернёт Check после Shutdown
Прочитайте код и выберите, что он выведет:
healthSrv := health.NewServer()
healthSrv.SetServingStatus("shop.v1.OrderService",
healthpb.HealthCheckResponse_SERVING)
_ = healthSrv // ...сервер работает, статусы: "" и OrderService -> SERVING
healthSrv.Shutdown() // мягкий останов: все статусы -> NOT_SERVING
healthSrv.SetServingStatus("shop.v1.OrderService",
healthpb.HealthCheckResponse_SERVING) // поздно
// Клиенты делают два вызова Check:
// Check(Service: "") -> ?
// Check(Service: "shop.v1.OrderService") -> ?
// А третий клиент зовёт Check(Service: "shop.v1.PaymentService") -> ?Watch: поток статусов вместо опроса#
Check отвечает «как сейчас», и опрашивать его каждую секунду — значит плодить unary-вызовы и терять изменения между опросами. Для подписчиков есть Watch — server streaming: «The server will immediately send back a message indicating the current serving status» — сервер немедленно отсылает текущий статус первым сообщением, а дальше: «It will then subsequently send a new message whenever the service's serving status changes» — по каждому изменению. Протокол описывает и поведение на незнакомое имя, и здесь оно отличается от Check: «server will send a message setting the serving status to SERVICE_UNKNOWN but will not terminate the call» — подписка на несуществующий сервис не роняет стрим: придёт SERVICE_UNKNOWN, и если сервис позже появится через SetServingStatus, подписчик получит настоящий статус в тот же стрим. Watch — единственный метод, которому разрешён этот статус; Check на то же имя ответил бы NotFound. Завершение стрима протокол тоже регламентирует, и это редкий случай, когда клиенту прямо предписана ретрай-политика: «If the call terminates with status UNIMPLEMENTED, then clients should assume this method is not supported and should not retry the call. If the call terminates with any other status (including OK), clients should retry the call with appropriate exponential backoff» — Unimplemented значит «на этом сервере Watch нет, не дёргайте его больше», любой другой финал, даже чистый OK, — повод переподписаться с бэкоффом. Внутри server.go механика собрана на каналах: карта updates хранит для каждого сервиса карту «стрим → канал на одно сообщение»; setServingStatusLocked при изменении статуса сначала вычищает из канала непрочитанный старый апдейт — комментарий объясняет, когда такое бывает: «the client is not reading and the server gets flow control limited» — клиент читает медленнее, чем сервер пишет, и канал мог остаться занятым, — затем кладёт свежий статус. В цикле чтения стоит дедупликация: lastSentStatus сравнивается с новым значением, и повтор одинаковых статусов в сеть не уходит; обрыв отправки или завершение контекста закрывают подписчика с Canceled «Stream has ended.», а defer вычищает канал из карты. Итог: Watch — это точный, но тонкий инструмент, и в следующем блоке он внезапно оказывается внутри самого клиента grpc-go, а не только в вашем коде.
Клиентская проверка руками: Check с дедлайном и кодами#
Начнём с простого: проверить здоровье чужого или своего сервера из Go-кода можно за десять строк, и это обычный unary-вызов из пятого урока. Клиентская заглушка создаётся grpc_health_v1.NewHealthClient(conn), дальше Check с контекстом и дедлайном. Про дедлайн протокол говорит в обязательном тоне: «Clients should set a deadline when calling Check, and can declare the server unhealthy if they do not receive a timely response» — задайте срок, и если ответ не приехал вовремя, сервер считается нездоровым. Док health-checking добавляет ту же мысль с другой стороны: «A client can declare the server as unhealthy if the rpc is not finished after some amount of time» — отсутствие ответа за отведённое время приравнивается к «unhealthy», и это осознанное упрощение: зависший сервер неотличим от мёртвого, и для проверяющего разницы нет. Второе правило для клиента — не зависеть от того, реализован ли health вообще: «The client should be able to handle the case where server does not have the Health service» — ответ Unimplemented означает «сервер жив, но протокол не знает», и трактовать его как аварию обычно неверно. Разбор кода ошибки из тринадцатого урока здесь и есть вся логика: NotFound — имя не регистрировалось (сервер жив, статуса для этого сервиса нет), Unimplemented — health-сервиса нет вовсе, DeadlineExceeded — не успел (нездоров), NOT_SERVING в успешном ответе — сервис осознанно сняли с обслуживания. Отдельный нюанс, который стоит помнить: Check — это сетевой вызов как любой другой, и он сам инициирует ленивое подключение канала из пятого урока; проверка «жив ли сервер» перед отправкой бизнес-вызова в половине случаев просто устанавливает соединение, и её результат говорит скорее о сети и бэклоге, чем о здоровье бизнес-логики.
Клиент: Check и Watch через grpc_health_v1
GO// cmd/healthctl/main.go — здоровье сервиса глазами клиента.
conn, err := grpc.NewClient("dns:///orders.internal:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()))
if err != nil {
log.Fatal(err)
}
defer conn.Close()
hc := grpc_health_v1.NewHealthClient(conn)
// Check — unary: короткий дедлайн, и по нему же судим о здоровье.
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
resp, err := hc.Check(ctx, &grpc_health_v1.HealthCheckRequest{
Service: "shop.v1.OrderService",
})
switch {
case status.Code(err) == codes.NotFound:
// Сервер жив, но такое имя не регистрировалось: опечатка
// или забытый SetServingStatus, а не авария.
log.Print("нет такого сервиса в health")
case status.Code(err) == codes.Unimplemented:
log.Print("health-сервиса на сервере нет вовсе")
case err != nil:
// Включая DeadlineExceeded: не успел — считаем нездоровым.
log.Printf("проверка не прошла: %v", err)
case resp.Status != grpc_health_v1.HealthCheckResponse_SERVING:
log.Printf("статус: %s", resp.Status) // NOT_SERVING и иже с ними
default:
log.Printf("healthy: %s", resp.Status)
}
// Watch — server streaming: первый статус придёт немедленно,
// дальше — по каждому изменению; выходим по отмене контекста.
wctx, wcancel := context.WithCancel(context.Background())
defer wcancel()
stream, err := hc.Watch(wctx, &grpc_health_v1.HealthCheckRequest{Service: ""})
if err != nil {
log.Fatal(err)
}
for {
r, err := stream.Recv()
if err != nil {
log.Fatalf("watch завершён: %v", err)
}
log.Printf("сервер целиком: %s", r.Status)
}Смотрите на порядок веток switch: он повторяет протокольные правила — NotFound и Unimplemented разбираются раньше общей ошибки, потому что означают другие состояния; только после них err без кода трактуется как «нездоров». Watch с пустым Service подписывается на весь сервер: первое сообщение Recv вернёт текущий статус сразу, без ожидания изменений. Для командной проверки здоровья удобнее готовая утилита — о ней ниже, после Kubernetes.
Проверка: подписка Watch на незнакомый сервис
Клиент вызвал Watch с Service: «shop.v2.InventoryService», а сервер такой сервис в health никогда не регистрировал. Что произойдёт?
healthCheckConfig: когда каналу здоровье важнее пикера#
Теперь то, ради чего health-сервер ставят даже на бэкендах без оркестратора. Service config из пятнадцатого и шестнадцатого уроков умеет включать клиентскую health-проверку на уровне соединений: поле healthCheckConfig с вложенным serviceName. Комментарий в service_config.go описывает поле коротко: «serviceName is the service name to use in the health-checking request» — это ровно то имя, которое клиент положит в HealthCheckRequest, когда будет проверять SubConn. Форма в JSON привычная: «healthCheckConfig»: { «serviceName»: «shop.v1.OrderService» } — на уровне всего service config или внутри methodConfig. Запуск проверки требует одновременного выполнения четырёх условий, перечисленных в clientconn.go дословно: стартует она, только когда «it is not disabled by the user with the WithDisableHealthCheck DialOption», когда «internal.HealthCheckFunc is set by importing the grpc/health package», когда «a service config with non-empty healthCheckConfig field is provided» и когда «the load balancer requests it». Второе условие самое коварное: функция проверки подключается в init() пакета health, и это тот самый импорт, о котором предупреждает док пакета — «must be imported to enable support for client-side health checks». Забудете импорт в клиентском бинарнике — канал честно напишет в лог «Health check is requested but health check function is not set» и продолжит работать без проверки: соединение станет Ready обычным порядком. Сама проверка живёт в health/client.go и смотрит здоровье через Watch: метод захардкожен как «/grpc.health.v1.Health/Watch», и дальше три ветки поведения. Ответ SERVING — SubConn объявляется Ready; любой другой статус — TransientFailure с ошибкой «connection active but health check failed. status=%s» — заметьте, соединение живо, нездоров именно сервис. Ошибка стрима, кроме Unimplemented, — тоже TransientFailure: «connection active but received health check RPC error: %v». А вот Unimplemented — особая ветка: «Reports healthy for the LBing purposes if health check is not implemented in the server» — сервер без health-сервиса считается здоровым, и это защита от ситуации, когда вы включили healthCheckConfig на клиентах раньше, чем добавили health на все бэкенды. Разрывы и переподписки гасятся экспоненциальным бэкоффом backoff.DefaultExponential, счётчик попыток сбрасывается при каждом полученном сообщении. Отключить механизм разом можно опцией WithDisableHealthCheck — её док: «disables the LB channel health checking for all SubConns of this ClientConn». Шестнадцатый урок упоминал, что round_robin включает health listener для своих детей, — это ровно про четвёртое условие: политика попросила проверку, клиент держит Watch на каждом SubConn, и нездоровый бэкенд не попадает в пикер, хотя TCP-соединение с ним живо.
healthCheckConfig против сервера без health
Прочитайте код и выберите, что он выведет:
// Клиент:
const sc = `{
"healthCheckConfig": { "serviceName": "shop.v1.OrderService" }
}`
conn := grpc.NewClient("dns:///orders.internal:50051",
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithDefaultServiceConfig(sc)) // + импорт grpc/health есть
// Сервер: OrderService работает, но grpc_health_v1.RegisterHealthServer
// забыли вызвать — метода /grpc.health.v1.Health/Watch на сервере нет.
//
// Клиент делает вызов GetOrder. Что увидит бизнес-код?Kubernetes: liveness, readiness и gRPC-пробы#
Kubernetes проверяет контейнеры периодическими пробами, которые выполняет kubelet, и у него три вида задач. Liveness — про рестарт: «Liveness probes determine when to restart a container», и правило жёсткое: «If a container fails its liveness probe more times than the configured tolerance, the kubelet restarts that container» — провал подряд больше failureThreshold, контейнер убивают и перезапускают. Readiness — про трафик: провал не рестартует ничего, «the kubelet marks the container as not ready, and the Pod stops receiving traffic from matching Services» — Pod просто перестаёт получать трафик от подходящих Service'ов, пока проба не станет успешной снова. Startup — про долгий старт: пока она не прошла, «Kubernetes does not execute liveness or readiness probes until the startup probe succeeds» — остальные пробы ждут, чтобы медленно стартующее приложение не убили на размине. Между liveness и readiness есть важная деталь асимметрии: «Liveness probes do not wait for readiness probes to succeed» — живучесть проверяется своим графиком независимо от готовности. С v1.27 у kubelet есть нативная gRPC-проба (в документации — «Stable since Kubernetes v1.27»): если приложение реализует gRPC Health Checking Protocol, kubelet сам делает вызов Check — «The diagnostic is considered successful if the status of the response is SERVING» — и всё, что не SERVING, включая ошибки, считается провалом. Конфигурация минимальна: поле grpc с портом, опционально service — имя для HealthCheckRequest. Полезные мелочи из документации, каждая стоит часа отладки. Адрес: «The probes run against the pod IP address or its hostname» и дальше — «Be sure to configure your gRPC endpoint to listen on the Pod's IP address»: сервер, слушающий только localhost:50051, health-пробу kubelet'а не пройдёт — вспомните развилку из четвёртого урока про «:50051» против «localhost:50051». Транспорт: «The probes connect over plaintext by default» — plaintext без TLS, режим TLS появился позже как отдельная альфа-возможность за feature gate GRPCContainerProbeTLS, и даже тогда «the kubelet connects over TLS with InsecureSkipVerify» — сертификат не проверяется. Имя порта: «gRPC probes do not support named ports» — только число, никаких name из блока ports. Ошибки: «There are no error codes for built-in probes. All errors are considered as probe failures» — и отдельно про конфигурацию: «Configuration problems (for example: incorrect port or service, unimplemented health checking protocol) are considered a probe failure» — неверный порт или несуществующий service это провал пробы, а не Exception в логах kubelet. Наконец, поле service — официальный способ развести пробы на одном health-сервере: документация предлагает называть сервисы по назначению, «myservice-liveness» с разделителем через дефис, чтобы один endpoint отвечал на liveness и readiness по-разному, не поднимая два порта.
Манифест: две gRPC-пробы на один health-сервер
GO# k8s/deployment.yaml — пробы против health-сервера из этого урока.
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
template:
spec:
containers:
- name: orders
image: registry.internal/orders:1.7.0
ports:
- containerPort: 50051
readinessProbe: # готовность: трафик снимают и возвращают
grpc:
port: 50051 # только число: named ports не поддерживаются
service: shop.v1.OrderService
periodSeconds: 5
failureThreshold: 2
livenessProbe: # живучесть: устойчивый провал = рестарт
grpc:
port: 50051
# service не указан: kubelet спросит пустое имя,
# то есть здоровье всего сервера
initialDelaySeconds: 10
failureThreshold: 3Читаем как контракт. Readiness смотрит конкретный сервис OrderService: NOT_SERVING после SIGTERM из кода выше выведет Pod из Service'а, и выкатка пройдёт без ошибок у пользователей. Liveness спрашивает пустое имя — весь сервер: если процесс завис так, что gRPC-сервер не отвечает вовсе, kubelet убьёт контейнер; но отдельная деградация OrderService его не рестартует. Обе пробы ходят plaintext против подового IP — TLS-сервер из урока про TLS для проб по умолчанию не подойдёт. Отсутствие service в liveness не значит «любой сервис»: это буквально пустая строка в запросе, и на неё отвечает запись «» из statusMap.
grpc-health-probe: exec-проба для старых кластеров#
Нативные gRPC-пробы появились не сразу, и до v1.27 стандартным ответом была утилита grpc-health-probe из экосистемы: статически скомпилированный бинарник, который кладут в образ и вызывают exec-пробой. Её контракт — одна фраза из README: утилита «makes a RPC to /grpc.health.v1.Health/Check» и, если в ответе SERVING, выходит с кодом успеха, иначе — с ненулевым. Сам README уже признаёт: «Kubernetes has now built-in gRPC health checking», и советует нативный механизм, — но перечисляет случаи, когда утилита жива: «older versions of Kubernetes, or using advanced configuration (such as custom metadata, TLS or finer timeout tuning), or not using Kubernetes at all». Флаги покрывают то, чего в нативной пробе нет: обязательный -addr в форме host:port, -service для имени сервиса, -connect-timeout и -rpc-timeout (оба по умолчанию одна секунда), -rpc-header для метаданных из десятого урока, -tls с набором сертификатных флагов и даже -user-agent со значением по умолчанию «grpc_health_probe». Коды выхода стоит помнить наизусть, потому что именно их читает exec-проба: единица — неверные аргументы, двойка — не установилось соединение (StatusConnectionFailure), тройка — RPC не прошёл (StatusRPCFailure), четвёрка — «rpc succeeded but indicates unhealthy service» (StatusUnhealthy): вызов дошёл, но статус не SERVING. В манифесте это выглядит как readinessProbe и livenessProbe с exec.command вида «/bin/grpc_health_probe -addr=:50051 -service=shop.v1.OrderService», а сам бинарник скачивают в Dockerfile из релизов и делают chmod +x. У exec-проб есть цена, о которой документация Kubernetes предупреждает отдельно: каждый запуск — fork нескольких процессов, и при плотной упаковке подов и частых пробах это заметная нагрузка на CPU узла — ещё одна причина переехать на нативную gRPC-пробу. Тонкость с таймаутами тоже задокументирована: при выключенном feature gate ExecProbeTimeout утилита не уважает timeoutSeconds пробы (который по умолчанию одна секунда), тогда как встроенная проба на таймауте честно провалится — настраивайте -rpc-timeout явно, не полагаясь на окружение.
Проверка: NOT_SERVING в readiness и в liveness
Pod с двумя gRPC-пробами: readiness по service «shop.v1.OrderService», liveness без service. Приложение перевело OrderService в NOT_SERVING, пустое имя осталось SERVING. Что сделает kubelet?
Кто спрашивает здоровье и что делает с ответом#
| Потребитель | Что спрашивает | Реакция на сбой |
|---|---|---|
| kubelet liveness | Check по grpc-пробе; без service — пустое имя | Провал failureThreshold подряд — kubelet рестартует контейнер |
| kubelet readiness | Check, часто с именем конкретного сервиса | Pod снимается с трафика Service до возвращения SERVING |
| kubelet startup | Та же gRPC-проба на старте | Пока не прошла, liveness и readiness не выполняются вовсе |
| Канал grpc-go (healthCheckConfig) | Watch по serviceName каждого SubConn | SubConn уходит в TransientFailure и выпадает из пикера |
| grpc-health-probe (exec) | Unary Check, коды выхода 1-4 | Ненулевой код выхода = провал exec-пробы для kubelet |
| Ваш клиент руками | Check с дедлайном или Watch-подписка | DeadlineExceeded и NOT_SERVING трактуете сами, Unimplemented — не авария |
Соберите health-сервер
Впишите конструктор, регистрацию, метод установки статуса и имя статуса.
Готовность против живучести: семантика, которую легко перепутать#
Вся практика health-проверок держится на одном различии: готовность (readiness) отвечает «можно ли слать мне трафик прямо сейчас», живучесть (liveness) — «жил ли процесс или его пора убить». Первое — обратимое состояние: Pod ушёл с трафика и вернулся, ничего не потеряно. Второе — необратимое действие: рестарт убивает все in-flight вызовы, теряет кэши и прогретые состояния. Из этого следуют конкретные правила. NOT_SERVING — это про готовность: сервис осознанно не принимает запросы (выкатка, прогрев, деградация конкретного под-сервиса), и правильный потребитель — readiness или клиентский Watch. Вешать NOT_SERVING на liveness значит просить о самоубийстве при каждом осознанном снятии с трафика. Liveness через health-протокол должен оставаться тупым и дешёвым: пустое имя, отсутствие зависимости от внешних систем. Классическая ошибка — liveness, который проверяет соединение с базой: база мигнула, все Pod'ы провалили liveness разом, кластер сам себя перезапустил в момент, когда достаточно было подождать; readiness на базу в спорных случаях тоже осторожен — снятие с трафика всех инстансов из-за одной зависимости оставляет сервис пустым, и SRE-практика советует сигнализировать о собственном здоровье, а не о здоровье окружения. Второе правило — про старт. health.NewServer сразу пишет «» -> SERVING, и если RegisterHealthServer случился до того, как бизнес-сервис прогрелся, health лжёт первые секунды жизни. Паттерн: сразу после старта выставить NOT_SERVING (или вообще не регистрировать имя сервиса до готовности — незарегистрированное имя провалит readiness через NotFound, а liveness по пустому имени продолжит считать процесс живым), затем SERVING по факту прогрева. Третье правило — про дедлайны и чужие серверы: proto требует «Clients should set a deadline when calling Check», а док напоминает «The client should be able to handle the case where server does not have the Health service». Проверка без дедлайна зависает вместе с сервером и теряет смысл; проверка, падающая от Unimplemented, ломает клиентов, которые ни при чём. И последнее: UNKNOWN — нулевое значение перечисления, и в норме сервер его не шлёт; если в логах появился UNKNOWN, ищите не статус, а баг в коде статусов.
Первая: liveness на внешние зависимости — база мигнула, весь кластер перезапустил сам себя; liveness отвечает только за сам процесс. Вторая: перепутанные имена в пробах — liveness на имя бизнес-сервиса рестартует контейнер при бизнес-деградации, readiness на пустое имя снимает весь Pod из-за одного хромающего сервиса; разделяйте service в пробах (вспомните рекомендацию вида «myservice-liveness»). Третья: статус «» не связан с сервисами — сняли OrderService в NOT_SERVING, а пустое имя до Shutdown продолжает быть SERVING; обновляйте оба ключа руками. Четвёртая: после Shutdown статусы заморожены — SetServingStatus молча игнорируется с логом «is ignored because health service is shutdown», и попытка «оживить» статус после остановки не работает; оживление только парой Resume. Пятая: забытый импорт пакета health в клиентском бинарнике — healthCheckConfig в service config есть, а функции проверки нет: канал пишет в лог «Health check is requested but health check function is not set» и живёт без проверки; включайте healthCheckConfig и проверяйте в логах, что она вообще стартовала.
Чек-лист: health checking protocol#
- Протокол — сервис grpc.health.v1: Check (unary), List (снимок всех статусов) и Watch (стрим изменений); в grpc-go реализован пакетом health.
- ServingStatus — четыре значения: UNKNOWN (нулевое, в норме не шлётся), SERVING, NOT_SERVING и SERVICE_UNKNOWN только для Watch.
- Пустое имя service — здоровье всего сервера; NewServer стартует с «» -> SERVING; незнакомое имя в Check — NotFound «unknown service».
- Сопоставление имён точное, без wildcards; статусы ключей независимы: сервис и пустое имя обновляются по отдельности.
- Статусы меняются только руками: SetServingStatus; Shutdown переводит всё в NOT_SERVING и замораживает карту, Resume — обратная операция.
- List ограничен maxAllowedServices = 100 и отдаёт несогласованный снимок; превышение — RESOURCE_EXHAUSTED.
- Watch шлёт текущий статус немедленно, SERVICE_UNKNOWN для незнакомых имён не завершает стрим; Unimplemented — не переподписываться, остальное — с бэкоффом.
- Клиентский режим: healthCheckConfig с serviceName в service config плюс четыре условия запуска; Unimplemented от сервера считается здоровым.
- Kubernetes: gRPC-пробы стабильны с v1.27; успешен только ответ SERVING; plaintext по умолчанию, подовый IP, named ports не поддерживаются.
- liveness — рестарт, readiness — снятие с трафика, startup — пауза для долгого старта; разделяйте их через service в пробах.
Мягкий останов: от SIGTERM до GracefulStop
Расставьте шаги вывода Pod'а из эксплуатации в правильном порядке.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: health checking protocol
Что дальше по курсу#
Честная оговорка про формат, одна на урок: в самоучителе есть песочница с интерпретатором Go, но сеть в браузере недоступна — сервер, kubelet и балансировщики в браузере не запустить, поэтому runner-ов в этом уроке, как и во всём gRPC-курсе, нет. Всё разобранное проверяется локально за вечер на паре из четвёртого и пятого уроков. Три эксперимента. Первый: добавьте health-сервер к OrderService, запустите клиент с Watch на пустое имя и в отдельном терминале дёрните SetServingStatus — увидите, как подписка получает изменения без опроса; затем завершите процесс через Shutdown и попробуйте вернуть статус — в логах появится сообщение об игнорировании. Второй: включите healthCheckConfig в service config клиента и по логам убедитесь, что соединение Ready против сервера без health-сервиса; добавьте RegisterHealthServer, снимите OrderService в NOT_SERVING и посмотрите, как вызовы начнут ждать TransientFailure — а с WaitForReady из пятого урока ждут готовности вместо отказа. Третий: запустите grpc_health_probe -addr=:50051 -service=shop.v1.OrderService локально и проверьте коды выхода: SERVING — ноль, затем NOT_SERVING — четвёрка, затем чужой порт — двойка. Дальше по маршруту: восемнадцатый урок — рефлексия сервера: grpcurl, серверная рефлексия и отладка живого сервиса; после health он предсказуем — тем же способом «встроенного сервиса» gRPC добавит серверу самоназвание и каталог методов, чтобы дёргать его руками без заглушек. Перед ним освежите урок про балансировку — health listener на SubConn'ах станет понятнее с пикером в голове, — и урок про клиент — Check в этом уроке был обычным вызовом поверх NewClient. Полный маршрут курса — на странице gRPC-Go.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Health checking protocol — сервис grpc.health.v1: Check (unary), List (снимок) и Watch (стрим); в grpc-go готовая реализация — пакет health и grpc_health_v1.RegisterHealthServer.
- ServingStatus: UNKNOWN — нулевое и в норме не отсылается; SERVING и NOT_SERVING — рабочие состояния; SERVICE_UNKNOWN существует только для Watch.
- Пустое имя service — здоровье всего сервера: NewServer стартует с «» -> SERVING; незнакомое имя — NotFound «unknown service»; сопоставление точное, без wildcards.
- Статусы независимы и меняются только руками: SetServingStatus; Shutdown переводит всё в NOT_SERVING и замораживает карту (SetServingStatus игнорируется), Resume — обратная операция; List ограничен maxAllowedServices = 100.
- Watch шлёт текущий статус немедленно, не завершает стрим на SERVICE_UNKNOWN; Unimplemented — не переподписываться, любой другой финал — переподписка с экспоненциальным бэкоффом.
- Клиентский режим — healthCheckConfig с serviceName в service config и четыре условия запуска, включая импорт пакета health; Unimplemented от сервера считается здоровым, NOT_SERVING уводит SubConn в TransientFailure.
- Kubernetes gRPC-пробы стабильны с v1.27: успех — только SERVING; plaintext по умолчанию, адрес — подовый IP, named ports не поддерживаются, любая ошибка — провал пробы.
- liveness — рестарт контейнера, readiness — снятие Pod'а с трафика, startup — пауза на прогрев; разделяйте пробы через service и держите liveness на пустом имени, а NOT_SERVING — для readiness.
Домашнее задание
Соберите лабораторию здоровья на паре сервер/клиент из четвёртого и пятого уроков. Первое: добавьте health.NewServer к серверу OrderService, зарегистрируйте оба сервиса и напишите отдельную команду healthctl, которая вызывает Check с дедлайном и разбирает все четыре исхода: SERVING, NOT_SERVING, NotFound и Unimplemented; проверьте её против сервера без health — убедитесь, что Unimplemented не выглядит аварией. Второе: добавьте в клиент режим Watch с пустым именем и переподпиской по правилу proto — Unimplemented без повторов, любой другой финал с экспоненциальным бэкоффом; в отдельной горутине сервера переключайте статусы и убедитесь, что подписка получает изменения, а после Shutdown — NOT_SERVING без новых обновлений. Третье: реализуйте прогрев-паттерн — сразу после старта сервис OrderService отсутствует в statusMap, readiness-клиент получает NotFound, через пять секунд прогрева вызывается SetServingStatus; объясните по логам, почему liveness по пустому имени всё это время не рестартует контейнер. Четвёртое: соберите манифест с двумя gRPC-пробами — readiness по OrderService и liveness без service — и прогоните выкатку локально (minikube или kind): посмотрите в describe pod, как Pod снимается с Endpoints при NOT_SERVING и возвращается после SERVING; если кластера нет — эмулируйте пробу утилитой grpc_health_probe с флагами -connect-timeout и -rpc-timeout. Пятое: включите healthCheckConfig с serviceName в клиенте и сравните поведение против трёх серверов: без health, с health и снятым сервисом, с health и SERVING; зафиксируйте в таблице состояния SubConn (Ready, TransientFailure) и объясните, почему сервер без health не считается нездоровым. Шестое: попробуйте Resume после Shutdown и опишите по исходникам server.go, что именно произойдёт со статусами и флагом shutdown; затем объясните, почему пара Shutdown+Resume возвращает SERVING всем сервисам разом, а одиночный SetServingStatus после Shutdown — нет.
Личные заметкиПусто
Вопросы и ответы по уроку
Как отличить «сервис нездоров» от «health не настроен» и почему это важно?
Протокол даёт три разных сигнала, и путать их — источник ложных аварий. NOT_SERVING в ответе Check — осознанное состояние: имя зарегистрировано, но сервис не готов. NotFound («unknown service») — имя никогда не встречалось: опечатка в service, забытый SetServingStatus или сервис поднимается позже. Unimplemented — health-сервиса на сервере нет вовсе. Плюс четвёртый случай — дедлайн: не ответило вовремя, считаем нездоровым («Clients should set a deadline when calling Check»). Для kubelet любой исход, кроме SERVING, — провал пробы («All errors are considered as probe failures»), поэтому настраивайте пробы так, чтобы NotFound при прогреве проваливал только readiness, а не liveness. В коде разбирайте ошибки через status.Code — так, как в уроке тринадцатом.
Что выбрать: клиентский healthCheckConfig, k8s-пробы или ручной Check в приложении?
Это три слоя, а не конкуренты. k8s-пробы нужны всегда, когда сервис живёт в Kubernetes: kubelet снимает Pod с трафика и рестартует контейнеры — никакой клиентский код этого не заменит. healthCheckConfig в service config — для клиентской балансировки: каналы сами перестают выбирать нездоровый SubConn, и работает это даже в облачках без оркестратора; помните про импорт пакета health и про то, что Unimplemented считается здоровым. Ручной Check/Watch — инструмент прикладного слоя: проверить чужой сервис из CLI-утилиты, собрать health-панель, подождать готовности перед прогоном тестов. Слои не конфликтуют: один и тот же health-сервер отвечает и kubelet'у, и каналам, и вашим скриптам.
Почему после SetServingStatus(NOT_SERVING) Pod ещё получает трафик и как это ускорить?
Потому что между сменой статуса и реакцией инфраструктуры всегда есть задержка: kubelet опрашивает readiness с периодом periodSeconds, Endpoints обновляются, клиенты узнают об изменении. Сократить окно можно тремя ручками: уменьшить periodSeconds у readiness-пробы, добавить паузу между NOT_SERVING и GracefulStop (в уроке — пять секунд), и держать на клиентах WaitForReady, чтобы вызовы в момент переключения ждали готовности вместо отказа. Порядок в order-задании урока — именно про это: сначала статусы, потом пауза, потом GracefulStop. Обратный процесс не мгновенный тоже: при возврате SERVING первые periodSeconds Pod остаётся снятым с трафика — это нормально и лечится только тюнингом периодов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Connect: современный RPC от Buf
Курс Connect (Buf): протоколы, клиенты, ошибки, interceptors, браузерные клиенты, buf CLI и BSR. 25 уроков и итоговый проект API.
- Строить сервисы на connectrpc.com/connect
- Понимать три протокола и выбирать правильный
- Настраивать buf: lint, breaking changes, generate
- Писать interceptors и обрабатывать ошибки