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


Анализ производительности можно применять для проверки узких мест в программе, применяя научный подход при проверке экспериментов по настройке. Эта статья определяет общий подход к анализу производительности и настройке с использованием в качестве примера вебсервера на Go.


Go тут особенно хорошо подходит, поскольку у него есть инструменты профилирования pprof в стандартной библиотеке.



Стратегия


Давайте создадим сводный список для нашего структурного анализа. Мы попытаемся использовать некоторые данные для принятия решений вместо того, чтобы вносить изменения, основанные на интуиции или догадках. Для этого сделаем так:


  • Определяем границы оптимизации (требования);
  • Рассчитываем транзакционную нагрузку для системы;
  • Выполняем тест (создаем данные);
  • Наблюдаем;
  • Анализируем — все ли требования соблюдаются?
  • Настраиваем по-научному, делаем гипотезу;
  • Выполняем эксперимент для проверки этой гипотезы.


Архитектура простого сервера HTTP


Для этой статьи мы будем использоват маленький сервер HTTP на Golang. Весь код из этой статьи можно найти здесь.


Анализируемое приложение — HTTP-сервер, который опрашивает Postgresql на каждый запрос. Дополнительно есть Prometheus, node_exporter и Grafana для сбора и отображения метрик приложения и системы.



Для упрощения считаем, что для горизонтального масштабирования (и упрощения расчетов) каждый сервис и база данных разворачиваются вместе:



Определяем цели


На этом шаге определяемся с целью. Что мы пытаемся проанализировать? Как мы узнаем, что пора заканчивать? В этой статье мы представим, что у нас есть клиенты, и что наш сервис будет обрабатывать 10 000 запросов в секунду.


В Google SRE Book подробно рассмотрены способы выбора и моделирование. Поступим так же, построим модели:


  • Задержка: 99% запросов должны выполнятся меньше чем за 60мс;
  • Стоимость: сервис должен потреблять минимальную сумму денег, которая нам покажется разумно возможной. Для этого максимизируем пропускную способность;
  • Планирование мощностей: требуется понимание и документирование того, сколько экземпляров приложения потребуется запустить, включая общую функцию масштабирования, а также сколько экземпляров потребуется для удовлетворения начальных требований по нагрузке и обеспечению избыточности n+1.

Задержка может потребовать оптимизации в довесок к анализу, но пропускную способность явно надо проанализировать. При использовании процесса SRE SLO требование о задержке исходит от клиента и\или бизнеса, представленных владельцем продукта. И наш сервис будет выполнять это обязательство с самого начала без каких-либо настроек!


Настраиваем тестовое окружение


С помощью тестового окружения мы сможем выдать дозированную нагрузку на нашу систему. Для анализа будут создаваться данные о производительности вебсервиса.


Транзакционная нагрузка


Это окружение использует Vegeta для создания настраиваемоей частоты запросов по HTTP, пока не будет остановлено:


$ make load-test LOAD_TEST_RATE=50
echo "POST http://localhost:8080" | vegeta attack -body tests/fixtures/age_no_match.json -rate=50 -duration=0 | tee results.bin | vegeta report

Наблюдение


Во время выполнения будет применена транзакционная нагрузка. В дополнение к метрикам приложения (число запросов, задержки при ответе) и операционной системы (память, CPU, IOPS) будет запущено профилирование приложения, чтобы понять, где в нем есть проблемы, а также как потребляется процессорное время.


Профилирование


Профилирование — это тип измерения, позволяющий увидеть куда идёт процессорное время при работе приложения. Оно позволяет определить где именно и сколько идёт процессорного времени:



Эти данные можно использовать во время анализа, чтобы получить представление о бесполезно потраченном процессорном времени и выполняемой ненужной работе. Go (pprof) может генерировать профили и визуализировать их в виде flame graph, используя стандартный набор инструментов. Я расскажу об их использовании и руководстве по настройке чуть ниже в статье.


Выполнение, наблюдение, анализ.


Проведём эксперимент. Мы будем выполнять, наблюдать и анализировать, пока производительность нас не устроит. Выберем произвольно низкую величину нагрузки, чтобы применить её для получения результатов первых наблюдений. На каждом последующем шаге будем увеличивать нагрузку с некоторым коэффициентом масштабирования, выбранным с некоторым разбросом. Каждый запуск нагрузочного тестирования выполняется с регулировкой числа запросов: make load-test LOAD_TEST_RATE=X.


50 запросов в секунду



Обратите внимание на два верхних графика. Верхний левый показывает, что наше приложение обрабатывает 50 запросов в секунду (по его мнению), а верхний правый — продолжительность каждого запроса. Оба параметра помогают нам смотреть и анализировать: влезаем ли в наши границы производительности или нет. Красная линия на графике HTTP Request Latency показывает SLO в 60мс. По линии видно, что мы намного ниже нашего максимального времени ответа.


Посмотрим со стороны стоимости:


10000 запросов в секунду / на 50 запросов на сервер = 200 серверов + 1


Мы все еще можем улучшить этот показатель.


500 запросов в секунду


Более интересные вещин начинают происходить, когда нагрузка становится 500 запросов в секунду:



Опять же на левом верхнем графике видно, что приложение фиксирует обычную нагрузку. Если это не так — есть проблема на сервере, на котором запущено приложение. График с задержкой ответа расположен сверху справа, показывает, что 500 запросов в секунду привели к задержке ответа в 25-40мс. 99 перцентиль все еще шикарно влезает в SLO 60мс, выбранный выше.


С точки зрения стоимости:


10000 запросов в секунду / на 500 запросов на сервер = 20 серверов + 1


Все еще можно улучшить.


1000 запросов в секунду



Отличный запуск! Приложение показывает, что обработало 1000 запросов в секунду, однако граница по задержке была нарушена со стороны SLO. Это видно по линии p99 на верхнем правом графике. Несмотря на то, что линия p100 намного выше, реальные задержки выше максимума в 60мс. Давайте нырнем в профилирование, чтобы узнать, что на самом деле делает приложение.


Профилирование


Для профилирования мы ставим нагрузку в 1000 запросов в секунду, затем используем pprof для снятия данных, чтобы узнать, где приложение тратит процессорное время. Это можно сделать активируя HTTP endpoint pprof, после чего при нагрузке сохранить результаты с помощью curl:


$ curl http://localhost:8080/debug/pprof/profile?seconds=29 > cpu.1000_reqs_sec_no_optimizations.prof

Результаты могут быть отображены так:


$ go tool pprof -http=:12345 cpu.1000_reqs_sec_no_optimizations.prof


График показывает, где и сколько приложение тратит процессорного времени. Из описания от Brendan Gregg:


По оси X — заполнение профиля стека, отсортированное в алфавитном порядке (это не время), ось Y показывает глубину стека, считая от нуля в [top]. Каждый прямоугольник — кадр стека. Чем шире кадр — тем чаще она присутствует в стеках. То что сверху — работает на CPU, а ниже — дочерние элементы. Цвета, как правило, не означают ничего, а просто выбираются случайным образом для различения кадров.

Анализ — гипотеза


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


Следуя рекомендациям Brendan Gregg мы будем читать график сверху вниз. Каждая строчка отображает кадр стека (вызов функции). Первая строчка — точка входа в программу, родитель всех остальных вызовов (другими словами все другие вызовы будут иметь ее в своем стеке). Следующая строчка уже отличается:



Если навести курсор на имя функции на графике — отобразится общее время, сколько она была в стеке во время отладки. Функция HTTPServe находилась там 65% времени, другие функции runtime, runtime.mcall, mstart и gc, заняли остальное время. Интересный факт: 5% общего времени потрачено на запросы в DNS:



Адреса, которые программа ищет, принадлежат Postgresql. Щелкаем по FindByAge:



Занятно, программа показывает, что в принципе есть три основных источника, которые добавляют задержки: открытие\закрытие соединений, запрос данных и соединение с базой. На графике видно, что запросы в DNS, открытие и закрытие соединений занимают порядка 13% всего времени выполнения.


Гипотеза: переиспользование соединений с помощью пула дожно сократить время одного запроса по HTTP, позволяя более высокую пропускную способность и более низкие задержки.


Настройка приложения — эксперимент


Обновляем исходный код, пробуем убрать соединение с Postgresql на каждый запрос. Первый вариант — использование пула соединений на уровне приложения. В этом эксперименте мы настроим пул соединений с помощью драйвера sql для go:


db, err := sql.Open("postgres", dbConnectionString)
db.SetMaxOpenConns(8)

if err != nil {
   return nil, err
}

Выполнение, наблюдение, анализ


После перезапуска теста с 1000 запросами в секунду видно, что p99 по задержкам пришел в норму со SLO 60мс!


Что по стоимости?


10000 запросов в секунду / на 1000 запросов на сервер = 10 серверов + 1


Давайте сделаем еще лучше!


2000 запросов в секунду



Удвоение нагрузки показывает то же самое, левый верхний график демонстрирует, что приложение успевает отработать 2000 запросов за секунду, p100 ниже чем 60мс, p99 удовлетворяет SLO.


С точки зрения стоимости:


10000 запросов в секунду / на 2000 запросов на сервер = 5 серверов + 1


3000 запросов в секунду



Здесь приложение может обработать 3000 запросов с задержкой p99 меньше 60мс. SLO не нарушается, а стоимость принята так:


10000 запросов в секунду / на 3000 запросов на сервер = 4 сервера + 1 (автор округлил до большего, прим. переводчика)


Давайте попробуем еще один раунд анализа.


Анализ — гипотеза


Собираем и отображаем результаты отладки приложения на 3000 запросах в секунду:



Всё еще 6% времени тратится на установку соединений. Настройка пула улучшила производительность, но все еще видно, что приложение продолжает работу по созданию новых соединений с базой.


Гипотеза: Соединения, несмотря на наличие пула, все еще отбрасываются и зачищаются, поэтому приложению надо переустанавливать их. Установка числа ожидающих соединений размеру пула должна помочь с задержкой путем минимизации времени, которое приложение тратит на создание соединения.


Настройка приложения — эксперимент


Пробуем установить MaxIdleConns равным размеру пула (также описано здесь):


db, err := sql.Open("postgres", dbConnectionString)
db.SetMaxOpenConns(8)
db.SetMaxIdleConns(8)
if err != nil {
   return nil, err
}

Выполнение, наблюдение, анализ


3000 запросов в секунду



p99 меньше 60мс с значительно меньшим p100!



Проверка flame graph показывает, что установка соединения больше не заметна! Проверяем детальнее pg(*conn).query — также не замечаем установки соединения здесь.



Заключение


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