Привет, Хабр!

График памяти, вроде — ровный. runtime/metrics показывает те же 600 МиБ, что и месяц назад, heap-профиль не растёт, утечек нет. А kubectl describe pod пишет OOMKilled, и рестарты идут каждые пару часов. Оба числа правдивые, просто меряют разное: рантайм считает память, которую сам попросил у системы и за которой следит, а ядро перед убийством смотрит на RSS — сколько страниц реально лежит в физической памяти.

Расхождение это ровесник самого Go в контейнерах. Новое тут другое: за последний год к нему добавились два изменения, которые меняют поведение под лимитами вообще без правок в коде. В 1.25 рантайм научился читать лимит CPU из cgroup и выставлять по нему GOMAXPROCS. В 1.26 сборщик Green Tea стал дефолтным. Go 1.27 вышел 19 августа и унаследовал оба.

В статье рассмотрим пять мест, где Go под лимитами ведёт себя не так, как от него ждут. Первые два про то, сколько потоков он себе возьмёт. Остальные три — про то, сколько памяти он готов держать и почему это число не сходится с тем, что видит ядро.

Обновили тулчейн до 1.27, а GOMAXPROCS остался хостовым

Container-aware GOMAXPROCS подаётся как «обновитесь и заработает». Работает не у всех, и виноват go.mod.

Дефолты GODEBUG в Go привязаны не к версии компилятора, а к строке go в go.mod. Логика простая: собрать старый модуль новым Go — не значит сломать поведение. Оба флага из 1.25, containermaxprocs и updatemaxprocs попадают под это правило.

Проверим на пустом модуле. Один файл, один тулчейн, разница только в строке go:

$ cat m21/go.mod
module m21

go 1.21

$ go build -o b21 ./m21 && go version -m b21 | grep GODEBUG
	build	DefaultGODEBUG=containermaxprocs=0,...,updatemaxprocs=0,...

$ go build -o b25 ./m25 && go version -m b25 | grep GODEBUG
	build	DefaultGODEBUG=cryptocustomrand=1,...

В бинарник из модуля с go 1.21 зашито containermaxprocs=0,updatemaxprocs=0, хотя тулчейн — 1.27. Сервис собран новым Go, работает в поде с лимитом в два ядра на машине с 128 — и продолжает думать, что ядер 128. Новые дефолты получает только тот, у кого в go.mod стоит 1.25.0 или выше.

Поэтому после обновления проверяйте не «какой у нас тулчейн», а go version -m по собранному бинарю. Если в DefaultGODEBUG видно containermaxprocs=0 — правьте go.mod, а не радуйтесь новому рантайму.

Второй способ — спросить сам процесс. Метрика /sched/gomaxprocs:threads показывает актуальное значение и, в отличие от runtime.GOMAXPROCS(0), читается без побочных эффектов:

s := []metrics.Sample{{Name: "/sched/gomaxprocs:threads"}}
metrics.Read(s)
fmt.Println("NumCPU:", runtime.NumCPU(),
	"GOMAXPROCS:", s[0].Value.Uint64())

Разница между этими двумя числами в поде с лимитом и ответит, включилась автоматика или нет. Если они равны и оба совпадают с числом ядер хоста — лимит рантайм не увидел.

Дробный лимит, целый GOMAXPROCS и расхождение с automaxprocs

Как именно лимит превращается в число потоков? Тут два момента: рантайм читает не то поле манифеста, которое обычно правят, и округляет не в ту сторону.

Читается только limits, requests рантайм игнорирует полностью. Запрос гарантирует минимум, а не потолок — контейнер без лимита вполне может занять простаивающие ядра. Пода с одним лишь requests: 500m для рантайма ничем не отличается от поды без ограничений.

Само вычисление живёт в runtime/cgroup_linux.go и умещается в пару строк:

func adjustCgroupGOMAXPROCS(procs int32, cpu cgroup.CPU) int32 {
	limit, ok, err := cgroup.ReadCPULimit(cpu)
	if err == nil && ok {
		limit = ceil(limit)
		limit = max(limit, 2)
		if int32(limit) < procs {
			procs = int32(limit)
		}
	}
	return procs
}

Округление вверх, снизу подпёрто двойкой. Посмотрим, что получается на практике. limits: 500m — это 0.5, ceil даёт 1, max поднимает до 2. На limits: 1500m — 2. На limits: 100m — снова 2. Пода с десятой долей ядра получит два потока. Округление вверх нужно, чтобы программа могла выжать лимит целиком.

Теперь сравним с go.uber.org/automaxprocs, который семь лет закрывал всю эту проблему. У него DefaultRoundFuncmath.Floor, минимум по умолчанию — 1. На limits: 1500m библиотека даст GOMAXPROCS=1, рантайм — 2. На limits: 500m библиотека даст 1, рантайм — 2. Числа расходятся вдвое, и решает, кто последний: библиотека вызывает runtime.GOMAXPROCS() в init(), а любой явный вызов навсегда выключает автоматику рантайма.

Есть и обратный вариант, если автоматику случайно выключили. С 1.25 в рантайме появилась runtime.SetDefaultGOMAXPROCS(), которая возвращает значение к дефолтному, игнорируя переменную окружения и предыдущие вызовы:

// рантайм снова читает лимит cgroup
// и снова обновляет значение при его изменении
runtime.SetDefaultGOMAXPROCS()

Пригодится как аварийная кнопочка в чужом коде, где кто-то в глубине зависимостей уже выставил число руками.

GOGC=100 обещает удвоение, а пода столько не даст

По умолчанию Go живёт с GOGC=100. Значит, сборка запускается, когда куча вырастет вдвое относительно живого набора. Целевой размер кучи равен живому набору плюс сто процентов от суммы живого набора и корней GC.

Живой набор 500 МиБ означает, что рантайм спокойно доведёт кучу до гигабайта, прежде чем соберёт мусор. Если в поде стоит limits.memory: 700Mi, до сборки дело не дойдёт. Механизма, который связал бы GOGC с лимитом контейнера, в рантайме нет. GOGC задаёт отношение, а не абсолютное число, и про существование лимита ничего не знает.

Эту дыру закрывает GOMEMLIMIT, появившийся в 1.19. Программа держит живой набор в 200 МиБ и прогоняет через кучу несколько гигабайт мусора:

live := make([][]byte, 0, 200)
for i := 0; i < 200; i++ {
	b := make([]byte, 1<<20)
	for j := 0; j < len(b); j += 4096 {
		b[j] = 1 // трогаем страницы, чтобы они попали в RSS
	}
	live = append(live, b)
}
for i := 0; i < 4000; i++ {
	_ = make([]byte, 1<<20)
}

Один и тот же бинарник, разница только в переменной окружения:

$ ./memdemo
GOMEMLIMIT=8796093022207 MiB | HeapAlloc=418 MiB HeapSys=432 MiB
Sys=436 MiB RSS=423 MiB NumGC=26

$ GOMEMLIMIT=300MiB ./memdemo
GOMEMLIMIT=300 MiB | HeapAlloc=277 MiB HeapSys=292 MiB
Sys=296 MiB RSS=285 MiB NumGC=64

Без лимита рантайм отчитывается числом 8796093022207 МиБ — это math.MaxInt64, то есть «лимита нет». Читается оно через debug.SetMemoryLimit(-1), которая ничего не меняет, а только возвращает текущее значение.

GOMEMLIMIT ровно по лимиту пода

Соблазн выставить GOMEMLIMIT в то же число, что и limits.memory имеется. И это самый частый способ получить OOM при формально настроенном лимите. Рантайм ограничивает не всю память процесса, а только ту, которую сам учитывает. Точное определение через runtime/metrics:

/memory/classes/total:bytes - /memory/classes/heap/released:bytes

Или через runtime.MemStatsSys - HeapReleased. За скобками остаются страницы самого исполняемого файла, всё, что аллоцировал C-код через cgo, чужие mmap из подключённых библиотек, буферы ядра на сокетах. Ядро при подсчёте RSS всё это учитывает, рантайм — нет. Можно оставлять от пяти до десяти процентов запаса.

Промах в другую сторону падением не заканчивается. Он заканчивается подом, который жив, отвечает на пробы и почти не работает. Когда GOMEMLIMIT оказывается на уровне пикового живого набора или ниже, сборщик пытается втиснуть кучу в недостижимое число и запускается почти без перерыва. Бесконечное зависание хуже быстрой смерти: упавший процесс перезапустят, а вставший будет числиться здоровым.

Поэтому ограничение и объявлено мягким. Рантайму разрешено превысить GOMEMLIMIT, лишь бы не встать, и держится это на потолке доли CPU, которую сборщик имеет право забрать себе, — примерно половина в окне длиной 2 * GOMAXPROCS CPU-секунд. Худшее, что даёт заниженное значение — двукратное замедление и рост памяти сверх заданного числа. Что случится с таким подом дальше, решает уже не Go, а OOM Killer.

Отсюда две границы, между которыми GOMEMLIMIT и живёт. Сверху — процентов на десять ниже limits.memory, чтобы осталось место на память, которую рантайм не учитывает. Снизу — заведомо выше пикового живого набора. Пик берётся из /memory/classes/heap/objects:bytes, RSS и размер кучи для этого не годятся, потому что включают мусор, который ещё не собрали.

Рантайм считает своё, ядро считает RSS

Вернусь к тому расхождению, с которого начал. Чтобы его увидеть, ничего особенного не нужно. Программа набирает 800 МиБ живых данных, отпускает их, зовёт сборку и печатает четыре числа рядом: RSS из /proc/self/statm и три метрики рантайма.

func rss() float64 {
	b, _ := os.ReadFile("/proc/self/statm")
	f := strings.Fields(string(b))
	p, _ := strconv.Atoi(f[1])
	return float64(p) * float64(os.Getpagesize()) / (1 << 20)
}

func m(name string) float64 {
	s := []metrics.Sample{{Name: name}}
	metrics.Read(s)
	return float64(s[0].Value.Uint64()) / (1 << 20)
}

func show(tag string) {
	fmt.Printf("%-22s RSS %6.0f | total %6.0f | released %6.0f | живая куча %6.0f\n",
		tag, rss(),
		m("/memory/classes/total:bytes"),
		m("/memory/classes/heap/released:bytes"),
		m("/memory/classes/heap/objects:bytes"))
}

Дальше — набор, отпускание, сборка и пауза:

show("старт")

live := make([][]byte, 0, 800)
for i := 0; i < 800; i++ {
	b := make([]byte, 1<<20)
	for j := 0; j < len(b); j += 4096 {
		b[j] = 1
	}
	live = append(live, b)
}
show("после 800 МиБ")

runtime.KeepAlive(live)
live = nil
runtime.GC()
show("сразу после GC")

time.Sleep(6 * time.Second)
show("через 6 с")

debug.FreeOSMemory()
show("после FreeOSMemory")

Вывод:

                       RSS      total   released  живая куча
старт                    2 МиБ    7 МиБ     3 МиБ      0 МиБ
после 800 МиБ          804 МиБ  809 МиБ     3 МиБ    800 МиБ
сразу после GC         804 МиБ  809 МиБ     3 МиБ      0 МиБ
через 6 с              804 МиБ  809 МиБ     3 МиБ      0 МиБ
после FreeOSMemory       4 МиБ  809 МиБ   803 МиБ      0 МиБ

Живая куча обнулилась сразу после сборки, а RSS как был 804 МиБ, так и остался. Через шесть секунд тоже. Внутри рантайма память свободна, но страницы всё ещё висят на процессе. Отдаёт их фоновый уборщик, и делает это не спеша: дёргать ядро на каждый освободившийся кусок дороже, чем подержать страницы про запас. FreeOSMemory отдаёт всё разом, и released подскакивает до 803 МиБ. А total не двигается, виртуальный адрес рантайм за собой оставляет, потому что держать его ничего не стоит, а выпрашивать заново дорого.

Отсюда и берётся спокойный график перед самым OOM. Если он нарисован по живой куче, этой пилы на нём просто нет. Смотреть надо на два числа сразу: со стороны Go — /memory/classes/total:bytes минус /memory/classes/heap/released:bytes, со стороны кластера — container_memory_working_set_bytes. Разошлись и долго не сходятся — значит, память ушла куда-то мимо кучи.

Как это выглядит в проде, хорошо описали тут. После перехода на Go 1.24 память сервиса выросла процентов на двадцать, а метрики рантайма и heap-профили не шелохнулись. Через /proc/pid/smaps рост нашли в области кучи, дальше вместе с командой Go докопались до причины. Рефакторинг mallocgc потерял старую хитрость: память, только что взятую у ядра, занулять не надо, она и так нулевая. Хитрость пропала и объекты с указателями крупнее 32 КиБ стали занулять всегда. А зануление трогает страницы, страницы переезжают в RSS, и во внутреннем учёте это никак не видно. Починили в 1.25.

Green Tea в 1.26 и цифра, которой я не нашёл

Про Green Tea гуляет цифра: RSS вырос на 8-15%. Я попробовал найти, откуда она. Не нашёл. В release notes 1.26 про память нет ни слова. А цифра кочует по агрегаторам, и все ссылаются друг на друга. Может, за ней и стоят чьи-то замеры, но публичного источника я не нашёл, так что в расчёты бы её не брал.

Проверить можно вот что. Сборщик стал дефолтным в 1.26. Выигрыш по накладным расходам обещают в диапазоне 10-40%, на amd64 от Ice Lake и Zen 4 — ещё около десяти процентов сверху, за счёт векторных инструкций. Выключается сборкой с GOEXPERIMENT=nogreenteagc. В release notes 1.26 написано, что выключатель уберут в следующем релизе, но в 1.27 он на месте: тулчейн собирает с ним молча, а на выдуманное имя эксперимента честно ругается.

Обновлять рантайм я бы обновлял так: собрать два бинаря из одного коммита и прогнать по обоим одну и ту же нагрузку. Смотреть не только на задержки, но и на RSS, и на то, сколько CPU уходит на сборку. Последнее считается одним делением:

s := []metrics.Sample{
	{Name: "/cpu/classes/gc/total:cpu-seconds"},
	{Name: "/cpu/classes/total:cpu-seconds"},
}
metrics.Read(s)
gcShare := s[0].Value.Float64() / s[1].Value.Float64() * 100

У меня без лимита на сборку уходило 0.2% процессорного времени. Поставил GOMEMLIMIT чуть выше живого набора — стало 12.5%.

Что со всем этим делать

Все пять мест держатся на одном: Go и кластер говорят про одну и ту же память разными словами. Рантайм считает кучу и то, что взял у ядра сам. Ядро считает резидентные страницы. Kubernetes считает рабочий набор. Пока три числа идут рядом, разницы не видно, а расходятся они как раз в плохие моменты — на всплеске, на обновлении тулчейна, на чужой памяти из cgo.

Отсюда такой действий: сначала go.mod со строкой не ниже 1.25 и проверка через go version -m. Потом манифесты: limits.cpu там, где стоял один requests, и выкинутый automaxprocs. Дальше GOMEMLIMIT — процентов на десять ниже limits.memory и заведомо выше пикового живого набора. GOGC трогать в последнюю очередь, если он вообще понадобится.

Проблемы с памятью, нагрузкой и конкурентностью обычно становятся заметны уже в продакшене, когда смотреть только на код недостаточно. На этих бесплатных уроках можно разобрать такие ситуации вместе с практикующими Go-разработчиками, посмотреть на формат обучения и задать вопросы по своим кейсам.

  • 9 сентября в 20:00. «Go-профилирование: как найти и исправить „тормоза“ в продакшене». Записаться

  • 22 сентября в 20:00. «Горутины и каналы: под капотом (under the hood) и нюансы в продакшене». Записаться

Полный список бесплатных уроков сентября смотрите в дайджесте.

Комментарии (1)


  1. Gutt
    08.09.2026 14:22

    А ещё, если у вас в поде контейнер запускает подпроцесс, и этот подпроцесс жрёт слишком много помяти, то вы получите OOM на воркере и ноль ругани в events в Kubernetes. Я недавно так с Prisma Cloud Compute Edition плясал: контейнер тупо выпадает через некоторое время с ошибкой. Окзалось, что он втихую mongodb запускает, и тот вылезает за лимит памяти для пода. Выяснилось только анализом логов ядра на воркере.