Как раскатывать опасный рефакторинг на прод с миллионом пользователей?
Фильм «Аэроплан», 1980 г.
Примерно так я себя чувствовал, когда выливал очередной рефакторинг на прод. Даже если весь код покрыть метриками и логами, протестировать функционал на всех окружениях — это не спасет на 100% от факапов после деплоя.
Первый факап
Как-то мы рефакторили наш процессинг интеграций с Google Sheets. Для пользователей это очень ценный функционал, т.к. они используют много инструментов одновременно, которые нужно связывать между собой — отправлять контакты в таблицу, выгружать ответы на вопросы, экспортировать пользователей и т.д.
Код интеграций не рефакторился с первой версии и его становилось все сложнее поддерживать. Это начало аффектить наших пользователей — выявлялись старые баги, которые мы боялись править из-за сложности кода. Пришло время что-то сделать с этим. Никаких логических изменений не предполагалось — просто написать тесты, подвигать классы и причесать имена. Конечно, мы протестировали функционал на dev окружении и пошли деплоить.
Через 20 минут пользователи написали, что интеграция не работает. Отвалился функционал отправки данных в Google Sheet — оказалось, что для дебага мы отправляем данные в разных форматах для прода и локального окружения. При рефакторинге мы задели формат для прода.
Интеграцию мы починили, но все же осадочек от веселого пятничного вечера (а вы думали!) остался. На ретроспективе (встрече команды по завершению спринта) мы стали думать, как предотвратить такие ситуации в будущем — нужно улучшить практики ручного тестирования, авто-тестирования, работу с метриками и алярмами, а кроме этого нам пришла идея использовать фича-флаги для тестирования рефакторинга на проде, собственно, об этом и пойдёт речь.
Реализация
Схема простая: если у пользователя включен флаг — идем в код с новой версией, если нет — в код со старой версией:
if ($user->hasFeature(UserFeatures::FEATURE_1)) {
// new version
} else {
// old version
}
С помощью такого подхода у нас появилась возможность тестировать рефакторинг на проде сначала на себе и дальше выливать на пользователей.
Почти с самого старта проекта у нас была примитивная реализация фича флагов. В БД для двух базовых сущностей, пользователь и аккаунт, были добавлены поля features, которые представляли из себя битовую маску. В коде мы прописывали новые константы для фич, которые потом добавляли в маску, если пользователю становится доступна определенная фича.
public const ALLOW_FEATURE_1 = 0b0000001;
public const ALLOW_FEATURE_2 = 0b0000010;
public const ALLOW_FEATURE_3 = 0b0000100;
Использование в коде выглядело так:
If ($user->hasFeature(UserFeatures::ALLOW_FEATURE_1)) {
// feature 1 logic
}
При рефакторинге мы обычно сначала открываем флаг команде для тестирования, потом нескольким пользователям, которые активно использую фичу, и наконец открываем на всех, но иногда появляются и более сложные схемы, о них ниже.
Рефакторинг перегруженного места
Одна из наших систем принимает вебхуки от Facebook и обрабатывает их через очередь. Процессинг очереди перестал справляться и пользователи начали получать определенные сообщения с задержкой, что может критично повлиять на опыт подписчиков бота. Мы начали рефакторить это место переводя процессинг на более сложную схему очередей. Место критичное — опасно выливать новую логику на все сервера, поэтому мы закрыли новую логику под флаг и смогли протестировать ее на проде. Но что будет, когда мы откроем этот флаг на всех? Как поведет себя наша инфраструктура? В этот раз мы деплоили открытие флага по серверам и следили за метриками.
Все критичные процессинги данных у нас разделены по кластерам. У каждого кластера есть id. Мы решили упростить тестирование таких сложных рефакторингов с помощью открытия фича флага только на определенных серверах, проверка в коде выглядит так:
If ($user->hasFeature(UserFeatures::CGT_REFACTORING) ||
\in_array($cluster, Configurator::get('cgt_refactoring_cluster_ids'))) {
// new version
} else {
// old version
}
Сначала мы выливали рефакторинг и открывали флаги команде. Дальше нашли несколько пользователей, которые активно использовали фичу cgt, открыли им флаги и посмотрели, все ли у них работает. И, наконец, начали открывать флаги по серверам и следить за метриками.
Изменить значение флага cgt_refactoring_cluster_ids можно через админку. Изначально значение cgt_refactoring_cluster_ids присваиваем пустому массиву, дальше добавляем по одному кластеру — [1], смотрим метрики некоторое время и добавляем еще кластер — [1, 2], пока не протестируем всю систему полностью.
Реализация Configurator
Немного расскажу, что такое Configurator и как он реализован. Он был написан для возможности менять логику без деплоя, например как в кейсе выше, когда нам нужно резко откатить логику. Так же мы применяем его для динамических конфигов, например, когда нужно протестировать разное время кеширования, можно вынести это для быстрого тестирования. Для разработчика это выглядит как список полей со значениями в админке, которые можно менять. Храним все это в БД, кешируем в Redis и в статике для наших воркеров.
Рефакторинг устаревших мест
В следующем квартале мы рефакторили логику регистрации, готовя ее к переходу на возможность регистрации через несколько сервисов. В наших условиях логику регистрации невозможно кластеризовать, чтобы определенный пользователь был привязан к определенной логике, и мы не придумали ничего лучше, как тестировать логику, раскатывая на процент от всех запросов на регистрацию. Это легко сделать по похожей схеме с флагами:
If (Configurator::get('auth_refactoring_percentage') > \random_int(0, 99)) {
// new version
} else {
// old version
}
Соответственно значение auth_refactoring_percentage в админке мы устанавливаем от 0 до 100. Конечно мы «обмазали» всю логику авторизации метриками, чтобы понимать, что мы в итоге не снизили конверсию.
Метрики
Чтобы рассказать, как мы следим за метриками в процессе открытия флагов, рассмотрим еще один кейс более детально. ManyChat принимает от Facebook вебхуки при отправке сообщения подписчиком в Facebook Messenger. Каждое сообщение мы должны обработать в соответствии с бизнес-логикой. Для фичи cgt нам нужно определить, стартовал ли подписчик общение через комментарий на Facebook, чтобы отправить ему релевантное сообщение в ответ. В коде это выглядит как определение контекста текущего подписчика, если мы можем определить widgetId, то по нему мы определяем ответное сообщение.
Общие настройки —> Приватный ответ на комментарий —> Ответ на ответ пользователя —> После настройки такого виджета нужно создать пост в Facebook:
Теперь пользователи могут пройти следующий путь:
Оставить комментарий —> Получить приватный ответ —> Написать ответ
При получение вебхука о сообщении «ТАК ТОЧНО, КАПИТАН!» нам нужно понять, что пользователь отвечал на приватный ответ на комментарий. Сложности добавляет то, что вебхук на сообщение «Я не слышу!» приходил без id подписчика и мы не могли напрямую понять, кто написал это сообщение — приходилось придумывать способы, как это делать без id.
Раньше мы определяли контекст 3 способами, выглядело это примерно так:
function getWidgetIdContext(User $user, WatchService $watcher): int?
{
// Виджет уже лежит в модели пользователя
if (null !== $user->сgt_widget_id_context) {
$watcher->logTick('cgt_match_processor_matched_via_context');
return $user->сgt_widget_id_context;
}
// Пытаемся определить виджет по имени пользователя
if (null !== $user->name) {
$widgetId = $this->cgtMatchByThread($user);
if (null !== $widgetId) {
$watcher->logTick('cgt_match_processor_matched_via_thread');
return $widgetId;
}
$widgetId = $this->cgtMatchByConversation($user);
if (null !== $widgetId) {
$watcher->logTick('cgt_match_processor_matched_via_conversation');
return $widgetId;
}
}
return null;
}
Сервис watcher отправляет аналитику в момент матчинга, соответственно у нас были метрики по всем трем кейсам:
Количество нахождений контекста по разным методам связывания во времени
Дальше мы нашли еще один способ матчинга, который должен был заменить все старые варианты. Чтобы протестировать это, мы завели еще одну метрику:
function getWidgetIdContext(User $user, WatchService $watcher): int?
{
// Новый способ нахождения контекста
$widgetId = $this->cgtMatchByEcho($user);
if (null !== $widgetId) {
$watcher->logTick('cgt_match_processor_matched_via_echo_message');
}
// Дальше код без изменений
// ...
}
На этом этапе мы хотим удостовериться, что количество новых срабатываний равно сумме старых срабатываний, поэтому просто пишем метрику без возвращения $widgetId:
Количество нахождений контекста по новому методу полностью перекрывает сумму связывания по старым методам
Но это не гарантирует нам правильную логику матчинга на всех кейсах. Следующий этап — постепенное тестирование через открытие флагов:
function getWidgetIdContext(User $user, WatchService $watcher): int?
{
// Новый способ нахождения контекста
$widgetId = $this->cgtMatchByEcho($user);
if (null !== $widgetId) {
$watcher->logTick('cgt_match_processor_matched_by_echo_message');
// Возвращаем результат нового способа, если флаг открыт
If ($this->allowMatchingByEcho($user)) {
return $widgetId;
}
}
// ...
}
function allowMatchingByEcho(User $user): bool
{
// Флаг на уровне пользователей
If ($user->hasFeature(UserFeatures::ALLOW_CGT_MATCHING_BY_ECHO)) {
return true;
}
// Флаг на уровне инфраструктуры
If (\in_array($this->clusterId, Configurator::get('cgt_matching_by_echo_cluster_ids'))) {
return true;
}
return false;
}
Дальше начался процесс тестирования: сначала мы протестировали новый функционал своими силами на всех окружениях и на рандомных пользователях, которые часто используют матчинг с помощью открытия флага UserFeatures: ALLOW_CGT_MATCHING_BY_ECHO. На этом этапе мы отловили несколько кейсов, когда матчинг отработал неправильно, и починили их. Дальше начали раскатывать по серверам: в среднем мы раскатывали по одному серверу за 1 день в течение недели. Перед тестированием мы предупреждаем саппорт, чтобы они внимательнее смотрели на тикеты, связанные с функционалом, и писали нам о любых странностях. Благодаря саппорту и пользователям было пофикшено несколько корнеркейсов. И наконец последний шаг — открытие на всех без условий:
function getWidgetIdContext(User $user, WatchService $watcher): int?
{
$widgetId = $this->cgtMatchByEcho($user);
if (null !== $widgetId) {
$watcher->logTick('cgt_match_processor_matched_by_echo_message');
return $widgetId;
}
return null;
}
Новая реализация фича флагов
Реализация фича флагов, описанная в начале статьи, прослужила нам около 3 лет, но с ростом команд она стала неудобной — нам приходилось деплоиться при создании каждого флага и не забывать чистить значение флагов (мы переиспользовали значения констант для разных фич). Недавно компонент был переписан и теперь мы можем гибко управлять флагами через админку. Флаги были отвязаны от битовой маски и хранятся в отдельной таблице — это позволяет легко создавать новые флаги. Также у каждой записи появилось описание и владелец, управление флагами стало более прозрачным.
Минусы таких подходов
У такого подхода есть большой минус — появляется две версии кода и их нужно одновременно поддерживать. При тестировании приходится учитывать, что есть две ветки логики, и нужно их все проверить, а это очень больно. При разработке бывали ситуации, когда мы вносили фикс в одну логику, но забывали про другую, и в какой-то момент это стреляло. Поэтому мы применяем такой подход только в критичных местах и стараемся максимально быстро избавляться от старой версии кода. Весь остальной рефакторинг стараемся делаем небольшими итерациями.
Итог
Текущий процесс выглядит так — сначала мы закрываем логику под условие флагов, дальше деплоимся и начинаем постепенно открывать флаги. При раскрытии флагов пристально следим за ошибками и метриками, как только что-то идет не так — сразу откатываем флаг и разбираемся с проблемой. Плюс в том, что открытие/закрытие флага происходит очень быстро — это просто изменение значения в админке. Через какое-то время вырезаем старую версию кода, это должно быть минимальное время, чтобы не допустить изменения в обеих версиях кода. Важно предупредить коллег о таком рефакторинге. Мы делаем ревью через github и используем code owners, во время таких рефакторингов, чтобы изменения не попали в код без ведома автора рефакторинга.
Совсем недавно я раскатывал новую версию Facebook Graph API. В секунду мы делаем больше 3000 запросов к API и любая ошибка стоит нам дорого. Поэтому я выкатывал изменение под флагом с минимальным импактом — получилось отловить один неприятный баг, протестировать новую версию и в итоге перейти на нее полностью без переживаний.