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

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

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

Урок 11 из 25 ~20 мин +50 XP

Load balancer и прокси

stringsvc уже умеет логировать, считать метрики и трейсы, ограничивать трафик и переживать сбои зависимостей — но всё ещё живёт в единственном экземпляре. Микросервис начинается в момент, когда один и тот же endpoint поднят на нескольких машинах, а твой сервис умеет звать соседей. Готовый go kit load balancer живёт в пакете sd/lb: он превращает набор endpoint'ов в один надёжный вызов с балансировкой и ретраями. Ты соберёшь прокси service-middleware из канонического stringsvc3: фиксированный список через sd.FixedEndpointer, стратегии lb.NewRoundRobin и lb.NewRandom, обёртка lb.Retry с RetryError и честная ошибка ErrNoEndpoints на пустом списке. Балансировка go kit перестанет быть заклинанием из вакансий и станет строками, которые ты писал сам.

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

Зачем балансировка: один 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

GO
import (
        "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: потолок попыток и потолок времени

GO
var (
        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.Endpointerkit/sdEndpoints() ([]endpoint.Endpoint, error)поставщик актуального набора endpoint'ов
sd.FixedEndpointerkit/sdтип-срез, реализует Endpointerфиксированный список, заданный руками
sd.Factorykit/sdfunc(instance string) (endpoint.Endpoint, io.Closer, error)инстанс-строка в endpoint; полный разбор в уроке 12
lb.Balancerkit/sd/lbEndpoint() (endpoint.Endpoint, error)стратегия выбора одного endpoint'а
lb.NewRoundRobinkit/sd/lbконструктор от Endpointerравномерный обход по кругу, первый — индекс 0
lb.NewRandomkit/sd/lbконструктор от Endpointer и seedслучайный выбор, воспроизводимый в тестах
lb.Retrykit/sd/lbfunc(max int, timeout time.Duration, b Balancer) endpoint.Endpointпопытки до max ИЛИ до таймаута — что раньше
lb.RetryErrorkit/sd/lbRawErrors []error, Final errorитоговая ошибка: последняя плюс история попыток
lb.ErrNoEndpointskit/sd/lbошибка-сентинелпустой список — честный отказ, не nil

Пять правил прокси-middleware#

  1. Прокси — service middleware: интерфейс сервиса не меняется, сетевая деталь спрятана за методом Uppercase.
  2. Каждый прокси-endpoint строится клиентским транспортом и оборачивается breaker'ом и лимитером ДО добавления в список.
  3. Err-поле соседа возвращается как Err-поле — бизнес-отказ не превращается в err канала и не будит breaker.
  4. Прокси первым в цепочке middleware: логи и метрики обязаны видеть сетевую реальность вызова.
  5. Контекст запроса едет вниз без потерь: дедлайн, request_id и trace id — обязательные пассажиры.

Что вернёт 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'а у балансировщика.

endpoints := sd.FixedEndpointer{e1, e2, e3} balancer := lb.(endpoints) retry := lb.(3, 250*time.Millisecond, balancer) // внутри любой стратегии из пакета sd/lb: e, err := balancer.()

Путь запроса через прокси

Расставь этапы вызова Uppercase в сервисе с прокси-middleware: от клиента до ответа.

Строки

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

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

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

Термин

sd.Endpointer

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

Интерфейс Endpoints() ([]endpoint.Endpoint, error): поставщик актуального набора endpoint'ов для балансировщика.

1 / 6

Что дальше#

Обещание урока 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 мс на вызов.

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

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

Чем client-side балансировка хуже облачного балансировщика?

Серверный балансировщик снимает с кода выбор и ретраи, но добавляет сетевой хоп, точку отказа и очередь на настройку инфраструктуры. Client-side из go-kit живёт в твоём процессе: выбор за микросекунды, стратегии и политики в коде, тесты без стенда. Плата — каждый вызывающий сервис сам следит за списком соседей: для этого в следующем уроке подключается discovery.

Почему breaker ставится на каждый endpoint, а не на балансировщик?

Breaker на балансировщике размыкает ВЕСЬ набор от одного больного соседа — отказ одного инстанса отключит сервис целиком. Breaker на отдельном endpoint изолирует больного: остальные продолжают работать, а retry переключается на живых. Именно поэтому в stringsvc3 обёртки цепляются к каждому прокси-endpoint до добавления в список.

Можно ли ретраить небезопасные POST-запросы?

Только с идемпотентностью на стороне сервера: idempotency-key в заголовке и дедупликация на получателе, как это делают платёжные API. Чистый lb.Retry повторит вызов после таймаута, не зная, дошёл ли первый запрос, — для Uppercase это неважно, для перевода денег критично. Правило курса: ретраи на операциях записи включаются осознанно и с ключом идемпотентности.

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

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

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

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

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

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

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