PHP + Go = ♥ или RoadRunner в действии

Привет.

Много раз уже слышал о RoadRunner — сервер веб-приложений, написанный на Go. Думаю, настало время пощупать, что это такое.

Проблема

PHP работает по простому принципу — один запрос — один процесс. Мы открываем страницу — на сервер уходит запрос. Где-то на сервере есть стек с горячими PHP-FPM процессами, один из которых выделяется и обрабатывает пришедший запрос. Отдав ответ — процесс выгружает все данные из памяти, очищает своё состояние и умирает. Это, я думаю, одна из вещей, почему многие не любят PHP. И правда, при растущих нагрузках рост процессов — неприятный удар по производительности, часто требующий пересмотра архитектуры, масштабирования и редкий поглядываний в сторону потоков, когда разработчик внезапно узнаёт, что они есть в PHP.

Как правило, в сторону потоков мы смотрим недолго. Потоки появились не так давно, и, как нас уверяет php.net — стабильно работают в PHP 7.2. У меня был опыт реализации многопоточной системы, где почему-то на PHP я решил разрулить проблемы с множественной обработкой большого числа запросов. Первое, что неприятно ударило по разработке — пересборка PHP с флагом ZTS — чтобы многопоточка заработала в принципе. Второе — это далеко не все библиотеки поддреживают многопоточный режим, и, если одна из либ его не поддерживает — мы обламываемся.

Решение

Недавно я наткнулся на статью — Разработка гибридных PHP/Go приложений с использованием RoadRunner. Если коротко — ребята написали сервер, который имеет интеграцию с большинством PHP-фреймворков. Этот сервер поднимает несколько PHP-процессов, в которые уже инициализированы все библиотеки и весь исполняющий код. Эти процессы держаться в памяти, в неком вечном цикле. Все входящие запросы сервер ловит в где-то снаружи, вне наших PHP-процессов, а затем отправляет данные на обработку в PHP-процессы, которые, после обработки, не умирают. Обработав результат, сервер забирает данные и отдаёт клиенту, а процесс продолжает жить. Весь контроль памяти и балансировка процессов — на плечах сервера, и справляется с ней он, судя по отзывам, неплохо.

Мы любим графики и сравнения, поэтому приведу сравнение из этой статьи:

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

Интеграция с Symfony

Создадим новый проект:

symfony new rrt --full
cd rrt

Устанавливаем roadrunner:

composer req spiral/roadrunner symfony/psr-http-message-bridge

Теперь в bin создаём файл worker.php, который будет поднимать воркер, обрабатывающий запросы:

И в него пишем:

<?php
ini_set('display_errors', 'stderr');

use App\Kernel;
use Spiral\Goridge\StreamRelay;
use Spiral\RoadRunner\PSR7Client;
use Spiral\RoadRunner\Worker;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\Debug\Debug;
use Symfony\Component\HttpFoundation\Request;
use Zend\Diactoros\ResponseFactory;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\StreamFactory;
use Zend\Diactoros\UploadedFileFactory;

require __DIR__ . '/../config/bootstrap.php';

$env = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? 'dev';
$debug = (bool)($_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? ('prod' !== $env));

if ($debug) {
    umask(0000);

    Debug::enable();
}

if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? $_ENV['TRUSTED_PROXIES'] ?? false) {
    Request::setTrustedProxies(
        explode(',', $trustedProxies),
        Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST
    );
}

if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? $_ENV['TRUSTED_HOSTS'] ?? false) {
    Request::setTrustedHosts(explode(',', $trustedHosts));
}

$kernel = new Kernel($env, $debug);
$relay = new StreamRelay(STDIN, STDOUT);
$psr7 = new PSR7Client(new Worker($relay));
$httpFoundationFactory = new HttpFoundationFactory();
$psrHttpFactory = new PsrHttpFactory(
    new ServerRequestFactory,
    new StreamFactory,
    new UploadedFileFactory,
    new ResponseFactory
);

while ($req = $psr7->acceptRequest()) {
    try {
        $request = $httpFoundationFactory->createRequest($req);
        $response = $kernel->handle($request);
        $psr7->respond($psrHttpFactory->createResponse($response));
        $kernel->terminate($request, $response);
        $kernel->reboot(null);
    } catch (\Throwable $e) {
        $psr7->getWorker()->error((string)$e);
    }
}

В отличии от документации я подключаю bootstrap.php, а не autoload.php vendor’а и удаляю последующий код инициализации переменных окружения, инцилизируя их в bootstrap.php. Лично у меня, вариант с просто загрузкой autoload.php не сработал.

Далее в директории config создаём файл .rt.yaml, где прописывается конфиг для roadrunner’а:

http:
  address: 0.0.0.0:8080
  workers:
    command: "php ../bin/worker.php"
    pool:
      numWorkers: 4

static:
  dir:   "../public"

Теперь установим roadrunner в систему:

cd /opt
git clone https://github.com/spiral/roadrunner.git roadrunner
cd roadrunner
sudo chown youuser:youuser ./
make

Прокинем символьную ссылку, чтобы roadrunner можно было запускать глобально:

sudo ln -srf rr /usr/local/bin/rr

Теперь, в дирректории с только что установленной Symfony переходим в config и запускаем сервер:

rr serve

Всё работает. Разберём код worker.php:

<?php
// Устанавливаем вывод ошибок в стандартный поток stderr
ini_set('display_errors', 'stderr');

use App\Kernel;
use Spiral\Goridge\StreamRelay;
use Spiral\RoadRunner\PSR7Client;
use Spiral\RoadRunner\Worker;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
use Symfony\Component\Debug\Debug;
use Symfony\Component\HttpFoundation\Request;
use Zend\Diactoros\ResponseFactory;
use Zend\Diactoros\ServerRequestFactory;
use Zend\Diactoros\StreamFactory;
use Zend\Diactoros\UploadedFileFactory;

// подключаем autoload.php и инициализируем переменные окружения
require __DIR__ . '/../config/bootstrap.php';

Затем идёт блок кода, инициализирующий настройки для запуска ядра:

// определяем текущий режим запуска приложения: dev, test или prod
$env = $_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? 'dev';
// определяем, включать или не включать режим отладки, в зависимости от того, в dev или prod-режиме запускается приложение
$debug = (bool)($_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? ('prod' !== $env));

// если приложение запускается в dev-среде - инициализируем Debug, для вывода предупреждений и ошибок
if ($debug) {
    umask(0000);

    Debug::enable();
}

// если приложение находится за proxy, то пробрасываем в ответ реальный адрес пользователя, а не proxy-адрес
if ($trustedProxies = $_SERVER['TRUSTED_PROXIES'] ?? $_ENV['TRUSTED_PROXIES'] ?? false) {
    Request::setTrustedProxies(
        explode(',', $trustedProxies),
        Request::HEADER_X_FORWARDED_ALL ^ Request::HEADER_X_FORWARDED_HOST
    );
}

// аналогично пробрасываем доверенные хосты
if ($trustedHosts = $_SERVER['TRUSTED_HOSTS'] ?? $_ENV['TRUSTED_HOSTS'] ?? false) {
    Request::setTrustedHosts(explode(',', $trustedHosts));
}

Наконец, инициализация и запуск цикла обработки:

// инициализируем ядро приложения
$kernel = new Kernel($env, $debug);
// создаём объект, позволяющий передавать данные воркерам
$relay = new StreamRelay(STDIN, STDOUT);
// инициализируем диспетчер процессов и балансировщик нагрузки
$psr7 = new PSR7Client(new Worker($relay));
// инициализируем фабрику, позволяющую создавать объекты Symfony-Request, для последующей передачи в них загруженных данных запроса
$httpFoundationFactory = new HttpFoundationFactory();
// фабрика, генерующая объект ответа
$psrHttpFactory = new PsrHttpFactory(
    new ServerRequestFactory,
    new StreamFactory,
    new UploadedFileFactory,
    new ResponseFactory
);
// вечный цикл, обрабатывающий запросы пользователя
while ($req = $psr7->acceptRequest()) {
    try {
        $request = $httpFoundationFactory->createRequest($req);
        $response = $kernel->handle($request);
        $psr7->respond($psrHttpFactory->createResponse($response));
        $kernel->terminate($request, $response);
        $kernel->reboot(null);
    } catch (\Throwable $e) {
        $psr7->getWorker()->error((string)$e);
    }
}

Сервер можно запустить с минимальным логированием инициализации и запросов:

rr serve -v -d

На всякий случай, исходники тут. В следующий раз более глубоко разберёмся, как работает RoadRunner и поиграемся с нагрузками.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *