Go в контейнерах: GOMAXPROCS против квоты CPU, GOMEMLIMIT против OOM и multi-stage Dockerfile с правильным кэшем
Рантайм по умолчанию видит CPU хоста: под с лимитом 2 CPU и GOMAXPROCS=64 троттлит сам себя до развала p99; GOMEMLIMIT меняет OOM-kill на давление GC. Multi-stage сборка с отдельным слоем go mod download превращает статический бинарник в крошечный кэшируемый образ.
Переезд на большие общие ноды должен был пройти незаметно — пока не загрузились дашборды задержек. Все Go-сервисы переехали с 4-ядерных виртуалок на 64-ядерные ноды Kubernetes с limits.cpu: 2, и p99 ушёл с 40 мс на 320 мс, хотя графики CPU уверяли, что поды всего лишь «в лимите». GOMAXPROCS не выставил никто. Рантайм спросил машину — шестьдесят четыре ядра — и запустил 64-поточный параллелизм внутри cgroup, которой разрешено 200 мс CPU на каждое 100-мс окно. Шестьдесят четыре занятых потока сжигают этот запас примерно за три миллисекунды; затем ядро замораживает их все на оставшиеся девяносто семь. Сервис не был медленным. Он стоял на паркинге, десять раз в секунду, посреди запроса. Правду рассказал cpu.stat: nr_throttled покрывал 96% периодов. Лечение — одна переменная окружения, GOMAXPROCS=2 (позже флот перешёл на automaxprocs, а Go 1.25 сделал это дефолтом), — и p99 упал в 8 раз, обратно к 41 мс. Счёт за игнорирование разницы между машиной и cgroup: четверть ёмкости флота, месяцами, невидимо.
Ложь про CPU: GOMAXPROCS против квоты CFS
GOMAXPROCS по умолчанию равен числу CPU, которое сообщает ОС, — а в контейнере это хост, потому что cgroup-лимит CPU не является числом ядер. limits.cpu: 2 превращается в cpu.max: 200000 100000: 200 мс процессорного времени на 100-мс учётный период, суммарно по всем потокам — именно так CFS (Completely Fair Scheduler, планировщик ядра Linux, делящий процессорное время квотами по периодам) видит ограничение контейнера. Эти два числа живут в разных единицах, и рантайм исторически читал только не то. Механика отказа на 64-ядерной ноде: GOMAXPROCS=64 означает до 64 OS-потоков, параллельно исполняющих горутины, плюс фоновые воркеры GC размером в четверть GOMAXPROCS — ещё 16 потоков, которые просыпаются одновременно во время сборки мусора. Под нагрузкой они исчерпывают 200-мс квоту через считаные миллисекунды после начала периода, и планировщик жёстко останавливает всю cgroup до следующего. Задержка не деградирует плавно — она квантуется в кратные окна троттлинга; это и есть обрыв p99 в 8 раз из крючка. Диагноз — два чтения: /sys/fs/cgroup/cpu.stat (nr_throttled растёт вровень с nr_periods, throttled_usec огромен) и метрика рантайма, показывающая GOMAXPROCS в размер хоста.
Лестница лечения, от старого к новому: переменная GOMAXPROCS в спеке пода рядом с лимитом CPU, чтобы они менялись вместе; либо импорт go.uber.org/automaxprocs, читающего квоту cgroup на старте; либо Go 1.25+, где рантайм наконец делает это сам — на Linux дефолт GOMAXPROCS теперь уважает cgroup-лимит CPU (с округлением вверх, не ниже 2, по-прежнему не выше числа ядер хоста), и рантайм даже перепроверяет его при изменении лимита. Честные оговорки: считаются только лимиты — CPU-реквесты задают cpu.weight, то есть коэффициент при конкуренции, а не квоту, и из него GOMAXPROCS не выводится; явная переменная или вызов в коде всегда побеждают; а поды, сознательно живущие без лимита, по-прежнему получают дефолт в размер хоста — и для них это корректно.
Под с limits.cpu: 2 на 64-ядерной ноде, Go 1.24, GOMAXPROCS не задан. Под нагрузкой p99 взрывается, а средний CPU держится около лимита. Каков механизм?
Память: трейдофф OOM-kill против давления GC
У лимита памяти та же форма: ядро принуждает limits.memory через OOM-kill (принудительное завершение процесса из-за нехватки памяти) контейнера, а куча Go растёт по политике, которая про cgroups не слышала. С дефолтным GOGC=100 пиковая куча достигает примерно удвоенного живого набора — сервис с 600 МиБ живых данных в поде на 1 ГиБ не «близок к лимиту», он за ним на границе следующего цикла GC. GOMEMLIMIT (Go 1.19+) замыкает контур: поставьте его около 90% лимита контейнера, и пейсер GC примет его как мягкий потолок для всей памяти под управлением рантайма — кучи, стеков, структур рантайма. Механизм — то, что стоит запомнить: по мере приближения к лимиту пейсер планирует сборки всё агрессивнее, превращая давление памяти в процессорное время GC вместо SIGKILL. Трейдофф явный и обычно правильный: сервис, жгущий 30% CPU на GC в пике, деградирует; сервис, убитый ядром, лежит — и по пути потерял все запросы в полёте.
Честность конструкции держится на двух деталях. Лимит сознательно мягкий: рантайм ограничивает CPU сборщика примерно 50%, так что когда живая куча действительно не помещается, Go превышает лимит (и тогда может быть убит OOM), а не уходит в лайвлок из непрерывных сборок — защита от смертельной спирали. А 90% запаса — не суеверие: аллокации cgo, mmap-регионы и память ядра, записанная на cgroup, рантайму невидимы, поэтому GOMEMLIMIT=лимит означает смерть пода, пока Go уверен, что всё в порядке. Задавайте его переменной окружения в деплойменте, рядом с лимитом памяти, чтобы оба числа ревьюились и менялись вместе — env-конфигурация здесь и есть контракт, та же спираль, что в уроке про конфигурацию в go/07.
▸Почему это работает
Почему лимит мягкий, а не жёсткий? Потому что у Go нет пути отказа аллокации — make и append не умеют вернуть «память кончилась», — и жёсткий потолок проявился бы либо паникой в произвольном коде, либо лайвлоком GC, пытающегося собрать кучу под невозможную планку. Конструкция «пейсер плюс кап 50%» выбирает наименее плохой режим отказа: сначала деградировать (больше CPU на GC), а если живой набор правда не помещается — отдать решение ядру, чьи убийства оркестратор хотя бы умеет обрабатывать.
Сервис в поде с лимитом 1 ГиБ получает OOM-kill на пиках трафика. Вы ставите GOMEMLIMIT=900MiB. Что реально меняется?
Dockerfile с правильным кэшем
Статический бинарник сводит контейнерную историю к упаковке: артефакт деплоя — один файл, а образ существует, чтобы доставить его плюс хранилище TLS-доверия:
FROM golang:1.25 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download # отдельный слой: сбрасывается только при смене зависимостей
COPY . .
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/api ./cmd/api
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/api /api
ENTRYPOINT ["/api"]Порядок слоёв и есть стратегия кэширования. Слой пересобирается, когда меняются его входы, — и вместе с ним пересобирается всё, что после него; значит, вопрос к каждой инструкции — как часто меняются её входы. go.mod и go.sum меняются редко; исходники — каждый коммит. Скопировать только манифесты модулей, скачать, и потом копировать исходники — значит обычный коммит воспроизводит слой скачивания из кэша (0 с) и платит только за компиляцию; переверните порядок — сначала COPY . . — и каждый коммит заново качает весь граф модулей (30-90 с на реальном дереве зависимостей, каждую сборку, вечно). Кэш-монтирования BuildKit идут на шаг дальше (RUN --mount=type=cache,target=/go/pkg/mod go mod download сохраняет кэш модулей между сборками даже при изменении go.mod), но именно порядок COPY — то место, где ошибаются.
Выбор рантайм-стадии — настоящее решение. scratch — минимум: образ буквально равен бинарнику, но вы наследуете все дыры: нет CA-сертификатов (первый HTTPS-вызов падает), нет tzdata, нет /etc/passwd для непривилегированного пользователя, нет шелла для отладки. distroless/static стоит примерно 2 МиБ сверху и привозит ровно это: корневые CA, tzdata, пользователя nonroot — шелла по-прежнему нет, что одновременно фича безопасности и отладочный трейдофф, закрываемый эфемерными debug-контейнерами. В обоих случаях итоговый образ — около 10-15 МиБ против билдера на ~850 МиБ, которому в прод нельзя никогда. Числа на память: бинарник ~7-9 МиБ со стрипом, база distroless/static ~2 МиБ, время пулла — фактически круговой путь до реестра.
- 01Объясни отказ GOMAXPROCS-в-контейнере целиком: дефолт, механика CFS, сигнатура симптомов и лечение по версиям Go.
- 02Как взаимодействуют GOMEMLIMIT и лимит памяти контейнера и почему конвенция — ~90%, а не 100%?
Контейнер даёт рантайму Go два числа, которых тот исторически не видел, и оба дефолта на общих нодах неверны. Сначала CPU: GOMAXPROCS по умолчанию — ядра хоста, но limits.cpu — это квота полосы CFS, время на период, а не число ядер. Шестьдесят четыре потока против запаса 200 мс на 100 мс сжигают его за миллисекунды, после чего ядро паркует всю cgroup; задержка квантуется в кратные окна троттлинга, p99 рушится, а средний график CPU не показывает ничего необычного. Правду читайте в cpu.stat, затем выравнивайте: явный GOMAXPROCS рядом с лимитом, automaxprocs на старте или Go 1.25+, где рантайм выводит его из cgroup сам (только лимиты — реквесты есть вес, а не квота; округление вверх, не ниже 2, с перепроверкой при изменении лимитов). Затем память: ядро принуждает limits.memory через SIGKILL, а GOGC=100 по пути туда радостно удваивает живой набор. GOMEMLIMIT на ~90% лимита вручает пейсеру мягкий потолок над всей памятью рантайма: приближение к границе покупает агрессивный GC вместо смерти; лимит мягок, а CPU сборщика ограничен около 50% именно затем, чтобы невмещающийся набор перелился, а не лайвлокнулся, — и запас покрывает невидимые рантайму cgo, mmap и память ядра. Упаковка — лёгкая треть: multi-stage сборка, манифесты и кэшируемый go mod download отдельным слоем до COPY исходников, компиляция с CGO_ENABLED=0 и рантайм-стадия distroless/static (или осознанно голый scratch) — образ на 10-15 МиБ, чьи переменные окружения, по спирали конфигурации из go/07, несут GOMAXPROCS и GOMEMLIMIT рядом с лимитами, которые они зеркалят. Теперь, когда p99 взрывается после переезда подов, а графики CPU показывают «в лимите» — вы первым делом идёте в cpu.stat за nr_throttled, а не в код приложения.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.