Контейнер представляет собой обычный процесс Linux, изолированный ядром: namespaces дают ему собственный взгляд на систему, а cgroups ограничивают, сколько CPU и памяти он может занять. Виртуальная машина загружает целое гостевое ядро на виртуальном железе; все контейнеры на хосте делят одно ядро хоста.
Namespaces решают, что он видит: свою корневую файловую систему, распакованную из образа (mnt), своё дерево процессов, в котором он PID 1 (pid), свои интерфейсы, IP и порты (net), имя хоста (uts) и id пользователей (user).
cgroups решают, сколько он получит: cpu.max (квота, которую задаёт limits.cpu в Kubernetes и которую Go 1.25 читает для GOMAXPROCS), memory.max (за ним OOM killer и код выхода 137), а также лимиты на число процессов и I/O.
Образ и контейнер: образ состоит из слоёв файловой системы только для чтения; контейнер работает поверх них как процесс с тонким слоем для записи, который умирает вместе с ним.
Компромисс: контейнер стартует за миллисекунды и стоит мегабайты; ВМ загружается за секунды и резервирует гигабайты. Цена в общем ядре: один баг в ядре достаёт всё на хосте, поэтому недоверенным арендаторам дают ВМ, микро-ВМ (Firecracker) или gVisor.
На macOS Docker Desktop запускает маленькую Linux-ВМ, потому что Linux-контейнерам нужно ядро Linux.
GoMiddle
Go в проде
Docker multi-stage, конфиг и секреты, graceful shutdown, health и readiness, лимиты ресурсов и GOMAXPROCS в контейнере, миграции
Закрыто вопросов
0 / 21
- 0Повторить
Вопросы
Pod запускает один или несколько контейнеров вместе; Deployment держит N подов одной версии и выкатывает их на следующую; Service даёт этим подам одно стабильное имя и адрес и шлёт трафик только в Ready-поды; ConfigMap хранит конфигурацию, которая становится переменными окружения или файлами.
От образа до трафика: CI пушит образ с неизменяемым тегом; kubectl apply (или Helm, Argo CD) отправляет Deployment, тот создаёт ReplicaSet, а ReplicaSet создаёт поды. Планировщик выбирает ноду, где хватает места под requests пода, и kubelet этой ноды скачивает образ и запускает его.
Ready означает трафик: когда readiness-проба проходит, IP пода попадает в EndpointSlices сервиса, и kube-proxy направляет адрес сервиса на него. Под, который не Ready, ничего не получает.
Снаружи: Ingress или маршрут Gateway API (или Service типа LoadBalancer) доводит внешние запросы до сервиса.
Поды одноразовые: при каждом перезапуске у пода новый IP, поэтому к нему обращаются по имени сервиса (http://orders внутри namespace, orders.shop.svc.cluster.local откуда угодно).
Соседи: Secret (ConfigMap для учётных данных), Job и CronJob (выполняются до завершения), HorizontalPodAutoscaler (меняет replicas).
Прочитать её из окружения один раз при старте в одну типизированную структуру Config: с дефолтами для безопасных значений и валидацией для остальных. Отсутствующее или некорректное обязательное значение останавливает процесс с понятной ошибкой ещё до первого запроса.
12-factor: конфигурацией считается всё, что отличается между деплоями (адреса, учётные данные, лимиты), и живёт она в окружении, а не в коде и не в образе. Тогда один и тот же образ работает и на стейдже, и в проде.
Env или флаги: переменные окружения легко задать в Docker, Kubernetes и CI; флаги удобны для CLI-утилит. Сервисы часто принимают и то и другое, и флаг побеждает.
Fail fast: завершиться с кодом 1 и перечислить все проблемы сразу (errors.Join). Под в CrashLoopBackOff с понятной строкой в логе лучше сервиса, который падает на первом запросе в три часа ночи.
**Разбирать один раз в main**: и передавать Config вниз. Пакеты, которые сами вызывают os.Getenv, прячут свои входные данные, и их трудно тестировать.
Библиотеки: caarlos0/env или kelseyhightower/envconfig раскладывают переменные по тегам структуры; Viper добавляет файлы и удалённые источники ценой размера и магии.
Никогда не логируйте конфиг целиком: в нём секреты.
SIGTERM означает вежливое «пожалуйста, остановись», которое шлют docker stop и Kubernetes, SIGINT приходит от Ctrl+C, и оба перехватываются через signal.NotifyContext. SIGKILL нельзя ни перехватить, ни игнорировать: ядро завершает процесс сразу, и ни один defer не выполнится.
Поведение Go по умолчанию: без signal.Notify сигналы SIGTERM и SIGINT завершают программу немедленно: никаких отложенных вызовов, никакого Shutdown, запросы в обработке обрываются.
Перехват: signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM) возвращает контекст, который отменяется на первом сигнале. Вызовите stop(), когда он сработал, и тогда второй Ctrl+C убьёт процесс обычным способом.
Кто что шлёт: docker stop отправляет SIGTERM, ждёт 10 с, потом SIGKILL. Kubernetes выполняет хук preStop, отправляет SIGTERM, а по истечении terminationGracePeriodSeconds (по умолчанию 30 с) отправляет SIGKILL.
Коды выхода: процесс, убитый сигналом, возвращает 128 + номер сигнала: 137 для SIGKILL (OOM kill выглядит так же), 143 для неперехваченного SIGTERM. Паника в Go завершает процесс с кодом 2.
Без user namespaces UID 0 в контейнере остаётся UID 0 на хосте, которого сдерживают только урезанные capabilities и seccomp, так что взломанный процесс под root отделяет от ноды один баг в ядре или рантайме. Go-бинарнику привилегии не нужны: задайте числовой USER в образе и закрепите это через runAsNonRoot.
**На scratch**: USER 65534:65534 (nobody). Без /etc/passwd работает только числовой UID.
На distroless: берите тег :nonroot (UID 65532) и пишите USER 65532:65532, а не USER nonroot. При runAsNonRoot: true kubelet отказывается запускать пользователя по имени (image has non-numeric user (nonroot), cannot verify user is non-root), если в поде не задан runAsUser.
Порты: слушайте 8080, а не 80: порты ниже 1024 во многих рантаймах требуют CAP_NET_BIND_SERVICE, а Service всё равно может отдавать наружу 80.
Остальное усиление: readOnlyRootFilesystem: true с emptyDir для временных файлов, allowPrivilegeEscalation: false и сброс всех capabilities.
PID 1 занимает скрипт, а Go-бинарник работает как его дочерний процесс, поэтому SIGTERM от kubelet получает sh, который его не пересылает и, будучи PID 1 без обработчика, даже не умирает от него. Go сигнала не видит, и через 30 с всё получает SIGKILL.
PID 1 особенный: ядро доставляет сигнал init-процессу пространства имён, только если тот установил для него обработчик. У sh, ждущего дочерний процесс, обработчика нет, и SIGTERM просто отбрасывается. Go обработчики ставит всегда, поэтому в роли PID 1 сигнал получает.
Исправление: закончить скрипт строкой exec /app/server "$@". exec заменяет shell бинарником в том же PID 1, и сигнал попадает в signal.NotifyContext.
В shell-форме та же ловушка: ENTRYPOINT /app/server --port=8080 запускается через /bin/sh -c; одни shell делают exec единственной команды, другие нет. Используйте exec-форму ENTRYPOINT ["/app/server", "--port=8080"]; на distroless только она вообще работает: /bin/sh там нет.
Симптомы: каждый деплой ждёт полный grace period на каждый под, код выхода 137, а запросы в обработке обрываются, потому что дренаж так и не запустился.
Дочерние процессы: если сервис запускает подпроцессы, добавьте крошечный init (tini или docker run --init), который пересылает сигналы и подбирает зомби.
Любой слой образа может прочитать каждый, кто может его скачать, а удаление файла или переопределение переменной в следующем слое не убирает их из предыдущего. Секреты для сборки передаются через secret mounts в BuildKit, а секреты для рантайма приходят от платформы: переменными окружения или смонтированными файлами.
Как они утекают: ENV DB_PASSWORD=... лежит в конфиге образа (docker inspect), ARG, использованный в RUN, виден в docker history, скопированный .env остаётся в своём слое после rm. А образ пушится в registry, кешируется в CI и скачивается на каждую ноду.
При сборке: обычно это токен для приватных модулей. RUN --mount=type=secret,id=netrc,target=/root/.netrc go mod download даёт его только этой команде и не пишет ни в один слой.
В рантайме: Kubernetes Secret, в идеале синхронизированный из Vault или облачного secret manager, подставленный как переменная окружения или файл. Тогда один и тот же образ работает во всех окружениях.
Env или файл: env проще всего, но его наследуют дочерние процессы, он виден в /proc/<pid>/environ и меняется только при рестарте. Смонтированному файлу можно дать более строгие права, и он обновляется на месте при ротации секрета (но не с subPath).
В Go: загружать один раз при старте, не пускать в логи и тексты ошибок и никогда не передавать флагом командной строки: флаги видны в ps и /debug/pprof/cmdline.
-s убирает таблицу символов ELF, а -w убирает отладочную информацию DWARF, что обычно уменьшает бинарник примерно на 30%. Стектрейсы паник и pprof продолжают работать, потому что опираются на собственную pclntab Go, которая остаётся; теряются Delve, читаемые core dump и нативные инструменты вроде perf.
Остаётся: pclntab сопоставляет адреса инструкций с функциями, файлами и строками. Стектрейсы паник, runtime.Callers, профили pprof (символизируются внутри процесса) и build info, которую читает go version -m, остаются на месте.
Теряется: Delve без DWARF не ставит брейкпоинты и не показывает переменные, core dump почти не читается, а perf и другие инструменты, работающие с ELF-символами, показывают голые адреса.
Go 1.22+: один -s уже подразумевает -w; -s -w=0 оставляет DWARF без таблицы символов.
**-trimpath нужен для другого**: он заменяет абсолютные пути сборки (/home/runner/work/...) на пути модулей в стектрейсах и делает сборку воспроизводимой; размер он заметно не меняет.
Компромисс: несколько мегабайт на образ почти не влияют на скачивание. Команды, которые отлаживают прод через Delve или разбирают core dump, не ставят -w или хранят нестрипнутую сборку того же коммита как артефакт CI.
ListenAndServe возвращает ErrServerClosed в тот момент, когда Shutdown закрывает листенер, а не когда заканчивается дренаж. Поэтому run закрывает пул БД и main выходит, пока обработчики ещё работают: запросы в обработке падают с sql: database is closed или умирают вместе с процессом.
Почему: «всё дренировано» означает только возврат из Shutdown, а здесь его никто не ждёт: он выполняется в горутине, результат которой выброшен.
Исправление: отправить результат Shutdown в канал и ждать его после возврата ListenAndServe; БД закрывать только после этого.
Таймаут: если бюджет в 25 с закончился, Shutdown вернёт context.DeadlineExceeded, а обработчики ещё работают; srv.Close() обрывает их, затем закрывается БД.
Проигнорированная ошибка: голый srv.Shutdown(sctx) скрывает дренаж, который не уложился в таймаут; errcheck в golangci-lint это ловит.
Бюджет: 25 с плюс sleep в preStop должны укладываться в terminationGracePeriodSeconds, иначе SIGKILL придёт посреди дренажа.
У пода всего 30 с, но один только sleep в preStop занимает 30 с, а Shutdown разрешено ещё 25 с. Kubelet присылает SIGKILL (код выхода 137), пока запросы ещё дренируются, поэтому они обрываются, а строка лога о завершении так и не появляется.
Одни часы: terminationGracePeriodSeconds стартует, когда под становится Terminating, и покрывает хук preStop и дренаж после SIGTERM. После preStop отсчёт не начинается заново; если хук съел всё время, у контейнера после SIGTERM остаётся пара секунд.
Бюджет: preStop + таймаут Shutdown + запас должны уложиться в grace period: 10 + 25 + 10 = 45 с.
Как это возникает: дефолтных 30 с хватает на обычный дренаж. Баг появляется, когда кто-то добавляет sleep в preStop или увеличивает таймаут shutdown и забывает про grace period.
**Длина preStop**: достаточно, чтобы удаление endpoint дошло до балансировщика, обычно 5-15 с. Нативный обработчик sleep (beta с Kubernetes 1.30, GA в 1.34) работает и на distroless, где нет бинарника sleep.
Отдавать его на отдельном admin-листенере, который не выставлен ни через Service, ни через Ingress, ни через балансировщик; проще всего использовать loopback и kubectl port-forward. Открытый в интернет, он раскрывает командную строку и структуру кода и даёт любому дешёвый способ подвесить процесс.
Что утекает: /debug/pprof/cmdline возвращает os.Args, включая секреты, переданные флагами. goroutine?debug=2 выводит стек каждой горутины с именами функций и путями к файлам, а профили heap и CPU показывают структуру кода и зависимости.
Чем это грозит: goroutine?debug=2 останавливает мир, пока форматирует все стеки; при 100 000 горутин это заметная пауза, и ничто не мешает дёргать его в цикле.
Типичная ловушка: import _ "net/http/pprof" регистрирует обработчики в http.DefaultServeMux, так что любой сервер, запущенный с nil-хендлером, или роутер, который падает обратно на дефолтный mux, отдаёт их публично.
Admin-порт: второй http.Server со своим mux (pprof, /metrics) на 127.0.0.1 или на порту, которого нет в Service; NetworkPolicy ограничивает, кто может достучаться до него внутри кластера.
Если нужен удалённый доступ: закройте его auth-мидлварью или отдайте сбор профилей continuous profiler (Pyroscope, Parca, агент вендора), чтобы люди не ходили в эндпоинт напрямую.
Память контейнера упёрлась в лимит cgroup, и OOM killer ядра отправил SIGKILL: ни паники, ни отложенных вызовов, ни последней строки в логе. Собственная ошибка Go fatal error: runtime: out of memory в контейнере редкость, потому что ядро убивает процесс раньше, чем аллокация успеет завершиться ошибкой.
Как убедиться: kubectl describe pod показывает Last State: Terminated, Reason: OOMKilled, Exit Code: 137. Вытеснение из-за нехватки памяти на ноде выглядит как Evicted.
Что учитывается: cgroup считает кучу и стеки Go плюс память, которой Go не управляет: аллокации через cgo и файлы в emptyDir с medium: Memory. Сравнивайте с лимитом container_memory_working_set_bytes, а не только кучу.
Почему Go вырос за лимит: при GOGC=100 GC позволяет куче дорасти примерно до двух объёмов живых данных и ничего не знает о лимите контейнера; всплеск параллельных запросов или выросший живой набор переносит следующую цель кучи за лимит.
Что делать: задать GOMEMLIMIT ниже лимита (см. тему про рантайм), ограничить параллелизм, чтобы всплеск не держал безграничные буферы, и снять heap-профиль до следующего убийства, чтобы исключить утечку.
Другие коды выхода: 2 означает панику или фатальную ошибку рантайма Go (читается через kubectl logs --previous), 1 обычно говорит о log.Fatal или os.Exit(1), а 143 о неперехваченном SIGTERM.
Каждые 15 с HPA считает ceil(replicas × current / target) по каждой метрике и масштабирует к наибольшему результату в пределах minReplicas и maxReplicas. CPU работает, только когда узкое место в CPU: API, который ждёт базу, или отстающий от топика консьюмер бывают перегружены при 20% CPU.
Арифметика: 4 пода при 140% против цели 70% просят ceil(4 × 140/70) = 8; отношение в пределах 10% от 1,0 игнорируется. Масштабирование вверх мгновенное, а вниз берёт наибольшую рекомендацию за последние 5 минут, чтобы провал не убрал поды, нужные на следующем пике.
**Утилизация считается от requests.cpu: под без CPU request не даёт HPA ничего посчитать, а CPU подов, которые ещё стартуют, откладывается в сторону.
I/O-bound API: горутины, которые ждут Postgres, не тратят CPU, поэтому латентность растёт, CPU стоит на месте, и HPA ничего не делает. Масштабируйте по запросам в обработке или RPS на под, которые metrics adapter отдаёт HPA из Prometheus.
Больше подов может навредить: когда узкое место в базе, каждый новый под добавляет пул соединений, а масштабирование по латентности добавляет поды как раз в этот момент.
Kafka-консьюмеры: сигналом служит отставание консьюмера (lag), которое KEDA читает напрямую. Консьюмеры сверх числа партиций простаивают, и Kafka-скейлер KEDA по умолчанию на этом числе и останавливается.
Память не лучше**: память Go-пода падает медленно после того, как нагрузка распределилась (рантайм возвращает память ОС постепенно), поэтому HPA по памяти масштабирует вверх и редко обратно.
Сначала дешёвые проверки, потом тесты, потом артефакт: go mod tidy -diff, go vet и линтер за секунды; затем параллельно go test -race, интеграционные тесты и govulncheck; затем один образ на коммит с тегом по SHA. Быстрым пайплайн делают восстановленные кэши модулей и сборки и шардированные тесты.
Падать быстро: ошибка линтера должна стоить 30 секунд, а не ждать за десятиминутным интеграционным набором; джоба образа ждёт все остальные. Что проверяет каждый инструмент, разобрано в теме про инструменты.
Собрать один раз и продвигать: образ, собранный из коммита, уходит сначала на стейджинг, а потом в прод; пересборка под каждое окружение выкатывает бинарник, который никто не тестировал.
Кэш модулей: GOMODCACHE экономит загрузки. actions/setup-go восстанавливает его, но начиная с v6 ключ кэша по умолчанию строится по go.mod; укажите go.sum в cache-dependency-path.
Кэш сборки: GOCACHE хранит скомпилированные пакеты и результаты тестов, поэтому неизменённые пакеты не пересобираются и не перетестируются (прогоны с -race и -coverprofile тоже кэшируются; -count=1 и -shuffle его выключают). setup-go сохраняет его только при смене ключа, так что между обновлениями зависимостей он устаревает.
Сборка в Docker: --mount=type=cache в BuildKit не входит в экспортируемый кэш слоёв, поэтому на свежем раннере он пустой. Сборка бинарника с восстановленным GOCACHE и копирование его в образ это обходит.
Шардирование: go test и так гоняет пакеты параллельно на одной машине. Когда один набор доминирует, разнесите список пакетов по раннерам, а интеграционные тесты держите за build tag.
Rolling заменяет поды по несколько штук, blue-green поднимает полную вторую копию и переключает весь трафик разом, а canary отправляет на новую версию малую долю трафика и расширяет её, пока метрики в порядке. Во всех трёх старая и новая версии работают одновременно, и сервис должен это пережить.
Стратегии
Rolling: поведение Deployment в Kubernetes по умолчанию (maxSurge и maxUnavailable по 25%). Дёшево и автоматически; плохая версия доходит до всех подов, если её не остановит readiness или человек.
Blue-green: два полных окружения, а переключение сводится к смене селектора Service или цели балансировщика. Откат делается таким же переключением обратно, ценой двойной мощности на время переключения.
Canary: сначала 1–5% трафика, сравнение по доле ошибок и задержке, затем пошаговое расширение. Нужны разделение трафика (Argo Rollouts, Flagger, mesh или веса в ingress) и метрики, которым вы доверяете.
Что нужно от сервиса
Две версии одновременно: API, форматы сообщений и схема БД должны работать для N и N-1 вместе: добавить поле до того, как его использовать, держать старое, пока его кто-то шлёт, миграции по схеме expand/contract.
Чистый вход и выход: readiness, который зеленеет, только когда инстанс может обслуживать, и graceful-дренаж по SIGTERM.
Идемпотентные консьюмеры: во время переключения сообщение может быть наполовину обработано старым подом и доставлено заново новому.
Фича-флаги: отделить деплой от релиза: выкатить код выключенным, включить позже, выключить без отката.
В проде запускайте отдельным шагом до раскатки (CI-джоба или Kubernetes Job из того же образа), чтобы их применил один процесс один раз, со своим таймаутом и правами на DDL. Миграции при старте нормальны для одного инстанса, но с репликами они гоняются друг с другом и превращают упавшую миграцию в crash loop.
При старте
Десять подов стартуют разом, и все пытаются мигрировать. golang-migrate и goose умеют брать advisory lock в Postgres, чтобы работал только один, но остальные девять ждут, пока тикают их пробы.
Длинная миграция переживает startup- или liveness-пробу: kubelet убивает под посреди ALTER, следующий под начинает заново, а golang-migrate оставляет версию с пометкой dirty, пока кто-нибудь не выполнит force.
Пользователю БД, под которым ходит приложение, нужны права на DDL, которых у него в остальное время быть не должно.
Отдельным шагом деплоя
Хук Helm pre-upgrade, хук Argo CD PreSync или стадия пайплайна запускает app migrate или CLI инструмента, и раскатка начинается, только если он отработал успешно.
Новая схема какое-то время работает со старым кодом, поэтому каждая миграция должна быть обратно совместимой: expand выкатывают до кода, которому он нужен, а contract релизами позже (см. тему про базы данных).
Вынесите расписание из реплик или заставьте их договориться, кто запускает: Kubernetes CronJob стартует один под на запуск, либо каждая реплика пытается захватить запуск в базе, и удаётся это только одной. В обоих случаях задача должна быть идемпотентной, потому что ни один планировщик не обещает ровно один раз.
**CronJob**: задача становится командой того же образа (app send-report) с расписанием по настенным часам и timeZone; concurrencyPolicy: Forbid не даёт запускам пересекаться. В документации Kubernetes сказано, что он создаёт Job «примерно один раз» на слот: изредка два, изредка ни одного.
Захватите период: каждая реплика тикает раз в минуту и вставляет (job, hour) с ON CONFLICT DO NOTHING; задачу запускает та, чья вставка прошла. Именно захват часа, а не блокировка на время работы, не даёт опоздавшей реплике запустить тот же час ещё раз, когда первая уже закончила.
Падение посреди запуска оставляет час захваченным и незавершённым. Записывайте started_at и done_at и позвольте следующему тику забрать захват старше таймаута задачи без done_at.
Выбор лидера: одна реплика держит Kubernetes Lease (leaderelection из client-go) и запускает все расписания; лидер, потерявший лидерство, может ещё доделывать работу, поэтому она остаётся идемпотентной (см. вопрос про распределённую блокировку). river на Postgres ставит периодические задачи из выбранного лидера.
Идемпотентная работа: записывайте «отчёт за 13:00 отправлен» в той же транзакции или передавайте этот ключ провайдеру почты, чтобы второй запуск ничего не делал.
**time.Ticker к тому же меряет не то**: он отсчитывает время от старта пода, и каждый перезапуск его сбрасывает.
Rolling update убирает очередные старые поды только после того, как новые стали Ready и продержались в этом состоянии minReadySeconds, так что readiness служит тормозом раскатки. Он работает, только если сломанный релиз не может стать Ready: проверьте конфиг и зависимости один раз при старте, а дальше держите readiness локальным.
Как раскатка использует readiness
При maxSurge: 2 и maxUnavailable: 0 Kubernetes поднимает два новых пода и убирает старый, только когда новый стал доступен. Новые поды, которые так и не стали Ready, останавливают раскатку на этом месте, а старые продолжают обслуживать трафик.
minReadySeconds ловит версию, которая проходит первую пробу и падает через несколько секунд.
progressDeadlineSeconds помечает раскатку как ProgressDeadlineExceeded, но не откатывает её. Тогда kubectl rollout status завершается с ненулевым кодом, и пайплайн выполняет kubectl rollout undo; Argo Rollouts умеет прерывать раскатку и по метрикам.
Что заставляет плохой релиз упасть рано
Readiness-хендлер, который всегда отвечает 200, пропускает версию с неверным DATABASE_URL или без нужного секрета на все поды, и она падает уже на реальном трафике.
Рискованные проверки делайте при старте: провалидировать конфиг, открыть пул и выполнить один запрос, сверить версию схемы, которую ждёт код. Проваливайте их выходом из процесса или тем, что started так и не станет true.
После этого readiness сообщает только локальное состояние. Короткий сбой базы не должен опустошить весь парк (см. тему про observability).
Чего он не поймает
Логические баги, которые проходят старт и отдают 500 на реальном трафике. Это задача canary: малая доля трафика и проверка доли ошибок перед расширением.
Балансировщик держит простаивающие keep-alive соединения к бэкенду и переиспользует их. Если IdleTimeout Go-сервера короче, чем у балансировщика, сервер закрывает соединение ровно тогда, когда балансировщик отправляет в него запрос; запрос попадает в закрытый сокет, и балансировщик отвечает 502.
Почему логи пустые: запрос так и не дошёл до хендлера: сервер закрыл соединение, не успев его прочитать. Это видно только в логах балансировщика (в AWS ALB это 502 без статус-кода от таргета).
Правило: сторона, которая переиспользует соединение, должна отказываться от него первой, так что idle-таймаут бэкенда должен быть длиннее, чем у прокси. В AWS ALB по умолчанию 60 с, значит IdleTimeout 75–120 с; балансировщик Google Cloud держит соединения к бэкендам около 600 с, и бэкендам нужно больше.
Ловушка по умолчанию: при нулевом IdleTimeout Go берёт ReadTimeout, так что ReadTimeout в 5 с молча закрывает keep-alive соединения после 5 с простоя.
То же правило для исходящих: IdleConnTimeout должен быть короче idle-таймаута апстрима. У http.DefaultTransport он 90 с, но у собранного вручную &http.Transport{} его нет (0 значит без ограничения), поэтому переиспользованные соединения падают с EOF или connection reset, а неидемпотентный запрос Go не повторит.
Во время деплоя: Shutdown тоже закрывает простаивающие соединения, поэтому такие же 502 кучкуются вокруг раскаток. Это закрывает снятие таргета с балансировщика (deregistration delay, sleep в preStop) до SIGTERM.
Память: лимит равен request, а GOMEMLIMIT составляет около 90% от него. CPU: многие команды убирают лимит, чтобы избежать CFS-троттлинга, но тогда Go 1.25 не видит лимита cgroup и ставит GOMAXPROCS равным числу всех ядер ноды, так что задайте его сами из CPU request.
Память
Память не сжимается: выход за лимит означает SIGKILL, а request ниже лимита позволяет ноде занять больше, чем есть (overcommit), и под нагрузкой вытеснить вас или убить через OOM killer. limits.memory, равный requests.memory, снимает обе проблемы.
Рантайм не читает лимит памяти. Задайте GOMEMLIMIT (900MiB для 1Gi), чтобы GC начинал работать усерднее раньше, чем ядро убьёт процесс; запас покрывает память вне учёта Go, например cgo.
CPU
С лимитом по CPU Go 1.25+ (нужна строка go 1.25 или новее в go.mod) ставит GOMAXPROCS равным лимиту (с округлением вверх, минимум 2) и следит за его изменениями; до 1.25 для этого был нужен automaxprocs. Цена: троттлинг, когда цикл GC или всплеск нагрузки выбирает квоту периода раньше времени.
Без лимита под может занимать свободные ядра, но GOMAXPROCS равен числу ядер ноды: 64 P для пода, который просил 2, и больше потоков GC, которые борются за его долю, когда нода загружена.
Задайте GOMAXPROCS из requests.cpu через downward API; значение округляется вверх до целого. Явное значение заодно выключает периодическое обновление из Go 1.25, и это нормально: requests в рантайме не меняются.
Подбор
Прогоните нагрузочный тест на одном поде, найдите точку, где p99 начинает расти, поставьте CPU request чуть выше устойчивого потребления и дайте HPA масштабировать по утилизации CPU, которая считается от requests, а не от limits.
Перестать брать новую работу, доделать уже взятую и последним закрыть то, от чего эта работа зависит: readiness в 503, подождать обновления эндпоинтов, параллельно дренировать HTTP-сервер и консьюмер, затем воркеры, затем продюсеры, затем пул БД и в самом конце телеметрию.
Почему такой порядок
Каждый компонент закрывается после всего, что им пользуется. Хендлеры используют воркеры, продюсеры и БД, поэтому если закрыть пул первым, чистый дренаж превратится в шквал sql: database is closed.
HTTP-сервер и консьюмер работают как независимые точки входа, поэтому они дренируются параллельно в рамках одного бюджета.
Телеметрия идёт последней, чтобы сам дренаж попал в логи, трейсы и метрики.
Консьюмер
Отменять получение, а не обработку: один контекст для цикла poll, который отменяет SIGTERM, и другой для обработки сообщения, который отменяет только дедлайн shutdown. Иначе сообщение умрёт на середине своей транзакции.
Закоммитить offset того, что завершилось, затем закрыть консьюмер, чтобы он сразу покинул группу и его партиции переехали за секунды, а не после session timeout.
То, что получено, но не доделано, будет доставлено другому участнику заново, поэтому хендлеры должны быть идемпотентными.
Бюджет
Один дедлайн на весь дренаж, меньше terminationGracePeriodSeconds минус sleep в preStop. Всё, что ещё работает к дедлайну, обрывается, а его сообщения доставляются снова: at-least-once по построению.