Привет, меня зовут Максим, я бэкенд-разработчик в ManyChat. 

Эта статья — о нашем переходе на PHP8. Однажды мы решили немного поисследовать — посмотреть, чего нам будет стоить возможный переход на новую версию, и запланировать эти работы на следующий год, сразу на 8.1. Перспектива переезда тогда вызывала у нас чувства примерно как на КДПВ: от мыслей обо всех тестах и внешних зависимостях было немного неуютно.

Мультсериал «Рик и Морти», 4 сезон, 6 серия
Мультсериал «Рик и Морти», 4 сезон, 6 серия

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

Эта статья — о шагах, из которых складывался переезд, и проблемах, которые мы встретили в процессе. Надеюсь, статья будет полезна для тех, кому ещё только предстоить перейти на PHP8 — поможет подготовиться хотя бы морально. Для всех остальных (кто уже на PHP8 или не собирается) — давайте сверимся по ощущениям.

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

Технически это миллиарды внутренних событий, путешествующих по архитектуре из десятков внутренних сервисов, очередей, баз данных и вороха внешних API.

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

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

У нас было 13к тестов, тысячи очередей, полста их обработчиков десятки серверов, и внешних зависимостей, Composer, Xdebug и куча dev окружений. Единственное, что меня беспокоило это PHPUnit 10, я знал, что рано или поздно придется переходить на эту фигню.

Переходить на новую версию пока что страшно — ок, давайте пока не переходить, а просто исследуем. Запустим тесты; обновим Composer; посмотрим, что там с Xdebug; проверим внешние зависимости; потестируем на Dev-окружении.

Так мы и поступили.

Чиним тесты

Первым шагом мы запустили тесты на PHP8 — проверить, сколько успешно выполнится, а сколько попадает. Для этого пришлось обновить Codeception и PHPUnit до последних на тот момент версий, так как только они поддерживали PHP8.

Красненьким светились ~40% тестов. В большинстве случаев проблема была в самом тесте.

В версии PHPUnit 9 метод at() стал deprecated из-за его неоднозначного поведения; а в PHPUnit 10 был удалён совсем.

Было:

$mock->expects($this->at(0))
    ->method('post')
    ->with(
        '/me/messages',
        $params0
    )
    ->willReturn($responseStub0));

$mock->expects($this->at(1))
    ->method('post')
    ->with(
        '/me/messages',
        $params1
    )
    ->willReturn($responseStub1);

Стало:

$mock->expects($this->exactly(2))
    ->method('post')
    ->withConsecutive(
        [
            '/me/messages',
            $params0
        ],
        [
            '/me/messages',
            $params1
        ]
    )
    ->willReturnOnConsecutiveCalls(
        $responseStub0,
        $responseStub1
    );

Вот тут можно подробнее почитать обсуждения по теме.

Увы, такие тесты у нас присутствовали в большом количестве и переписывать их было весьма больно. Где-то на сотом тесте автор сдался и сделал хак — замьютил депрекейт:

/**
 * overloads \\PHPUnit\\Framework\\TestCase::at to suppress specific deprecation warning
 * @deprecated
 */
public static function at(int $index): InvokedAtIndexMatcher
{
    return new InvokedAtIndexMatcher($index);
}

Решение проблемы было делегировано будущему себе, переходящему на PHPUnit 10. 

Некоторые тесты падали из-за формата текста ошибок — мы были завязаны на один, а в PHP8 он стал немного другим. Мы планировали плавный переход с возможностью одновременной сборки кода на 7.4 и 8 версии, поэтому в тестах сделали так:

if (8 === PHP_MAJOR_VERSION) {

	$this->expectExceptionMessage('Undefined variable $undefinedValue');

} else {

	$this->expectExceptionMessage('Undefined variable: undefinedValue');

}

Функция $this->assertContains() в новом PHPUnit стала вести себя по-другому. Пришлось переделать её вызов:

static::assertContains(‘onboarding_state’, $actualResponse);

заменяется на

static::assertArrayHasKey(‘onboarding_state’, $actualResponse);

$this->assertContains($expectedData, $actualData['mainmenu']);

заменяется на

$this->assertArraySubset($expectedData, $actualData['mainmenu']);

Итого, чтобы разобраться с тестами, мы сделали следующее: 

  • обновились до последних версий Codeception и PHPUnit;

  • замьютили at();

  • стали обрабатывать два формата текста ошибок (для PHP7 и PHP8);

  • переделали вызов $this->assertContains() на более правильные методы.

Обновляем Composer

Раз обновляемся, давайте заодно перейдём на Composer версии 2. Помимо всего прочего, он быстрее. 

В целом обновление прошло незаметно, за исключением одной библиотеки — SDK для работы с одним SMS-провайдером. По неведомым причинам этот SDK требовал какой-то ненужный для его работы пакет, который в свою очередь требовал Composer версии 1. Мы решили сделать -ignore-platform-req=composer-plugin-api и не обращать на это внимания до лучших времен (когда SDK провайдера обновится).

Обновляем Xdebug

Xdebug 2 не поддерживает PHP8, поэтому на Dev-окружениях пришлось переезжать на Xdebug 3.

Изменений много, из заметного — новый Xdebug стал пошустрее.

Переход на версию 3 был достаточно простым. Самое сложное — всем разработчикам в настройках PhpStorm поменять конфиги подключения к дебагеру и использовать другие параметры в консоли.

Было:

php7 \
-dxdebug.remote_enable=1 \
-dxdebug.remote_mode=req \
-dxdebug.remote_port=NNNN  \
-dxdebug.remote_host=127.0.0.1 \
-dxdebug.start_with_request=1 \
-dxdebug.remote_autostart=1

Стало:

php8 \
-dxdebug.mode=debug \
-dxdebug.client_port=NNNN \
-dxdebug.client_host=127.0.0.1 \
-dxdebug.start_with_request=1

Проверяем библиотеки из vendor

В процессе устранения проблем в упавших тестах выяснилось, что некоторые внешние библиотеки, которые мы использовали, были не полностью готовы к PHP8. Например, Hoa — некоторые pull requests о совместимости висят до сих пор. 

К счастью, 99% нашего кода было уже переписано на Symfony Expression Language довольно давно (вот тут можно посмотреть доклад с подробностями).

Однако в тёмном углу оставались тесты, проверяющие, что Hoa === Symfony expression с точки зрения пользователя, и что одни и те же формулы считаются одинаково. Переход на PHP8 был отличным поводом переписать эти тесты и выбросить Hoa из зависимостей require-dev. 

Для некоторых библиотек проблема совместимости с PHP 8 была в каком-нибудь одном простом методе – в этих случаях мы переписывали нужную функциональность руками и выкидывали всю зависимость. В основном это касалось библиотек, которые редко мейнтейнятся или заброшены, так что переход на PHP8 стал отличным поводом избавиться от ненужного.

На удивление, для библиотек, которые активно используются – нам не пришлось сделать ни одного PR, достаточно было обновить версию библиотеки. Были библиотеки, которые в require указывали PHP7 и ещё не обновились, но нужная нам функциональность работала с PHP8. Для таких зависимостей достаточно было в Composer-сборках добавить ключ -ignore-platform-req=php.

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

  • Убедиться, что библиотека работает корректно с новой версией PHP; при использовании пакетного Composer-менеджера добавить флаг --ignore-platform-reqs.

  • Сделать свой PR (issue) в нужную библиотеку и ждать, пока вмёрджат; или сделать временный форк (нам не пришлось так делать).

  • В composer.json добавить post-install-cmd, который пропатчит нужный файл прямо в директории vendor. Для случаев, когда изменение совсем маленькое и не хочется возиться с форком. (одна старая не поддерживаемая мейнтейнером библиотека, которую обязательно заменим)

Делаем пробный выход в свет на Dev-окружениях

Composer собирается, тесты зелёные. 

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

Решаемся: новый Composer, обновлённые библиотеки, фиксы тестов – мёрджим в master. Там в основном фиксы тестов и мелкие фиксы кода из разряда $array['item'] ?? null вместо $array['item'] – поэтому мёрджить не страшно. К тому же тесты прогоняются одинаково успешно и PHP7.4 и PHP8 интерпретаторами.

Дальше мы перевели PHP-FPM нашего Develop-окружения на PHP8. 

Про наше develop окружение

Разработку фич мы ведём в ветках от develop — в окружениях разработчиков; по окончании разработки мёрджим фичи обратно в develop. Перед релизом develop мёрджится в master. При этом Develop-окружение смотрит на одноименную ветку. На Develop-окружении проводятся финальные ручные командные тесты и запускаются некоторые интеграционные тесты для фронтенда.

В нашем случае PHP-FPM работает только для взаимодействия с веб-приложением, основная подкапотная бизнес-логика крутится в PHP CLI. Поэтому следующим этапом было перевести CLI-команды на PHP8. Мы хотели сделать возможность плавной раскатки на продакшене — поэтому менеджер процессов доработали так, чтобы PHP8 можно было запускать только часть команд.

Мы погоняли PHP-код в таком "предпродакшене" ещё неделю, отловили по логам несколько ошибок в корнер-кейсах, не покрытых тестами, пофиксили их.

Для закрепления результата сделали так, чтобы бинарник по умолчанию был от PHP8; /usr/bin/php – симлинк на /usr/bin/php8. Чтобы убедиться, что нигде случайно PHP7.4 не остался.

Всё, мы были готовы ехать в продакшен.

Раз-раз и в продакшен

Мы подняли отдельный сервер PHP-FPM с PHP8. Прописали в Nginx перенаправление трафика только со своих адресов, чтобы если что-то сломается, пользователи не заметили бы проблем. 

В течении недели плавно поднимали nginx upstream weight, перенаправляли всё больше уже пользовательского трафика на новый сервер, отслеживали и чинили в коде редкие ошибки, чаще всего обращение к несуществующему элементу массива. Как перестали появляться ошибки, перевели на PHP8 все "фронтовые" сервера (те, где PHP-FPM, с которым взаимодействует веб приложение на ReactJS и мобильные iOS/Android приложения).

Дальше пришло время "бэкенд" серверов с их PHP CLI. На этих серверах у нас крутятся консольные команды, разгребающие Redis-очереди (подробнее об этом тут). 

В этот момент стало по-настоящему тревожно: основная нагрузка на продакшен у нас именно на бэкенд-серверах; цена ошибки там выше, чем для фронтового приложения.

Мультсериал «Рик и Морти», 3 сезон, 5 серия
Мультсериал «Рик и Морти», 3 сезон, 5 серия

Команды обработки очередей мы переключили на PHP8 в ручном режиме: останавливаешь процессинг очереди на PHP7, запускаешь на PHP8, ждёшь, переходишь к следующей очереди.

У нас был предусмотрен механизм, чтобы максимально быстро откатиться назад в случае чего, но он не понадобился. Воистину, “зонт возьмешь – дождя не будет”.

Мелкие ошибки прилетали ещё примерно месяц. Это были редкие корнер-кейсы, не покрытые тестами и не случающиеся в happy path, но присутствующие в редко используемой функциональности. Примерно 99% этих ошибок – E_WARNING на том, что такого-то элемента нет в массиве (в PHP7 это был незаметный E_NOTICE).

Что может пойти не так: JIT и производительность

Автор так сильно переживал за код и бизнес-логику (и хотел поскорее написать эту статью), что совершенно не подумал про JIT и производительность. Новый PHP8 по материалам статей/докладов, чуть быстрее, чем PHP7; тесты выполняются за то же время. Что может пойти не так?

Как оказалось, для работы JIT его нужно специально включить; наличия дефолтного opcache.jit=tracing для этого не достаточно, нужно сделать ненулевой opcache.jit_buffer_size.

Автор включил JIT прямо в продакшене на одном из фронтовых серверов. Это была фатальная ошибка. Где-то треть пользователей, работавших с нашим Flow Builder – стали получать рандомные 500 ошибки; к нам в Slack посыпались алармы. Хотя вся эта ситуация длилась всего пару минут, она оставила неизгладимый след на совести и репутации автора.

Дело было в том, что opcache.jit=tracing в нашем коде вызывал segfault. Несколько похожих тикетов с ошибками уже тогда можно было найти на bugs.php.net (к слову, в 8.1 это исправлено). Опытным путём мы выяснили, что opcache.jit=function в нашем коде не вызывает segfault но и не даёт прироста производительности.

Увы, от JIT нам тогда пришлось отказаться до лучших времен (до выхода PHP8.1).

Хотя вроде бы в 8.0.12 довольно много исправлений для segfault  (https://github.com/php/php-src/blob/PHP-8.0.12/NEWS) Так что может и попробуем JIT ещё раз при следующем апдейте.

Проблемы на этом не закончились. Прогон тестов показывал, что тесты на PHP8 и PHP7.4 исполняются за одинаковое время. А на синтетических тестах (вроде множества Мандельброта) с включенным JIT мы вообще наблюдали космический прирост. Однако в продакшене мы видели, что PHP8 стабильно на 10-15% больше потребляет CPU, чем PHP7.4.

Заглянули в flamegraph phpspy, но ничего там не нашли.

Тогда мы пошли сравнивать опкоды PHP7.4 и PHP8: 

/usr/bin/phpdbg7.4 -p* path-to-prod-file.php > opcodes7

/usr/bin/phpdbg8.0 -p* path-to-prod-file.php > opcodes8

diff -y opcodes7 opcodes8 

Обнаружили пару занятных фактов. 

Во-первых, в PHP8 есть оптимизация, которая $var === null превращает в такой же опкод, что и \is_null($var). 

У нас в команде даже случался на этот счёт локальный холивар – мол, в PHP7 is_null чуть-чуть быстрее и даже PHPStorm подсказывает такую замену при определенных настройках. В PHP8 это одно и то же с точки зрения опкодов. 

L18 IS_NOT_IDENTICAL ~0 null ~1 /vendor/composer/autoload_real.php - php7

L18 TYPE_CHECK<1020> ~0 /vendor/composer/autoload_real.php - php8

Для сравнения с true/false применили тот же подход:

L7 IS_IDENTICAL @0 false ~1 /vendor/myclabs/deep-copy/src/DeepCopy/deep_copy.php - php7

L7 TYPE_CHECK<4> @0 /vendor/myclabs/deep-copy/src/DeepCopy/deep_copy.php - php8

Во-вторых, в phpdbg8 в отличие от phpdbg7 нет вывода новых opcodes, объявленных в register_shutdown_function() после exit(0). Однако в обычном php (не dbg) код этой shutdown функции выполняется. Честно, мы до конца не разобрались, почему так – рассказывайте в комментариях, если знаете!

Никакая из этих двух находок не объясняла прирост нагрузки в 10-15%.

G1 (красный) – сервер с php7-cli
G1 (фиолетовый) – сервер с php8-cli
зелёный цвет – свободная память (метрика приходит с активного в данный момент сервера)
G1 (красный) – сервер с php7-cli G1 (фиолетовый) – сервер с php8-cli зелёный цвет – свободная память (метрика приходит с активного в данный момент сервера)

На помощь пришёл старый добрый /usr/bin/strace – показал какое-то невероятное количество ECHO в Redis-соединениях. Подробности мы нашли тут

Оказывается, вместе с PHP7.4 мы использовали расширение php-redis версии 5.1.1, а в новой сборке PHP8 была версия 5.3.4. Тем временем в версии 5.2.1 появилась постоянная проверка живучести персистентного соединения. В версии 5.2.2 она стала опциональной через php.ini и redis.pconnect.echo_check_liveness, но по умолчанию была включена. Мы очень активно используем Redis, так что такая постоянная проверка через ECHO в итоге дала тот самый рост нагрузки в 10-15%.

Внимательный читатель может спросить о php8+JIT на графике. Там выяснилось, что JIT в CLI работает немного не так, как от него ожидаешь.

Когда в php.ini включен opcache.enable_cli и opcache.file_cache, а в файловом кэше лежат опкоды, которые туда заранее сложили при прогреве кэша – в этом случае JIT не срабатывает (подтягиваются опкоды из файла и код работает на них).

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

Эти выводы основаны не на глубинном знании о том, как работает opcache и JIT, но на эмпирических наблюдениях за strace и выводом интерпретатора при включенном opcache.jit_debug

С помощью strace мы увидели, что opcache читается из файла, если он там есть, но обратно не пишется, если его там нет. Инсайты от  jit_debug: если opcache прочитан из файла, то JIT-компиляции не произошло (дебаг пустой); если же opcache из файла не прочитан, то есть сгенерированный jit_debug.

В нашем случае получилось, что время, выигрываемое с помощью JIT – “проигрывалось” обратно из-за потерь на интерпретации php-файла в opcodes; при этом затраты по памяти выходили даже больше.

Ваш путь к PHP8

Переезд на PHP8 занял у нас пару месяцев в фоновом режиме. История с JIT и производительностью под конец добавила драмы и потрепала нервы, но в целом весь процесс перехода на новую версию прошёл довольно гладко.

В начале было страшно переезжать, но ни 13К тестов, ни внешние библиотеки не создали особых проблем. Используешь Redis — посмотри новые настройки; с JIT — будь аккуратнее. Ничего такого.

Надеемся, ваш переезд на PHP8 будет простым и недолгим. А если вы уже переехали, поделитесь впечатлениями — будем рады послушать!

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


  1. hatman
    29.10.2021 09:28
    +1

    Тоже ловил ситуацию, что после перехода на PHP8 память на тестах жралась сильнее, чем на PHP7.4.


    1. tifeit Автор
      29.10.2021 16:23

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