14 января 2019

Сравниваем PHP FPM, PHP PPM, Nginx Unit, React PHP и RoadRunner

Высокая производительностьPHPПрограммированиеТестирование веб-сервисов


Тестирование производилось с помощью Yandex Tank.
В качестве приложения использовались Symfony 4 и PHP 7.2.
Целью являлось сравнение характеристик сервисов при разных нагрузках и нахождение оптимального варианта.
Для удобства все собрано в docker-контейнеры и поднимается с помощью docker-compose.
Под катом много таблиц и графиков.

Исходный код лежит тут.
Все примеры команд, описанные в статье, должны выполняться из директории проекта.


Приложение


Приложение работает на Symfony 4 и PHP 7.2.


Отвечает только на один роут и возвращает:


  • случайное число;
  • окружение;
  • pid процесса;
  • имя сервиса, с помощью которого работает;
  • переменные php.ini.

Пример ответа:


curl 'http://127.0.0.1:8000/' | python -m json.tool
{
    "env": "prod",
    "type": "php-fpm",
    "pid": 8,
    "random_num": 37264,
    "php": {
        "version": "7.2.12",
        "date.timezone": "Europe/Paris",
        "display_errors": "",
        "error_log": "/proc/self/fd/2",
        "error_reporting": "32767",
        "log_errors": "1",
        "memory_limit": "256M",
        "opcache.enable": "1",
        "opcache.max_accelerated_files": "20000",
        "opcache.memory_consumption": "256",
        "opcache.validate_timestamps": "0",
        "realpath_cache_size": "4096K",
        "realpath_cache_ttl": "600",
        "short_open_tag": ""
    }
}

В каждом контейнере настроен PHP:



Логи пишутся в stderr:
/config/packages/prod/monolog.yaml


monolog:
    handlers:
        main:
            type: stream
            path: "php://stderr"
            level: error
        console:
            type: console

Кеш пишется в /dev/shm:
/src/Kernel.php


...
class Kernel extends BaseKernel
{
    public function getCacheDir()
    {
        if ($this->environment === 'prod') {
            return '/dev/shm/symfony-app/cache/' . $this->environment;
        } else {
            return $this->getProjectDir() . '/var/cache/' . $this->environment;
        }
    }
}
...

В каждом docker-compose запускаются три основных контейнера:


  • Nginx — реверсивный прокси-сервер;
  • App — подготовленный код приложения со всеми зависимостями;
  • PHP FPM\Nginx Unit\Road Runner\React PHP — сервер приложения.

Обработка запросов ограничивается двумя инстансами приложения (по числу ядер процессора).


Сервисы


PHP FPM


Менеджер PHP процессов. Написан на C.


Плюсы:


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

Минусы:


  • на каждый запрос PHP должен инициализировать переменные.

Команда для запуска приложения с docker-compose:


cd docker/php-fpm && docker-compose up -d

PHP PPM


Менеджер PHP процессов. Написан на PHP.


Плюсы:


  • инициализирует переменные один раз и затем использует их;
  • не нужно ничего менять в приложении (есть готовые модули для Symfony/Laravel, Zend, CakePHP).

Минусы:


  • нужно следить за памятью.

Команда для запуска приложения с docker-compose:


cd docker/php-ppm && docker-compose up -d

Nginx Unit


Сервер приложений от команды Nginx. Написан на С.


Плюсы:


  • можно менять конфигурацию по HTTP API;
  • можно запускать одновременно несколько инстансов одного приложения с разными конфигурациями и версиями языков;
  • не нужно следить за памятью;
  • не нужно ничего менять в приложении.

Минусы:


  • на каждый запрос PHP должен инициализировать переменные.

Чтобы передать переменные окружения из файла конфигурации nginx-unit, необходимо поправить php.ini:


; Nginx Unit
variables_order=E

Команда для запуска приложения с docker-compose:


cd docker/nginx-unit && docker-compose up -d

React PHP


Библиотека для событийного программирования. Написана на PHP.


Плюсы:


  • c помощью библиотеки можно написать сервер, который будет инициализировать переменные только один раз и дальше работать с ними.

Минусы:


  • необходимо написать код для сервера;
  • необходимо следить за памятью.

Если использовать для воркера флаг --reboot-kernel-after-request, то Symfony Kernel будет инициализироваться заново на каждый запрос. При таком подходе не нужно следить за памятью.


Код воркера
#!/usr/bin/env php

<?php

use App\Kernel;
use Symfony\Component\Debug\Debug;
use Symfony\Component\HttpFoundation\Request;

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));
}

$loop   = React\EventLoop\Factory::create();
$kernel = new Kernel($env, $debug);
$kernel->boot();
$rebootKernelAfterRequest = in_array('--reboot-kernel-after-request', $argv);

/** @var \Psr\Log\LoggerInterface $logger */
$logger = $kernel->getContainer()->get('logger');
$server = new React\Http\Server(function (Psr\Http\Message\ServerRequestInterface $request) use ($kernel, $logger, $rebootKernelAfterRequest) {

    $method  = $request->getMethod();
    $headers = $request->getHeaders();
    $content = $request->getBody();
    $post    = [];
    if (in_array(strtoupper($method), ['POST', 'PUT', 'DELETE', 'PATCH']) &&
        isset($headers['Content-Type']) && (0 === strpos($headers['Content-Type'], 'application/x-www-form-urlencoded'))
    ) {
        parse_str($content, $post);
    }
    $sfRequest = new Symfony\Component\HttpFoundation\Request(
        $request->getQueryParams(),
        $post,
        [],
        $request->getCookieParams(),
        $request->getUploadedFiles(),
        [],
        $content
    );
    $sfRequest->setMethod($method);
    $sfRequest->headers->replace($headers);
    $sfRequest->server->set('REQUEST_URI', $request->getUri());

    if (isset($headers['Host'])) {
        $sfRequest->server->set('SERVER_NAME', current($headers['Host']));
    }

    try {
        $sfResponse = $kernel->handle($sfRequest);
    } catch (\Exception $e) {
        $logger->error('Internal server error', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
        $sfResponse = new \Symfony\Component\HttpFoundation\Response('Internal server error', 500);
    } catch (\Throwable $e) {
        $logger->error('Internal server error', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
        $sfResponse = new \Symfony\Component\HttpFoundation\Response('Internal server error', 500);
    }

    $kernel->terminate($sfRequest, $sfResponse);
    if ($rebootKernelAfterRequest) {
        $kernel->reboot(null);
    }

    return new React\Http\Response(
        $sfResponse->getStatusCode(),
        $sfResponse->headers->all(),
        $sfResponse->getContent()
    );
});

$server->on('error', function (\Exception $e) use ($logger) {
    $logger->error('Internal server error', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
});

$socket = new React\Socket\Server('tcp://0.0.0.0:9000', $loop);
$server->listen($socket);

$logger->info('Server running', ['addr' => 'tcp://0.0.0.0:9000']);

$loop->run();

Команда для запуска приложения с docker-compose:


cd docker/react-php && docker-compose up -d --scale php=2

Road Runner


Web-сервер и менеджер PHP-процессов. Написан на Golang.


Плюсы:


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

Минусы:


  • необходимо написать код для воркера;
  • необходимо следить за памятью.

Если использовать для воркера флаг --reboot-kernel-after-request, то Symfony Kernel будет инициализироваться заново на каждый запрос. При таком подходе не нужно следить за памятью.


Код воркера
#!/usr/bin/env php

<?php

use App\Kernel;
use Spiral\Goridge\SocketRelay;
use Spiral\RoadRunner\PSR7Client;
use Spiral\RoadRunner\Worker;
use Symfony\Bridge\PsrHttpMessage\Factory\DiactorosFactory;
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
use Symfony\Component\Debug\Debug;
use Symfony\Component\HttpFoundation\Request;

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);
$kernel->boot();
$rebootKernelAfterRequest = in_array('--reboot-kernel-after-request', $argv);
$relay                    = new SocketRelay('/tmp/road-runner.sock', null, SocketRelay::SOCK_UNIX);
$psr7                     = new PSR7Client(new Worker($relay));
$httpFoundationFactory    = new HttpFoundationFactory();
$diactorosFactory         = new DiactorosFactory();

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

Команда для запуска приложения с docker-compose:


cd docker/road-runner && docker-compose up -d

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


Тестирование производилось с помощью Yandex Tank.
Приложение и Yandex Tank были на разных виртуальных серверах.


Характеристики виртуального сервера с приложением:
Virtualization: KVM
CPU: 2 cores
RAM: 4096 МБ
SSD: 50 GB
Connection: 100MBit
OS: CentOS 7 (64x)


Тестируемые сервисы:


  • php-fpm
  • php-ppm
  • nginx-unit
  • road-runner
  • road-runner-reboot (c флагом --reboot-kernel-after-request)
  • react-php
  • react-php-reboot (c флагом --reboot-kernel-after-request)

Для тестов 1000/10000 rps добавлен сервис php-fpm-80
Для него использовалась конфигурация php-fpm:


pm = dynamic
pm.max_children = 80

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


Для каждого сервиса и конфигурации Yandex Tank проводился всего один тест. Из-за этого цифры могут быть неточными. Важно было оценить характеристики сервисов относительно друг друга.


100 rps


Конфигурация phantom Yandex Tank


phantom:
    load_profile:
        load_type: rps
        schedule: line(1, 100, 60s) const(100, 540s)

Ссылки с детальным отчетом



Перцентили времени ответа


95%(ms) 90%(ms) 80%(ms) 50%(ms) HTTP OK(%) HTTP OK(count)
php-fpm 9.9 6.3 4.35 3.59 100 57030
php-ppm 9.4 6 3.88 3.16 100 57030
nginx-unit 11 6.6 4.43 3.69 100 57030
road-runner 8.1 5.1 3.53 2.92 100 57030
road-runner-reboot 12 8.6 5.3 3.85 100 57030
react-php 8.5 4.91 3.29 2.74 100 57030
react-php-reboot 13 8.5 5.5 3.95 100 57030

Мониторинг


cpu median(%) cpu max(%) memory median(MB) memory max(MB)
php-fpm 9.15 12.58 880.32 907.97
php-ppm 7.08 13.68 901.72 913.80
nginx-unit 9.56 12.54 923.02 943.90
road-runner 5.57 8.61 992.71 1,001.46
road-runner-reboot 9.18 12.67 848.43 870.26
react-php 4.53 6.58 1,004.68 1,009.91
react-php-reboot 9.61 12.67 885.92 892.52

Графики



График 1.1 Среднее время ответа в секунду



График 1.2 Средняя нагрузка процессора в секунду



График 1.3 Среднее потребление памяти в секунду


500 rps


Конфигурация phantom Yandex Tank


phantom:
    load_profile:
        load_type: rps
        schedule: line(1, 500, 60s) const(500, 540s)

Ссылки с детальным отчетом



Перцентили времени ответа


95%(ms) 90%(ms) 80%(ms) 50%(ms) HTTP OK(%) HTTP OK(count)
php-fpm 13 8.4 5.3 3.69 100 285030
php-ppm 15 9 4.72 3.24 100 285030
nginx-unit 12 8 5.5 3.93 100 285030
road-runner 9.6 6 3.71 2.83 100 285030
road-runner-reboot 14 11 7.1 4.45 100 285030
react-php 9.3 5.8 3.57 2.68 100 285030
react-php-reboot 15 12 7.2 4.21 100 285030

Мониторинг


cpu median(%) cpu max(%) memory median(MB) memory max(MB)
php-fpm 41.68 48.33 1,006.06 1,015.09
php-ppm 33.90 48.90 1,046.32 1,055.00
nginx-unit 42.13 47.92 1,006.67 1,015.73
road-runner 24.08 28.06 1,035.86 1,044.58
road-runner-reboot 46.23 52.04 939.63 948.08
react-php 19.57 23.42 1,049.83 1,060.26
react-php-reboot 41.30 47.89 957.01 958.56

Графики



График 2.1 Среднее время ответа в секунду



График 2.2 Средняя нагрузка процессора в секунду



График 2.3 Среднее потребление памяти в секунду


1000 rps


Конфигурация phantom Yandex Tank


phantom:
    load_profile:
        load_type: rps
        schedule: line(1, 1000, 60s) const(1000, 60s)

Ссылки с детальным отчетом



Перцентили времени ответа


95%(ms) 90%(ms) 80%(ms) 50%(ms) HTTP OK(%) HTTP OK(count)
php-fpm 11050 11050 9040 195 80.67 72627
php-fpm-80 3150 1375 1165 152 99.85 89895
php-ppm 2785 2740 2685 2545 100 90030
nginx-unit 98 80 60 21 100 90030
road-runner 27 15 7.1 3.21 100 90030
road-runner-reboot 1110 1100 1085 1060 100 90030
react-php 23 13 5.6 2.86 100 90030
react-php-reboot 28 24 19 11 100 90030

Мониторинг


cpu median(%) cpu max(%) memory median(MB) memory max(MB)
php-fpm 12.66 78.25 990.16 1,006.56
php-fpm-80 83.78 91.28 746.01 937.24
php-ppm 66.16 91.20 1,088.74 1,102.92
nginx-unit 78.11 88.77 1,010.15 1,062.01
road-runner 42.93 54.23 1,010.89 1,068.48
road-runner-reboot 77.64 85.66 976.44 1,044.05
react-php 36.39 46.31 1,018.03 1,088.23
react-php-reboot 72.11 81.81 911.28 961.62

Графики



График 3.1 Среднее время ответа в секунду



График 3.2 Среднее время ответа в секунду (без php-fpm, php-ppm, road-runner-reboot)



График 3.3 Средняя нагрузка процессора в секунду



График 3.4 Среднее потребление памяти в секунду


10000 rps


Конфигурация phantom Yandex Tank


phantom:
    load_profile:
        load_type: rps
        schedule: line(1, 10000, 30s) const(10000, 30s)

Ссылки с детальным отчетом



Перцентили времени ответа


95%(ms) 90%(ms) 80%(ms) 50%(ms) HTTP OK(%) HTTP OK(count)
php-fpm 11050 11050 11050 1880 70.466 317107
php-fpm-80 3260 3140 1360 1145 99.619 448301
php-ppm 2755 2730 2695 2605 100 450015
nginx-unit 1020 1010 1000 980 100 450015
road-runner 640 630 615 580 100 450015
road-runner-reboot 1130 1120 1110 1085 100 450015
react-php 1890 1090 1045 58 99.996 449996
react-php-reboot 3480 3070 1255 91 99.72 448753

Мониторинг


cpu median(%) cpu max(%) memory median(MB) memory max(MB)
php-fpm 5.57 79.35 984.47 998.78
php-fpm-80 85.05 92.19 936.64 943.93
php-ppm 66.86 82.41 1,089.31 1,097.41
nginx-unit 86.14 93.94 1,067.71 1,069.52
road-runner 73.41 82.72 1,129.48 1,134.00
road-runner-reboot 80.32 86.29 982.69 984.80
react-php 73.76 82.18 1,101.71 1,105.06
react-php-reboot 85.77 91.92 975.85 978.42


График 4.1 Среднее время ответа в секунду



График 4.2 Среднее время ответа в секунду (без php-fpm, php-ppm)



График 4.3 Средняя нагрузка процессора в секунду



График 4.4 Среднее потребление памяти в секунду


Итоги


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



График 5.1 95% перцентиль времени ответа



График 5.2 95% перцентиль времени ответа (без php-fpm)



График 5.3 Максимальная нагрузка процессора



График 5.4 Максимальное потребление памяти


Оптимальным решением (без изменения кода), на мой взгляд, является менеджер процессов Nginx Unit. Он показывает хорошие результаты в скорости ответа и имеет поддержку компании.


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


UPD
Для тестов 1000/10000 rps добавлен сервис php-fpm-80
Для него использовалась конфигурация php-fpm:


pm = dynamic
pm.max_children = 80
Теги:php fpmphp ppmnginx unitreact phproad runnerphpdockeryandex tanksymfony 4
Хабы: Высокая производительность PHP Программирование Тестирование веб-сервисов
+62
33,3k 162
Комментарии 46
Лучшие публикации за сутки

Партнерские материалы

Разместить