Как мы планировали повысить версию PHP за месяц, а потратили на это год

в 7:01, , рубрики: perf, php, phpunit, rector, strace, symfony framework, монолит, рефакторинг
Как мы планировали повысить версию PHP за месяц, а потратили на это год - 1

Как и множество больших сервисов, Яндекс Еда основана на микросервисной архитектуре. Все сервисы написаны на C++ с использованием фреймворка userver. Также мы активно развиваем внутренний фреймворк для сервисов на Golang. При этом стараемся использовать последние версии и стандарты языков. В общей сложности у нас чуть больше 200 микросервисов.

Но есть один сервис, который совсем не микро. Это легаси-монолит, доставшийся Яндекс Еде после объединения со стартапом Foodfox. Долгое время его продолжали развивать и поддерживать, но около трёх лет назад было принято решение максимально сконцентрироваться на его распиле. И как-то так получилось, что за сам монолит никто не отвечал. Все команды начали выносить свои зоны ответственности в собственные микросервисы, а этот огромный монстр остался бесхозным. 

Монолит написан на PHP 7.2 разработчиками разного уровня и в разное время. Мы подумали, что так больше нельзя, и решили навести порядок. В ходе разбирательств выяснилось, что версия языка, на котором всё написано, устарела и уже не поддерживается, что ведёт к рискам безопасности. Делать нечего — мы приняли решение обновиться до 8-й версии.

В этой статье я расскажу, чего стоило нам проапгрейдить монолит, сколько тестов мы сломали и как в этом проекте поучаствовали почти все PHP-разработчики Яндекс Еды. Это интересный и уникальный опыт, которым я хотел бы с вами поделиться. В конце дам несколько советов тем, кто тоже захочет ввязаться в подобную авантюру.

С чего мы начали

Вот что представлял собой наш монолит к моменту, когда мы решили его обновить:

  • Язык: PHP 7 

  • Фреймворк: Symfony 3.4

  • Количество юнит-тестов: ~12 000

  • Количество приёмочных тестов: ~600 

  • Покрытие тестами: ~30%

  • Количество строк кода: ~500 000

  • Количество методов: ~100 000

  • Средний RPS: ~2700

  • Вмёрженных в мастер задач в неделю: 16–75

  • Среднее количество разработчиков, вносящих изменение в сервис: 20

  • Библиотек, подключённых в composer.json: ~120

  • Количество микросервисов, зависящих от монолита: ~179

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

Воодушевлённые вызовом, мы взялись за работу и составили план действий:

  1. Обновляем все бандлы и библиотеки.

  2. Собираем монолит.

  3. Запускаем тесты.

  4. Собираем все ошибки из всех упавших тестов.

  5. Понимаем объём работ, делаем переоценку сроков.

  6. Чиним все тесты.

  7. Периодически подливаем себе актуальный код.

  8. Фризим всю разработку в монолит.

  9. Тестируем в тестинге.

  10. Катим в прод.  

  11. Отмечаем шампанским!

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

По моим прикидкам, на такой анализ ушло бы 2–3 месяца: ведь у нас много кода и сторонних библиотек, а мы собираемся обновляться сразу через несколько версий. Тратить три месяца только на то, чтобы прикинуть сроки, не хотелось.

Второй вариант был проще: обновить что-то небольшое и аппроксимировать это на монолит. Решили начать с бандлов: у нас в монолите используются 32, а для эксперимента мы взяли пять. В среднем, чтобы перевести один бандл на PHP 8 и Symfony 6, нужен один день. То есть весь монолит, по нашим расчётам, можно было перевести за пару месяцев. 

Мы обрадовались и принялись за работу. Спойлер: метрика оказалась не очень точной.

Как мы повышали версию PHP

Обновляем все бандлы и библиотеки

После обновления и подключения к монолиту всех бандлов мы начали обновлять библиотеки (напомню, их было около 120) и поняли, что часть из них больше не поддерживается и нет их версии для PHP 8. Пришлось делать форк от них и дописывать своими силами.

Да и в целом подобрать версии библиотек, чтобы они не конфликтовали друг с другом, было той ещё задачей. В итоге мы пометили почти все библиотеки звёздочкой и позволили композеру самому решить эту проблему. Самые важные библиотеки (например, doctrine/dbal) мы зафиксировали на минимально допустимой версии, чтобы после работы автоматики требовалось меньше переделывать.

Кстати, а почему Symfony 6? 

Всё просто: в ходе работы выяснилось, что текущая версия фреймворка 3.4 не может работать на PHP 8. Актуальная версия на тот момент — Symfony 6. 

Мы понимали, что легче пойти итеративным путём и обновиться до минимальной версии фреймворка, поддерживающей PHP 8. Но посмотрев на разницу между 5-й и 6-й версиями, мы пришли к выводу, что она не очень большая и не стоит того, чтобы дважды останавливать разработку.

В нашем случае Symfony закрывает базовые вещи вроде роутинга, ORM, структуры каталогов, организации ивентов, листенеров и другой удобной магии. Казалось, что весь этот «обвес» можно обновить, а внутренности контроллеров и классов оставить как есть. И это бы так и было правдой, если бы код был написан идеально. Но, увы, жизнь немного суровее.

Собираем монолит

Когда мы обновили библиотеки и бандлы, настала пора запускать монолит. Выполняем команду, чтобы собрать кэш…

./bin/console cache:clear

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

Почти все ошибки на этом этапе возникли из-за более строгих правил новой версии фреймворка. Например, если в DI класс описывает интерфейс и у него всего один наследник, в старой Symfony брался именно он. Теперь это считается ошибкой, потому что нужно явно описать интерфейс в yaml-файле с определением, какой класс его имплементирует. А ещё какие-то классы и интерфейсы не были описаны, поэтому в фабриках нужно было указать класс. И некоторые библиотеки вовсе изменили интерфейс конфигурации.

На решение этой проблемы я потратил все майские праздники. Часть ошибок удалось формализовать и поправить с помощью замены через регулярные выражения. 

Например:

  • в роутинге убрали type: rest,

  • заменили @serializer на @jms_serializer,

  • kernel.root_dir на kernel.project_dir,

  • type: annotation на type: attribute,

  • @templating.engine.twig на Twig_Environment.

Для части ошибок пришлось написать автоматические скрипты. Например, для решения проблемы с интерфейсами я написал скрипт, который самостоятельно запускал сборку, читал ошибку, и если она попадала под паттерн, то добавлял в yaml нужное описание нужного интерфейса и делал его алиасом для правильного класса, а затем перезапускал сборку заново. В итоге получился файл на 1143 строчки для описания алиасов и на 94 строчки для описания интерфейсов.

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

Запускаем тесты

Когда монолит начал собираться, мы запустили тесты. В первый запуск упало около 2500 тестов из 12 000. Мы подумали, что это не так плохо — ведь упало всего около 20%.

Но наш изначальный план запустить все тесты и сразу увидеть весь список ошибок, провалился: вылезли массовые ошибки, которые скрывали другие ошибки. Например, было около 1000 ошибок из-за неправильной конфигурации классов. Мы внесли изменения, но общее количество ошибок не уменьшилось, потому что за этой тысячей пряталась другая тысяча.

Для упрощения анализа я написал несколько скриптов, которые группируют результаты ошибок запуска тестов и выводят в разных удобных форматах: с группировками по файлам тестов, тексту ошибки и паттерну вхождения в сообщение о проблеме.

Так мы начали понимать, где сидят массовые ошибки, а где нет. В первую очередь мы чинили массовые, чтобы быстрее находить скрытые ошибки, которые мы до этого не видели.

По графику видно, как быстро уменьшалось количество сломанных тестов.

График уменьшения ошибок в тестах с июня по август 2022 года

График уменьшения ошибок в тестах с июня по август 2022 года

За первую неделю мы исправили примерно 1100 ошибок. С каждым днём тесты становились всё сложнее. На решение некоторых уходило до недели времени одного разработчика.

Вот небольшой список типов ошибок, с которыми нам пришлось разбираться:

  • Полностью изменился API некоторых библиотек. Например, библиотека загрузки фикстур раньше загружала их в базу данных, а теперь просто формировала список объектов из фикстур. Запись результата работы пришлось реализовывать самим.

  • Библиотека Money изменила интерфейс. Раньше она принимала все типы данных, а теперь только string|int. Пришлось по всему проекту приводить данные к этому типу.

  • Изменились тесты и пропала конструкция at(), assertArraySubset(), setAccessible(). Пришлось разбираться в тестах и переписывать их на использование других конструкций.

  • Изменился порядок передачи параметров в диспатчер. Раньше первым параметром передавалось имя, потом объект события, а теперь — наоборот. Поправили инструментом Rector, о котором расскажу позже.

  • Некоторые классы, которые мы переопределяли, стали финальными.
    Здесь пришлось сильно помучиться, разобраться в каждом таком случае отдельно, выяснить, как теперь можно сделать то, что мы делали, но другими средствами.

  • Изменилось поведение библиотек по дефолту. Старого можно было достигнуть, проставив дополнительные опции по всему коду. Например, у нас была библиотека, которая форматировала данные, и она по умолчанию перестала тримить значения (то есть удалять лишние пробелы). Пришлось везде добавить true вторым параметром, чтобы починились тесты.

  • Поменялись многие интерфейсы. Это тоже было больно. Пришлось много разбираться и переписывать код на новые интерфейсы.

  • Некоторые методы перестали существовать. Аналогично предыдущим пунктам: разбирались и переделывали на использование новых методов.

  • В контроллерах стал недоступен контейнер. Из-за этого 99% контроллеров сломалось. Чтобы не переделывать все контроллеры, мы написали автоматику, которая пробросила контейнеры во все контроллеры. Решили, что потом от них избавимся.

  • Все стандартные сервисы типа twig или security стали приватными. Хотя методы, где из контейнера достаются эти сервисы, остались, и это тоже усложнило понимание ситуации. Здесь мы не придумали никакой магии, просто начали пробрасывать нужные нам объекты напрямую.

  • Изменился глобальный класс контейнера. Это повлияло на работу 20% тестов. Теперь нельзя переопределить сервис через метод ->set. Потратили много времени на обходное решение. Подменили контейнер для тестов на свою реализацию.

  • Аннотация Methods для Route перестала поддерживаться. Регулярками поправили по всему коду — перенесли Methods внутрь Route.

  • Изменились правила формирования названия роутов. Пришлось почти всем контроллерам в аннотации прописывать старый роутер руками (написанным скриптом), чтобы не менять функциональность, завязанную на названия роутов.

Это далеко не полный список того, что ломалось. В целом сами по себе эти ошибки несложные. Основная проблема — понять, что изменилось в каждом случае. А для многих проблем даже списки изменений не помогут увидеть, как их решить. Приходилось лезть в недра Symfony, выяснять, что ошибка происходит из-за того, что где-то не описан атрибут или неправильно выставлен флаг, прикидывать, с какой частью фреймворка или библиотеки это связано. И только после этого можно было искать нужное место в документации и разбираться, что и как теперь настраивать.

Очень много времени тратилось на починку монстрообразных тестов. Например, конструкции ->at() больше не было, значит, тест стоило переписать по-другому, а для этого нужно вникнуть в бизнес-логику того, что должно было происходить. Это отнимало уйму времени, а в итоге писалось всего пять строчек кода.

Чтобы выявить большое количество сломанных эндпойнтов, мы написали простейший тест, который брал все эндпойнты проекта, посылал в них фейковый запрос и ожидал любого ответа, кроме кода 500. В первом запуске успешно прошло только 200 таких тестов.

Не все инструменты одинаково полезны

Для решения части проблем мы использовали инструмент Rector. На Хабре о нём можно почитать эту статью. Для подобного рефакторинга он просто необходим, но если вы никогда им не пользовались, закладывайте время на его изучение.

До того момента я ничего не знал про Rector: просто решил посмотреть, какие есть инструменты, которые хоть как-то облегчат мне жизнь, и нашёл это чудо. Поставил, увидел режимы:

  • LevelSetList::UP_TO_PHP_81 — обновить всё (автоматически исправить старый код на новый) до нужной мне версии языка; 

  • SymfonySetList::SYMFONY_62 — обновить всё до нужной мне версии фреймворка.

«Чу́дно!» — подумал я и запустил Rector с этими настройками на весь проект, ожидая, что он всё сделает за меня. Но с первого раза он не переварил мой проект, поэтому пришлось написать скрипт, который будет запускать Rector для разных участков кода последовательно. В итоге я получил тысячи отредактированных файлов… и неработающий проект.

Посмотрев на изменения, я понял одну очень важную вещь: мне не нужны они все, мне нужны только те, которые позволят проекту запуститься. Я совершил ошибку, переработав «руками» Rector все аннотации на атрибуты. 

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

А может, ну его?

Нам часто приходилось уделять время поддержке существующих сервисов и библиотек, за которые мы отвечали, чтобы не блокировать продуктовую разработку. В итоге мы сильно отвлекались и могли по 2–3 недели не касаться проекта. Так мы прожили до сентября.

Наконец пришло время подлить актуальную ветку, так как разработка ушла далеко вперёд. После ребейза количество сломанных тестов увеличилось с 150 до 1800: из-за ошибок в решении конфликтов, из-за добавления новой функциональности, где использовались старые подходы, не поддерживаемые в новой версии, просто из-за введения нового кода, который содержал те ошибки, которые я описывал выше. 

За пару дней удалось вернуться к 200 упавшим тестам. В целом все ошибки были незначительные, поэтому получилось их быстро устранить.

И только вроде бы всё наладилось, как мы узнаём что к Яндексу присоединяется компания Delivery Club. В тот момент мы подумали, что, скорее всего, поддерживать две платформы будет нерентабельно, раз грядёт техническое объединение. А так как множество процессов всё ещё зависит от монолита, начнётся активное внесение изменений именно в него: поменяется код, поменяются тесты, добавится новая функциональность и новые библиотеки. И наша команда должна будет принять участие в этом мероприятии. В общем, загрузка будет максимальной на этот период — станет не до PHP 8.

Так и произошло. Всю осень мы были заняты интеграцией. 

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

Ближе к Новому году мы всё же решили вернуться к нашей задаче и уже не откладывать её решение. В это время у нас всегда фриз, который запрещает катить значимые фичи в прод, и на этот момент разработка затихает. Это время для задач техдолга. 

Вот мы и решили воспользоваться данным фризом. Так как большинство разработчиков должны быть более свободны в это время, мы собрали всех тимлидов, у которых есть ребята, пишущие на PHP, и попросили нам помочь.

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

  • Мы разобрались со всеми проблемами, которые мешали запустить проект «одной кнопкой».

  • Вмёржили актуальную версию кода к нам и решили все конфликты.

  • Собрали список проблем, сгруппированных по типу ошибки и отсортированных по количеству встречаемости в тестах, чтобы чинить от самых массовых к более частным.

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

  • Оформили задачи с чек-листами под каждую проблему.

  • Организовали ежедневный синк.

  • Создали два чатика в Телеграме: для статуса по проекту и для технических вопросов.

  • Назначили ответственного за помощь всем нуждающимся для старта.

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

В первый день к нам пришло примерно 10 человек — процесс пошёл. Несмотря на то что Новый год на носу и атмосфера из-за этого не очень рабочая, в среднем каждый день над проектом работали от 5 до 8 человек. За две недели нам удалось починить все ~250 тестов!

Ситуацию ещё осложнило то, что фризом решили воспользоваться не только мы, но и другие команды. Во время фриза, например, начали переносить монолит в собственный монорепозиторий Arcadia. Новая система, хотя и имеет похожий на git-интерфейс, но при этом реализует trunk-base флоу работы с ней. Это стало для нас ещё одним препятствием на пути, которое пришлось преодолевать, так как разработчики больше не могли коммитить в одну долгоживущую ветку. Тут тоже пришлось разбираться в особенностях системы и придумывать удобные способы коллективной работы над параллельными ветками.

И снова тестирование

Изначально мы возлагали на тесты большие надежды и ожидали, что если они все станут зелёными, то и проблем не будет. Но как только мы выложили код в тестинг, сразу поняли, что ничего не работает. 

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

Тестирование и исправление багов заняло у нас около двух недель.

Выкладка в прод

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

Было много разных проблем, но мне хочется выделить две:

  • в какой-то тайминги на эндпойнтах увеличились в 3 раза;

  • в какой-то момент (как нам тогда казалось, раз в 6–8 часов) поды начинали умирать друг за другом.

С первой проблемой разобрались относительно быстро. Кто-то из разработчиков решил проверить конфигурацию PHP. Оказывается, мы просто забыли сконфигурировать OPcache для PHP 8, скопировать конфиг из папки php7.2 в папку php8.1. Естественно, что в тестинге под малой нагрузкой мы этого не заметили.

А вот второе заставило понервничать. Несколько раз мы собирались в выходные в рандомное время из-за того, что на каких-то серверах CPU начинал загружать 100%. Причём это происходило резко и последовательно на всех серверах в том порядке, в котором они включались при релизе. В замешательство нас ввёл ещё и тот факт, что первые два инцидента случились через 6 часов после релиза. Естественно, срабатывал алерт, приходили ребята из DevOps-команды, перезагружали PHP-FPM, после чего всё восстанавливалось. Но постфактум мы ничего обнаружить не могли. Было ясно только то, что проблема связана с PHP-FPM.

Первое, что мы сделали, — запустили крон, который раз в 5 часов рестартил PHP-FPM везде, кроме двух подов, на которых мы рассчитывали дебажить причины в режиме реального времени. Но что-то пошло не так, и мы вновь собрались в субботу вечером по зову прилетевшего алерта. Наша теория про время не подтвердилась. Чтобы в моменте всё спасти, мы всё рестартанули одной командой и изменили время перезагрузки PHP-FPM на каждые 2 часа. 

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

Strace

PID=$(ps -Ao pid,args,pcpu --sort=pcpu | grep fpm | tail -1 | awk '{ print
$1; }'); timeout 120s strace -p $PID -ttt -T -s 512 -o
/root/strace-${PID}.log

Perf

PID=$(ps -Ao pid,args,pcpu --sort=pcpu | grep fpm | tail -1 | awk '{ print
$1; }'); 
  perf record -F 250 --call-graph dwarf -g -p $PID --proc-map-timeout=10000
-- sleep 120 
  && perf script > /root/out-${PID}.perf 
  && sed -i 's/^([-a-zA-Z0-9_]*):[0-9]*/1/g' /root/out-${PID}.perf

Потом анализировали результат снятых perf руками и через FlameGraph. Увидели, что идёт много обращений, которых быть не должно, к файлам. Когда мы воспользовались утилитой, позволяющей посмотреть статус OPcache, то увидели, что количество элементов кэша постоянно растёт и рост не прекращается: дело было явно в этом.

Оказывается, в момент перехода мы неправильно настроили кэширование: добавили кеширование доктрины в файлы. Естественно, OPcache загибался через какое-то время. В итоге мы изменили кэширование на хранение в массиве, и всё стало прекрасно.

Как мы планировали повысить версию PHP за месяц, а потратили на это год - 3

Итоги

  • Потратили времени: 1 год

  • Обновили версию PHP: 8.1

  • Обновили версию Symfony: 6.1

  • Изменили файлов: ~9000

  • Изменили строк кода: ~68 000 

  • Было задействовано:

    • 90% времени — 2 человека

    • момент активной фазы — от 5 до 20 человек

  • Починили тестов: от 2500 до 4000

  • Оставили костылей для техдолга: много

  • Полученный опыт: колоссальный

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

А я стал известным человеком в Яндекс Еде. Правда, — как человек, остановивший все бизнесовые релизы, зависящие от монолита, на месяц.

На прощание

Переварив всё случившееся (лично и на ретро), я бы дал такие советы всем, кто планирует провернуть подобное:

Проверьте покрытие тестами заранее. Это кажется очевидным, но лично я об этом не подумал, понадеявшись на огромное число в 12 000 тестов. А это всего лишь 30% покрытия! Я бы ввёл правило для всех, кто вносит изменения в монолит, писать как можно больше тестов, чтобы к моменту тестирования покрытие сильно увеличилось.

Минимизируйте рефакторинг. С этим, как мне кажется, мы справились хорошо. Мы изменили только то, что было необходимо, придумали решения, позволяющие нам оставить функциональность, которую выпилили в новых версиях. Да, теперь у нас есть техдолг, но это не так страшно.

Уделите время проверке библиотек, которые вы используете. Самое сложное, что было, — это починка сторонних библиотек. Если с кодом монолита можно было к кому-то прийти и выяснить, почему тут работает вот так и как можно переделать, то со сторонними так не получалось. Пришлось очень много разбираться в чужом коде, читать документацию, гуглить — и это занимало уйму времени. Чем меньше сторонних библиотек вы используете или чем меньше разница у вас между версиями, тем быстрее вы сможете закончить. 

Разработайте план релиза. Мы с самого начала примерно предполагали, как будем релизиться, чтобы сохранить возможность отката и внесения изменений в старую версию хотфиксом. Но детали мы не продумывали. Это привело к тому, что в самое жаркое время нам пришлось разбираться в деталях реализации релизного цикла и придумывать обходные пути для решения проблем. Времени для реализации подходящего инструмента было достаточно, но мы отложили это на потом.

Проверьте основные настройки проекта перед запуском. Мы не проверили OPcache и настройку кэшей и поплатились за это. 

Используйте инструменты автоматизации. Rector и регулярки в IDE очень помогли. Также помогли самописные скрипты, автоматизирующие поиск и исправление ошибок. Ещё очень помог Xdebug.

По возможности не растягивайте проект надолго. Если у вас большой проект, то стоит привлечь в него максимум людей для быстрого рефакторинга. Но сначала нужно подготовиться.

А вообще, прежде чем приступать к подобной задаче, несколько раз подумайте, нужно ли оно вам.

Автор: Олег

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js