|
Professor Seleznov
|
Эта статья совсем не технический анализ, а увлекательный рассказ о том, как маленький, но очень перспективный стартап стал топовым приложением, а также о том, какие сложности встали на пути команды разработки, DevOps и тестирования X5 Tech. Меня зовут Алексей Юрченко. В X5 Tech я пришёл как разработчик, занимался личным кабинетом сотрудника розницы, потом стал тимлидом команды продукта экспресс-доставки в мобильном приложении ещё на заре его существования. Постепенно команда выросла, разделилась на несколько, и сейчас я тимлид тимлидов команд, разрабатывающих функциональность доставки в мобильном приложении «Пятёрочка». Я отвечаю за часть мобильного приложения, связанную с формированием каталога, отображением карточек товаров, наполнением и оплатой корзины. Пара слов о масштабах. На текущий момент мобильное приложение «Пятёрочка» находится в топе приложений электронной коммерции. Им ежемесячно пользуются порядка 25 миллионов уникальных пользователей. Суммарный RPS — около 10 тысяч. В нашем мобильном приложении собрана информация по ~25 тысяч магазинов и 15 тысяч товаров. А поиск в каталоге осуществляется примерно по 1 миллиарду документов, хранящихся в ElasticSearch. Итак, поехали! Экскурс в прошлое: как всё начиналось Шесть лет назад наш продукт только зарождался. Посмотрите, как выглядела его первоначальная архитектура.
 Мы сразу заложили основные принципы нагруженного приложения: микросервисы как основа всего, полное покрытие метриками, асинхронность, кэширование на максималках. Какую-то функциональность разрабатывали сами, где-то задействовали сервисы других техкоманд из X5, а где-то и сторонние решения с рынка. Весь код писали на Python, использовали FastAPI и другие популярные на тот момент фреймворки и технологии. Первое испытание: latency
 У нас появилась первая итерация продукта и заказы. Изначально заказов было мало, но потом наступил 2020-й год, пандемия. Люди перешли на удалёнку и начали массово заказывать товары из магазинов с доставкой на дом. В этот момент мы столкнулись с одним из первых вызовов. Начали замечать, что при работе сервисов ощутимо рос latency. Гипотез, почему это происходит, было много. Ведь на latency влияет огромное количество факторов и не меньшее количество компонент в составе системы. Одна из первых гипотез — нашим подам не хватает ресурсов, поэтому решили масштабироваться, чтобы снизить latency. Но гипотеза не сработала. Скейлились по-разному: сначала вручную, потом прикрутили автоскейлинг, основанный на HPA, отслеживающим метрики по утилизации CPU-памяти. Но… картина не менялась. CPU оставался статичен, тогда как latency мог внезапно взлететь до небес.
 Решение: к нам едет… лимитёр А теперь, чтобы понять, что собственно происходило, представьте, что огромную толпу людей заставили бежать друг за другом по узкому коридору. И вот бегущий впереди человек внезапно падает. Через него спотыкаются и за ним падают следующие. Потребуется немало времени, чтобы поднять эту толпу и заставить двигаться с той же скоростью, что была до падения. Мы пришли к выводу, что примерно то же самое происходит на подах, когда всего один запрос может внезапно нарушить работоспособность всего бэкенда приложения. Когда это озарение на нас снизошло, мы подумали про интересную метрику «Количество одновременных запросов, которое может находиться в поде». И про возможность ограничивать её. Сначала искали готовые решения на рынке, в том числе рассматривали Request Limiter из Uvicorn. Но по разным причинам нам ничего не подошло. Тогда решили разработать собственный инструмент. Определили простые принципы:
- Когда под получает запрос от клиента, лимитер должен определить, сможет ли под обработать этот запрос.
- Если под может обработать запрос, лимитер его пропускает, а под обрабатывает и формирует корректный ответ клиенту.
- Если под перегружен и не может обработать запрос, то он отдаёт клиенту ошибку 503: сервис недоступен. А клиент отправляет повторный запрос — retry.
 Мы задумались, что хорошим решением было бы завязать эту метрику на скейлинг по HPA со стороны Kubernetes. Тогда если по агрегирующей метрике со всех подов окажется, что подов не хватает, то мы сможем их добавить. Написали для этого свой middleware:
class Requests LimitMiddleware (BaseHTTPMiddleware): ... async def____call_(self, scope: Scope, receive: Receive, send: Send) -> None: ... if scope["path"] in self._ignored_paths: await self.app(scope, receive, send) return if self._counter >= self._unauth_limit: await Response(content="503", status_code=503) (scope, receive, send) return self._counter += 1 try: await self.app(scope, request.receive, send) return finally: self._counter = 1
Принцип работы у middleware достаточно простой: когда в неё попадает запрос, внутренний счетчик инкрементируется на единицу. И декрементируется в момент, когда запрос выходит из пода и клиенту поступает ответ. Указали определённые исключения. Лимитер работает далеко не по каждому пути, по которому приходит запрос. Есть ещё сервисные эндпоинты, например, ручки healthcheck’ов, readiness и liveness проб, callback’и от доверенных систем, по которым лимитер срабатывать не должен. Ведь если бы лимитер работал на этих ручках при поступлении запроса и формировании ошибки 503, то мы бы вхолостую перезапускали под со стороны контроллера Kubernetes, или пропускали важные обновления по заказам клиентов, что неправильно. Поэтому заранее обозначили пути, по которым лимитер гарантированно должен пропускать запросы. Испытание 2. Балансировка
 Метрика исходящих запросов пушилась в Prometheus. Мы обратили внимание, каким образом запросы распределяются по подам. И увидели, что небольшая их часть, 5-10%, сильно перегружены — содержат гораздо больше запросов, чем большинство других. В этом есть логика: под держит запрос, а когда в него параллельно поступают другие запросы, то они скапливаются. В это время другие поды под капотом имеют хэндлеры и могут обращаться только в базу данных. Поэтому обработка запросов у них происходит быстро и они не накапливаются. И тут мы вспомнили (барабанная дробь): а ведь принципов балансировки на самом деле несколько. Решение: Least connection вместо Round Robin По умолчанию везде используется стандартный round-robin, который как раз и даёт такую неравномерную ситуацию с перегруженными и недогруженными подами. А ведь есть ещё один способ балансировки, который как раз позволяет исключить эту проблему и равномерно нагружать поды, тем самым равномерно утилизируя ресурсы, на которых развёрнут бэкэнд. Речь про балансировку Least Connection, позволяющую выбирать наименее загруженный под, и отправлять на него запрос, тем самым равномерно распределяя запросы между всеми развёрнутыми подами.
 Внедрение лимитера и использование метрики одновременных запросов стало важным шагом, чтобы повысить наблюдаемость и прозрачность для команд разработки, тестирования и поддержки. Испытание 3. Поиск в каталоге
 В первые месяцы работы приложения периодически возникала проблема, когда при поиске в каталоге по запросу, например, «молоко», в ответ выкидывало ошибку. Это запрос с мобильного устройства в бэкэнд приложения, где его обрабатывал под с поиском и по запросу обращался в ElasticSearch. ElasticSearch существовал в виде индекса, развёрнутого на n-ном количестве нод. А на нодах происходило то, что вы видите на графике выше: CPU внезапно упирался в потолок, загрузка в 100%. Понятно, что при таком раскладе никакой ответ мобильное приложение отдать не могло. Начали думать, почему такое происходит. Сначала связывали это с резким ростом трафика, мол ElasticSearch с новыми цифрами не справляется. Решение: принципы виртуализации Оказалось, дело не в числе запросов. Ответ скрывался в принципах виртуализации. Если вы пользуетесь облачной инфраструктурой, то наверняка при нарезке виртуалок замечали параметр под названием «коэффициент переподписки» или «коэффициент одновременного использования». Этот коэффициент и сообщает, какая часть процессорного времени гарантированно отведена для работы вашего пода. Это означает, что в какой-то момент времени несколько виртуалок могут конкурировать за ресурсы CPU. Возникает эффект стилтайма, ноды начинают красть процессорные мощности друг у друга и приложение перестаёт работать корректно. Победить эту проблему можно двумя способами. Либо гарантированным резервированием времени для виртуалок, на которых находится ElasticSearch. Либо дополнительно приложить усилия и правильно запроектировать хранение документов в ElasticSearch, чтобы повысить надёжность его работы.
 Если в ElasticSearch всё хранится в одном индексе, то при появлении эффекта still time весь индекс становится неработоспособным. Тогда мы разделили документы о товарах, их наличии и магазинах по n-ному количеству индексов. Теперь в случае still time эффекта на одной из нод, где расположен индекс, максимум один индекс становится неработоспособным. В этом случае лишь минимальный процент пользователей сталкиваются с неработоспособностью приложения. Так мы повысили надёжность работы каталога в целом. Испытание 4. Скорость ответов БД Другая важная составляющая, влияющая на latency — база данных. Мы использовали bouncer, чтобы управлять пулом коннектов. Сделали из мастера в нескольких реплик, даже внедрили CQRS паттерн, чтобы можно было разделить запросы к мастеру на запись и на чтение к реплике. Вроде, всё сделали красиво, но latency от PostgreSQL росло. А связано это было с дефолтными настройками базы, не приспособленными для работы реального высоконагруженного приложения. Настройки statement timeout, времени нахождения сессии в определённых статусах и прочие, по умолчанию на сервере PostgreSQL выключены. Может сложиться ситуация, когда под направляет запрос в PostgreSQL, а база бесконечно долго пытается сформировать ответ. Параллельно с ожиданием будут накапливаться запросы, поступать дополнительные коннекты. И вот уже не спасает даже bouncer. Решение: новые настройки для базы данных Мы поняли, что нужно настроить базу данных иначе.
 Можно было настроить все таймауты и параметры со стороны сервера БД. В теории, звучит неплохо. На практике возникают ситуации, когда одни запросы могут исполняться за считанные миллисекунды, а другие, более тяжёлые, — за десятки, сотни миллисекунд, а у миграции счёт идёт уже на секунды. Сложно подобрать для всего широкого спектра запросов значения настроек универсальные для всех ситуаций. В какой-то момент мы начали использовать сессионные параметры. Когда под устанавливает соединение с PostgreSQL, он получает сессию и в этот момент может прокидывать конкретные настройки под конкретную сессию. В таком случае мы получаем сессию, чтобы сделать легковесный запрос и передать небольшие значения таймаутов. А если осуществляем миграцию, то передаём в сессию уже другие значения. Ещё мы обратили внимание на JIT, который используется по умолчанию, чтобы ускорять даже тяжёлые запросы в реальном времени за счёт компиляции запросов в момент их выполнения. В нашей конфигурации JIT давал нестабильное поведение на части тяжёлых запросов, поэтому мы приняли решение его отключить. Испытание 5. Скрейпинг
 Внезапно мы получили картинку как на изображении выше. Latency рос, снова! И в этот раз наша гипотеза, что это происходит из-за резкого роста трафика, оказалась верной. Трафик мог вырасти в 2-6 раз, а было даже в 12. Мы начали искать причины. Думали, что дело в релизе мобильного приложения, новой функциональности в ручках API. Но тогда новый трафик приложения был бы сопряжён с периодом выкатки и рос бы относительно плавно. А в нашем случае трафик рос моментально. Начали искать дальше. Ещё одна версия — релиз на бэкэнде, который за счёт оптимизации архитектуры перераспределил трафик с одного микросервиса на другой. Версия хорошая, но тоже не подтвердилась. Всё оказалось банальнее. Трафик поступал из интернета, но не был связан с нашим мобильным приложением. Так мы познакомились со скрейперами, собирающими публичную информации с ресурсов автоматизированным способом. Наш каталог доступен нашим пользователям без авторизации, и скрейперы начали этим пользоваться и ходили по определённому графику. У нас даже было расписание, когда нужно быть готовыми отражать их нагрузку. Решение: не можешь победить, возглавь Сначала попробовали договориться со скрейперами грубой силой, запрещая им к нам обращаться по различным формальным признакам. Но это продлилось недолго: каждая итерация становилась всё сложнее, наверное, не только для нас, но и для скрейперов, которые каждый раз пытались придумать всё более изощрённый способ обойти защиту. Кроме того, мы применяли разные практики, чтобы обезопасить себя от ботового трафика, но лучшим решением оказалось расслабиться и следовать принципу «не можешь победить, возглавь». Мы выделили отдельный контур обработки подозрительного автоматизированного трафика и снизили его влияние на пользовательский путь. Детали классификации и маршрутизации не раскрываем, но ключевой принцип — пользовательский трафик не должен конкурировать за ресурсы с нерелевантной автоматизированной нагрузкой.
 Испытание 6. Проблемы с релизом Прошло примерно полтора года с начала работы нашего проекта. Мы разобрались с основными проблемами, выросли как продукт, у нас много заказов и команд разработки. Мы начали больше коммитить, разрабатывать новую функциональность, и обнаружили проблему с релизами:
 До релиза всё хорошо. Когда отдельный релиз случается, внезапно выскакивает, например, ошибка 500 по важной для жизненного цикла заказа ручке и всё становится плохо. Откатываем релиз — всё снова налаживается. Сначала решили, что нужно лучше тестировать, но это не всегда помогало, потому что есть моменты, которые в ходе тестирования не проверишь. Например, миграции базы данных. Поэтому начали технически проверять возможность выкатки миграции на уровне пайплайна. Не секрет, что на сервере PostgreSQL есть различные процессы, накладывающие блокировки на целые таблицы баз. Такие блокировки удерживаются, например, в случае с автовакуумом, когда необходимо подчистить «мёртвые строки». И может оказаться, что… в момент релиза он сработает и заблокирует какую-то таблицу, на которую необходимо накатить миграцию. В итоге накладываемая блокировка от миграции встаёт в очередь за блокировкой от автовакуума, а за ней встают в очередь все блокировки, накладываемые в ходе прилетающих в БД запросов на изменение данных. В конечном итоге, база оказывается работоспособна только на чтение. По сути, это означает деградацию продукта: часть пользовательских сценариев становилась недоступной. Решение: дополнительная проверка Чтобы решить проблему с неожиданными блокировками БД в ходе релизов с миграциями, использовали сервисную информацию из таблицы pg_stat_activity.Мы написали джобу, которая проверяет, есть ли возможность наложить блокировку и нет ли на текущий момент блокировок, наложенных на таблицы, в которые мы пытаемся сделать миграцию. В случае, если блокировки есть, пайплайн не даёт разработчику выкатить релиз. Если блокировок нет, релиз катится дальше.
 А ещё мы воссоздали условия на проде в процессе тестирования. И теперь тестируем миграции под нагрузкой перед выкаткой в прод. У нас есть отдельный стенд для нагрузочного тестирования, на который мы подаём трафик, эквивалентный продовому, и в этот момент запускаем наши миграции.
 После того, как миграции отработают, какое-то время ждём. Затем разработчик, ответственный за релиз, смотрит метрики, ошибки и принимает решение катить ли релиз.
 Также, чтобы стабилизировать релизы, мы используем staged rollout/canary-подход: новая версия сначала получает ограниченную долю трафика, после чего команда смотрит на метрики и ошибки. Для пользовательских сценариев с состоянием отдельно контролируем консистентность маршрутизации. Испытание 7. Аналитика Постепенно мы накопили значительные объёмы данных. Коллеги-аналитики решили, что у нас содержится много полезной для развития продукта информации. Но, чтобы её использовать, надо её сначала выкопать. Запросы, с которыми пользователи ходили в базы данных, были разными — простыми, сложными, лёгкими и тяжёлыми.
 Тяжёлые запросы могли аффектить нашу БД. С этим нужно было что-то делать. Самое простое решение — запретить все пользовательские доступы к базе. Так мы и сделали. Но затем нужно было заменить такое радикальное решение элегантным и правильным. Решение: отдельная система для пользователей Мы решили обращать все пользовательские запросы в систему Data Management Platform (DMP), которая реплицировалась от наших реальных продовых баз с минимальным лагом не более, чем в минуту. Так, все данные, необходимые пользователям, системным/бизнес-аналитикам и техподдержке достаются из DMP. При этом к реальным продовым базам, мастер-ноде и репликам никто доступа не имеет. И после того, как мы полностью переселили пользовательские запросы в DMP, мы существенно повысили стабильность и надёжность работы базы данных.
 Испытание 8. Исторические данные Как-то мы заметили, что пользователи слишком долго задерживаются на одном из экранов мобильного приложения, в истории заказов. Так происходило из-за того, что у нас скопилось много данных по заказам и товарам, которые нам для работы по текущим заказам не особо и нужны. К данным по текущим заказам мы обращаемся, как правило, в течение нескольких дней, а затем они лежат в БД практически мертвым грузом. Поэтому мы решили разделить данные на холодные и горячие и стали их хранить по-разному. Решение: разделение на холодные и горячие данные
 Горячие данные положили в Clickhouse и перекладываем воркерами ежедневно по расписанию из PostgreSQL в Elastic. При этом, если, например, приходит запрос на получение списка заказов для отображения экрана с историей, мы на уровне пода синхронно делаем запрос с одной стороны в PostgreSQL, с другой стороны — в Clickhouse. Агрегируем ответ, и отдаём пользователю единое полотно со списком заказов и товаров.
 Испытание 9. Метрики не совпадают с реальностью
 Вам наверняка знакома ситуация, когда в чате скидывают скриншот, что у вас не работает приложение. Вы смотрите на метрики, а там всё «зеленое», проблем нет и делаете вывод, что это, наверное, у пользователя что-то с интернетом. На самом деле далеко не всегда всё так радужно. Как правило, систему мониторинга мы строим, отталкиваясь от бэкэнда. Но вспомните, что между бэкэндом и приложением существует определённый тракт доставки запросов, который состоит, как правило, из n-ного количества компонентов, о которых нам, зачастую, ничего неизвестно. Решение: резервный канал формирования метрик с ошибками
 Чтобы повысить наблюдаемость по проблемам пользователей, у нас появилась идея сформировать резервный канал доставки метрик с ошибками и запросов в бэкэнд. При этом, если что-то на пути основного тракта приходит в негодность и не работает, то не отправляются ни запросы, ни метрики, а формируются тревожные события, например: не отображается каталог, невозможно добавить товар в корзину. Мы сделали независимый канал доставки клиентских диагностических событий, чтобы видеть проблемы, которые не доходят до основного backend-мониторинга и получили единую цельную картину из метрик бэкенда и мобилки. Если в мобилке обнаруживали проблемы, которых нет на бэкенде, то мы сможем сделать предположение, с каким участком тракта доставки запросов до нашего бэкенда что-то пошло не так. Выводы Есть несколько универсальных правил, соблюдая которые, можно сделать highload продукт более надёжным, работоспособным и производительным.
- Latency зависит от многих факторов. Чтобы взять его под контроль и понимать, что происходит, в каждом отдельно взятом случае нужно рассмотреть ситуацию «под микроскопом». Зачастую ответы не найти ни на форумах, ни в документации. Используйте метод проб и ошибок, чтобы докопаться до истины. Тот опыт, который вы получаете, строя Highload проекты, зачастую является уникальным.
- Часто не стоит решать проблему «в лоб», иногда лучше расслабиться, подстроиться и попытаться адаптироваться к условиям её появления.
- Если вы что-то делаете вручную более одного раза, это означает, что процесс пора автоматизировать.
- О любых проблемах должны узнавать первыми не пользователи, а разработчики.
-Источник
|