---
title: "Service discovery: Consul"
description: "Service discovery в go-kit: Registrar, Instancer и Factory, sd.Event, адаптеры Consul и etcdv3, health checks, TTL-lease и живая подписка на обновления инстансов."
course: "go-kit: микросервисный тулкит"
url: "https://golangmaster.ru/courses/go-kit/lessons/12-discovery/"
published: "2026-09-19"
updated: "2026-09-21"
minutes: 22
---

# Service discovery: Consul

*Курс «go-kit: микросервисный тулкит», урок 12 из 25. ~22 минут. Обновлено 21.09.2026. Источник: https://golangmaster.ru/courses/go-kit/lessons/12-discovery/*

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

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

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

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 — и проверить поведение по логам и метрикам.

> **Зомби-инстансы: kill -9 без Deregister.** 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-инстансе?

- Ничего — события придут только при следующем изменении каталога
- ✅ Сразу sd.Event с полным текущим набором инстансов
- Только дифф: добавленные и удалённые инстансы
- Ошибка: Register для новых подписчиков не определён

Контракт sd.Instancer: полный набор отправляется СРАЗУ при регистрации канала и на каждом будущем обновлении. Поэтому подписчикам не нужен начальный запрос списка — первый Event уже актуален. Диффов нет тоже: каждый Event — срез «кто жив сейчас», и потребителю нечего реконструировать.

**Вопрос:** Consul-Instancer потерял связь с агентом. Что увидят клиенты?

- ✅ sd.Event с Err — и Endpointer продолжит отдавать последние известные endpoints
- sd.Event с пустым Instances — все инстансы считаются мёртвыми
- Панику Instancer — клиент обязан пересоздать его
- Ничего: connection проблемы скрыты внутри blocking query

При ошибке связи рассылается sd.Event{Err: ...} без нового списка. Endpointer трактовает это как «данные устарели, но не неверны»: он продолжает отдавать ранее созданные endpoint'ы, пока не придёт свежий Event. Работать по последней известной карте — дефолт; если сервис требует обратного, включи sd.InvalidateOnError с таймаутом, и после него Endpointer начнёт возвращать ошибку — балансировщик ответит ErrNoEndpoints.

**Вопрос:** Что означает passingOnly=true в consulsd.NewInstancer?

- Возвращать только инстансы, отвечающие быстрее 100 мс
- ✅ Возвращать только инстансы, чей health check сейчас passing
- Разрешить незарегистрированные инстансы из каталога
- Пропускать ошибки Consul без события Err

passingOnly фильтрует по статусу health checks: в список попадут только инстансы с зелёным check. Это дефолт для вызовов в проде: физически живой, но больной инстанс не должен получать трафик. false полезен для отладки и мониторинга — посмотреть весь каталог, включая красные, и сравнить с тем, что видит клиент.

### Один из двух инстансов покраснел

```go
// 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?
```

**Что выведет программа?**

- Два — health check не влияет на список инстансов
- Ноль — ошибка одного инстанса инвалидирует весь список
- ✅ Один — красный инстанс исчез из очередной рассылки sd.Event
- Два, но половина вызовов к больному будет падать

passingOnly=true — и Consul исключил больного из результатов blocking query; Instancer разослал sd.Event с одним инстансом, Endpointer пересобрал endpoint'ы через фабрику, балансировщик перестал выбирать красного. Никакого кода на клиенте — механика discovery сделала всё сама. Если бы passingOnly был false, больной остался бы в ротации, и его переживал бы только breaker из урока 10 — медленная смерть вместо чистой.

### etcd-регистрация без heartbeat

```go
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. Через сколько максимум
// клиенты перестанут видеть инстанс?
```

**Что выведет программа?**

- Мгновенно — kill -9 удаляет ключ из etcd
- До 3 секунд — heartbeat ещё успеет сработать
- ✅ До 10 секунд — TTL lease истечёт, ключ удалят, watch разошлёт событие
- Никогда — без Deregister запись вечная

kill -9 не даёт defer'у шанса, но в etcd-модели deregister и не нужен: lease держится heartbeat-горутиной, которая умерла вместе с процессом. Lease истекает максимум через TTL (10 с), etcd удаляет ключ, Instancer видит изменение по watch и рассылает sd.Event без инстанса. Сравни с Consul: там мёртвого красит health check, а из каталога чистит DeregisterCriticalServiceAfter — механика другая, итог тот же.

### Собери серверную и клиентскую стороны

Восстанови четыре строки. Пропуски — по порядку: конструктор Registrar в адаптере Consul, метод жизненного цикла, конструктор Instancer, конструктор Endpointer из пакета sd.

```go
registrar := consulsd.___(client, asr, logger)
registrar.Register()
defer registrar.___()

instancer := consulsd.___(client, logger, "stringsvc", tags, true)
endpointer := sd.___(instancer, factory, logger)
```

Подсказка: Первый и третий пропуски — конструкторы пакета sd/consul; второй — парный к Register метод sd.Registrar; четвёртый — склейка Instancer и Factory.

Правильные ответы в пропусках: `NewRegistrar`, `Deregister`, `NewInstancer`, `NewEndpointer`

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

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

Правильный порядок строк:

1. `Процесс поднял слушатель и собрал AgentServiceRegistration с health check`
2. `Registrar.Register записал инстанс в каталог Consul`
3. `Consul начал ходить по /healthz с интервалом Interval`
4. `check зелёный — инстанс попал в выдачу blocking query`
5. `Instancer клиента разослал sd.Event с новым набором инстансов`
6. `Endpointer построил endpoint через Factory и добавил в кеш`
7. `lb.NewRoundRobin включил соседа в ротацию прокси`
8. `check покраснел — passingOnly вычеркнул инстанс, клиенты обновились`

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

- **sd.Registrar** — Интерфейс Register()/Deregister(): жизненный цикл одного инстанса в реестре; реализации — consulsd.NewRegistrar, etcdv3.NewRegistrar.
- **sd.Instancer** — Register(chan<- sd.Event), Deregister, Stop: подписка на изменения; каждый Event несёт ПОЛНЫЙ список инстансов или ошибку связи.
- **sd.Event** — {Instances []string; Err error}: срез текущих инстансов либо ошибка discovery; рассылается сразу при подписке и на каждом изменении.
- **sd.Factory** — func(instance string) (endpoint.Endpoint, io.Closer, error): строка-инстанс в endpoint; Closer закрывается, когда инстанс уходит.
- **sd.NewEndpointer** — Канонический слушатель обновлений: канал у Instancer + кеш endpoint'ов, пересобираемых через Factory; опция InvalidateOnError меняет поведение при ошибках.
- **DeregisterCriticalServiceAfter** — Поле AgentServiceCheck: Consul вычёркивает инстанс из каталога, чей check красный дольше указанного срока — страховка от зомби при kill -9.

### Что дальше

Круг замкнулся: сервер регистрируется с health check, клиент подписывается через Instancer, Factory превращает адреса в endpoint'ы, а балансировочная цепочка из урока 11 теперь питается живым списком — и вся эта конструкция переживает деплои, автоскейлинг и падения соседей. Освежить фундамент можно в [уроке 02-endpoint](/courses/go-kit/lessons/02-endpoint/), клиентский транспорт — в [уроке 04-http-transport](/courses/go-kit/lessons/04-http-transport/); карта курса — на [странице курса](/courses/go-kit/). Один штрих остался: фабрика пока строит HTTP-ноги, а индустрия для service-to-service выбирает gRPC — бинарный протокол с контрактом в .proto и кодогенерацией. Дальше — [урок 13-grpc-transport](/courses/go-kit/lessons/13-grpc-transport/): grpctrans.NewServer, биндинг proto-сервиса и gRPC-клиент, который встаёт в нашу фабрику одной функцией.

## Коротко: что запомнить

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