Перевод/заметки Go Production Performance Gotcha - GOMAXPROCS


Недавно мы столкнулись с неприятной проблемой: производительность одной из наших систем оказалась почти в два раза ниже ожидаемого уровня.

После проведения ряда тестов мы выяснили, что причина этого кроется в том, что мы не указали параметр GOMAXPROCS.

Предыстория

Metoro — это платформа для обеспечения прозрачности систем, работающих в Kubernetes. Для сбора данных о кластере и его рабочих нагрузках мы разворачиваем на наблюдаемых кластерах daemonset. Этот демонсет создает на каждом узле pod, под названием node-agent. Этот агент собирает информацию о рабочих нагрузках и отправляет ее за пределы кластера для хранения.

Агент выполняет ряд операций ядра через eBPF, что позволяет автоматически генерировать распределенные трассировки и другую телеметрию. Это означает, что использование процессора агентом зависит от количества запросов, поступающих в/из pod на node. Обычно для обработки 12 000 HTTP-запросов агенту требуется около 1 секунды процессорного времени (на современных хостах EC2).

Проблема

Во время установки нового кластера для клиента мы заметили, что некоторые из наших агентов используют значительно больше процессорного времени, чем мы предполагали. Хосты в этом кластере обрабатывали до 200 тысяч запросов в минуту, и мы ожидали, что для обработки этих запросов агенту потребуется около 17 секунд процессорного времени (28% одного ядра) в минуту. Однако мы обнаружили, что агент использовал 30 секунд (50% одного ядра), что почти в два раза больше, чем ожидалось для такой нагрузки.

К счастью, в Metoro есть профилировщик процессора, основанный на eBPF, который мы можем использовать для проверки использования процессора. Вот что мы обнаружили:

Go Production Performance Gotcha - GOMAXPROCS

Одна вещь сразу же бросалась в глаза: функции runtime использовали значительное количество процессорного времени. Вот тот же график, но с выделенными функциями runtime:

Go Production Performance Gotcha - GOMAXPROCS

Нагрузка на процессор была распределена между двумя основными процессами:

  • runtime.schedule - ~30% of cpu usage
  • runtime.gcBgMarkWorker - ~20% of cpu usage

runtime.Schedule отвечает за поиск горутин, которые нужно выполнить, и их запуск в потоке ОС.

runtime.gcBgMarkWorker занимается определением участков памяти, которые могут быть помечены для последующей очистки сборщиком мусора.

Чтобы лучше понять причины происходящего, мы провели эксперимент в dev-кластере, в котором обрабатывалось то же количество запросов. Вот что мы обнаружили:

Go Production Performance Gotcha - GOMAXPROCS

Выделенная область runtime.Schedule - на этот раз всего 5% и runtime.gcBgMarkWorker - еще 6%.

Мы стали искать другие различия в среде, пока не обратили внимание на размер хоста. Агент, который потреблял 50% процессорного времени, работал на хосте с 192 ядрами. В то же время наш собственный хост имел всего 4 ядра.

Мы добавили 192-ядерный хост в наш кластер и провели эксперимент заново. И это сработало! 50% времени было потрачено на две функции runtime.

Это заставило нас задуматься. Почему на более мощном хосте runtime использует в пять раз больше ресурсов процессора, хотя сама программа Go не выполняет никаких дополнительных задач?

После долгих поисков в интернете мы наткнулись на пару проблем на GitHub, в которых связывали использование CPU runtime с параметром GOMAXPROCS.

Согласно документации:

Переменная GOMAXPROCS ограничивает количество потоков операционной системы, которые могут одновременно выполнять user-level Go код. Количество потоков, которые могут быть заблокированы в системных вызовах от имени кода Go, не ограничено; они не учитываются в ограничении GOMAXPROCS.

В исходниках go:

procs := ncpu
if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 {
	procs = n
}

ncpu вычисляется по-разному в зависимости от ОС. Обычно это количество ядер на хосте, но на самом деле оно может быть немного другим.

Эти две функции: runtime.Schedule и runtime.gcBgMarkWorker масштабируются по количеству процессов используемых программой go, которых на нашем большом хосте 192, а на маленьком - всего 4. Вот почему они используют гораздо больше процессорного времени.

Это значение по умолчанию не особенно полезно, если вы работаете в контейнерной среде. В такой среде, скорее всего, на том же хосте работает множество других контейнеров.

В нашем случае мы по умолчанию ограничиваем количество процессоров на агенте одним ядром, чтобы не влиять на рабочие нагрузки клиентов. Поэтому в нашем случае мы хотим, чтобы GOMAXPROCS был равен 1, так как мы все равно не можем использовать более одного ядра.

Решение

Нам нужно установить GOMAXPROCS на более разумное значение.

  • https://github.com/uber-go/automaxprocs - библиотека, которая программно устанавливает GOMAXPROCS равным cpu-квоте контейнера.
  • kubernetes downward api - позволяет вам выставлять значения для работающего контейнера через переменные окружения.

Мы постоянно работаем с k8s, поэтому решили использовать downwards api для установки переменной окружения GOMAXPROCS на node-agent контейнере во время развертывания. Выглядит это следующим образом.

env:
  - name: GOMAXPROCS
    valueFrom:
     resourceFieldRef:
       resource: limits.cpu
       divisor: "1"

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

Go Production Performance Gotcha - GOMAXPROCS


Комментарии в Telegram-группе!