Введение
В рамках мер повышения рентабельности наша команда недавно занялась снижением затрат на вычислительные мощности посредством увеличения их эффективности. Один из самых значимых вкладов был внесён в области оптимизации GOGC. В этом посте мы хотим поделиться высокоэффективным, малоопасным, крупномасштабным полуавтоматизированным механизмом настройки сбора мусора в Go.
Технологический стек Uber состоит из тысяч микросервисов на базе нативной облачной архитектуры на основе планировщика. Большинство этих сервисов написано на Go. Наша команда Maps Production Engineering ранее сыграла важную роль в значительном повышении эффективности множества сервисов Java при помощи настройки сборки мусора. В начале 2021 года мы исследовали возможности достичь такого же эффекта в сервисах на Go. Мы запустили несколько профилей CPU для оценки текущего состояния дел и выяснили, что сборка мусора была главным потребителем ресурсов CPU в подавляющем большинстве критически важных сервисов. Ниже приведено описание некоторых профилей CPU, в которых сборка мусора (определяемая объектом runtime.scanobject
) потребляет значительную долю выделенных вычислительных ресурсов.
Сервис №1
Рисунок 1: затраты CPU на сборку мусора в примере сервиса №1
Сервис №2
Рисунок 2: затраты CPU на сборку мусора в примере сервиса №2
Воодушевлённые этим открытием, мы приступили к настройке сборки мусора для соответствующих сервисов. К нашей радости, реализация и простота настройки сборки мусора в Go позволили нам автоматизировать подавляющую часть механизма обнаружения и настройки. Ниже мы подробно расскажем о своей методологии и воздействии изменений на систему.
Настройщик GOGC
Среда выполнения Go с периодическими интервалами вызывает параллельный процесс сборщика мусора, если только до него не было инициирующего события. Инициирующие события основываются на обратном давлении (back pressure) памяти. Благодаря этому затронутые сборкой мусора сервисы Go могут пользоваться бОльшим объёмом памяти, потому что это уменьшает частоту запуска сборки мусора. Кроме того, мы поняли, что соотношение CPU и памяти на уровне хоста составляет 1:5 (1 ядро: 5 ГБ RAM), а большинство сервисов Golang сконфигурировано с соотношением от 1:1 до 1:2. Поэтому мы были уверены, что сможем использовать больше памяти, чтобы снизить влияние сборщика мусора на CPU. Это независимый от типа сервиса механизм, который при продуманном использовании может оказать большое влияние.
Подробное изучение сборки мусора Go выходит за рамки этой статьи, поэтому скажем только самое важное: сборка мусора в Go выполняется параллельно и задействует анализ всех объектов для выявления тех, которые по-прежнему достижимы. Мы будем называть достижимые объекты «живым массивом данных». У Go есть только один «регулятор» для управления сборкой мусора — GOGC, выражаемый в проценте от живого массива данных. Значение GOGC используется как множитель для массива данных. По умолчанию GOGC имеет значение 100%, то есть среда выполнения Go резервирует тот же объём памяти под новые распределения, что и память живого массива данных. Например:
hard_target = live_dataset + live_dataset * (GOGC / 100)
При этом pacer отвечает за прогнозирование того, когда лучше всего запускать GC, чтобы не упереться в жёстко заданную границу (hard target).
Динамика и разнообразие: одна конфигурация не подходит для всех
Мы выяснили, что настройка на основе фиксированного значения GOGC не подходит для всех сервисов Uber. Среди прочего мы столкнулись со следующими проблемами:
- Она не знает максимального объёма памяти, назначенного контейнеру, что может вызвать проблемы с исчерпанием памяти.
- Наши микросервисы имеют очень разнообразную конфигурацию использования памяти. Например, система с шардами может иметь очень разные живые массивы данных. Мы столкнулись с этим в одном из наших сервисов, где в случае 99 перцентиля использовался 1ГБ, а в случае 1 перцентиля — 100 МБ, поэтому на инстансы с 100 МБ сильно влияла сборка мусора.
Возможность автоматизации
Описанные выше проблемы стали причиной создания концепции GOGCTuner. GOGCTuner — это библиотека, упрощающая процесс настройки сборки мусора для владельцев сервисов, добавляющая поверх неё слой обеспечения надёжности.
GOGCTuner динамически вычисляет правильное значение GOGC в соответствии с ограничениями памяти контейнера (или верхнего предела, установленного владельцем сервера) и задаёт его при помощи API среды выполнения Go. Библиотека GOGCTuner имеет следующие функции:
- Упрощённое конфигурирование для простоты выбора и детерминированных вычислений. Значение GOGC, равное 100%, непонятно для начинающих разработчиков на Go и оно не детерминировано, потому что всё равно зависит от живого массива данных. С другой стороны, ограничение в 70% гарантирует, что сервис всегда будет использовать 70% от пространства кучи.
- Защита от OOM (Out Of Memory): библиотека считывает ограничение памяти из cgroup и использует стандартную жёсткую границу в 70% (по нашему опыту, это безопасное значение).
- Важно заметить, что у этой защиты есть ограничение. Настройщик может изменять только распределение буфера, поэтому если живые объекты сервиса превышают ограничение настройщика, то настройщик выставит стандартное пониженное ограничение 1,25X от использования памяти живыми объектами.
- Обеспечивает повышенные значения GOGC для следующих пограничных случаев:
- Как говорилось выше, ручной GOGC не является детерминированным. Мы всё равно полагаемся на размер живого массива данных. А что если live_dataset вдвое превысит последнее пиковое значение? GOGCTuner принудительно установит то же ограничение памяти ценой большей траты ресурсов CPU. Ручная настройка вместо этого может вызывать OOM. Поэтому владельцы сервисов в подобных ситуациях обычно выделяли довольно большой буфер. См. пример ниже:
Обычный трафик (живой массив данных составляет 150 МБ)
Рисунок 4: обычная работа. Стандартная конфигурация слева, настраиваемая вручную справа.
Увеличение трафика в два раза (живой массив данных составляет 300 МБ)
Рисунок 5: удвоенная нагрузка. Стандартная конфигурация слева, настраиваемая вручную справа.
Увеличение трафика в два раза с GOGCTuner при 70% (живой массив данных составляет 300 МБ)
Рисунок 6: удвоенная нагрузка, но используется настройщик. Стандартная конфигурация слева, настраиваемая GOGCTuner справа.
- Сервисы используют политику памяти MADV_FREE, что приводит к неверным метрикам памяти. Например, наша метрика observability демонстрировала использование памяти на 50% (когда на самом деле уже было освобождено 20% из этих 50%). Поэтому владельцы сервисов настраивали GOGC на основе этой «неточной» метрики.
Observability
Мы выяснили, что нам не хватает некоторых критически важных метрик, которые могли бы дать нам большее понимание сборки мусора в каждом сервисе.
- Интервалы между сборками мусора: полезно знать, можем ли мы продолжать настройку. Например, Go принудительно запускает сборку мусора через каждые 2 минуты. Если на ваш сервис по-прежнему сильно влияет GC, но вы уже видите 120s для этого графика, то это значит, что вы больше не можете выполнять настройку при помощи GOGC. В таком случае вам понадобится оптимизировать свои распределения.
Рисунок 7: график интервалов между GC.
- Влияние GC на CPU: позволяет нам понять, на какие сервисы больше всего влияет GC.
Рисунок 8: график затрат CPU на GC для 99 перцентиля.
- Размер живого массива данных: помогает выявлять утечки памяти. Владельцы сервисов высказали обеспокоенность, когда заметили рост использования памяти. Чтобы показать им, что утечки памяти отсутствуют, мы добавили метрику «живое использование», демонстрирующую стабильное использование памяти.
Рисунок 9: график для расчётного 99 перцентиля живого массива данных.
- Значение GOGC: полезно знать, как реагирует настройщик.
Рисунок 10: график для значения GOGC минимума, 50 перцентиля, 99 перцентиля, назначенного приложению настройщиком.
Реализация
Изначально мы создали запускаемый каждую секунду тикер для мониторинга метрик кучи и соответствующей настройки значения GOGC. Недостаток такого подхода заключается в том, что лишняя трата ресурсов становится существенной, ведь для считывания метрик кучи Go требуется выполнять STW (ReadMemStats), и к тому же он довольно неточный, ведь за секунду может происходить несколько сборок мусора.
К счастью, нам удалось найти хорошую альтернативу. В Go есть финализаторы (SetFinalizer) — функции, запускаемые, когда объект подвергается сборке мусора. В основном они полезны для очистки памяти в коде на C или некоторых других ресурсов. Нам удалось использовать ссылающийся сам на себя финализатор, сбрасывающий себя при каждом вызове GC. Это позволяет нам уменьшить лишнюю трату ресурсов CPU. Например:
Рисунок 11: пример кода для событий, запускаемых GC.
Вызов runtime.SetFinalizer(f, finalizerHandler)
внутри finalizerHandler
позволяет обработчику выполняться при каждой GC; по сути, он не позволяет ссылке умереть, потому что сохранение её жизни не является затратным ресурсом (это всего лишь указатель).
Эффект
После внедрения GOGCTuner в нескольких десятках наших сервисов, мы изучили некоторые из них и выявили существенное (на два порядка) улучшение в использовании CPU. Суммарная экономия затрат лишь на эти сервисы составила около 70 тысяч ядер. Вот два таких примера:
Рисунок 12: сервис observability, работающий на тысячах вычислительных ядер с высоким стандартным отклонением live_dataset (максимальное значение было в 10 больше минимального), показал уменьшение примерно на 65% использования CPU в 99 перцентиле.
Рисунок 13: критически важный сервис Uber eats, работающий на тысячах вычислительных ядер, показал уменьшение использования CPU примерно на 30% в 99 перцентиле.
Полученное в результате снижение показателей использования CPU улучшило задержку в 99 перцентиле (и соответствующую SLA, а также уровень user experience) тактически, и затраты на объёмы стратегически (так как сервисы масштабируются на основании их использования).
В заключение
Сборка мусора — один из самых загадочных и недооценённых аспектов, влияющих на производительность приложения. Надёжный механизм сборки мусора в Go и упрощённая настройка, наша разнообразная крупномасштабная нагрузка сервисов Go и надёжная внутренняя платформа (Go, вычислительные ядра, observability) совместно позволили нам внести столь масштабное изменение. Мы продолжим совершенствовать процесс настройки GC, поскольку само пространство состояний эволюционирует из-за изменений в технологиях и нашего опыта.
Повторимся: нет ни одного универсального решения, подходящего для любой системы. Мы считаем, что производительность GC в облачной архитектуре останется изменчивой из-за высокой изменчивости производительности публичных облаков и выполняемых в них контейнированных нагрузок.
Автор:
PatientZero