Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 12 из 25 ~22 мин +50 XP

Service discovery: Consul

Прокси из урока 11 умеет выбирать между инстансами, но список соседей в нём задаётся флагом — а флаг не переживает ни одного деплоя. Дальше работает service discovery: каждый инстанс регистрируется при старте и дерегистрируется при остановке, а вызывающие сервисы спрашивают у реестра, кто сейчас жив. Готовый go kit consul-адаптер живёт в пакете sd/consul, рядом есть etcdv3, и все они реализуют одни и те же три контракта: Registrar, Instancer и Factory. Ты соберёшь полный круг service discovery go-сервиса: регистрацию с health check, подписку на обновления через sd.Event, живой Endpointer поверх sd.NewEndpointer и балансированную цепочку из урока 11, которая обновляется сама.

Содержание урока · 12 разделов

Статический список умирает при первом деплое#

У прокси из урока 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

GO
import (
        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

GO
tags := []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/consulHTTP/TCP health checks на стороне Consulblocking queries по индексуимя сервиса, tags, passingOnly
sd/etcdv3TTL-lease с heartbeat-горутинойwatch префикса ключейпрефикс путей
sd/kubernetesreadiness/liveness проб подаwatch через API Kubernetesnamespace, 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#

  1. Поднять реестр локально и убедиться, что API доступен, — до всякого кода.
  2. Добавить /healthz и убедиться, что он отражает реальную способность обслуживать.
  3. Сервер: описать AgentServiceRegistration или etcdv3.Service, Registrar.Register() после Listen, Deregister в defer.
  4. Клиент: фабрика sd.Factory поверх клиентского транспорта из урока 11.
  5. Клиент: consulsd.NewInstancer с passingOnly=true → sd.NewEndpointer → lb.NewRoundRobin → lb.Retry.
  6. Погонять хаос: рестарты инстансов, обрыв реестра, деградация check — и проверить поведение по логам и метрикам.

Что получит подписчик, только что вызвавший 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.

registrar := consulsd.(client, asr, logger) registrar.Register() defer registrar.() instancer := consulsd.(client, logger, "stringsvc", tags, true) endpointer := sd.(instancer, factory, logger)

Жизнь инстанса в discovery

Расставь события полного круга discovery: от старта процесса до исчезновения из списков.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Термины урока

Термин

sd.Registrar

нажмите, чтобы перевернуть
Определение

Интерфейс Register()/Deregister(): жизненный цикл одного инстанса в реестре; реализации — consulsd.NewRegistrar, etcdv3.NewRegistrar.

1 / 6

Что дальше#

Круг замкнулся: сервер регистрируется с 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; напиши в выводах, какая модель живости тебе кажется честнее для твоего продакшена и почему.

Личные заметкиПусто
Хранятся только в вашем браузере (go-kit · Service discovery: Consul)

Вопросы и ответы по уроку

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. Если хочешь своего слушателя — зарегистрируй канал и фильтруй события: интерфейсов не прибавилось, но контракты остались крошечными.

Термины урока

Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.

Следующая ступень учебного пути

gRPC25 уроков~14 чпродвинутый25 уроков уже доступны

gRPC-Go: высокопроизводительный RPC

Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.

  • Писать .proto-схемы и генерировать код
  • Реализовывать unary и streaming сервисы
  • Управлять метаданными, дедлайнами и отменами
  • Строить interceptors для логов и аутентификации