Сказ о том, как мы нагружаем Ozon в мультиЦОД-архитектуре
Привет, я Таня, и наша команда занимается разработкой инфраструктуры для нагрузочного тестирования (НТ) в Ozon. Наша цель — предоставить разработчикам простой и понятный инструмент для подготовки и самостоятельного запуска нагрузочных тестов — можно сказать, нагрузочное тестирование as a service. У нас НТ широко распространено и поставлено на поток — большинство продуктовых сервисов регулярно тестируется по расписанию, в автоматическом режиме. Кстати, подавляющая часть тестов проводится не на тестовых стендах, а прямо в продакшене. Это связано с определёнными рисками, ведь есть ещё и реальный пользовательский трафик. Обложившись алертами и автостопами (критериями для автоматической остановки тестов), мы сводим эти риски к минимуму.
Компания растёт, увеличивается число пользователей и сервисов. В один прекрасный день нам стало тесно в рамках одного дата-центра — началось масштабное расширение на три ЦОДа. Каждый сервис обзавёлся дополнительными инстансами — и новыми требованиями к нагрузке. У НТ-разработчиков появилась задача тестировать сервисы, разбросанные по разным ЦОДам, и при этом ничего не уронить (мы ребята высоконагруженные). Кроме того, для уменьшения объёмов трафика между ЦОДами и сетевых задержек сервисы при взаимодействии перешли с серверной на клиентскую балансировку. Так как при НТ требуется максимально точно воспроизводить клиентский трафик, от генераторов нагрузки ожидалось такое же поведение. О том, какие перед нами стояли задачи и как мы с ними справились, читайте под катом.
Перед инфраструктурой НТ стояло две основные задачи:
локализация нагрузочного трафика внутри ЦОДа, то есть генератор и нагружаемый им инстанс сервиса должны находиться в одном дата-центре в рамках одного кластера Kubernetes;
возможность тестировать сервисы с использованием клиентской балансировки: генератор нагрузки должен уметь отправлять запросы не только на Ingress, но и использовать service discovery для определения IP тестируемого инстанса.
Исходная архитектура
На момент переезда компании в несколько дата-центров инфраструктура нагрузочного тестирования представляла собой следующее:
В качестве основного инструмента для запуска тестов мы используем Яндекс.Танк.
В качестве генераторов нагрузки — в основном Pandora или JMeter (подробнее про теорию и инструменты НТ).
При запуске теста центральный сервис инфраструктуры НТ поднимал Docker с генератором нагрузки на выделенной виртуальной машине (VM). К слову, на ней же по соседству с генераторами нагрузки базировалась InfluxDB для хранения статистики нагрузочных тестов, что не добавляло радости и ресурсов.
Управление жизненным циклом генератора также происходило посредством центрального сервиса, который отдавал команду на запуск теста и сворачивал Docker после его завершения.
Упрощённая схема исходной архитектуры
Соответственно, у такой схемы было множество ограничений. Вот только самые существенные из них:
Так как генератор нагрузки находился на виртуальной машине, а тестируемые сервисы разворачивались в Kubernetes, при наличии инстансов сервиса в нескольких ЦОДах о локализации нагрузочного трафика внутри ЦОД не могло быть и речи. А объём трафика в условиях массового ночного тестирования мог быть существенным.
По той же причине невозможно было использовать клиентскую балансировку, имитируя реальный трафик. Конечно, service discovery позволяет получить все IP тестируемых подов и разделить между ними трафик как душе угодно, но это не совсем честное и безопасное решение.
На VM одновременно могло работать весьма ограниченное число генераторов нагрузки.
Таким образом, существующее решение не было эффективным в условиях нескольких дата-центров и не удовлетворяло новым требованиям: локализация нагрузочного трафика внутри ЦОДа и поддержка клиентской балансировки.
Варианты решения проблем
Очевидно было, что для решения указанных проблем генераторы нагрузки тоже должны запускаться в Kubernetes. Причём в тех же ЦОДах, что и тестируемый сервис, — для локализации трафика. То есть как минимум один генератор нагрузки на один ЦОД, трафик с которого должен идти только в инстансы тестируемого сервиса, расположенные в том же ЦОДе. Таким образом, инфраструктуру нагрузочного тестирования тоже ожидал переезд.
Рассматривалось несколько вариантов новой конфигурации НТ:
1. Пул постоянно запущенных генераторов нагрузки в Kubernetes во всех ЦОДах
Идея состояла в том, чтобы один раз запустить некоторое число деплойментов с генераторами нагрузки в каждом ДЦ и переиспользовать их по мере необходимости. В каждый момент времени на одном генераторе нагрузки может быть запущен только один тест. Такая конфигурация близка к исходной, за исключением местоположения генераторов — в Kubernetes, а не на виртуальной машине.
Вариант с пулом постоянно запущенных генераторов
Плюсы такого подхода:
решает основные поставленные задачи: можно организовать локализацию трафика и использовать клиентскую балансировку (при доработке генератора);
не нужно тратить время на деплой генератора нагрузки перед каждым тестом;
меньше точек отказа — генератор нагрузки заранее запущен и работает;
известны адреса генераторов.
Минусы тоже присутствуют:
всегда постоянное и ограниченное количество генераторов;
часть генераторов может не использоваться в определённые моменты времени и при этом потреблять общие ресурсы кластера;
невозможность динамически управлять выделяемыми генераторам ресурсами;
необходимость постоянно мониторить состояние генераторов и вести учёт свободных/занятых.
Данному решению явно не хватало гибкости, вследствие чего оно было отвергнуто. Ведь мы хотели не только решить насущную проблему, но и создать удобную и расширяемую инфраструктуру, способную адаптироваться к изменениям.
2. Запускать генератор нагрузки как Kubernetes Job или Kubernetes CronJob
Перед каждым тестом предполагалось средствами CI/CD запускать пайплайн, который стартовал бы Kubernetes Job с генератором нагрузки в указанном ЦОДе.
Вариант с Kubernetes Job
Плюсы:
возможность настраивать параметры содержащего генератор нагрузки Kubernetes pod в зависимости от требований теста;
возможность запускать разлное количество генераторов нагрузки в каждом ЦОДе. Такая необходимость может возникнуть, если тестируемый сервис не имеет инстансов в одном из ЦОДов либо имеет разное количество инстансов в разных ЦОДах.
Минусы:
дополнительная сложность реализации: при таком варианте запуска генераторов необходимо настраивать и запускать пайплайн для каждого из них перед каждой стрельбой;
усложнённый мониторинг (хотя в компании разработан функционал для мониторинга Kubernetes CronJob, его использование требует доработок в коде генератора).
дополнительная точка отказа: генератор нагрузки может вовремя не запуститься по каким-либо причинам (проблемы с доступом к GitLab, технические работы, отсутствие свободных раннеров и т. д.).
3. Kubernetes Operator
Сервис — клиент Kubernetes API, управляющий настройкой и развёртыванием генераторов нагрузки в Kubernetes. Подробнее о Kubernetes Operator.
Плюсы:
возможность гибко настраивать конфигурацию генератора нагрузки в зависимости от требований теста;
возможность запускать разное количество генераторов в разных ЦОДах;
возможность управления жизненным циклом генераторов;
лёгкий мониторинг статуса и логов генератора при помощи Kubernetes API.
Минусы:
возникновение сразу двух дополнительных точек отказа:
1. деплой самого управляющего сервиса,
2. развёртывание генератора нагрузки (например, при отсутствии достаточных ресурсов в кластере).
Итоговое решение
Взвесив все за и против, мы взялись за написание сервиса — оператора Kubernetes. Решение получилось достаточно гибким, простым и предоставило дополнительный функционал по мониторингу и управлению подами генераторов (об этом ниже).
Упрощённая схема архитектуры с оператором
Конфигурация запуска
Для управления параметрами запуска генераторов конфиг нагрузочного теста обзавёлся дополнительными полями и стал выглядеть примерно так:
launch:
- env: string (dev|stg|prod|infra) // окружение, в котором должен быть запущен тест
tank_config: string // конфигурация самого теста
resource_config: // выделяемые под генератор в К8s ресурсы
memory:
limit: string
request: string
cpu:
limit: string
request: string
zone_config: // в каких ДЦ и в каком количестве должны быть
- zone: string // запущены генераторы
magnitude: int
Таким образом, пользователь получил возможность самостоятельно определять, в каких зонах, в каком количестве и с каким запасом памяти и CPU запускать генераторы нагрузки для своего теста, исходя из конфигурации инстансов тестируемого сервиса.
Особенности
Оператор в нашем исполнении представляет собой сервис, написанный на Go с использованием библиотеки для доступа к Kubernetes API.
Для локализации нагрузочного трафика внутри ЦОДа инстансы операторов задеплоены во все дата-центры основных используемых в НТ кластеров Kubernetes (у нас это стейдж и продакшен).
Каждый оператор может создавать генераторы только в своём окружении.
Генераторы запускаются только на специально выделенных для них K8s nodes. Соседство с другими сервисами опасно и для этих сервисов, и для самих генераторов: кому-то может не хватить ресурсов в самый неожиданный момент.
На запуск пода с генератором есть тайм-аут. Тесты выполняются по расписанию, и не все можно запускать параллельно, так как часть трафика идёт на одни и те же сервисы. При длительном запуске генератора время старта теста сдвигается, что может зааффектить соседей.
Для расселения генераторов по нодам используются правила K8s affinity/anti-affinity.
Оператор следит за статусами подов с генераторами и удаляет завершившиеся.
Возможности
Для различных типов тестов и разных сервисов требуются разные ресурсы: выделять под короткий и лёгкий пятиминутный тест столько же памяти и CPU, как и для комплексного тестирования целой системы, — непозволительная роскошь. Следовательно, лимиты и реквесты для генератора хотелось бы уметь ограничивать в зависимости от особенностей теста. И одним из самых приятных плюсов использования оператора является как раз такая возможность настраивать выделение ресурсов под генераторы.
Кроме того, оператор в любой момент имеет доступ к любому запущенному поду с генератором в своём кластере. Благодаря этому открываются широкие возможности для управления жизненным циклом подов, мониторинга и логирования. Например, можно:
передавать в генератор переменные окружения (например, для использования в кастомных плагинах);
логировать статус подов и причину их завершения;
сохранять логи подов в любом удобном формате.
Флоу запуска генераторов
Процесс запуска генератора для нагрузочного теста теперь выглядит так:
Пользователь настраивает конфиг запуска (выше) в соответствии с требованиями к тестируемому сервису и отправляет его в центральный сервис НТ.
Если конфиг успешно проходит валидацию, параметры запуска (в каком дата-центре, сколько и с какими ресурсами надо запустить генераторы) передаются в оператор. В противном случае пользователю возвращается ошибка с пояснением, какая часть его конфига настроена неверно.
Оператор запускает генераторы в указанном количестве на специально выделенных K8s nodes.
Генераторы получают конфиг с параметрами теста (куда и какую нагрузку подавать) и начинают тестирование.
После завершения теста оператор удаляет закончившие работу генераторы, предварительно сохранив их логи и конечный статус для дебага.
Каждый генератор отправляет запросы только в инстансы тестируемого сервиса, расположенные в одних с ним K8s-кластере и зоне. Таким образом, нагрузочный трафик остаётся в пределах одного дата-центра.
Заключение
Так из одного дата-центра и виртуальных машин наша инфраструктура для нагрузочного тестирования переехала в Kubernetes и три ЦОДа. Это потребовало существенных изменений в архитектуре запуска тестов. Мы рассмотрели несколько вариантов её модификации, каждый из которых позволял решить основную задачу — локализацию нагрузочного трафика внутри дата-центров —, но обладал и своими недостатками. В конечном итоге был выбран наиболее гибкий — K8s-оператор. Такое решение позволяет:
запускать генераторы нагрузки с динамической конфигурацией в среде Kubernetes в определённых пользователем количестве и дата-центре;
передавать в генератор переменные окружения для использования в кастомных плагинах;
мониторить статус генераторов и сохранять их логи;
удалять генераторы (при необходимости).
Это далеко не все возможности, предоставляемые оператором как единой точкой управления подами генераторов нагрузки. В планах у нас административный интерфейс для управления генераторами, а также улучшение мониторинга и логирования. Пока же, успешно прожив с оператором неполный год, мы стали запускать в пять раз больше тестов, распределённых по трём дата-центрам. А то, как мы агрегируем данные распределённых тестов, — уже совсем другая история:)