Содержание урока · 12 разделов
- Зачем балансировка: один endpoint, много инстансов
- Клиентский endpoint: makeUppercaseProxy
- lb.Balancer: один метод и честная ошибка
- Round robin или random: выбираем стратегию
- Порядок обёрток: прокси первый, логи и метрики рядом
- Ретраи без лавины: идемпотентность, breaker, трейс
- Строительные блоки клиентской стороны
- Пять правил прокси-middleware
- Что дальше
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Зачем балансировка: один endpoint, много инстансов#
Последние три урока надели на stringsvc обёртки: спаны из 08-tracing, лимитер из 09-rate-limit, предохранитель из 10-circuit-breaker. Все они защищали один процесс. Но смысл микросервисов — в горизонтальном масштабировании: endpoint Uppercase поднимается в N копиях, и запросы надо размазывать по копиям так, чтобы ни одна не задыхалась, а упавшая молча исключалась из ротации. Кто это делает? Если перед сервисами стоит внешний балансировщик — это server-side discovery, и твой Go-код о нём не знает. А если копии ищут друг друга сами — client-side discovery: вызывающий сервис держит список адресов соседей, выбирает один на каждый вызов и повторяет попытку при сбое. go-kit реализует именно вторую схему, потому что она не требует инфраструктуры за пределами процессов и остаётся в твоих руках. Ключевая идея уже звучала в уроке 02-endpoint: исходящий вызов — это тоже endpoint, та же сигнатура func(ctx, request) (response, err). Урок 04 показал клиентский транспорт мельком, а теперь обещание выполняется целиком: под каждым из N инстансов прячется endpoint, построенный клиентским транспортом, а сверху стоят те же обёртки, к которым ты привык, — breaker из урока 10, лимитер из 09, спаны из 08. Сверху над всем этим — load balancer: стратегия выбора и ретраи. Соберём снизу вверх.
Клиентский endpoint: makeUppercaseProxy#
Прежде чем выбирать между инстансами, нужен endpoint на каждый инстанс. Роль играет клиентский HTTP-транспорт из урока 04 — там ты видел kithttp.NewServer, а зеркальный kithttp.NewClient ещё не встречали. Он принимает HTTP-метод, адрес, EncodeRequestFunc и DecodeResponseFunc — и возвращает объект с методом Endpoint(): замыкание, которое кодирует request-структуру в HTTP-запрос, отправляет его по адресу и декодирует ответ обратно в response-структуру. EncodeRequestFunc — func(ctx, http.Request, request interface{}) error: она пишет тело и заголовки в исходящий запрос; DecodeResponseFunc — func(ctx, http.Response) (interface{}, error): читает ответ соседа. Для stringsvc это те же JSON-структуры, что и на серверной стороне: encodeRequest сериализует uppercaseRequest в буфер и подставляет в r.Body, decodeUppercaseResponse разбирает uppercaseResponse. Адрес соседа arrive строкой-инстансом вроде localhost:8081 — приведём её к *url.URL: если нет префикса http, допишем, а путь зададим /uppercase. Получившуюся функцию и называют прокси-endpoint: для вызывающей стороны она неотличима от локального endpoint'а — та же сигнатура, те же структуры, только исполнение происходит в другом процессе. Именно поэтому все обёртки курса работают и здесь: оберни прокси-endpoint в instrumentingMiddleware из урока 07, и латентность сетевого вызова попадёт в ту же гистограмму, только с меткой method=uppercase_proxy.
Фиксированный список: sd.FixedEndpointer
GOimport (
"net/url"
"strings"
"github.com/go-kit/kit/endpoint"
"github.com/go-kit/kit/sd"
httptransport "github.com/go-kit/kit/transport/http"
)
func makeUppercaseProxy(instance string) endpoint.Endpoint {
if !strings.HasPrefix(instance, "http") {
instance = "http://" + instance
}
u, err := url.Parse(instance)
if err != nil {
panic(err)
}
u.Path = "/uppercase"
return httptransport.NewClient(
"POST", u, encodeRequest, decodeUppercaseResponse,
).Endpoint()
}
func proxyingMiddleware(instances string, logger log.Logger) ServiceMiddleware {
if instances == "" {
return func(next StringService) StringService { return next }
}
var (
list = split(instances) // "localhost:8081,localhost:8082"
endpoints sd.FixedEndpointer
)
for _, instance := range list {
e := makeUppercaseProxy(instance)
endpoints = append(endpoints, e)
}
// ... балансер и retry появятся ниже
}sd.FixedEndpointer — это просто тип-срез []endpoint.Endpoint, который реализует интерфейс sd.Endpointer методом Endpoints() ([]endpoint.Endpoint, error). Пока список задан флагом через запятую — так делает канонический stringsvc3 перед тем, как показать Consul-вариант. Заметь: каждый прокси-endpoint здесь можно обернуть обёртками из уроков 09 и 10 до добавления в срез — именно так и поступает upstream-пример: e = circuitbreaker.Gobreaker(...)(e), затем ratelimiter. Пустая строка instances означает «проксировать нечего» — middleware возвращает next без изменений, сервис работает локально.
lb.Balancer: один метод и честная ошибка#
Над набором endpoint'ов стоит балансировщик. Открой sd/lb/balancer.go — там двадцать строк, и контракт в них умещается в один метод: lb.Balancer — это интерфейс Endpoint() (endpoint.Endpoint, error), «дай мне один endpoint для следующего вызова». Никаких Select, Pick, стратегий в аргументах: стратегия — это конкретная реализация интерфейса, а не флаг. Вторая половина контракта — ошибка lb.ErrNoEndpoints: если под балансировщиком пусто, он не выбирает случайный индекс через панику и не возвращает nil молча, а возвращает ErrNoEndpoints. Это важно для всего, что стоит выше: ретраи решают, пробовать ли снова, метрики считают сбой, breaker'ы размыкаются — все они читают ошибку из одного честного канала. Слой под балансировщиком — sd.Endpointer: интерфейс Endpoints() ([]endpoint.Endpoint, error), поставщик актуального набора. Разделение обязанностей точное: Endpointer знает, КАКИЕ endpoint'ы сейчас живы, Balancer знает, КАКОЙ из них выбрать для следующего вызова, а Retry знает, ЧТО делать при ошибке. Три маленьких интерфейса вместо одного большого — тот же ход, что и во всём go-kit, и в этом смысле go kit load balancer — не монолитная библиотека, а три строчки контрактов и несколько готовых реализаций рядом.
lb.NewRoundRobin: атомарный счётчик
GO// Из исходников go-kit: kit/sd/lb/round_robin.go
func NewRoundRobin(s sd.Endpointer) Balancer {
return &roundRobin{s: s, c: 0}
}
type roundRobin struct {
s sd.Endpointer
c uint64
}
func (rr *roundRobin) Endpoint() (endpoint.Endpoint, error) {
endpoints, err := rr.s.Endpoints()
if err != nil {
return nil, err
}
if len(endpoints) <= 0 {
return nil, ErrNoEndpoints
}
old := atomic.AddUint64(&rr.c, 1) - 1
idx := old % uint64(len(endpoints))
return endpoints[idx], nil
}Вся стратегия — три строки: атомарно инкрементируем счётчик, вычитаем единицу, берём остаток от деления на размер списка. Первая деталь: atomic.AddUint64 возвращает НОВОЕ значение, поэтому «минус один» — и первый вызов получает индекс 0, а не 1. Вторая деталь: счётчик общий для всех горутин, но атомарность гарантирует лишь уникальность выданных номеров — порядок обхода при параллельных вызовах перемешивается, и это нормально. Третья деталь: остаток от деления берётся от ТЕКУЩЕГО размера списка — если Consul удалит инстанс, индексы пересчитаются сами, ничего настраивать не надо. Про mod и живые списки: список endpoint'ов запрашивается заново на каждый вызов — обновлённый Endpointer тут же отражается в ротации.
Round robin или random: выбираем стратегию#
Кроме round robin в пакете есть lb.NewRandom(s sd.Endpointer, seed int64) — та же пара «Endpointer + ошибка», но индекс выбирается rand.Intn по переданному источнику случайности. Что выбрать? Round robin даёт идеально равномерное распределение на длинном горизонте и предсказуемость: N запросов — по одному на каждый инстанс, разница между самыми нагруженными не больше одного запроса. Он же даёт воспроизводимость в тестах: при известном порядке вызовов ты знаешь, какой инстанс получит запрос. Random проще в объяснении, устойчив к «резонансу» — ситуации, когда два сервиса со синхронизированными round-robin-счётчиками бьют в один и тот же инстанс одновременно, — и позволяет изолировать тесты: одинаковый seed даёт одинаковую последовательность выборов без реальной сети. На практике для service-to-service вызовов дефолт — round robin: равномерность важнее, а резонанс лечится тем, что у каждого клиента свой счётчик и своя фаза. Random берут, когда список endpoint'ов дёргается часто: при постоянных добавлениях и удалениях равномерность round robin всё равно размывается, а random не даёт ложного чувства порядка. Важно понимать общий знаменатель: обе стратегии — client-side, состояние (счётчик или rand) живёт в твоём процессе, никакой координации между экземплярами вызывающего сервиса нет. Балансировка go kit — это выбор, который делается локально за микросекунды, а не сетевой прыжок к балансировщику.
proxymw: прокси как service middleware
GO// proxymw реализует StringService: Uppercase уезжает по сети,
// Count остаётся локальным.
type proxymw struct {
next StringService // локальный сервис
uppercase endpoint.Endpoint // прокси на соседние инстансы
}
func (mw proxymw) Uppercase(ctx context.Context, s string) (string, error) {
response, err := mw.uppercase(ctx, uppercaseRequest{S: s})
if err != nil {
return "", err
}
resp := response.(uppercaseResponse)
return resp.V, resp.Err // Err-поле из урока 02 едет как было
}
func (mw proxymw) Count(ctx context.Context, s string) int {
return mw.next.Count(ctx, s)
}Прокси — обычная service middleware, тот же приём, что loggingMiddleware из урока 03: структура с вложенным StringService и методом, который заменяет поведение одного из методов интерфейса. Разница с upstream-stringsvc3 в одном: наш сервисный интерфейс принимает ctx, поэтому прокси берёт контекст из аргумента метода, а не хранит context.Background в поле — дедлайны и спаны из входящего запроса честно едут к соседу. Бизнес-ошибка neighbour'а возвращается в Err-поле response-структуры и не превращается в err канала — иначе breaker из урока 10 считал бы «пустую строку» инфраструктурным сбоем. Типобезопасность восстанавливается единственным assertion'ом — тем самым швом из урока 02.
Порядок обёрток: прокси первый, логи и метрики рядом#
В main прокси встаёт раньше всех остальных service middleware: svc = proxyingMiddleware(*proxy, logger)(svc), затем loggingMiddleware, затем instrumentingMiddleware — ровно как в stringsvc3. Почему именно так: лог из урока 06 и метрики из урока 07 должны видеть НАСТОЯЩИЙ исход вызова. Если прокси стоит первым, то loggingMiddleware пишет took с учётом сетевой задержки к соседу, а счётчик request_count с меткой error=true загорается, когда сосед отказал, — и дежурный видит правду о том, что тормозит весь вызов. Переставь прокси внутрь — и наблюдаемость станет врать: локальный вызов «успешен», потому что прокси успел положить задачу в сеть. Второе решение — где обёртки из уроков 08-10: они цепляются к КАЖДОМУ прокси-endpoint до добавления в список, а не поверх retry. Разница существенная: breaker на отдельном endpoint размыкается для одного больного соседа, а не для всего набора; лимитер на endpoint считает попытки конкретного инстанса; спан client-ноги пишется на каждый реальный сетевой вызов. Третье — контекст: ctx запроса проходит через proxymw в endpoint'ы соседей, поэтому дедлайн из урока 04, request_id из урока 06 и trace id из урока 08 доезжают до конечного исполнителя — вся цепочка наблюдаемости остаётся сквозной, и Jaeger соберёт два сервиса в одно дерево.
lb.Retry: потолок попыток и потолок времени
GOvar (
maxAttempts = 3
maxTime = 250 * time.Millisecond
)
balancer := lb.NewRoundRobin(endpoints)
retry := lb.Retry(maxAttempts, maxTime, balancer)
// lb.Retry это обёртка над общим механизмом:
// lb.RetryWithCallback(timeout, b, cb), где
// type Callback func(n int, received error) (keepTrying bool, replacement error)lb.Retry(max, timeout, balancer) возвращает endpoint.Endpoint: каждый вызов получает новый контекст context.WithTimeout(ctx, timeout) — это wallclock-бюджет на ВСЕ попытки сразу, а не на каждую. Каждая попытка уходит в отдельной горутине: балансировщик выбирает следующий endpoint, вызов уезжает, ошибка попадает в канал errs, успех — в responses. При ошибке срабатывает callback-политика maxRetries: продолжать, пока попыток меньше max. Есть и lb.RetryWithCallback — своя политика: например, повторять только на ErrNoEndpoints или на сетевых таймаутах, но не на бизнес-отказах. Ошибка, которая в итоге возвращается вызывающему, — это lb.RetryError со слайсом RawErrors (все ошибки попыток) и полем Final (последняя): текст формируется как «final (previously: ...)», и в лог из урока 06 такое падает уже читаемым.
Ретраи без лавины: идемпотентность, breaker, трейс#
Механизм ретраев обманчиво прост, и в нём три ловушки. Первая — идемпотентность: retry повторяет запрос при ошибке, а значит вызов может выполниться дважды. Uppercase — чистая функция, повторять безопасно; списание денег или отправка письма — нет. Правило то же, что и в 10-circuit-breaker: ретраи только на идемпотентных операциях или на операциях с idempotency-key. Вторая ловушка — «потерянные» попытки: когда первая попытка вернула ошибку и запустилась вторая, первая горутина никуда не девается; если её ответ всё-таки придёт, select в lb.Retry уже закрыт — результат выбросится, но ресурсы соседа будут потрачены. Отсюда бюджет: maxTime меньше клиентского дедлайна, а попыток мало — три, не тридцать. Третья ловушка — ретрай-лавина: сосед моргнул, все клиенты разом сделали по три попытки, нагрузка утроилась в худший момент. Лечение многослойное: breaker из урока 10 на каждом прокси-endpoint размыкает больных, лимитер из урока 09 держит темп исходящих вызовов, а сам lb.Retry стоит последним — и его ошибка RetryError уезжает в трейс: в уроке 08 ты видел, как TraceEndpoint пишет каждую попытку отдельным полем gokit.retry.error.N, так что «три попытки к двум инстансам» видно в Jaeger без догадок. Вот почему порядок «breaker и лимитер на endpoint, retry сверху» — не вкус, а инженерное решение.
Строительные блоки клиентской стороны#
| Блок | Пакет | Контракт | Задача |
|---|---|---|---|
| sd.Endpointer | kit/sd | Endpoints() ([]endpoint.Endpoint, error) | поставщик актуального набора endpoint'ов |
| sd.FixedEndpointer | kit/sd | тип-срез, реализует Endpointer | фиксированный список, заданный руками |
| sd.Factory | kit/sd | func(instance string) (endpoint.Endpoint, io.Closer, error) | инстанс-строка в endpoint; полный разбор в уроке 12 |
| lb.Balancer | kit/sd/lb | Endpoint() (endpoint.Endpoint, error) | стратегия выбора одного endpoint'а |
| lb.NewRoundRobin | kit/sd/lb | конструктор от Endpointer | равномерный обход по кругу, первый — индекс 0 |
| lb.NewRandom | kit/sd/lb | конструктор от Endpointer и seed | случайный выбор, воспроизводимый в тестах |
| lb.Retry | kit/sd/lb | func(max int, timeout time.Duration, b Balancer) endpoint.Endpoint | попытки до max ИЛИ до таймаута — что раньше |
| lb.RetryError | kit/sd/lb | RawErrors []error, Final error | итоговая ошибка: последняя плюс история попыток |
| lb.ErrNoEndpoints | kit/sd/lb | ошибка-сентинел | пустой список — честный отказ, не nil |
Пять правил прокси-middleware#
- Прокси — service middleware: интерфейс сервиса не меняется, сетевая деталь спрятана за методом Uppercase.
- Каждый прокси-endpoint строится клиентским транспортом и оборачивается breaker'ом и лимитером ДО добавления в список.
- Err-поле соседа возвращается как Err-поле — бизнес-отказ не превращается в err канала и не будит breaker.
- Прокси первым в цепочке middleware: логи и метрики обязаны видеть сетевую реальность вызова.
- Контекст запроса едет вниз без потерь: дедлайн, request_id и trace id — обязательные пассажиры.
Endpointer с пустым списком не падает и не вешает вызовы: Balancer возвращает lb.ErrNoEndpoints, retry честно повторяет — и после исчерпания попыток ты получишь RetryError с Final = ErrNoEndpoints. Это мгновенный отказ, дешёвый и объяснимый. Опасно другое: закешировать Balancer при старте и никогда не обновлять Endpointer — тогда после смерти всех соседей сервис будет продолжать слать запросы в стену, и ошибка станет сетевым таймаутом вместо быстрого ErrNoEndpoints. Подписка на обновления — тема следующего урока.
Урок требует установленного Go и модуля github.com/go-kit/kit, а для живой картины — двух инстансов stringsvc на разных портах; в песочнице курса сторонние модули не исполняются, поэтому интерактивы построены на чтении кода. Локально: подними два экземпляра (8081, 8082), запусти третий с -proxy localhost:8081,localhost:8082 и дёргай его /uppercase — в логах соседей увидишь, как запросы чередуются, а после Ctrl+C одного из соседей — как RetryError меняет текст.
Что вернёт lb.Balancer.Endpoint(), если под ним Endpointer с пустым списком?
Куда уйдёт ПЕРВЫЙ запрос после создания lb.NewRoundRobin?
Почему lb.Retry принимает и max, и timeout — чего не хватает одного из них?
Round robin по трём инстансам
Прочитайте код и выберите, что он выведет:
endpoints := sd.FixedEndpointer{eA, eB, eC}
balancer := lb.NewRoundRobin(endpoints)
// четыре последовательных вызова balancer.Endpoint()
// из одной горутины, список не менялсяRetry на медленном соседе
Прочитайте код и выберите, что он выведет:
retry := lb.Retry(3, 250*time.Millisecond, balancer)
// каждый вызов endpoint'а занимает 200 мс и завершается ошибкой
response, err := retry(ctx, req)
// что вернётся и когда?Собери прокси-цепочку
Восстанови три строки сборки. Пропуски — по порядку: конструктор стратегии по кругу, обёртка ретраев, метод выбора endpoint'а у балансировщика.
Путь запроса через прокси
Расставь этапы вызова Uppercase в сервисе с прокси-middleware: от клиента до ответа.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Термины урока
Что дальше#
Обещание урока 02-endpoint выполнено: исходящий вызов стал endpoint'ом по всем правилам курса — клиентский транспорт из урока 04-http-transport построил ноги, а сверху стоят знакомые обёртки: спаны из 08-tracing, лимитер из 09-rate-limit, breaker из 10-circuit-breaker. Балансировка go kit собралась из трёх маленьких интерфейсов: Endpointer поставляет, Balancer выбирает, Retry повторяет, — и весь этот каркас уже работает, пока список инстансов задаётся флагом. Но флаг — это руками: при деплое, автоскейлинге и падении соседа кто-то должен обновить список, пока retry долбит в мёртвый адрес. Дальше — урок 12-discovery: Registrar, Instancer и Factory, адаптеры Consul и etcd, health checks, которые вычёркивают больных автоматически. Карта курса — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Исходящий вызов — endpoint: клиентский kithttp.NewClient(method, url, encodeRequest, decodeResponse).Endpoint() строит ногу к каждому инстансу.
- sd.FixedEndpointer — тип-срез endpoint'ов, реализующий sd.Endpointer; стартовая точка без discovery.
- lb.Balancer — интерфейс Endpoint() (endpoint.Endpoint, error); пустой список даёт lb.ErrNoEndpoints, а не nil и не панику.
- lb.NewRoundRobin — атомарный счётчик и остаток от деления: первый вызов получает индекс 0.
- lb.Retry(max, timeout, balancer) — общий wallclock-бюджет на все попытки; итог — RetryError{RawErrors, Final}.
- Прокси — service middleware: Uppercase уезжает по сети, Err-поле соседа сохраняется как бизнес-ответ, ctx едет вниз без потерь.
- Breaker и лимитер ставятся на каждый прокси-endpoint до списка, retry — сверху; ретраи — только для идемпотентных вызовов.
Домашнее задание
Собери трёхногий stringsvc по уроку. Первое: вынеси makeUppercaseProxy с клиентским транспортом из урока 04 и собери proxyingMiddleware, принимающую строку инстансов через запятую; проверь sd.FixedEndpointer и lb.NewRoundRobin на двух локальных инстансах — убедись в логах, что запросы чередуются, и зафиксируй, какой инстанс получил первый запрос. Второе: оберни каждый прокси-endpoint в breaker из урока 10 и лимитер из урока 09 до добавления в список, затем убей одного соседа и опиши по логам, как breaker переключился, а RetryError сменил текст. Третье: запусти стресс на минуту и сравни два порядка middleware — прокси первым и прокси после instrumentingMiddleware: объясни по графикам request_count и p90, чем отличаются показания. Четвёртое: замени lb.NewRoundRobin на lb.NewRandom с фиксированным seed и напиши тест, который при одинаковом seed предсказывает последовательность выборов. Пятое: на вызове с дедлайном 300 мс подбери maxTime и max для lb.Retry так, чтобы p95 вызова не превышал 200 мс, и запиши, сколько попыток выживает при деградации соседа до 150 мс на вызов.
Личные заметкиПусто
Вопросы и ответы по уроку
Чем client-side балансировка хуже облачного балансировщика?
Серверный балансировщик снимает с кода выбор и ретраи, но добавляет сетевой хоп, точку отказа и очередь на настройку инфраструктуры. Client-side из go-kit живёт в твоём процессе: выбор за микросекунды, стратегии и политики в коде, тесты без стенда. Плата — каждый вызывающий сервис сам следит за списком соседей: для этого в следующем уроке подключается discovery.
Почему breaker ставится на каждый endpoint, а не на балансировщик?
Breaker на балансировщике размыкает ВЕСЬ набор от одного больного соседа — отказ одного инстанса отключит сервис целиком. Breaker на отдельном endpoint изолирует больного: остальные продолжают работать, а retry переключается на живых. Именно поэтому в stringsvc3 обёртки цепляются к каждому прокси-endpoint до добавления в список.
Можно ли ретраить небезопасные POST-запросы?
Только с идемпотентностью на стороне сервера: idempotency-key в заголовке и дедупликация на получателе, как это делают платёжные API. Чистый lb.Retry повторит вызов после таймаута, не зная, дошёл ли первый запрос, — для Uppercase это неважно, для перевода денег критично. Правило курса: ретраи на операциях записи включаются осознанно и с ключом идемпотентности.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
gRPC-Go: высокопроизводительный RPC
Курс gRPC-Go: proto3, кодогенерация, unary и streaming вызовы, interceptors, TLS, дедлайны, load balancing. 25 уроков и итоговый проект.
- Писать .proto-схемы и генерировать код
- Реализовывать unary и streaming сервисы
- Управлять метаданными, дедлайнами и отменами
- Строить interceptors для логов и аутентификации