Ускорение Magento c помощью RoadRunner
PHP создан умирать. И все было бы хорошо, но в последнее время это сделать ему не дают. Год назад на хабре состоялся анонс инструмента RoadRunner, заставляющего PHP процесс выйти из бесконечного круга гибели и воскрешения.


Принцип работы RoadRunner заключается в удержании запущенного процесса и подкидывания в него поступающих запросов, что позволяет, по словам разработчиков, увеличить производительность приложения (иногда даже в 40 раз).


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


Стоимость инициализации Magento приложения


Способ ускорения приложения RoadRunner предполагает уменьшение времени ответа (после прогревочного запуска) за счет сокращения накладных расходов по инициализации приложения.


Скриншот из презентации Anton Tsitou "Designing hybrid Go/PHP applications using RoadRunner"
Скриншот из презентации Anton Tsitou "Designing hybrid Go/PHP applications using RoadRunner"


В случае с Magento основные временные затраты при старте приходятся на:


  • composer autoloading
  • bootstrapping

Composer autoloading не привлекает внимание, так как стандартен для PHP приложения.


Результаты профайлинга, связанные с Composer.
Результаты профайлинга, связанные с Composer.


Bootstraping приложения Magento включает инициализацию обработчика ошибок, проверки статуса приложения, и т.д.


Самое здесь тяжелое — инициализация IoC-контейнера (“ObjectManager” в терминах Magento) и рекурсивное создание экземпляров зависимостей через него для получения объекта приложения.


Результаты профайлинга, связанные с bootstraping.
Результаты профайлинга, связанные с bootstraping.


Внедрение RoadRunner


Для запуска RoadRunner требуется создать worker, который будет содержать цикл принятия входящих запросов и отсылку ответов. Причем инструмент работает с запросами и ответами, имплементирующими PSR-7. Из официальной документации это выглядит примерно так:


while ($req = $psr7->acceptRequest()) {
        $resp = new \Zend\Diactoros\Response();
        $resp->getBody()->write("hello world");
        $psr7->respond($resp);
}

Magento и PSR-7


Magento пока не внедрила PSR-7 и из коробки использует свои имплементации запросов и ответов, подходы в работе которых в основном перетянуты из предыдущей версии.


Для внедрения RoadRunner нужно найти точку входа, которая бы принимала запрос в каком-то виде и возвращала ответ (пример Symfony).


В Magento существует такая точка, \Magento\Framework\AppInterface, только одна проблема, этот интерфейс не рассчитан на то, чтобы принять запрос. Но постойте, откуда он тогда попадает в приложение? Здесь стоит вернуться к началу и мантре — PHP рожден умирать. Соответственно громадная часть библиотек, пакетов, фреймворков, при проектировании и разделении на слои просто не предполагают, что запрос, оказывается, бывает не один глобальный.


По такому же принципу построен транспортный слой Magento. Хотя в документации и расписаны отличия injectable/newable объектов, на деле мы имеем использование запроса как глобального statefull сервиса, инициализирующего самого себя из глобальных переменных ($_GET, $_POST). В дополнение ко всему этому, инжектирование этого сервиса можно увидеть на всех уровнях приложения в самом ядре, что уж говорить про качество сторонних модулей.


Исходя из вышеизложенного, надежда внедрения RoadRunner только через конвертацию запросов из PSR-7-style в Magento-style была потрачена.


Внедрение PSR-7 адаптера


Формулируем задачу, приняв во внимание полученную информацию.
Хотелось бы иметь некий интерфейс приложения, принимающего PSR-7 запрос и возвращающий PSR-7 ответ. Также необходимо создать имплементацию созданного интерфейса, адаптирующую данный формат взаимодействия к Magento приложению.


PSR-7 адаптер
PSR-7 адаптер


Как упоминалось выше, magento application уже возвращает ответ, так что нам надо его только сконвертировать в PSR-7 формат.


Для запроса понадобится класс, проксирующий все вызовы на текущий объект запроса, помещаемый нами в специальный регистр (да простят это извращение боги архитектуры). Кроме этого, обнаружилось, что класс запроса используется не один, а 3, так что требует зареврайтить их все через конфигурацию IoC контейнера.


Набор классов, использующихся для работы с запросами в Magento
Набор классов, использующихся для работы с запросами в Magento


Проблемы неумирающего PHP приложения


Приложение, запущенное посредством RoadRunner, имеет те же проблемы, что и любой долгоживущий php процесс, они описаны в документации (https://roadrunner.dev/docs/usage-production)


Основные проблемы уровня приложения, за которыми следует следить разработчику это:


  • утечки памяти (https://www.php.net/manual/ru/features.gc.php)
  • соединения с базами данных (внезапно) могут отвалиться по таймауту, как и любые другие
  • грамотное управление состоянием

Особое внимание в контексте Magento следует уделить управлению состоянием, поскольку как в ядре так и в сторонних модулях, кеширование текущего пользователя/продукта/категории внутри сервиса это очень часто встречающийся подход.


    protected function getCustomer(): ?CustomerInterface
    {
        if (!$this->customer) {
            if ($this->customerSession->isLoggedIn()) {
                $this->customer = $this->customerRepository->getById($this->customerSession->getCustomerId());
            } else {
                return null;
            }
        }
        return $this->customer;
    }

Пример метода из ядра, использующего состояние объекта.


Запуск Magento Rest API сервера через RoadRunner


Учитывая потенциальные проблемы с глобальным состоянием, основанные на опыте разработки фронтенд части Magento, для запуска была выбрана наиболее подходящая и безболезненная WebApi часть.


Первое, что надо сделать, это создать наш воркер, который будет запускаться через RoadRunner и жить бесконечно (почти). Для этого берем кусок кода из гайдов RoadRunner и добавляем туда наше приложение, обернутое PSR-7 адаптером.


$relay = new StreamRelay(STDIN, STDOUT);
$psr7 = new PSR7Client(new Worker($relay));

$bootstrap = \Magento\Framework\App\Bootstrap::create(BP, []);
/** @var \Magento\Framework\App\Http $app */
$app = $bootstrap->createApplication(\Magento\Framework\App\Http::class);

/** @var ApplicationInterface $psr7Application */
$psr7Application = $bootstrap->getObjectManager()->create(
    \Isxam\M2RoadRunner\Application\MagentoAppWrapper::class,
    [
        'magentoApp' => $app
    ]
);

while ($request = $psr7->acceptRequest()) {
    try {
        $response = $psr7Application->handle($request);

        $psr7->respond($response);
    } catch (\Throwable $e) {
        $psr7->getWorker()->error((string)$e);
    }
}

Код до цикла while будет выполнен при старте воркера, все что внутри цикла — при каждом новом запросе.


Когда наш воркер готов, приступаем к конфигурации RoadRunner сервера, написанного на Go. Здесь все приятно и быстро, только композер-пакет, который скачает исполняемый файл, ничего устанавливать не надо, что не может быть не приятно. Создаем конфигурацию своего сервера — самый простой выглядит примерно так.


http:
  address: 0.0.0.0:8086
  workers:
    command: "php worker.php"
    pool:
      numWorkers: 1

Конфигурация RoadRunner.


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


./rr serve -v -d

Запуск сервера


Тестирование решения


Инструменты


Для удобного тестирования берем что-нибудь простое, например artillery.io.


Тестировать производительность будем с помощью одного пользователя, выполняющего запросы последовательно (выполнение запросов в несколько потоков RoadRunner также поддерживает, тестирование этого вопроса оставим другим исследователям)


На входе у нас конфиг-файл artillery c двумя окружениями — Apache и RoadRunner. Они оба работают с одним и тем же инстансом Magento, так что здесь они в равных условиях.


Тестовые сценарии


Для измерения производительности двух решений использовались следующие сценарии.


Сценарий 1. Создание категории
  - name: "S1. Create category"
    flow:
      - loop:
          - post:
              url: "/rest/V1/categories"
              json:
                category:
                  name: "name-{{prefix}}-{{ $loopCount }}"
                  parent_id: 2
                  is_active: true
        count: 100

Сценарий 2. Получение списка стран
  - name: "S2. Countries list"
    flow:
      - loop:
          - get:
              url: "/rest/V1/directory/countries"
        count: 100

Сценарий 3. Получение списка типов продуктов
  - name: "S3. Product types list"
    flow:
      - loop:
          - get:
              url: "/rest/V1/products/types"
        count: 100

Сценарий 4. Получение списка атрибут-сетов
  - name: "S4. Product attribute sets list"
    flow:
     - loop:
         - get:
             url: "/rest/V1/products/attribute-sets/sets/list?searchCriteria"
       count: 100

Сценарий 5. Получение категории
  - name: "S5. Category get"
    flow:
      - loop:
          - get:
              url: "/rest/V1/categories/2"
        count: 100

Сценарий 6. Создание продукта
  - name: "S6. Create product"
    flow:
      - loop:
          - post:
              url: '/rest/V1/products'
              json:
                product:
                  sku: "sku-{{prefix}}-{{ $loopCount }}"
                  name: "name-{{prefix}}-{{ $loopCount }}"
                  attribute_set_id: 4
                  price: 100
                  type_id: "simple"
        count: 100

Сценарий 7. Получение списка продуктов
  - name: "S7. Get product list"
    flow:
      - loop:
        - get:
           url: "/rest/V1/products?searchCriteria[pageSize]=20"
        count: 100

Результат


После запуска всех сценарием поочередно через RoadRunner и Apache, был получены медианы длительности выполнения запроса. По медианам видно, что скорость работы всех сценариев отличается на примерно одинаковую величину ~50ms.


Результат тестирования производительности.
Результат тестирования производительности.


Итог


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


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


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


Magento Open Source в целом пригодна для запуска в приведенном окружении, однако требует доработок транспортного слоя и исправления бизнес-логики для предотвращения некорректного поведения при повторяющихся запросах в рамках одного процесса. Также использование RoadRunner накладывает определенные ограничения на подходы в разработке, однако они не противоречат устоявшимся практикам.


Напоследок приятный скриншот. Когда вы еще увидите запросы к Magento с таким временем ответа?

Шок


Ссылки


  1. Пример из статьи
  2. Официальный сайт RoadRunner

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


  1. Lachezis
    04.09.2019 21:09
    +2

    Кстати в rr есть класс https://github.com/spiral/roadrunner/blob/master/src/HttpClient.php на нем можно делать интеграции не используя PSR-7. Работает он так же как и PSR7Client.


    Ускорить с rr еще можно если заюзать очереди, memory queue позволяет запускать часть тяжелых задач в фоне.


    1. isxam Автор
      04.09.2019 22:32

      Не совсем понял про memory queue. Имеется ввиду внутри приложения отдать ответ как только возможно и после уже еще что-то тяжелое/ненужное досчитать (статистику затрекать)? Мы говорим все еще про кейс с HTTP сервером или про еще один вариант применения rr?

      P.S.
      Спасибо за крутой инструмент


      1. Lachezis
        05.09.2019 10:37

        Да, именно очередь для обработки задач в фоне. В rr можно установить http и queue одновременно, если интересно то вот сборка сразу с 3 пакетами: https://github.com/spiral/framework/releases/tag/v2.0.14


        Идея пакета https://github.com/spiral/jobs в том что можно гонять задачи в фоне без какого либо брокера, чисто на каналах. А позже, поправить конфиг и вынести координацию в rabbitmq, sqs или куда-то еще.


        1. isxam Автор
          05.09.2019 21:27

          У magento есть из коробки абстракция над message queue на уровне фреймворка. И 2 ее имплементации на выбор — rabbitmq и очередь на основе mysql+cron


          1. Lachezis
            05.09.2019 21:37

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


  1. SamDark
    05.09.2019 00:52

    С numWorkers > 1 не работает?


    1. isxam Автор
      05.09.2019 05:57

      Да, работает. У меня даже получилось словить пару дедлоков БД на 16 воркерах и другом конфиге artillery.io с конкурентными юзерами. Но для измерений я использовал вариант с одним воркером


  1. kirmorozov
    05.09.2019 02:36

    Для кого-то свершилась мечта.
    Срезали 150ms инициализации системы.
    Так держать.


  1. KAndy
    05.09.2019 02:48

    Возможно подобное решение появится и в коре github.com/magento/graphql-ce/blob/MC-15512-graphql-state-test/app/code/Magento/GraphQl/App/GraphQl.php#L155


    1. isxam Автор
      05.09.2019 07:35

      Это отлично, может есть какие-то публичные данные по производительности?


  1. SbWereWolf
    05.09.2019 07:57

    спасибо что поделились результатами замеров :)


  1. flancer
    05.09.2019 08:17

    Коллега, снимаю шляпу. Это сродни попытке поставить Франкенштейна на беговую дорожку. То, что он побежал — уже чудо, а то, что при этом от него не отвалились части тела, говорит о вашем высоком профессиональном уровне! Читал, как в детстве "Науку и жизнь".


    1. isxam Автор
      05.09.2019 09:07

      Уверен, что какие-то части тела отвалились, но в целом да, обнадёживает.


  1. maghamed
    05.09.2019 20:46
    +1

    проблема со стейтом, к сожалению остается и с Web API, так как тот же ObjectManager и его состояние будет переиспользоваться между запросами. И если в случае одного потока это незаметно, то когда потоков будет много, и они будут в разных контекстах (сторов), то результаты могут быть неконсистентны.

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

    Но поддержку PSR-7, мы точно хотим добавить. Если у вас есть время/желание — можете помочь Magento это сделать в виде контрибьюшена.


    1. isxam Автор
      05.09.2019 21:23

      Хм, но om конфигурируется на уровне area(frontend, cron, etc) только, соответственно для одного скоупа конфиг идентичен и инстансы созданные om могут быть пошарены, если мы говорим про стейтлесс сервисы, которыми оперирует om. Или я что-то упускаю?


      1. maghamed
        05.09.2019 21:41

        у нас в om можем оставаться \Magento\Store\Model\StoreManager::getStore, или текущая категория, или значения из регистра, которые были сохранены в рамках выполнения предыдущего запроса.
        В идеальном мире — если сам сервис stateless, то да. Но мы, к сожалению не в идеальном мире и у нас попрежнему есть состояния.
        Было бы интересно, в частности запустить Magento web api тесты, на таком сетапе и посмотреть проходят ли они.


        1. isxam Автор
          05.09.2019 22:52

          Вот, тоесть с om все вроде ок, дело в конкретных сервисах. Но, насколько я понимаю, что проблема как в перечисленных сервисах (и всех остальных) не нова и может возникнуть и без swoole/rr.


          Мне просто кажется, что с очисткой om будет значительно меньше выигрыш. Возможно следует выбрать что-то промежуточное, если нет возможности привести к работоспособному виду "плохие" сервисы. Например атрибут для di.xml похожее на "shared=false", но в рамках процесса.


          Что касается тестов, то думаю будет не очень результат)


  1. iproger
    05.09.2019 20:56

    Интересное решение. Я решал проблему обычными способами типа мощного сервера, nginx, opcache, production mode, composer -o —no-dev, built-in cache. Все это позволяет сократить запросы к api до 80-250мс, в зависимости от запроса.