← назад к разделу

У Go-сервиса в Kubernetes нет фреймворка, который сам подключит реестр сервисов, сервер конфигурации и размыкатели: стандартная библиотека даёт HTTP-клиент и сервер, остальное выбирают по задаче. Это упрощает вопрос «платформа или библиотека» до инженерного: для каждой задачи смотрят, видит ли её платформа. Инфраструктурные задачи (кто где живёт, как доставить конфиг, кого перезапустить) платформа видит и решает лучше кода. Прикладные (ждать ли ответа платёжного шлюза тридцать секунд, повторять ли списание денег) платформа не видит, и их место в коде.

Обязательно

Задача за задачей

ЗадачаKubernetesGo-сервис
Найти соседний сервисDNS и Service: http://ordersклиент по имени, без реестра
Балансировкаkube-proxy на уровне соединениясрок жизни соединений; для gRPC свой резолвер
КонфигурацияConfigMap и Secret в переменные или файлычтение при старте, валидация, при нужде перечитывание
СекретыSecret плюс внешнее хранилище через операторфайл или переменная, без SDK хранилища
Перезапуск упавшегоliveness и readiness probesчестные обработчики /healthz и /readyz
Выкат без простояrolling updateкорректное завершение по SIGTERM
Маршрутизация с краяIngress или Gateway APIсвой шлюз только ради прикладной логики
Таймауты, повторы, размыкателитолько через meshcontext, gobreaker, go-retryablehttp
Трассировка и метрикиmesh видит только сетевой слойOpenTelemetry внутри кода
Задачи по расписаниюCronJobтикер плюс выбор лидера, если нужно состояние
Лимиты запросовна входе, по IP и путипо пользователю и тарифу, в коде

Дальше три строки, в которых чаще всего ошибаются, и две, которые платформа закрывает только наполовину.

Как сосед находится на самом деле

http://orders в кластере это имя объекта Service, полностью orders.shop.svc.cluster.local. Запрос по нему приходит не «на сервис», а на один из его подов, и выбирает под не DNS, а сетевой слой узла: правила kube-proxy перенаправляют соединение на один из живых адресов. Балансировка происходит один раз на соединение, в ядре, без участия приложения.

Для Go-сервиса это означает две вещи. Хорошую: никакого реестра, клиента обнаружения и списка адресов в коде, достаточно http.Client и имени. И неприятную: http.Transport держит постоянные соединения и переиспользует их, пока они живы. Соединение установилось к первому поду, и все запросы через него идут туда часами. Новый под после масштабирования трафика не получает, нагрузка перекашивается, а у gRPC, где одно соединение несёт все вызовы, весь трафик клиента уходит в один под по определению.

У http.Transport нет настройки «срок жизни соединения», есть только IdleConnTimeout для простаивающих. Под постоянной нагрузкой соединение не простаивает никогда, поэтому его закрывают сами:

tr := &http.Transport{
    DialContext:         (&net.Dialer{Timeout: 2 * time.Second}).DialContext,
    MaxIdleConns:        100,
    MaxIdleConnsPerHost: 20,
    IdleConnTimeout:     30 * time.Second,
}
go func() {
    for range time.Tick(time.Minute) {
        tr.CloseIdleConnections()
    }
}()
client := &http.Client{Transport: tr, Timeout: 5 * time.Second}

Раз в минуту простаивающие соединения закрываются, новые открываются уже через kube-proxy и попадают на другие поды. Этого хватает большинству HTTP-сервисов. Для gRPC решение другое: безголовый Service (clusterIP: None) отдаёт в DNS адреса всех подов, а клиент gRPC балансирует вызовы между ними сам:

conn, err := grpc.NewClient("dns:///orders.shop.svc.cluster.local.:50051",
    grpc.WithTransportCredentials(insecure.NewCredentials()),
    grpc.WithDefaultServiceConfig(`{"loadBalancingConfig":[{"round_robin":{}}]}`),
    grpc.WithKeepaliveParams(keepalive.ClientParameters{Time: 30 * time.Second, Timeout: 5 * time.Second}),
)

Схема dns:/// включает резолвер gRPC, round_robin раскладывает вызовы по всем адресам из ответа DNS, а резолвер перечитывает имя при обрыве соединения. Третий вариант, service mesh, балансирует запросы, а не соединения, и снимает проблему для любого протокола ценой отдельного слоя инфраструктуры.

Две мелочи, которые всплывают на первой аварии. Короткое имя orders резолвер пода разворачивает через список суффиксов (ndots:5 в resolv.conf), и каждый вызов это несколько запросов к DNS; полное имя с точкой на конце (orders.shop.svc.cluster.local.) резолвится одним. И у Go нет собственного кэша DNS: имя разрешается при каждом новом соединении, что ещё один довод за переиспользование соединений и против DisableKeepAlives.

Конфигурация и секреты: читать просто, обновлять сложно

ConfigMap и Secret попадают в под переменными окружения или файлами, и сервис читает их самым скучным способом: os.Getenv через небольшую библиотеку (caarlos0/env, kelseyhightower/envconfig) или YAML из файла. Валидация при старте обязательна: сервис с лимитом 0 или пустым адресом базы не должен пройти readiness. Сервера конфигурации, который надо держать доступным раньше всех остальных, в этой схеме нет, и это хорошо.

Чем платят: изменение ConfigMap само в процесс не приезжает. Переменные окружения не меняются до перезапуска пода, смонтированный файл обновляется с задержкой до минуты-двух, а сервис об этом узнаёт, только если следит за файлом. Обычный ответ это перезапуск через выкат с хешем конфига в аннотации пода; обновление на лету нужно редко и разобрано в отдельной статье.

Secret хранит значение в base64, а не шифрует, поэтому пароли живут во внешнем хранилище (Vault, менеджер секретов облака), откуда их в кластер приносит оператор (External Secrets, Vault Agent). Сервис при этом остаётся простым: читает файл или переменную. Ходить в Vault по SDK из кода оправдано, только когда нужна ротация без перезапуска или динамические учётные данные базы, и тогда Vault становится зависимостью на старте.

Перезапуск и выкат: платформа делает половину

Kubernetes перезапустит упавший под и заменит поды по одному при выкате, но обе гарантии держатся на том, что сделает сервис. Liveness-проба отвечает на вопрос «процесс жив» и не должна зависеть от базы: иначе падение базы перезапустит все поды по кругу. Readiness-проба отвечает «можно давать трафик» и проверяет то, без чего запрос обслужить нельзя, с коротким таймаутом:

func readiness(deps ...func(context.Context) error) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), time.Second)
        defer cancel()
        for _, d := range deps {
            if err := d(ctx); err != nil {
                http.Error(w, err.Error(), http.StatusServiceUnavailable)
                return
            }
        }
        w.WriteHeader(http.StatusOK)
    }
}

Выкат без простоя требует, чтобы сервис по SIGTERM перестал отвечать на readiness, дождался текущих запросов и только потом вышел; без этого каждый rolling update роняет порцию запросов. Как это устроено, от signal.NotifyContext до preStop, разобрано в завершении Go-сервиса в Kubernetes.

Что остаётся коду: устойчивость

Платформа не знает, что POST /charge нельзя повторять, а GET /catalog можно, и не знает, сколько ждать партнёра. Поэтому таймауты, повторы и размыкатели живут в коде, и стандартных кирпичей здесь три.

Дедлайн на каждый исходящий вызов через context.WithTimeout и общий http.Client.Timeout как страховка. Повторы с нарастающей паузой и только для идемпотентных операций: hashicorp/go-retryablehttp оборачивает стандартный клиент и повторяет сетевые ошибки и 5xx, а CheckRetry позволяет исключить то, что повторять нельзя.

rc := retryablehttp.NewClient()
rc.RetryMax = 3
rc.RetryWaitMin = 100 * time.Millisecond
rc.RetryWaitMax = 2 * time.Second
rc.HTTPClient.Timeout = 3 * time.Second
client := rc.StandardClient()

Размыкатель, который перестаёт ходить к лежащему соседу и даёт ему подняться: sony/gobreaker считает ошибки за окно и открывается по порогу.

cb := gobreaker.NewCircuitBreaker[*http.Response](gobreaker.Settings{
    Name:        "payments",
    MaxRequests: 3,
    Interval:    30 * time.Second,
    Timeout:     20 * time.Second,
    ReadyToTrip: func(c gobreaker.Counts) bool {
        return c.Requests >= 20 && float64(c.TotalFailures)/float64(c.Requests) >= 0.5
    },
    IsSuccessful: func(err error) bool {
        return err == nil || errors.Is(err, context.Canceled)
    },
})
resp, err := cb.Execute(func() (*http.Response, error) { ... })
if errors.Is(err, gobreaker.ErrOpenState) || errors.Is(err, gobreaker.ErrTooManyRequests) {
    return ErrPaymentsDown
}

Service mesh умеет повторы и размыкатели на уровне сети, но не отличает списание денег от чтения каталога и не видит ошибок, спрятанных в теле ответа 200. Mesh дополняет код там, где сервисов десятки и нужны единые правила между ними, но не заменяет дедлайн в обработчике. Подробнее о каждом кирпиче и о том, как они складываются вместе, в устойчивости на Go.

Что остаётся коду: наблюдаемость, задачи, лимиты

Трассировка и метрики. Mesh показывает, что запрос из orders в payments занял 800 мс, но не покажет, что 700 из них ушли в запрос к базе внутри payments. Отрезки трассировки внутри процесса, метрики по сценариям и логи с идентификатором запроса создаёт код через OpenTelemetry, и это не отменяется никакой платформой.

Задачи по расписанию. Одноразовую или редкую задачу удобнее отдать CronJob: платформа запустит под, дождётся, перезапустит при падении и покажет историю. Задачу, которой нужно состояние процесса или запуск каждые несколько секунд, оставляют внутри сервиса с тикером, и тогда при трёх репликах нужен выбор лидера: Lease через client-go/tools/leaderelection или блокировка в PostgreSQL (pg_try_advisory_lock), а чаще всего просто идемпотентная задача с SKIP LOCKED в таблице, которую могут выполнять все реплики сразу.

Лимиты. Ingress и Gateway API ограничивают по адресу клиента и пути, и этого хватает против грубого потока. Лимит по пользователю, тарифу или ключу API требует знать, кто пришёл, а это знает только код после аутентификации: golang.org/x/time/rate в памяти для одной реплики, Redis для общего счётчика между репликами.

Шлюз. Для маршрутизации, TLS и простых лимитов хватает Ingress. Собственный шлюз на Go (httputil.ReverseProxy плюс middleware) оправдан, когда на краю нужна прикладная логика: проверка токена и обогащение заголовков, агрегация ответов, разные лимиты для разных клиентов. Как устроен такой шлюз и каким заголовкам за ним нельзя верить, разобрано в структурных паттернах микросервисов на Go.

Как выбирать

  • Сервис в Kubernetes (типовой случай): обнаружение, балансировка, конфигурация, перезапуск и выкат у платформы; в коде дедлайны, повторы, размыкатели, трассировка, лимиты по пользователю и корректное завершение. Реестр сервисов, сервер конфигурации и клиент обнаружения не заводят.
  • gRPC между сервисами: безголовый Service и резолвер dns:/// с round_robin с первого дня, иначе первый же рост нагрузки покажет один горячий под.
  • Десятки сервисов и единые правила между ними: mesh как дополнение к коду, не вместо него; дедлайны в обработчиках остаются.
  • Без оркестратора (виртуальные машины, свой хостинг): реестр вроде Consul и клиентская балансировка снова нужны, и честнее сначала спросить, почему без оркестратора.
Дополнительно: при первом чтении можно пропустить

Глубже: что ломается при переезде с клиентского обнаружениярасширенное

Сервис, который раньше сам выбирал экземпляр из реестра на каждый запрос, после переезда начинает выбирать его один раз на соединение, и это меняет три привычки. Повтор при ошибке перестаёт попадать на другой экземпляр: соединение то же, под тот же, и повторять имеет смысл только после закрытия соединения или с паузой, за которую kube-proxy успеет перенаправить новое. Проверка здоровья соседа из кода теряет смысл: за это отвечает readiness, и адрес нездорового пода просто исчезает из Service. И «мягкое» снятие с балансировки при выкате становится обязанностью самого соседа: DNS и kube-proxy убирают адрес не мгновенно, поэтому часть запросов в момент выката уйдёт в под, который уже останавливается, и спасает только корректное завершение на его стороне и повтор на стороне клиента.

Коротко

  • Инфраструктурные задачи у платформы, прикладные в коде; граница проходит по вопросу «видит ли это Kubernetes».
  • Сосед находится по имени Service, балансировка на уровне соединения в kube-proxy; реестр и клиент обнаружения не нужны.
  • http.Transport не ограничивает срок жизни соединения: закрывать простаивающие по тикеру, иначе трафик залипает на одном поде.
  • gRPC: безголовый Service, dns:/// и round_robin, keepalive; иначе весь трафик клиента в один под.
  • Полное имя с точкой экономит DNS-запросы при ndots:5; кэша DNS у Go нет.
  • Конфигурация из переменных и файлов с валидацией при старте; обновление через выкат с хешем, на лету только по необходимости.
  • Секреты во внешнем хранилище, в под их приносит оператор; SDK хранилища в коде только ради ротации без перезапуска.
  • Liveness без базы, readiness с короткими проверками зависимостей; выкат без простоя держится на SIGTERM и ожидании запросов.
  • Дедлайны, повторы для идемпотентного, размыкатели и трассировка внутри процесса остаются в коде даже с mesh.
  • Расписание через CronJob или тикер с лидером; лимиты по пользователю в коде, по адресу на входе.

Что почитать дальше