Тестирование производительности веб-сервиса в рамках Continuous Intergation. Опыт Яндекса
Почти всех новых сотрудников Яндекса поражают масштабы нагрузок, которые испытывают наши продукты. Тысячи хостов с сотнями тысяч запросов в секунду. И это только один из сервисов. При этом отвечать на запросы мы должны за доли секунды. Даже незначительное изменение в продукте может оказать существенное влияние на производительность, поэтому важно тестировать и оценивать влияние своего кода на сервис.
В нашем сервисе рекламных технологий тестирование работает в рамках методологии Continuous integration, более подробно об организации которой мы расскажем 25 октября на мероприятии Яндекс изнутри, а сегодня мы поделимся с читателями Хабра опытом автоматизации оценки важных продуктовых метрик, связанных с производительностью сервиса. Вы узнаете, как доверить анализ машине, а не следить за ними на графиках. Поехали!
Речь не пойдет о том, как протестировать сайт. Для этого есть немало онлайн-инструментов. Сегодня мы поговорим о высоконагруженном внутреннем бэкенд-сервисе, который является частью большой системы и готовит информацию для внешнего сервиса. В нашем случае — для страниц поисковой выдачи и сайтов партнеров. Если наш компонент не успевает отвечать, то информацию от него просто не отдадут пользователю. А значит, компания потеряет деньги. Поэтому очень важно отвечать вовремя.
Какие важные показатели сервера можно выделить?
• Request per second (RPS). Счастье одного пользователя нам, конечно же, важно. Но что, если к вам пришел не один, а тысячи пользователей. Сколько запросов в секунду может выдержать ваш сервер и не упасть?
• Time per request. Контент сайта должен отрисовываться как можно быстрее, чтобы пользователю не надоело ждать, и он не ушел в магазин за попкорном. В нашем случае он не увидит важную часть информации на странице.
• Resident set size (RSS). Обязательно следим за тем, сколько ваша программа потребляет памяти. Если сервис съест всю память, вряд ли можно будет говорить об отказоустойчивости.
• HTTP-ошибки.
Итак, давайте по порядку.
Request per second
Наш разработчик, который долгое время занимался вопросами нагрузочного тестирования, любит говорить про критический ресурс системы. Давайте разберемся, что это такое.
У каждой системы есть свои конфигурационные характеристики, которые определяют работу. Например, длина очереди, время ожидания ответа, threads-worker pool и т.д. И вот может так случиться, что ёмкость вашего сервиса упирается в какой-то из этих ресурсов. Можно провести эксперимент. Увеличивать по очереди каждый ресурс. Ресурс, увеличение которого повысит ёмкость вашего сервиса, и будет для вас критическим. В хорошо сконфигурированной системе, чтобы поднять ёмкость, придется увеличить не один ресурс, а несколько. Но такой всё равно можно «нащупать». Будет отлично, если вы сможете настроить свою систему так, чтобы все ресурсы работали в полную силу, а сервис укладывался в заданные ему временные рамки.
Чтобы оценить, сколько запросов в секунду выдержит ваш сервер, нужно направить в него поток запросов. Так как у нас этот процесс встроен в CI-систему, мы используем очень простую «пушку», с ограниченной функциональностью. Но из открытого ПО для этой задачи отлично подойдет Яндекс.Танк. У него есть подробная документация. В подарок к Танку идёт сервис для просмотра результатов.
Небольшой офтоп. У Яндекс.Танка достаточно богатая функциональность, не ограниченная автоматизацией обстрела запросами. Он также поможет собрать метрики вашего сервиса, построить графики и прикрутить модуль с нужной вам логикой. В общем, очень рекомендуем познакомиться с ним.
Теперь в Танк нужно скормить запросы, чтобы ими обстрелять наш сервис. Запросы, которыми вы будете обстреливать сервер, могут быть однотипные, искусственно созданные и размноженные. Однако куда точнее будут измерения, если вы сможете собрать реальные пул запросов от пользователей за некоторый интервал времени.
Ёмкость можно измерять двумя способами.
Открытая модель нагрузки (стресс-тестирование)
Сделать «пользователей», то есть несколько потоков, которые будут отправлять запрос в вашу систему. Нагрузку мы будем давать не постоянную, а наращивать или даже подавать её волнами. Тогда это приблизит нас к реальной жизни. Наращиваем RPS и ловим точку, в которой обстреливаемый сервис «пробьёт» SLA. Таким образом можно найти пределы работы системы.
Для расчета количества пользователей можно воспользоваться формулой Литтла (о ней можно почитать тут). Опуская теорию, формула выглядит так:
RPS = 1000 / T * workers, где
• T — среднее время обработки запроса (в миллисекундах);
• workers — количество потоков;
• 1000 / T запросов в секунду — такое значение выдаст однопоточный генератор.
Закрытая модель нагрузки (нагрузочное тестирование)
Берём фиксированное количество «пользователей». Нужно настроить так, чтобы входная очередь, соответствующая конфигурации вашего сервиса, была всегда забита. При этом делать число потоков больше, чем лимит очереди, бессмысленно, так как мы будем упираться в это число, а остальные запросы будут отбрасываться сервером с 5xx ошибкой. Cмотрим, сколько запросов в секунду конструкция сможет выдать. Такая схема в общем случае не похожа на реальный поток запросов, но она поможет показать поведение системы при максимальной нагрузке и оценить её пропускную способность на текущий момент.
Для подавляющего большинства систем (где критический ресурс не имеет отношения к обработке соединений) результат будет одинаковый. При этом у закрытой модели шум меньше, потому что система всё время теста находится в интересующей нас области нагрузки.
Мы при тестировании нашего сервиса используем закрытую модель. После отстрела пушка выдает нам, сколько запросов в секунду наш сервис смог выдать. Яндекс.Танк этот показатель тоже легко подскажет.
Time per request
Если вернуться к предыдущему пункту, то становится очевидно, что при такой схеме оценивать время ответа на запрос не имеет никакого смысла. Чем сильнее мы нагрузим систему, тем сильнее она будет деградировать и тем дольше будет отвечать. Поэтому для тестирования времени ответа подход должен быть другим.
Для получения среднего времени ответа воспользуемся тем же Яндекс.Танком. Только теперь зададим RPS, соответствующий среднему показателю вашей системы в продакшене. После обстрела получим времена ответов каждого запроса. По собранным данным можно посчитать процентили времен ответов.
Дальше нужно понять, какой процентиль мы считаем важным. Например, мы отталкиваемся от продакшена. Мы можем оставить 1% запросов на ошибки, неответы, дебажные запросы, которые отрабатывают долго, проблемы с сетью и т.п. Поэтому мы считаем значимым время ответа, в которое вмещается 99% запросов.
Resident set size
Непосредственно наш сервер работает с файлами через mmap. Измеряя показатель RSS, мы хотим знать, сколько памяти программа забрала у операционной системы за время работы.
В Linux пишется файл /proc/PID/smaps — это расширение, основанное на картах, показывающее потребление памяти для каждого из отображений процесса. Если вы ваш процесс использует tmpfs, то в smaps попадёт как анонимная, так и неанонимная память. В неанонимную память входят, например, файлы, подгруженные в память. Вот пример записи в smaps. Указан конкретный файл, а его параметр Anonymous = 0kB.
7fea65a60000-7fea65a61000 r--s 00000000 09:03 79169191 /place/home/.../some.yabs
Size: 4 kB
Rss: 4 kB
Pss: 4 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 4 kB
Private_Dirty: 0 kB
Referenced: 4 kB
Anonymous: 0 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Locked: 0 kB
VmFlags: rd mr me ms
А это пример анонимного выделения памяти. Когда процесс (тот же mmap) делает запрос в операционную систему на выделение определенного размера памяти, ему выделяется адрес. Пока процесс занимает только виртуальную память. В этот момент мы ещё не знаем какой физический кусок памяти выделится. Мы видим безымянную запись. Это пример выделения анонимной памяти. У системы запросили размер 24572 kB, но им не воспользовались и фактически заняли только RSS = 4 kB.
7fea67264000-7fea68a63000 rw-p 00000000 00:00 0
Size: 24572 kB
Rss: 4 kB
Pss: 4 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 0 kB
Private_Dirty: 4 kB
Referenced: 4 kB
Anonymous: 4 kB
AnonHugePages: 0 kB
Swap: 0 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Locked: 0 kB
VmFlags: rd wr mr mw me ac
Так как выделенная неанонимная память никуда не денется после остановки процесса, файл не удалится, то такой RSS нас не интересует.
Перед тем, как начать отстрел по серверу, мы суммируем RSS из /proc/PID/smaps, выделенный под анонимную память, и запоминаем его. Проводим обстрел, аналогичный тестированию time per request. После окончания считаем RSS ещё раз. Разница между начальным и конечным состоянием и будет количество памяти, которое использовал ваш процесс во время работы.
HTTP-ошибки
Не забывайте следить за кодами ответов, которые во время тестирования возвращает сервис. Если что-то в настройке теста или окружения пошло не так, и вам на все запросы сервер вернул 5хх и 4хх ошибки, то смысла в таком тесте было не много. Мы следим за долей плохих ответов. Если ошибок много, то тест считается невалидным.
Немного о точности измерений
А теперь самое важное. Давайте вернёмся к предыдущим пунктам. Абсолютные величины посчитанных нами метрик, оказывается, не так уж нам и важны. Нет, конечно же, вы можете добиваться стабильности показателей, учтя все факторы, погрешности и флуктуации. Параллельно написать научную работу на эту тему (кстати, если кто искал себе такую, эта может быть неплохим вариантом). Но это не то, что нас интересует.
Нам важно влияние конкретного коммита на код относительно предыдущего состояния системы. То есть важна разница метрик от коммита к коммиту. И вот тут необходимо настроить процесс, который будет сравнивать эту разницу и при этом обеспечит стабильность абсолютной величины на этом интервале.
Окружение, запросы, данные, состояние сервиса — все доступные нам факторы должны быть зафиксированы. Вот эта система и работает у нас в рамках Continuous intergation, обеспечивая нас информацией о всевозможных изменениях, которые произошли в рамках каждого коммита. Несмотря на это, всё зафиксировать не удастся, останется шум. Уменьшить шум мы можем, очевидно, увеличив выборку, то есть произвести несколько итераций отстрела. Далее, после отстрела, скажем, 15 итераций, можно посчитать медиану получившейся выборки. Кроме того, необходимо найти баланс между шумом и длительностью отстрела. Мы, например, остановились на погрешности в 1%. Если вы хотите выбрать более сложный и точный статистический метод в соответствии со своими требованиями, рекомендуем книгу, в которой перечислены варианты с описанием, когда и какой применяется.
Что ещё можно сделать с шумом?
Отметим, что немаловажную роль в таком тестировании занимает среда, в которой вы проводите тесты. Тестовый стенд должен быть надежным, на нём не должны быть запущены другие программы, так как они могут приводить к деградации вашего сервиса. Кроме того, результаты могут и будут зависеть от профиля нагрузки, окружения, базы данных и от различных «магнитных бурь».
Мы в рамках одного теста коммита проводим несколько итераций на разных хостах. Во-первых, если вы пользуетесь облаком, то там может происходить что угодно. Даже если облако специализированное, как наше, всё равно там работают служебные процессы. Поэтому полагаться на результат от одного хоста нельзя. А если хост у вас железный, где нет, как в облаке, стандартного механизма поднятия окружения, то его можно вообще один раз случайно сломать и так и оставить. И врать он будет вам всегда. Поэтому мы гоняем наши тесты в облаке.
Из этого, правда, вытекает другой вопрос. Если ваши измерения производятся каждый раз на разных хостах, то результаты могут немного шуметь и из-за этого в том числе. Тогда можно нормировать показания на хост. То есть по историческим данным собрать «коэффициент хоста» и учитывать его при анализе результатов.
Анализ исторических данных показывает, что «железо» у нас разное. В слово «железо» тут входят версия ядра и последствия uptime (по-видимому, не перемещаемые объекты ядра в памяти).
Таким образом, каждому «хосту» (при перезагрузке хост «умирает» и появляется «новый») ставим в соответствие поправку, на которую умножаем RPS перед агрегацией.
Поправки считаем и обновляем крайне топорным способом, подозрительно напоминающим некоторый вариант обучения с подкреплением.
Для заданного вектора похостовых поправок считаем целевую функцию:
• в каждом тесте считаем стандартное отклонение «поправленных» полученных результатов RPS
• берем от них среднее с весами равными ,
у нас tau = 1 неделя.
Дальше одну поправку (для хоста, у которого сумма этих весов наибольшая) фиксируем в 1.0 и ищем значения всех остальных поправок, дающие минимум целевой функции.
Чтобы провалидировать результаты на исторических данных, считаем поправки на старых данных, считаем поправленный результат на свежих, сравниваем с неисправленным.
Ещё один вариант корректировки результатов и уменьшения шума — это нормировка на «синтетику». Перед запуском трестируемого сервиса запустить на хосте «синтетическую программу», по работе которой можно оценить состояние хоста и рассчитать поправочный коэффициент. Но в нашем случае мы используем поправки по хостам, а эта идея так и осталась идеей. Возможно, кому-то из вас она приглянется.
Несмотря на автоматизацию и все её плюсы, не забывайте о динамике ваших показателей. Важно следить, чтобы сервис не деградировал во времени. Маленькие просадки можно не заметить, они могут накопиться, и на большом временном промежутке ваши показатели могут просесть. Вот пример наших графиков, по которым мы смотрим на RPS. Он показывает относительное значение на каждом проверенном коммите, его номер и возможность посмотреть откуда был отведен релиз.
Если вы дочитали статью, значит, вам точно будет интересно посмотреть доклад про Яндекс.Танк и анализ результатов нагрузочного тестирования.
Также напоминаем, что более подробно об организации Continuous integration мы расскажем 25 октября на мероприятии Яндекс изнутри. Приходите в гости!