PHP создан умирать. И все было бы хорошо, но в последнее время это сделать ему не дают. Год назад на хабре состоялся анонс инструмента RoadRunner, заставляющего PHP процесс выйти из бесконечного круга гибели и воскрешения.
Принцип работы RoadRunner заключается в удержании запущенного процесса и подкидывания в него поступающих запросов, что позволяет, по словам разработчиков, увеличить производительность приложения (иногда даже в 40 раз).
Поскольку долгом работы я связан с Magento, то показалось отличной идеей проверить инструмент не на мифическом фреймворке, а на реальном приложении, для чего отлично подошла Magento Open Source.
Стоимость инициализации Magento приложения
Способ ускорения приложения RoadRunner предполагает уменьшение времени ответа (после прогревочного запуска) за счет сокращения накладных расходов по инициализации приложения.
Скриншот из презентации Anton Tsitou "Designing hybrid Go/PHP applications using RoadRunner"
В случае с Magento основные временные затраты при старте приходятся на:
- composer autoloading
- bootstrapping
Composer autoloading не привлекает внимание, так как стандартен для PHP приложения.
Результаты профайлинга, связанные с Composer.
Bootstraping приложения Magento включает инициализацию обработчика ошибок, проверки статуса приложения, и т.д.
Самое здесь тяжелое — инициализация IoC-контейнера (“ObjectManager” в терминах Magento) и рекурсивное создание экземпляров зависимостей через него для получения объекта приложения.
Результаты профайлинга, связанные с 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 адаптер
Как упоминалось выше, magento application уже возвращает ответ, так что нам надо его только сконвертировать в PSR-7 формат.
Для запроса понадобится класс, проксирующий все вызовы на текущий объект запроса, помещаемый нами в специальный регистр (да простят это извращение боги архитектуры). Кроме этого, обнаружилось, что класс запроса используется не один, а 3, так что требует зареврайтить их все через конфигурацию IoC контейнера.
Набор классов, использующихся для работы с запросами в 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, так что здесь они в равных условиях.
Тестовые сценарии
Для измерения производительности двух решений использовались следующие сценарии.
- 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
- name: "S2. Countries list"
flow:
- loop:
- get:
url: "/rest/V1/directory/countries"
count: 100
- name: "S3. Product types list"
flow:
- loop:
- get:
url: "/rest/V1/products/types"
count: 100
- name: "S4. Product attribute sets list"
flow:
- loop:
- get:
url: "/rest/V1/products/attribute-sets/sets/list?searchCriteria"
count: 100
- name: "S5. Category get"
flow:
- loop:
- get:
url: "/rest/V1/categories/2"
count: 100
- 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
- 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 накладывает определенные ограничения на подходы в разработке, однако они не противоречат устоявшимся практикам.
Lachezis
Кстати в rr есть класс https://github.com/spiral/roadrunner/blob/master/src/HttpClient.php на нем можно делать интеграции не используя PSR-7. Работает он так же как и PSR7Client.
Ускорить с rr еще можно если заюзать очереди, memory queue позволяет запускать часть тяжелых задач в фоне.
isxam Автор
Не совсем понял про memory queue. Имеется ввиду внутри приложения отдать ответ как только возможно и после уже еще что-то тяжелое/ненужное досчитать (статистику затрекать)? Мы говорим все еще про кейс с HTTP сервером или про еще один вариант применения rr?
Lachezis
Да, именно очередь для обработки задач в фоне. В rr можно установить http и queue одновременно, если интересно то вот сборка сразу с 3 пакетами: https://github.com/spiral/framework/releases/tag/v2.0.14
Идея пакета https://github.com/spiral/jobs в том что можно гонять задачи в фоне без какого либо брокера, чисто на каналах. А позже, поправить конфиг и вынести координацию в rabbitmq, sqs или куда-то еще.
isxam Автор
У magento есть из коробки абстракция над message queue на уровне фреймворка. И 2 ее имплементации на выбор — rabbitmq и очередь на основе mysql+cron
Lachezis
Спасибо, не знал про это. В теории, если подцепится можно будет гонять очереди и без крона, локально + больше драйверов.
SamDark
С numWorkers > 1 не работает?
isxam Автор
Да, работает. У меня даже получилось словить пару дедлоков БД на 16 воркерах и другом конфиге artillery.io с конкурентными юзерами. Но для измерений я использовал вариант с одним воркером
kirmorozov
Для кого-то свершилась мечта.
Срезали 150ms инициализации системы.
Так держать.
KAndy
Возможно подобное решение появится и в коре github.com/magento/graphql-ce/blob/MC-15512-graphql-state-test/app/code/Magento/GraphQl/App/GraphQl.php#L155
isxam Автор
Это отлично, может есть какие-то публичные данные по производительности?
SbWereWolf
спасибо что поделились результатами замеров :)
flancer
Коллега, снимаю шляпу. Это сродни попытке поставить Франкенштейна на беговую дорожку. То, что он побежал — уже чудо, а то, что при этом от него не отвалились части тела, говорит о вашем высоком профессиональном уровне! Читал, как в детстве "Науку и жизнь".
isxam Автор
Уверен, что какие-то части тела отвалились, но в целом да, обнадёживает.
maghamed
проблема со стейтом, к сожалению остается и с Web API, так как тот же ObjectManager и его состояние будет переиспользоваться между запросами. И если в случае одного потока это незаметно, то когда потоков будет много, и они будут в разных контекстах (сторов), то результаты могут быть неконсистентны.
Мы смотрим в это сторону, в частности на swoole. Но для полноты решения нам еще нужно очищать состояние специфичное для выполнения запроса, а также очищать состояние при возниктновании исключений или ошибок.
Но поддержку PSR-7, мы точно хотим добавить. Если у вас есть время/желание — можете помочь Magento это сделать в виде контрибьюшена.
isxam Автор
Хм, но om конфигурируется на уровне area(frontend, cron, etc) только, соответственно для одного скоупа конфиг идентичен и инстансы созданные om могут быть пошарены, если мы говорим про стейтлесс сервисы, которыми оперирует om. Или я что-то упускаю?
maghamed
у нас в om можем оставаться \Magento\Store\Model\StoreManager::getStore, или текущая категория, или значения из регистра, которые были сохранены в рамках выполнения предыдущего запроса.
В идеальном мире — если сам сервис stateless, то да. Но мы, к сожалению не в идеальном мире и у нас попрежнему есть состояния.
Было бы интересно, в частности запустить Magento web api тесты, на таком сетапе и посмотреть проходят ли они.
isxam Автор
Вот, тоесть с om все вроде ок, дело в конкретных сервисах. Но, насколько я понимаю, что проблема как в перечисленных сервисах (и всех остальных) не нова и может возникнуть и без swoole/rr.
Мне просто кажется, что с очисткой om будет значительно меньше выигрыш. Возможно следует выбрать что-то промежуточное, если нет возможности привести к работоспособному виду "плохие" сервисы. Например атрибут для di.xml похожее на "shared=false", но в рамках процесса.
Что касается тестов, то думаю будет не очень результат)
iproger
Интересное решение. Я решал проблему обычными способами типа мощного сервера, nginx, opcache, production mode, composer -o —no-dev, built-in cache. Все это позволяет сократить запросы к api до 80-250мс, в зависимости от запроса.