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

fkhu2yx-ka6-lgomyrmgvgsqzvm.png

Тестирование производилось с помощью 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

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

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 МБ
HDD: 50 GB SSD
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)

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


Графики


ok9upykku7x_gy7gal5dq1yfa2m.png

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


an_bz8p8tg8dmac-k9brr6lergc.png

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


lx61xaarcbvpauk4gzkhfxjk9gm.png

График 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


Графики


c-_14taximv1ifxdlpalgybfyyq.png

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


g4ql9sqqt3sf9tvtivr0pc1g0ne.png

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


rdb1300ij_nmcntsalodw-j5pca.png

График 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-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-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


Графики


srrq7qprmobh6qudrdjv41nb93a.png

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


7x2vg4kue4ufutvkuujkdqzn73y.png

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


appnfutim6f04_zw7nqr1n5nvgq.png

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


g5bg2uipj3azus4aeweym23lx20.png

График 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-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-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


acutvk0sbsvtrc4vptsmf4jqq1o.png

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


1nhnvbq6hl9xdm7bxlm0rrx2mps.png

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


_xvx1uk0yyhw7nllhix9tkoyzh0.png

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


ynysx_34loidfn4r7mio_3sbofa.png

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


Итоги

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


uhpcvdx0nczsku53ojpjvrikccu.png

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


vaoc_uj-wlybxghwhu_snmr7jkq.png

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


bwguxcwuo3w4j4_blvrr1-qlcdk.png

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


xhr8mxqo3en9o-qv4zrbzayu1vs.png

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

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

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

Habrahabr.ru прочитано 35028 раз