Приветствую всех читателей, меня зовут Вадим, я — бэкенд-разработчик в компании Домклик. Я работаю в команде, которая разрабатывает CRM-систему для подготовки и осуществления ипотечных сделок. В этой статье я хотел бы поделиться своим интересным опытом мажорного повышения зависимостей в проекте, который свыше пяти лет находится в проде под ежедневной нагрузкой более 2000 RPS.

Предыстория

Итак, все сервисы нашей команды на бэке написаны на Python, большинство из них — с использованием фреймворка Sanic. До момента, приведшего впоследствии к этой статье, никаких серьёзных проблем с этим фреймворком мы не испытывали. Однако одним прекрасным декабрьским днём, когда сезонность оформления ипотечных сделок традиционно приводит к повышенной нагрузке на все сервисы Домклика, мы обнаружили проблему на центральном бэкенд-сервисе нашей системы. Суть этой проблемы заключалась в том, что в случайный момент времени воркеры приложения бесследно умирали, а у реализации мультипроцессинга в используемой нами на тот момент версии Sanic есть такая хитрая (нет) особенность, что состояние воркеров после запуска никак не отслеживается, и заданное количество никак не поддерживается в случае их смерти. Как результат, спустя некоторое (от нескольких минут до нескольких часов) время после развёртывания наши поды лишались всех воркеров, кроме одного единственного (от которого Sanic первоначально и форкает новые процессы), что драматически снижало перевариваемую нашим сервисом нагрузку: поды начинали тротлить по CPU, event loop забивался корутинами, приложение обжиралось коннектами к базе данных, запросы обрабатывались гораздо медленнее, и в конце концов мы начинали отдавать 500-ки.

В тот момент наш Саник выглядел как-то так
В тот момент наш Саник выглядел как-то так

Пользователи жаловались на замедление работы системы, разработчики хаотично рестартили поды или целиком переразвёртывали сервис, бизнес-специалисты ужасались называемым разработчиками ETA по устранению проблем. В этот момент наш обыкновенный продуктовый спринт резко превратился в технический: все разработчики экстренно переключились на стабилизацию сервиса. Мер таковых было достаточно много (возможно, когда-нибудь я расскажу о них подробнее в отдельной статье): кто-то занимался выделением части функциональности в отдельно поднятое второе развёртывание нашего сервиса, кто-то пытался оптимизировать существующие ручки. Мне же выпала участь обновлять версию нашего основного фреймворка, и сейчас я расскажу о главных подводных камнях, удариться об которые мне довелось во время моего экстремального плавания.

А, собственно, зачем?

Вопрос вполне резонный, как обновление фреймворка поможет в сложившейся ситуации? Мы быстро смекнули, что проблема с производительностью сервиса связана с низким количеством живых воркеров. Понять с ходу причину их смерти было затруднительно, поэтому одним из треков решения стал автоматический запуск новых воркеров вместо умерших старых. Для этого нам понадобился какой-то внешний инструмент, позволяющий оркестрировать процессы нашего приложения, и выбор быстро пал на связку gunicorn + uvicorn, хорошо зарекомендовавшую себя в приложениях, написанных с использованием FastAPI. Одна проблема: поддержку ASGI-интерфейса в бета-режиме в Sanic завезли в более поздней версии 19.6.2, чем используемая нами на тот момент версия. Так мы пришли к выводу, что стоит обновить версию, причём чем выше, тем лучше. Бонусом, мы получили уникальную возможность поднять версии некоторых остальных зависимостей в нашем проекте, в том числе в надежде, что это поможет повысить производительность. Если кому интересно — под спойлером список наиболее важных изменений.

Список обновленных библиотек
  • aiofiles

  • aiohttp

  • aiopg

  • alembic

  • async-timeout

  • gunicorn

  • hiredis

  • httpx

  • pytest

  • sanic

  • Sanic-Cors

  • sanic-openapi

  • uvicorn

  • uvloop

  • pytest-sanic

NB: 27 декабря 2022 года, то есть спустя неделю, как мы столкнулись с нашей проблемой, вышла версия Sanic 22.12, добавляющая Worker Manager, реализующий так необходимую нам функциональность. Но это случилось после того, как наши мероприятия начались, поэтому мы пошли собственным путём, переезжая на gunicorn.

Осознание масштабов и решение проблем

С моей (не сильно опытной) точки зрения, повышение зависимости было достаточно простым: поднять версию библиотеки в requirements-файле и переустановить необходимые зависимости. Конечно же, я ошибался!

Достаём (свои же) палки из колес

Первой проблемой, с которой я столкнулся, была неразрешимость зависимостей. Мы пользовались специфической backport-версией внутренней библиотеки компании, которая по историческим причинам зависела от определённой версии Sanic и некоторых сопутствующих библиотек, что мешало нам обновиться. Было два пути: повышать зависимости в новой версии этой библиотеки, либо съезжать с неё вовсе. Я, получив разрешение техлида, избрал второй путь, благо из этой библиотеки мы использовали в основном только Enum’ы и некие общие функции-тулзы. Заведя эти вещи непосредственно в наш проект, зависимость благополучно выпилили, и я достиг успеха перешёл к устранению следующих проблем.

Метод научного п̶о̶д̶г̶о̶н̶а подбора версий

Краеугольным камнем и одновременно гарантией базовой работоспособности приложения после всех манипуляций стали тесты. В проекте у нас их более 6000, это обеспечивает более 65 % покрытия (но мы стремимся его улучшить!), да и все прекрасно понимают, что без тестов в современной разработке совсем никуда. Наш проект с самого своего рождения использовал для тестов плагин pytest-sanic. В какой-то момент он перестал поддерживаться, и, к сожалению, на новых версиях Sanic его использование в наших проектах стало невозможным. Перепробовав несколько доступных версий и не достигнув успеха, я пошёл штудировать списки изменений Sanic’а, и наконец обнаружил версию, на которую мы смогли перейти без радикального изменения тестов. Но несмотря на трудности, созданные тестами, их обширное наличие помогло мне отловить все те проблемы, с которыми я столкнулся в дальнейшем, так что каким бы тяжким не был этот путь, я всё-таки бесконечно благодарен каждому из коллег, написавшему даже самый маленький и простенький тест.

Работоспособность нашего приложения сразу после обновления зависимостей
Работоспособность нашего приложения сразу после обновления зависимостей

Breaking changes, или Как заставить разработчика изменить 90 % файлов проекта

Одним из ключевых (с точки зрения взаимодействия с кодом) моментов повышения версии стало ломающее обратную совместимость изменение в классе Request: теперь у него появились __slots__ и исчезли методы __getitem__ и __setitem__, а для хранения дополнительного пользовательского контекста был создан новый атрибут ctx. Таким образом, во всех ручках, где используются какие-либо данные из контекста, все строки вида

async def handler(request: Request):
    auth_session = request['auth_session']
	...

пришлось заменить на:

async def handler(request: Request):
    auth_session = request.ctx.auth_session
    ...

Схожим образом я обновил и все middleware, в которых эти самые экземпляры и обогащались контекстом по примеру auth_session.

На мой взгляд, новый вариант выглядит чуточку лаконичнее и логичнее, но в момент переноса было немного больно найти все места, где используются объекты Request, потому что, во-первых, далеко не в каждой функции коллеги-разработчики давали переменной одноимённое название, а во-вторых, далеко не всё, что названо в коде request, является тем самым нужным нам объектом. Ещё в объекте запроса поменялись такие ранее существовавшие атрибуты, как, например, server_path — шаблонизированная часть пути, по которому пришел обрабатываемый запрос — в новой версии он стал называться uri_template. Этот атрибут мы используем в метриках, группируя по нему запросы для подсчёта их количества по каждой из наших ручек.

На этом функциональность “Find and replace” в IDE не оставили в покое: в новой версии pytest-sanic асинхронный тестовый HTTP-клиент стал базироваться на HTTPX вместо AIOHTTP, что немного изменило интерфейс выполнения запроса и получения его результата в тестах. Поэтому в финальном pull request было много изменений следующего вида:

- response_json = await result.json()
- assert result.status == 200
+ response_json = result.json()
+ assert result.status_code == 200
- app_client.session.cookie_jar.update_cookies({
+ app_client.session.cookies.update({
        SESSION_COOKIE: session_cookie,
    })

Ключевым во всей этой затее было сначала «воскресить» максимум тестов, а затем, раз за разом прогоняя их, исправить уже проблемы изменённых интерфейсов классов Sanic. Одному PyСharm’у известно, сколько таких циклов я повторил, но рано или поздно все улизнувшие от моего взора места были обнаружены и исправлены.

Возможно, в этот раз Ваас оказался не прав, ведь я своего все-таки достиг ????
Возможно, в этот раз Ваас оказался не прав, ведь я своего все-таки достиг ????

Финишная прямая

Когда все тесты позеленели, зависимости обновились и проект наконец-то заработал, осталось совсем немного: слегка переписать конфигурацию запуска приложения (вместо Python теперь точкой входа у нас является gunicorn) и всего лишь протестировать всё это как можно тщательнее уже не модульными тестами, а на E2E-тестировании. Такой процесс мы называем happy path: это когда мы продвигаем нашу основную сущность — сделку — по всему её жизненному циклу по-честному, как это делают сотрудники во время своей работы. Такой процесс мы обычно выполняем перед каждым релизом в пред-продовом окружении, но на этот раз выполнили и на тестовом стенде. Причём несколько раз с разными вариациями параметров бизнес-процесса, чтобы попасть в как можно более отдалённые кусочки кода.

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

На десерт

Спустя почти месяц мы всё-таки нашли ещё один побочный эффект, который я хотел бы упомянуть. Дело в том, что в качестве path-параметра в одной из ручек наш сервис принимает от фронтенда ID сущности в стороннем сервисе. Для нас этот ID является произвольной строкой, которая может содержать любые символы. В какой-то момент коллеги-разработчики этого стороннего сервиса пришли к нам с проблемой, что мы ходим в их сервис со странными ID, не похожими на те, какие были ранее. Мы устроили совместный разбор и выяснили, что стали приходить со строкой, в которой некоторые символы были закодированы percent-encoding’ом. Расследование показало, что причина кроется в обновлении Sanic, а точнее даже в переходе на ASGI-режим: в нём path-параметры стали принудительно URL-кодироваться, чего сначала мы не заметили. Написав дополнительную middleware для раскодирования параметров, чтобы по примеру этой проблемы параллельно поправить ещё и потенциальные другие схожие, мы окончательно поставили точку в нашей авантюре обновления версии Sanic вместе с переездом на gunicorn-uvicorn.

Заключение и выводы

Тут мне хотелось бы зафиксировать свои мысли, появившиеся во время выполнения работ и написания этой статьи:

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

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

  • Выполняя масштабные и запутанные задачи, всегда ведите какую-то верхнеуровневую текстовую историю изменений, помимо самих изменений в коде. Это поможет вам вспомнить о проблемах, их причинах и способах решения, когда спустя полгода вы решите написать об этом статью на Хабр. Я этого не делал, о чём успел пожалеть, но в будущем буду вести в подобных случаях дневник. Кстати, здесь меня сильно выручил мой коллега из смежной команды разработки — Илья Тлеукенов. Он повторил мой путь в своём сервисе, сильно похожем по стеку на наш, причём сделал это на более высоком уровне сложности, затронув и повышение версии Python, и более обширный набор зависимостей. Дневник Ильи позволил мне вспомнить мои собственные шаги.

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

  • Запасайтесь терпением, упорно двигайтесь к цели, но не забывайте отдыхать.

P.S. Сервис-то мы всё равно стабилизировали с помощью выноса части логики в отдельное развёртывание, а я этим уже по инерции дальше занимался. Зато теперь воркеры рестартятся сами, правда, они и не умирают больше…

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

Комментарии (3)


  1. gusarov_va
    19.09.2023 15:17

    ВАУ ОЧЕНЬ КРУТАЯ СТАТЬЯ !!!!!


    1. ClearThree Автор
      19.09.2023 15:17
      +1

      Спасибо!


  1. slonopotamus
    19.09.2023 15:17
    +1

    в момент переноса было немного больно найти все места, где используются объекты Request

    Типизация не нужна, давайте писать на динамических языках, говорили они...