У Go-сервиса в Kubernetes нет фреймворка, который сам подключит реестр сервисов, сервер конфигурации и размыкатели: стандартная библиотека даёт HTTP-клиент и сервер, остальное выбирают по задаче. Это упрощает вопрос «платформа или библиотека» до инженерного: для каждой задачи смотрят, видит ли её платформа. Инфраструктурные задачи (кто где живёт, как доставить конфиг, кого перезапустить) платформа видит и решает лучше кода. Прикладные (ждать ли ответа платёжного шлюза тридцать секунд, повторять ли списание денег) платформа не видит, и их место в коде.
Задача за задачей
| Задача | Kubernetes | Go-сервис |
|---|---|---|
| Найти соседний сервис | DNS и Service: http://orders | клиент по имени, без реестра |
| Балансировка | kube-proxy на уровне соединения | срок жизни соединений; для gRPC свой резолвер |
| Конфигурация | ConfigMap и Secret в переменные или файлы | чтение при старте, валидация, при нужде перечитывание |
| Секреты | Secret плюс внешнее хранилище через оператор | файл или переменная, без SDK хранилища |
| Перезапуск упавшего | liveness и readiness probes | честные обработчики /healthz и /readyz |
| Выкат без простоя | rolling update | корректное завершение по SIGTERM |
| Маршрутизация с края | Ingress или Gateway API | свой шлюз только ради прикладной логики |
| Таймауты, повторы, размыкатели | только через mesh | context, 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 или тикер с лидером; лимиты по пользователю в коде, по адресу на входе.
Что почитать дальше
- Сеть в Kubernetes — Service, kube-proxy, DNS и почему
ndotsдорого обходится. - Деплой и конфигурация в Kubernetes — ConfigMap, Secret, пробы и rolling update.
- Завершение Go-сервиса в Kubernetes — SIGTERM, preStop и readiness при выкате.
- Устойчивость на Go — таймауты, повторы и размыкатели как система.
- Структурные паттерны микросервисов на Go — шлюз, обратный прокси и заголовки, которым нельзя верить.
- Конфигурация на лету в Go-сервисе — когда перезапуск не подходит и как перечитать файл из ConfigMap.