В этом техническом разборе рассмотрим, как инженеры Harness обнаружили и исправили критическую утечку памяти в Go: переназначение переменной контекста в циклах воркеров порождало невидимые цепочки, мешавшие сборщику мусора освобождать память в тысячах горутин, из-за чего их сервис-делегат CI/CD в итоге потреблял гигабайты памяти.
Загадка: тревожная корреляция между CPU и памятью
В нашем стейджинг-окружении, которое обрабатывает ежедневные CI/CD-процессы всех разработчиков Harness, наш Hosted Harness Delegate вёл себя любопытно: CPU и память росли и падали в подозрительно тесной связке, почти идеально повторяя системную нагрузку.
На первый взгляд это выглядело нормально. Разумеется, в периоды высокой нагрузки ожидаешь роста CPU и памяти, а при простое — выравнивание. Но детали говорили о другом:
Память не колебалась. Вместо того чтобы расти и снижаться, она стабильно увеличивалась в периоды высокого трафика, а затем «застывала» на новом плато в простое, так и не возвращаясь к исходному уровню.
Ещё показательнее было то, что CPU идеально отражал этот рост памяти. Почти полная синхронность намекала, что процессорные циклы уходят не только на реальную работу — их «съедает» сборка мусора, без конца борющаяся с постоянно растущей кучей.

Иными словами, то, что выглядело как «занятая система», на деле оказалось характерным следом утечки: память накапливалась вместе с нагрузкой, а всплески CPU отражали попытки рантайма удержать это под контролем.
Расследование: идём по следу
Следующим шагом было понять, откуда берётся этот рост потребления памяти. Мы обратили внимание на ядро системы — пул воркеров. Делегат опирается на классический шаблон пула воркеров, порождая тысячи долгоживущих горутин, которые опрашивают источник задач и выполняют эти задачи.
// Типичная конфигурация пула воркеров — порождаем тысячи горутин
func (p *poller) StartWorkerPool(ctx context.Context, numWorkers int) {
var wg sync.WaitGroup
// Запускаем рабочие горутины (в продакшене их может быть 1000+)
for i := 0; i < numWorkers; i++ {
wg.Add(1)
go func(workerID int) {
defer wg.Done()
p.PollEvents(ctx, workerID, fmt.Sprintf("worker-%d", workerID))
}(i)
}
wg.Wait()
}
На первый взгляд реализация выглядела надёжной. Каждый воркер должен был работать независимо: обрабатывать задачи и корректно освобождать ресурсы. Тогда что же вызывало утечку, которая идеально масштабировалась вместе с нашей нагрузкой?
Мы начали с обычных подозреваемых — незакрытых ресурсов, «подвисших» горутин и неограниченного глобального состояния, — но ничего, что объясняло бы рост памяти, не нашли. Вместо этого выделялся сам паттерн: объём памяти увеличивался строго пропорционально числу обрабатываемых задач, а затем тут же выходил на плато в периоды простоя.

Чтобы копнуть глубже, мы сосредоточились на цикле воркера, который обрабатывает каждую задачу:
// Внутри PollEvents, который выполняется в ОДНОЙ горутине:
func (p *poller) PollEvents(ctx context.Context, /*...*/) {
for acquiredTask := range events {
// Эта строка привлекла наше внимание ?
ctx = logger.AddLogLabelsToContext(ctx, map[string]string{
"task_id": acquiredTask.TaskID,
})
p.process(ctx, /*...*/)
}
}
На вид всё невинно. Мы просто переназначали ctx, добавляя идентификаторы задач для логирования, а затем обрабатывали входящую задачу.
Момент «эврика»: невидимая цепочка
Настоящий прорыв случился, когда мы сократили число воркеров до одного. При тысячах параллельных горутин утечка «размазывалась», но один воркер наглядно показал вклад каждой задачи.
Чтобы убрать шум от краткоживущих выделений памяти, мы принудительно запускали сборку мусора после каждой задачи и фиксировали размер кучи после GC. Так график отражал только действительно удерживаемую память, а не временные выделения, которые сборщик обычно очищает. Результат говорил сам за себя: память медленно ползла вверх с каждой задачей — даже после полного прохода сборщика.

Это и был момент «эврика». Задачи вовсе не были независимыми. Что-то связывало их между собой, и виновником оказался context.Context из Go.
Контекст в Go неизменяем. Такие функции, как context.WithValue, на самом деле не изменяют переданный контекст. Вместо этого они возвращают новый дочерний контекст, который держит ссылку на родителя. Наша функция AddLogLabelsToContext делала ровно это:
func AddLogLabelsToContext(ctx context.Context, labels map[string]string) context.Context {
return context.WithValue(ctx, logLabelsKey, labels)
}
Само по себе это нормально, но становится опасным при неправильном использовании внутри цикла. Переназначая переменную ctx на каждой итерации, мы создавали связанный список контекстов, где каждый новый контекст указывал на предыдущий:
ctx = logger.AddLogLabelsToContext(ctx, labels1) // ctx1 -> исходный
ctx = logger.AddLogLabelsToContext(ctx, labels2) // ctx2 -> ctx1 -> исходный
ctx = logger.AddLogLabelsToContext(ctx, labels3) // ctx3 -> ctx2 -> ctx1 -> исходный
Каждый новый контекст ссылался на всю предыдущую цепочку, не позволяя сборщику мусора освободить её.
Ущерб: утечка, умноженная масштабом
При тысячах горутин в нашем пуле была не одна запутанная цепочка — их были тысячи, и все они росли параллельно. Каждый воркер создавал утечку памяти — по одной задаче за раз.
Цепочка контекстов для одной горутины выглядела так:
Задача 1: ctx1 →
initialContextЗадача 2: ctx2 → ctx1 →
initialContextЗадача 100: ctx100 → ctx99 → ... →
initialContext
…и то же происходило у каждого отдельного воркера.
Влияние (прикидка «на салфетке»)
1 000 воркеров × 500 задач на одного воркера в день = 500 000 новых «утекших» объектов контекста в день.
Через неделю: 3,5 млн контекстов, застрявших в памяти по всем воркерам.
Каждая цепочка жила столько же, сколько и горутина-воркер — пока она выполнялась.
Починка: разрываем цепочку
Решение было не в «магии» конкурентности, а в корректном использовании области видимости переменных:
func (p *poller) PollEvents(ctx context.Context) {
for acquiredTask := range events {
// ✅ Локальная переменная — короткоживущая, будет собрана сборщиком мусора (GC)
taskCtx := AddLogLabelsToContext(ctx, map[string]string{
"task_id": acquiredTask.TaskID,
})
p.process(taskCtx)
}
}
Проблема была не в самой функции, а в том, как мы использовали её результат:
❌ ctx = AddLogLabelsToContext(ctx, ...) → цепочка растёт без ограничений в течение жизни горутины
✅ taskCtx := AddLogLabelsToContext(ctx, ...) → цепочка не накапливается, временные объекты собираются GC
Универсальный антипаттерн (и где он прячется)
Суть проблемы сводится к такому шаблону:
// ❌ Плохо: переназначаем «родителя» внутри цикла
for item := range items {
parent = wrap(parent, item)
process(parent)
}
// ✅ Хорошо: используем локальную переменную
for item := range items {
child := wrap(parent, item)
process(child)
}
Это универсальный антипаттерн, который возникает везде, где вы «оборачиваете» неизменяемый (или фактически неизменяемый) объект внутри цикла.
Пример 1: контексты HTTP-запросов
// ❌ Проблема
func handleRequests(ctx context.Context) {
for request := range requestChan {
ctx = addTraceID(ctx, request.TraceID)
ctx = addUserID(ctx, request.UserID)
handleRequest(ctx, request)
}
}
// ✅ Исправление
func handleRequests(ctx context.Context) {
for request := range requestChan {
requestCtx := addTraceID(ctx, request.TraceID)
requestCtx = addUserID(requestCtx, request.UserID)
handleRequest(requestCtx, request)
}
}
Пример 2: цепочки полей логгера
// ❌ Проблема
func processEvents(logger *logrus.Entry, events []Event) {
for _, event := range events {
logger = logger.WithField("event_id", event.ID)
logger.Info("processing event")
}
}
// ✅ Исправление
func processEvents(logger *logrus.Entry, events []Event) {
for _, event := range events {
eventLogger := logger.WithField("event_id", event.ID)
eventLogger.Info("processing event")
}
}
Та же ошибка, только в другой обёртке.
Ключевые выводы
Аккуратно работайте с областью видимости переменных в циклах: никогда не переназначайте переменную внешней области видимости её «обёрнутой» версией внутри долгоживущего цикла. Всегда создавайте новую локальную переменную для обёрнутого объекта.
Утечки могут быть параллельными: одна маленькая ошибка × тысячи горутин = катастрофа.
Упростите, чтобы отладить: уменьшение тестовой среды до одного воркера сделало рост памяти прямо наблюдаемым и прояснило корень проблемы. Иногда лучшая техника отладки — это вычитание, а не добавление.
Всем новичкам в разработке на Go рекомендуем курс «Golang Developer. Basic». Программа дает фундамент: инструменты и экосистема Go, горутины и каналы, контекст и конкурентные паттерны, профилирование, Docker и Git, OpenAPI/Swagger и работа с разными СУБД — всё через практику и выпускной проект.
Приходите на открытые уроки, которые бесплатно проведут преподаватели курса:
21 октября в 19:00: Сборщик мусора в Go
10 ноября в 20:00: Введение в горутины
17 ноября в 20:00: Работа со строками и рунами в Go
А если вы уже знакомы с основами и вам интересно развитие до уровня Pro — проверьте свои знания на вступительном тесте.
Комментарии (11)

BTRVODKA
21.10.2025 07:42Еще одно напоминание, что нужно пользоваться линтерами. Например линтер fatcontext из golangci-lint именно для поиска таких проблем и сделан.

aleksandr-s-zelenin
21.10.2025 07:42Тут проблема в переопределении входного параметра
ctxодноимённой локальной переменной.ctx = logger.AddLogLabelsToContext(ctx, labels1) // ctx1 -> исходный ctx = logger.AddLogLabelsToContext(ctx, labels2) // ctx2 -> ctx1 -> исходный ctx = logger.AddLogLabelsToContext(ctx, labels3) // ctx3 -> ctx2 -> ctx1 -> исходныйОднако переписав код так:
taskCtx := AddLogLabelsToContext(ctx, labels1) // taskCtx1 -> исходный taskCtx := AddLogLabelsToContext(ctx, labels2) // taskCtx2 -> исходный taskCtx := AddLogLabelsToContext(ctx, labels3) // taskCtx3 -> исходныйВы получаете не связный список длинной N, а N связных списков длинной 2. Почему это должно облегчать жизнь сборщику мусора и приложению в целом?

zelenin
21.10.2025 07:42в первом случае цепочка будет расти с каждой новой задачей. во втором случае taskCtx будет подчищен после завершения задачи (исходный ctx не будет подчищен)
PS забавно - мы полные тезки.
aleksandr-s-zelenin
21.10.2025 07:42Вот и встретились два одиночества )
Не понимаю вашей логики. И вообще не вижу логики, поэтому и задал свой вопрос. В первом случае мы получим один вот такой список длинной N + 1:
ctx3 -> ctx2 -> ctx1 -> исходный
Во втором случае получим N вот таких списков:
taskCtx1 -> исходныйtaskCtx2 -> исходныйtaskCtx3 -> исходный
Очевидно, что структура списков похожа: головой каждого является инстанс контекста, созданный во время работы цикла, а хвостом - инстанс переданный в функцию в качестве аргумента. Итого, я бы сказал, что списки одинаковые (только отличаются по длине и кол-ву). Так что мне совсем не очевидно, что в каком-то из этих двух случаев сборщику мусора или приложению почему-то должно быть легче.

zelenin
21.10.2025 07:42проблема первого кейса в том, что вся цепочка будет использована в следующей задаче и поэтому не может быть удалена сборщиком.
Во втором же случае между задачами будет использован только общий (исходный) ctx, а taskCtxN после выполнения его зоны видимости становится ненужным и будет почищен сборщиком. То есть в цепочках taskCtxN -> исходный ctx будет почищен taskCtxN, но не ctx.
aleksandr-s-zelenin
21.10.2025 07:42А, кажется, я понял, спасибо. А может и не понял. Вот ещё раз код:
func (p *poller) PollEvents(ctx context.Context) { for acquiredTask := range events { // ✅ Локальная переменная — короткоживущая, будет собрана сборщиком мусора (GC) taskCtx := AddLogLabelsToContext(ctx, map[string]string{ "task_id": acquiredTask.TaskID, }) p.process(taskCtx) } }Сама фраза, что переменная локальная ничего особо не значит, кажется. Под локальностью понимается, что она будет создана на стэке и по завершению работы функции указатель вершины стека откатиться назад высвободив память. Суть короткой жизни именно в этом. А вовсе не в том, что сборщик удалит эту переменную. Можно подумать, что сборщик работает в двух режимах: один обычный, а второй какой-то для быстрого сбора локальных переменных, но это не так. Здесь сборщик не при чём.
Скорее всего
taskCtxбудет создана в куче, но утверждать точно не возьмусь. Если так, то сборщик мусора когда-нибудь подчистит все эти контексты, но не быстро, хоть короткоживучесть и упоминается в комментарии.А теперь снова про связные списки контекстов. Как я уже говорил, их либо один длинной N+1, либо N длинной 2. Вы говорите, что N списков длинной 2 будут собираться, в отличии от одного длинного списка. Потому что вся цепочка будет использоваться в следующей задаче. Но и куча маленьких двухэлементных списков тоже будет использоваться в следующей задаче. Так что они примерно в одинаковых условиях. Здесь что-то не чисто. Сдаётся мне проблема не в этом.
Скорее всего отсутствие утечки во втором случае связано с тем, что переменные
taskCtxсоздаются на стэке и удаляются по завершению работы функции. Но и длинные списки должны были бы собираться сборщиком, хотя и не быстро, как я уже говорил. Если они не собираются, значит на элементы списка есть ещё указатели из программы и тогда... что тогда? Тогда проблема не в том, что список получается длинным, а в том, что его не собрать. Почему-то.

aleksandr-s-zelenin
21.10.2025 07:42Спросил у гошников в профильном чате, там сказали следующее:
Вообще дело оказалось не просто в "одной строке" а всего в одном символе. Если бы вместо
ctx=былоctx:=- всё работало бы правильно.ctx:=- новая переменная, область видимости которой ограничена телом цикла, она будет создаваться на каждой итерации и уничтожаться по её завершеннии.ctx=- старая переменная. Она объявлена в методе и будет "жить" до завершения всего метода (а метод долгоживущий, потому что в нем цикл забирающий данные из канала).Хоть новое имя переменной с
:=хоть переиспользование старого имени - это дало бы тот же эффект: новая переменная с укороченной областью видимости.Вот тут подробности, если кому интересно: https://t.me/golangl/396448
diderevyagin
Отличный разбор, спасибо !
kmoseenk Автор
Рада стараться