У Go нет -Xmx, и кажется, что в контейнере ему ничего настраивать не надо. Пока сервис не начинает дёргаться по задержкам на машине с 32 ядрами при квоте в два, или не умирает с кодом 137 на лимите в 512 МБ, хотя куча по метрикам держится около 200. Обе истории — про то, что рантайм видит хост, а не контейнер, и про два параметра, которые это чинят.
GOMAXPROCS и квота процессора: что видит планировщик
GOMAXPROCS — сколько потоков операционной системы одновременно выполняют код Go. По умолчанию он равен числу ядер, которое видит процесс, и до Go 1.25 это было число ядер хоста: --cpus=2 на 32-ядерной машине давал GOMAXPROCS=32. Планировщик Go разгонял 32 потока, ядро через CFS отмеряло им квоту в две ядро-секунды на каждые 100 мс и остальное время держало потоки на паузе. Снаружи это выглядит как необъяснимые скачки задержки: запрос попал на паузу квоты и ждёт десятки миллисекунд, а процессор при этом «свободен». Сборщик мусора с 32 потоками-помощниками выедает квоту ещё быстрее.
Два способа привести число в соответствие с квотой. Библиотека go.uber.org/automaxprocs одним пустым импортом читает квоту из cgroup и выставляет GOMAXPROCS при старте. А с Go 1.25 рантайм делает это сам: читает лимит cgroup, округляет его вверх до целого и обновляет значение, если лимит поменяли на лету. Если версия старше — импорт automaxprocs обязателен в каждом сервисе, который уедет в контейнер с квотой.
Проверка в логе старта стоит одну строку: log.Printf("GOMAXPROCS=%d NumCPU=%d", runtime.GOMAXPROCS(0), runtime.NumCPU()). Если первое число больше квоты контейнера — планировщик живёт в неведении. Дробную квоту (--cpus=0.5) рантайм округляет до одного: меньше одного потока не бывает, а значит сервис с половиной ядра всё равно будет упираться в паузы квоты, и такому сервису лучше дать целое ядро.
Память: GOGC и GOMEMLIMIT вместо -Xmx
У Go нет фиксированного размера кучи. Сборщик запускается, когда куча вырастает на GOGC процентов относительно живых данных после прошлой сборки: при GOGC=100 по умолчанию куча колеблется между объёмом живых данных и его удвоением. Сервис с 200 МБ живых данных регулярно занимает 400, плюс стеки горутин, кэш модулей и память самого рантайма. Про лимит контейнера сборщик ничего не знает: он не читает cgroup и спокойно вырастет до 450 МБ в контейнере с лимитом 512, после чего ядро убьёт процесс.
GOMEMLIMIT (с Go 1.19) задаёт мягкий потолок на всю память рантайма: кучу, стеки и служебные структуры. При приближении к нему сборщик работает чаще, удерживая процесс под лимитом, а пока памяти хватает, не делает лишней работы. Значение берут ниже лимита контейнера на 10–20 %, оставляя место тому, что рантайм не считает: буферам ядра, памяти cgo и файловому кэшу, который cgroup v2 тоже записывает на счёт контейнера:
ENV GOMEMLIMIT=450MiB
при --memory=512m. Единицы — KiB, MiB, GiB. Если лимитов несколько и переносить число в каждый манифест не хочется, библиотека KimMachineGun/automemlimit читает лимит cgroup и выставляет GOMEMLIMIT как долю от него при старте, по аналогии с automaxprocs.
Два предела, о которых стоит знать. GOMEMLIMIT — мягкий: если живых данных больше лимита, сборщик не может их освободить, и процесс всё равно вырастет и умрёт. И у сборщика есть предохранитель от смертельной спирали: он не тратит на себя больше половины процессорного времени, даже если это значит превысить лимит, иначе сервис бы перестал обслуживать запросы, бесконечно собирая мусор. Сочетание GOGC=off с GOMEMLIMIT имеет смысл для сервисов, у которых память — главный ресурс: сборка идёт только по достижении потолка, а не каждые сто процентов роста.
OOMKilled против паники: как выглядят два разных конца
Контейнер завершился с кодом 137, в логе последняя строка — обычный запрос, трассировки стека нет. Это OOMKilled: ядро убило процесс SIGKILL за превышение лимита памяти cgroup, и у рантайма не было шанса что-то написать. Подтверждение — docker inspect <id> с полем OOMKilled: true, в Kubernetes то же пишут в kubectl describe pod как причину последнего завершения.
Второй конец выглядит иначе: fatal error: runtime: out of memory с трассировками всех горутин. Так рантайм падает, когда операционная система отказывает в выделении памяти, а не убивает молча; в контейнерах это редкость, потому что ядро чаще убивает, чем отказывает. Лечение у обоих одно — понять, кто держит память.
Инструменты по нарастающей. GODEBUG=gctrace=1 печатает строку на каждую сборку: размер кучи до и после, доля времени на сборщик; по ней видно, растёт ли живая куча или сборщик просто редко ходит. Эндпоинт /debug/pprof/heap из net/http/pprof даёт профиль: go tool pprof -http=:0 http://сервис/debug/pprof/heap показывает, какие функции выделили удерживаемую память. И пакет runtime/metrics отдаёт те же числа в метрики: /memory/classes/heap/objects:bytes для живой кучи, /memory/classes/total:bytes для всего, что рантайм взял у системы, — второе число и сравнивают с лимитом контейнера на графике.
Горутины и утечки: память, которой не видно в куче
Стек горутины начинается с 8 КБ и растёт по потребности. Сто тысяч горутин, застрявших на чтении из канала, который никто не закроет, — это уже около гигабайта, и в профиле кучи этой памяти нет: стеки считаются отдельно. Признак — ровный рост /memory/classes/heap/stacks:bytes и числа горутин в /sched/goroutines:goroutines при постоянной нагрузке.
Профиль горутин /debug/pprof/goroutine?debug=2 показывает, где именно они стоят: обычно это select без таймаута, http.Client без Timeout, или обработчик, который пишет в канал после того, как читатель ушёл. Контекст с дедлайном на каждый исходящий вызов и Timeout у клиентов закрывают большинство таких утечек до того, как они дойдут до лимита контейнера.
Полная конфигурация: минимальный рабочий пример
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
ENV GOMEMLIMIT=450MiB
USER nonroot:nonroot
ENTRYPOINT ["/app"]
services:
app:
image: myapp:1.4.2
deploy:
resources:
limits:
cpus: "2"
memory: 512M
environment:
GOMEMLIMIT: 450MiB
Или то же для docker run: --cpus=2 --memory=512m -e GOMEMLIMIT=450MiB. На Go до 1.25 добавьте import _ "go.uber.org/automaxprocs" в main. При старте сервис пишет в лог GOMAXPROCS, GOMEMLIMIT и версию Go — три числа, с которых начинается любой разбор инцидента про память и задержки.
Глубже: cgroup v1 и v2: откуда рантайм читает лимитрасширенное
Лимиты контейнера живут в файлах виртуальной файловой системы cgroup, смонтированной в /sys/fs/cgroup. В cgroup v2 (по умолчанию в современных дистрибутивах и в Kubernetes последних версий) квота процессора лежит в cpu.max в виде двух чисел 200000 100000 — 200 мс каждые 100 мс, то есть два ядра, а лимит памяти в memory.max. В v1 те же данные разбросаны по cpu/cpu.cfs_quota_us, cpu/cpu.cfs_period_us и memory/memory.limit_in_bytes. Рантайм Go 1.25, automaxprocs и automemlimit умеют обе версии, но полезно уметь заглянуть туда руками из контейнера: cat /sys/fs/cgroup/cpu.max отвечает на вопрос «какую квоту нам дали на самом деле» быстрее любого манифеста.
Один нюанс v2: в memory.current засчитан файловый кэш страниц, которые процесс читал с диска. Сервис, который много читает файлы, выглядит в метриках контейнера толще, чем его куча; при давлении ядро этот кэш вытесняет, а не убивает процесс. Поэтому лимит сравнивают с memory.current минус inactive_file из memory.stat, и именно так считает Kubernetes.
Глубже: холодный старт: за что борются миллисекундырасширенное
Бинарник Go стартует за десятки миллисекунд: нет виртуальной машины, нет компиляции во время работы, пакеты инициализируются один раз. Поэтому у Go-сервисов короткий --start-period и быстрые перезапуски. Время старта уходит не в рантайм, а в вашу инициализацию: подключение к базе с проверкой Ping, первый TLS-рукопожатие, прогрев кэшей, миграции. Их выносят в проверку готовности: контейнер считается готовым, когда база отвечает, а не когда процесс запустился. Второй источник задержки на старте — DNS внутри контейнера: первый резолв имени через musl или через внутренний резолвер Go занимает заметное время, и его стоит сделать до объявления готовности.
Коротко
GOMAXPROCSпо умолчанию до Go 1.25 — ядра хоста, а не квота контейнера: 32 потока на двух ядрах дают паузы квоты CFS и скачки задержки;go.uber.org/automaxprocsили Go 1.25+ читают квоту из cgroup. Дробная квота округляется до одного ядра.- У сборщика нет понятия лимита: при
GOGC=100куча удваивает живые данные и в лимит 512 МБ не вписывается.GOMEMLIMITдаёт мягкий потолок на всю память рантайма, ставят 80–90 % лимита контейнера;automemlimitсчитает его сам. GOMEMLIMITмягкий: живые данные больше лимита всё равно убьют процесс; сборщик не тратит на себя больше половины процессора даже у потолка.- Код 137 без трассировки —
OOMKilledядром,docker inspectподтвердит;fatal error: runtime: out of memoryс трассировками — отказ системы в выделении, в контейнерах редкость. - Разбор:
GODEBUG=gctrace=1,/debug/pprof/heap, метрикиruntime/metrics(/memory/classes/total:bytesпротив лимита). - Стеки горутин в куче не видны: сто тысяч застрявших горутин — около гигабайта; смотреть
/debug/pprof/goroutine?debug=2, лечить таймаутами и контекстами. - Лимиты читают из
/sys/fs/cgroup/cpu.maxиmemory.max(v2); вmemory.currentвходит файловый кэш. - Старт за миллисекунды, поэтому готовность определяет не процесс, а подключение к базе и прогрев.
Что почитать дальше
- Dockerfile для сервиса на Go — как собрать образ, в который попадут эти настройки.
- Запуск контейнеров — флаги
--memory,--cpusи что происходит без лимитов. - Kubernetes: деплой и конфигурация — requests и limits, из которых берутся те же cgroup.
- Наблюдаемость на Go: метрики — как вывести память рантайма и число горутин на график.