golang

Одна строка — тысячи горутин: как мы поймали утечку памяти в сервисе на Go

  • вторник, 21 октября 2025 г. в 00:00:06
https://habr.com/ru/companies/otus/articles/957486/

В этом техническом разборе рассмотрим, как инженеры 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")
    }
}

Та же ошибка, только в другой обёртке.

Ключевые выводы

  1. Аккуратно работайте с областью видимости переменных в циклах: никогда не переназначайте переменную внешней области видимости её «обёрнутой» версией внутри долгоживущего цикла. Всегда создавайте новую локальную переменную для обёрнутого объекта.

  2. Утечки могут быть параллельными: одна маленькая ошибка × тысячи горутин = катастрофа.

  3. Упростите, чтобы отладить: уменьшение тестовой среды до одного воркера сделало рост памяти прямо наблюдаемым и прояснило корень проблемы. Иногда лучшая техника отладки — это вычитание, а не добавление.


Всем новичкам в разработке на Go рекомендуем курс «Golang Developer. Basic». Программа дает фундамент: инструменты и экосистема Go, горутины и каналы, контекст и конкурентные паттерны, профилирование, Docker и Git, OpenAPI/Swagger и работа с разными СУБД — всё через практику и выпускной проект.

Приходите на открытые уроки, которые бесплатно проведут преподаватели курса:

А если вы уже знакомы с основами и вам интересно развитие до уровня Pro — проверьте свои знания на вступительном тесте.