Содержание урока · 12 разделов
- Статический список умирает при первом деплое
- Три контракта: Registrar, Instancer, Factory
- Consul-адаптер: регистрация и health check
- Instancer по Consul: blocking queries и passingOnly
- etcd-адаптер: ключи вместо health checks
- Адаптеры sd и их механика живости
- Что регистрировать: ID, теги и метаданные
- Порядок внедрения discovery
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Статический список умирает при первом деплое#
У прокси из урока 11 есть скрытый долг: список localhost:8081,localhost:8082 живёт ровно до первого нормального деплоя. Автоскейлинг добавил пятый под — он молча простаивает; под с аварией заменён оркестратором — его адрес в списке мёртв, и retry добросовестно долбит в стену; IP в Kubernetes меняются при каждом рестарте — руками вести такой список бессмысленно. Ответ микросервисной индустрии — service discovery: реестр, где каждый инстанс сообщает «я жив, вот мой адрес», а вызывающие сервисы подписываются на актуальный список. go-kit реализует client-side discovery: реестр используется и серверами (зарегистрироваться), и клиентами (узнать список), а выбор инстанса остаётся в твоём процессе — тот же lb.Balancer из урока 11, только теперь под ним живой список. Роль реестра может сыграть Consul с его health checks и blocking queries, etcd с lease и watch, Kubernetes API, ZooKeeper или даже DNS — и в этом сила подхода go-kit: готовый go kit consul-адаптер живёт в пакете sd/consul, рядом sd/etcdv3, и оба адаптируют каждый бэкенд к ОДНИМ И ТЕМ ЖЕ трём интерфейсам. Сменишь реестр — сменится конструктор в main, фабрика и прокси-middleware из прошлого урока не заметят. Именно поэтому в курсе мы учим интерфейсы, а не кнопки конкретного продукта.
Три контракта: Registrar, Instancer, Factory#
Открой kit/sd — там три маленьких типа, и это весь язык discovery. sd.Registrar — интерфейс из двух методов Register() и Deregister(): жизненный цикл одного инстанса; реализация знает, куда и что именно писать в конкретный реестр. sd.Instancer — подписчик на изменения: Register(ch chan<- sd.Event), Deregister(ch) и Stop(); он постоянно следит за реестром и рассылает подписчикам sd.Event{Instances []string, Err error} — ПОЛНЫЙ текущий набор инстансов или ошибку связи, а не дифф. Подписка даёт и режим «слушателя обновлений»: любой твой компонент может зарегистрировать канал и получать срезы инстансов на каждое изменение — в старых версиях go-kit для этого существовал отдельный UpdateListener, сегодня его роль честно выполняет канал sd.Event. sd.Factory — func(instance string) (endpoint.Endpoint, io.Closer, error): превращает строку-инстанс в endpoint, а io.Closer нужен, когда endpoint держит соединение, которое надо закрыть, когда инстанс уходит. Замыкает тройку sd.NewEndpointer(instancer, factory, logger): он регистрирует внутренний канал у Instancer и является каноническим слушателем обновлений — каждый sd.Event превращается в пересобранный список endpoint'ов через factory; ошибочные события он переживает, продолжая отдавать последние живые endpoints, пока опцией sd.InvalidateOnError(timeout) не скажешь иначе. Запомни потоки данных: Registrar — сервер вверх в реестр, Instancer — вниз к клиенту, Factory — строка в endpoint, Endpointer — их склейка.
sd.Factory: строка-инстанс превращается в endpoint
GO// Фабрика строит HTTP-прокси-endpoint для строки "host:port".
func uppercaseFactory(instance string) (endpoint.Endpoint, io.Closer, error) {
if !strings.HasPrefix(instance, "http") {
instance = "http://" + instance
}
u, err := url.Parse(instance)
if err != nil {
return nil, nil, err
}
u.Path = "/uppercase"
e := httptransport.NewClient(
"POST", u, encodeRequest, decodeUppercaseResponse,
).Endpoint()
return e, nil, nil // HTTP-клиенту нечего закрывать
}Фабрика — это makeUppercaseProxy из урока 11, упакованный в контракт sd.Factory: та же пара encodeRequest и decodeUppercaseResponse, тот же u.Path. Тело фабрики — место, где выбирается транспорт: точно так же можно достать gRPC-соединение и вернуть endpoint из grpctrans.NewClient — увидим в уроке 13. Транспорт не утекает из функции: вызывающая сторона получает endpoint, как и положено по слоям из урока 01. Возвращать nil Closer честно: контракт разрешает nil, а вот когда фабрика строит gRPC-соединение, там вернётся conn — и Endpointer закроет его, когда инстанс вычеркнут из списка.
Consul-адаптер: регистрация и health check#
Первая половина круга — серверная: инстанс должен появиться в реестре и вычеркнуться сам. Пакет sd/consul оборачивает официальный github.com/hashicorp/consul/api: consulsd.NewClient(apiClient) даёт Client с тремя методами Register, Deregister и Service; конструктор принимает consul.Client, созданный api.NewClient(api.DefaultConfig()) — тот же клиент, что и весь остальной код против Consul, никакого параллельного протокола. Регистрация описывается структурой api.AgentServiceRegistration: ID — уникальное имя инстанса (конвенция: сервис-хост-порт, иначе два пода перезапишут друг друга), Name — имя сервиса, по которому его будут искать, Address и Port — куда ходить, Tags — фильтры вроде version=v2. Сердце discovery — Check api.AgentServiceCheck: Consul сам ходит по адресу, чтобы убедиться, что инстанс жив. HTTP-check пингует http://host:port/healthz каждые Interval=10s с Timeout=1s; для TCP-сервисов есть TCP-check. Добавь DeregisterCriticalServiceAfter (например, 1m): инстанс, чей check красный дольше минуты, вычёркивается из каталога совсем — иначе в реестре накопятся зомби. Обёртка над всей этой структурой — consulsd.NewRegistrar(client, registration, logger): он реализует sd.Registrar, логирует Register и Deregister в структурный лог из урока 06, а ошибки не паникует. В main: registrar.Register() сразу после поднятия слушателя и defer registrar.Deregister() — при graceful shutdown из урока 19 инстанс исчезнет из ротации до того, как процесс умрёт.
Регистрируем stringsvc в Consul
GOimport (
consulapi "github.com/hashicorp/consul/api"
consulsd "github.com/go-kit/kit/sd/consul"
)
func main() {
// ... слушатель на :8081 уже поднят
client, err := consulapi.NewClient(consulapi.DefaultConfig())
if err != nil {
logger.Log("err", err)
os.Exit(1)
}
asr := &consulapi.AgentServiceRegistration{
ID: "stringsvc-127.0.0.1-8081",
Name: "stringsvc",
Address: "127.0.0.1",
Port: 8081,
Tags: []string{"version", "v1"},
Check: &consulapi.AgentServiceCheck{
HTTP: "http://127.0.0.1:8081/healthz",
Interval: "10s",
Timeout: "1s",
DeregisterCriticalServiceAfter: "1m",
},
}
registrar := consulsd.NewRegistrar(consulsd.NewClient(client), asr, logger)
registrar.Register()
defer registrar.Deregister()
// ... ListenAndServe
}Три импорта: официальный консул-клиент под consulapi, адаптер go-kit под consulsd. ID строится по конвенции сервис-адрес-порт: в кластере с одинаковыми Address и Port два инстанса не должны делить ID. Tags — пары значений: фильтр version=v2 на клиенте выберет только вторую версию. Check.HTTP — эндпоинт здоровья: отдаёшь 200 — check зелёный. NewRegistrar логирует через kitlog с полями service, tags, address — ищи его строки в общем логе из урока 06.
Instancer по Consul: blocking queries и passingOnly#
Вторая половина круга — клиентская: узнать живых. consulsd.NewInstancer(client, logger, service, tags []string, passingOnly bool) создаёт Instancer для сервиса по имени. Внутри — blocking queries, фирменная механика Consul: клиент спрашивает «что изменилось с индекса N?» и сервер ДЕРЖИТ соединение открытым, пока в каталоге что-то не поменяется, — long polling вместо опроса по таймеру, минимальная латентность при почти нулевом трафике. Аргумент tags фильтрует инстансы по всем тегам сразу, а passingOnly=true — самый важный переключатель: true значит «только инстансы с зелёными health checks», и это дефолт для продакшена, потому что под красным check инстанс жив физически, но слать туда запросы нельзя. При изменении каталога Instancer рассылает подписчикам sd.Event с полным списком адресов; при сбое связи с Consul уходит sd.Event{Err: ...}, и Instancer переходит в exponential backoff — в исходнике интервал растёт от 10 мс через conn.Exponential. Слушателем событий по умолчанию служит Endpointer из sd.NewEndpointer: он держит endpoint-кеш и на каждое обновление пересоздаёт endpoint'ы через фабрику — новорождённые добавляются, вычеркнутые закрываются через их io.Closer. А если реестр умер целиком? Endpointer по умолчанию продолжает отдавать последние известные endpoints — лучше работать по вчерашней карте, чем лечь целиком; если для твоего сервиса наоборот, включи sd.InvalidateOnError(timeout): после таймаута без свежих данных Endpointer начнёт возвращать ошибку, и lb.Balancer честно ответит ErrNoEndpoints из урока 11.
Клиентская цепочка: instancer → endpointer → balancer → retry
GOtags := []string{"version", "v1"}
instancer := consulsd.NewInstancer(
client, logger, "stringsvc", tags, true, // passingOnly
)
factory := uppercaseFactory
endpointer := sd.NewEndpointer(instancer, factory, logger)
balancer := lb.NewRoundRobin(endpointer)
retry := lb.Retry(3, 250*time.Millisecond, balancer)
svc := proxymw{
next: stringService{},
uppercase: retry,
}Вся client-side цепочка в шести строках — ровно та же схема, что в каноническом apigateway-примере go-kit. Instancer подписан на Consul, Endpointer пересобирает endpoint'ы через uppercaseFactory на каждое событие, балансировщик из урока 11 выбирает, retry страхует. Сравни с фиксированным списком из прошлого урока: поменялась только первая строчка — вместо среза адресов появился живой Instancer, proxymw и вся наблюдаемость не тронуты. Список endpoint'ов можно посмотреть и руками: endpointer.Endpoints() вернёт текущий набор — удобно для дебага и метрик вида «сколько живых инстансов вижу».
etcd-адаптер: ключи вместо health checks#
etcd — тот же класс реестра с другой философией: вместо активных health checks — TTL-lease, вместо blocking queries — watch по префиксу. Пакет sd/etcdv3 оборачивает go.etcd.io/etcd/client/v3: etcdv3.NewClient(ctx, machines []string, etcdv3.ClientOptions{}) строит соединение с опциями DialTimeout и DialKeepAlive (дефолт по три секунды). Регистрация описывается структурой etcdv3.Service{Key, Value, TTL *TTLOption}: Key — уникальный путь вроде /services/stringsvc/127.0.0.1:8081, Value — строка-инстанс, которую получит фабрика (обычно host:port или URL). TTL создаётся NewTTLOption(heartbeat, ttl) с рекомендованными тремя и десятью секундами: Registrar заводит lease, горутина-heartbeat освежает его каждые heartbeat, и пока процесс жив, ключ существует; процесс умер — lease истёк, etcd сам удалил ключ, и через ttl инстанс исчез из списков. Это и есть живость без HTTP-check: реестр узнаёт о смерти по отсутствию пульса, а не по неответу на пинг. etcdv3.NewRegistrar(client, service, logger) реализует тот же sd.Registrar, а etcdv3.NewInstancer(client, prefix, logger) подписывается watch'ем на префикс и рассылает sd.Event при любом добавлении или удалении ключа — клиентская цепочка с sd.NewEndpointer и lb не меняется ни на символ. Что выбрать: Consul силён health checks, тегами и UI, etcd — простотой модели «ключ-значение» и тем, что уже стоит в кластере Kubernetes; в пакете sd есть также адаптеры kubernetes, dnssrv и zookeeper — все с теми же тремя интерфейсами.
Адаптеры sd и их механика живости#
| Адаптер | Живость инстанса | Механика обновлений | Фильтры |
|---|---|---|---|
| sd/consul | HTTP/TCP health checks на стороне Consul | blocking queries по индексу | имя сервиса, tags, passingOnly |
| sd/etcdv3 | TTL-lease с heartbeat-горутиной | watch префикса ключей | префикс путей |
| sd/kubernetes | readiness/liveness проб пода | watch через API Kubernetes | namespace, labels selector |
| sd/dnssrv | жизнеспособность DNS-записей | опрос по таймеру | имя и порт SRV-записи |
| sd (fixed) | никто не проверяет — ты сам | нет обновлений | то, что ты задал руками |
Что регистрировать: ID, теги и метаданные#
Discovery ломается не в коде, а в данных, поэтому пройдём по полям регистрации как по контракту. ID должен быть уникальным на весь реестр: сервис-хост-порт — рабочая конвенция, и рестарт на том же хосту перезапишет свою же запись вместо плодления дублей. Name — имя сервиса, под которым его ищут клиенты; договоритесь о едином стилистическом регистре, потому что поиск чувствителен к строке. Address — адрес, ДОСТУПНЫЙ вызывающим: IP пода, а не localhost, иначе инстанс зарегистрируется и ни один сосед не достучится — классическая ошибка первого запуска. Tags — это твои срезы трафика: version=v2 для канареек, dc=east для географии, env=prod как страховка; фильтр на клиенте обязан совпадать по всем тегам инстанса. В Meta можно положить пары ключ-значение — но помни урок кардинальности из 07: в реестре место для идентификации, а не для показаний счётчиков. Interval health check выбирай из цены: проверка каждые 10 секунд — это до 10 секунд, пока мёртвый инстанс числится живым; интервальные check'и — компромисс между скоростью реакции и нагрузкой на Consul. И последнее: healthz из урока 20 будет отдавать осмысленный readiness — сейчас достаточно 200 OK, но держи эндпоинт честным: 200 должен значить «я реально могу обработать запрос», иначе discovery превратится в театр.
Порядок внедрения discovery#
- Поднять реестр локально и убедиться, что API доступен, — до всякого кода.
- Добавить /healthz и убедиться, что он отражает реальную способность обслуживать.
- Сервер: описать AgentServiceRegistration или etcdv3.Service, Registrar.Register() после Listen, Deregister в defer.
- Клиент: фабрика sd.Factory поверх клиентского транспорта из урока 11.
- Клиент: consulsd.NewInstancer с passingOnly=true → sd.NewEndpointer → lb.NewRoundRobin → lb.Retry.
- Погонять хаос: рестарты инстансов, обрыв реестра, деградация check — и проверить поведение по логам и метрикам.
defer registrar.Deregister() не сработает при kill -9 и обрыве питания — запись останется в реестре, и клиенты будут слать запросы в мёртвый адрес. Спасают два механизма, и нужны ОБА: health check, который покраснеет после пропуска пингов (Consul: Interval и Timeout; etcd: TTL-lease, истёкший без heartbeat), и DeregisterCriticalServiceAfter в Consul — вычёркивание из каталога после минуты в красном. Без первого клиенты узнают о смерти через retry-таймауты; без второго каталог зарастает трупами, а dashboard'ы врут.
Урок требует установленного Go и реестра: подними локальный Consul одной командой consul agent -dev или etcd одной бинарью; в песочнице курса сторонние процессы не исполняются, поэтому интерактивы построены на чтении кода. Локально: зарегистрируй stringsvc, открой UI Consul на 8500 и увидь свой инстанс с зелёным check; потом Ctrl+C — и запись исчезнет из каталога. Клиентскую цепочку проверь так: подними два инстанса, чередуй запросы через прокси и останавливай один — список endpoint'ов обновится без рестарта клиента.
Что получит подписчик, только что вызвавший instancer.Register(ch) на живом Consul-инстансе?
Consul-Instancer потерял связь с агентом. Что увидят клиенты?
Что означает passingOnly=true в consulsd.NewInstancer?
Один из двух инстансов покраснел
Прочитайте код и выберите, что он выведет:
// stringsvc: два инстанса, v1, оба были passing.
// Один перестал отвечать на /healthz.
instancer := consulsd.NewInstancer(
client, logger, "stringsvc", []string{"version", "v1"}, true,
)
endpointer := sd.NewEndpointer(instancer, uppercaseFactory, logger)
// сколько endpoint'ов вернёт endpointer.Endpoints()
// после того, как check станет critical?etcd-регистрация без heartbeat
Прочитайте код и выберите, что он выведет:
ttlOpt := etcdv3.NewTTLOption(3*time.Second, 10*time.Second)
service := etcdv3.Service{
Key: "/services/stringsvc/10.0.0.5:8081",
Value: "10.0.0.5:8081",
TTL: ttlOpt,
}
registrar := etcdv3.NewRegistrar(client, service, logger)
registrar.Register()
// процесс убит kill -9. Через сколько максимум
// клиенты перестанут видеть инстанс?Собери серверную и клиентскую стороны
Восстанови четыре строки. Пропуски — по порядку: конструктор Registrar в адаптере Consul, метод жизненного цикла, конструктор Instancer, конструктор Endpointer из пакета sd.
Жизнь инстанса в discovery
Расставь события полного круга discovery: от старта процесса до исчезновения из списков.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
Круг замкнулся: сервер регистрируется с health check, клиент подписывается через Instancer, Factory превращает адреса в endpoint'ы, а балансировочная цепочка из урока 11 теперь питается живым списком — и вся эта конструкция переживает деплои, автоскейлинг и падения соседей. Освежить фундамент можно в уроке 02-endpoint, клиентский транспорт — в уроке 04-http-transport; карта курса — на странице курса. Один штрих остался: фабрика пока строит HTTP-ноги, а индустрия для service-to-service выбирает gRPC — бинарный протокол с контрактом в .proto и кодогенерацией. Дальше — урок 13-grpc-transport: grpctrans.NewServer, биндинг proto-сервиса и gRPC-клиент, который встаёт в нашу фабрику одной функцией.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Discovery в go-kit — три контракта: sd.Registrar (Register/Deregister), sd.Instancer (подписка на sd.Event), sd.Factory (инстанс → endpoint).
- sd.Event несёт полный список инстансов или Err; подписчик получает первый Event сразу после Register.
- consulsd.NewRegistrar над AgentServiceRegistration: ID сервис-хост-порт, Tags для фильтров, Check с Interval и DeregisterCriticalServiceAfter.
- consulsd.NewInstancer держит blocking queries; passingOnly=true — только инстансы с зелёными health checks.
- sd.NewEndpointer — канонический слушатель обновлений: пересобирает endpoint'ы через Factory; при Err держит последние живые, InvalidateOnError — исключение.
- etcdv3: Service{Key, Value, TTL}, NewTTLOption(3s, 10s), lease с heartbeat-горутиной и watch по префиксу вместо health checks.
- Клиентская цепочка не меняется от реестра: Instancer → NewEndpointer → lb.NewRoundRobin → lb.Retry из урока 11.
Домашнее задание
Проведи stringsvc через полный круг discovery. Первое: подними Consul в dev-режиме, добавь /healthz и зарегистрируй два инстанса с конвенцией ID сервис-хост-порт и тегом version=v1; убедись в UI, что оба green, и что Ctrl+C убирает запись. Второе: собери клиентскую цепочку consulsd.NewInstancer(passingOnly=true) → sd.NewEndpointer → lb.NewRoundRobin → lb.Retry и проверь: останови один инстанс — запросы перетекли на живого без рестарта клиента; запиши, сколько событий sd.Event было в логах. Третье: убей инстанс kill -9 и проследи по логам и каталогу, когда check покраснел, когда инстанс исчез из выдачи, когда DeregisterCriticalServiceAfter вычеркнул запись — три временные метки в отчёт. Четвёртое: зарегистрируй третий инстанс с тегом version=v2 и проверь два клиента с разными фильтрами тегов; объясни, почему клиент v1 его не увидел. Пятое: оберни тот же сервис etcdv3-регистрацией с NewTTLOption(3s, 10s), повтори kill -9 и сравни время реакции Consul и etcd; напиши в выводах, какая модель живости тебе кажется честнее для твоего продакшена и почему.
Личные заметкиПусто
Вопросы и ответы по уроку
Consul или etcd — что выбрать для go-kit сервиса?
Если у команды уже есть Consul — бери sd/consul: health checks проверяют способность СЕРВИСА отвечать, теги и UI экономят недели. Если инфраструктура Kubernetes — etcd уже стоит рядом, а под-адреса часто и так видны через sd/kubernetes, и отдельный реестр не нужен. Три интерфейса sd одинаковы для всех адаптеров, так что смена реестра — это конструктор в main, и выбор не становится судьбой проекта.
Нужен ли sd.InvalidateOnError или дефолт честнее?
Дефолт (работать по последней известной карте) лучше для большинства сервисов: реестр моргнул — вызовы продолжаются по вчерашним живым инстансам, часть из них реально работает. InvalidateOnError оправдан там, где устаревший список опаснее отказа: строгие финансовые операции, где нельзя бить по инстансу, чей статус неизвестен. В обоих случаях ошибку связи с реестром видно в логах и метриках — главное, чтобы они были настроены.
Кто такой UpdateListener и где он в современном go-kit?
В ранних версиях go-kit существовал интерфейс слушателя обновлений с методом Endpoints([]endpoint.Endpoint); в современном kit его роль выполняют два механизма: подписка каналом через sd.Instancer.Register — события sd.Event с полным списком инстансов, и sd.NewEndpointer, который является каноническим слушателем и пересобирает endpoint'ы через Factory. Если хочешь своего слушателя — зарегистрируй канал и фильтруй события: интерфейсов не прибавилось, но контракты остались крошечными.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации