Compare PHP FPM, PHP PPM, Nginx Unit, React PHP and RoadRunner



    Testing was done using Yandex Tank.
    Symfony 4 and PHP 7.2 were used as applications.
    The goal was to compare the characteristics of services under different loads and find the best option.
    For convenience, everything is collected in docker-containers and lifted using docker-compose.
    Under the cut a lot of tables and graphs.

    The source code is here .
    All command examples described in the article should be executed from the project directory.


    application


    The application runs on symfony 4 and php 7.2.


    It responds to only one route and returns:


    • random number;
    • environment;
    • process pid;
    • the name of the service with which it works;
    • php.ini variables.

    Sample answer:


    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 is configured in each container:


    • OPcache enabled;
    • bootstrap cache is configured using composer;
    • The php.ini settings correspond to the best practices of symfony .

    Logs are written to stderr:
    /config/packages/prod/monolog.yaml


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

    The cache is written in / dev / shm:
    /src/Kernel.php


    ...
    classKernelextendsBaseKernel{
        publicfunctiongetCacheDir(){
            if ($this->environment === 'prod') {
                return'/dev/shm/symfony-app/cache/' . $this->environment;
            } else {
                return$this->getProjectDir() . '/var/cache/' . $this->environment;
            }
        }
    }
    ...

    Each docker-compose runs three main containers:


    • Nginx - reverse proxy server;
    • App - prepared application code with all dependencies;
    • PHP FPM \ Nginx Unit \ Road Runner \ React PHP - application server.

    Request processing is limited to two instances of the application (by the number of processor cores).


    Services


    PHP FPM


    PHP process manager. Written in C.


    Pros:


    • no need to keep track of memory;
    • You do not need to change anything in the application.

    Minuses:


    • for each PHP request must initialize the variables.

    The command to run the application with docker-compose:


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

    PHP PPM


    PHP process manager. Written in PHP.


    Pros:


    • initializes variables once and then uses them;
    • There is no need to change anything in the application (there are ready-made modules for symfony / Laravel, Zend, CakePHP).

    Minuses:


    • need to keep track of the memory.

    The command to run the application with docker-compose:


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

    Nginx unit


    Application server from the Nginx team. Written in C.


    Pros:


    • you can change the configuration of the HTTP API;
    • you can run multiple instances of the same application at the same time with different configurations and versions of languages;
    • no need to keep track of memory;
    • You do not need to change anything in the application.

    Minuses:


    • for each PHP request must initialize the variables.

    To transfer environment variables from the nginx-unit configuration file, you need to fix php.ini:


    ; Nginx Unit
    variables_order=E

    The command to run the application with docker-compose:


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

    React PHP


    Library for event programming. Written in PHP.


    Pros:


    • With the help of the library, you can write a server that will initialize the variables only once and continue to work with them.

    Minuses:


    • you must write the code for the server;
    • need to monitor the memory.

    If you use the --reboot-kernel-after-request flag for the worker , the symfony Kernel will be reinitialized for each request. With this approach, you do not need to monitor the memory.


    Code worker
    #!/usr/bin/env php<?phpuseApp\Kernel;
    useSymfony\Component\Debug\Debug;
    useSymfony\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);
        }
        returnnew 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();

    The command to run the application with docker-compose:


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

    Road runner


    Web server and PHP process manager. Written in Golang.


    Pros:


    • You can write a worker who will initialize the variables only once and continue to work with them.

    Minuses:


    • it is necessary to write the code for the worker;
    • need to monitor the memory.

    If you use the --reboot-kernel-after-request flag for the worker , the symfony Kernel will be reinitialized for each request. With this approach, you do not need to monitor the memory.


    Code worker
    #!/usr/bin/env php<?phpuseApp\Kernel;
    useSpiral\Goridge\SocketRelay;
    useSpiral\RoadRunner\PSR7Client;
    useSpiral\RoadRunner\Worker;
    useSymfony\Bridge\PsrHttpMessage\Factory\DiactorosFactory;
    useSymfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
    useSymfony\Component\Debug\Debug;
    useSymfony\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);
        }
    }

    The command to run the application with docker-compose:


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

    Testing


    Testing was done using Yandex Tank.
    The application and Yandex Tank were on different virtual servers.


    Virtual server specifications with application:
    Virtualization : KVM
    CPU : 2 cores
    RAM : 4096 MB
    SSD : 50 GB
    Connection : 100MBit
    OS : CentOS 7 (64x)


    Tested services:


    • php-fpm
    • php-ppm
    • nginx unit
    • road-runner
    • road-runner-reboot (with --reboot-kernel-after-request flag )
    • react-php
    • react-php-reboot (with the --reboot-kernel-after-request flag )

    The php-fpm-80 service has been added for the 1000/1000 rps tests. The php-fpm
    configuration has been used for it:


    pm = dynamic
    pm.max_children = 80

    Yandex Tank determines in advance how many times he needs to shoot at the target, and does not stop until the ammo runs out. Depending on the speed of the service response, the test time may be longer than specified in the test configuration. Because of this, the graphics of different services may have different lengths. The slower the service responds, the longer its schedule will be.


    For each service and configuration Yandex Tank was conducted only one test. Because of this, the numbers may be inaccurate. It was important to evaluate the characteristics of the services relative to each other.


    100 rps


    Phantom Yandex Tank configuration


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

    Links with a detailed report



    Percentile response time


    95% (ms)90% (ms)80% (ms)50% (ms)HTTP OK (%)HTTP OK (count)
    php-fpm9.96.34.353.5910057030
    php-ppm9.463.883.1610057030
    nginx uniteleven6.64.433.6910057030
    road-runner8.15.13.532.9210057030
    road-runner-reboot128.65.33.8510057030
    react-php8.54.913.292.7410057030
    react-php-reboot138.55.53.9510057030

    Monitoring


    cpu median (%)cpu max (%)memory median (MB)memory max (MB)
    php-fpm9.1512.58880.32907.97
    php-ppm7.0813.68901.72913.80
    nginx unit9.5612.54923.02943.90
    road-runner5.578.61992.711,001.46
    road-runner-reboot9.1812.67848.43870.26
    react-php4.536.581,004.681,009.91
    react-php-reboot9.6112.67885.92892.52

    Charts



    Graph 1.1 Average response time per second



    Chart 1.2 Average processor load per second



    Chart 1.3 Average Memory Consumption Per Second


    500 rps


    Phantom Yandex Tank configuration


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

    Links with a detailed report



    Percentile response time


    95% (ms)90% (ms)80% (ms)50% (ms)HTTP OK (%)HTTP OK (count)
    php-fpm138.45.33.69100285030
    php-ppm1594.723.24100285030
    nginx unit12eight5.53.93100285030
    road-runner9.663.712.83100285030
    road-runner-reboot14eleven7.14.45100285030
    react-php9.35.83.572.68100285030
    react-php-reboot15127.24.21100285030

    Monitoring


    cpu median (%)cpu max (%)memory median (MB)memory max (MB)
    php-fpm41.6848.331,006.061,015.09
    php-ppm33.9048.901,046.321,055.00
    nginx unit42.1347.921,006.671,015.73
    road-runner24.0828.061,035.861,044.58
    road-runner-reboot46.2352.04939.63948.08
    react-php19.5723.421,049.831,060.26
    react-php-reboot41.3047.89957.01958.56

    Charts



    Graph 2.1 Average response time per second



    Graph 2.2 Average CPU load per second



    Graph 2.3 Average Memory Consumption Per Second


    1000 rps


    Phantom Yandex Tank configuration


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

    Links with a detailed report



    Percentile response time


    95% (ms)90% (ms)80% (ms)50% (ms)HTTP OK (%)HTTP OK (count)
    php-fpm1105011050904019580.6772627
    php-fpm-8031501375116515299.8589895
    php-ppm278527402685254510090030
    nginx unit9880602110090030
    road-runner27157.13.2110090030
    road-runner-reboot111011001085106010090030
    react-php23135.62.8610090030
    react-php-reboot2824nineteeneleven10090030

    Monitoring


    cpu median (%)cpu max (%)memory median (MB)memory max (MB)
    php-fpm12.6678.25990.161,006.56
    php-fpm-8083.7891.28746.01937.24
    php-ppm66.1691.201,088.741,102.92
    nginx unit78.1188.771,010.151,062.01
    road-runner42.9354.231,010.891,068.48
    road-runner-reboot77.6485.66976.441,044.05
    react-php36.3946.311,018.031,088.23
    react-php-reboot72.1181.81911.28961.62

    Charts



    Graph 3.1 Average response time per second



    Graph 3.2 Average response time per second (without php-fpm, php-ppm, road-runner-reboot)



    Chart 3.3 Average processor load per second



    Graph 3.4 Average memory consumption per second


    10,000 rps


    Phantom Yandex Tank configuration


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

    Links with a detailed report



    Percentile response time


    95% (ms)90% (ms)80% (ms)50% (ms)HTTP OK (%)HTTP OK (count)
    php-fpm110501105011050188070.466317107
    php-fpm-80326031401360114599.619448301
    php-ppm2755273026952605100450015
    nginx unit102010101000980100450015
    road-runner640630615580100450015
    road-runner-reboot1130112011101085100450015
    react-php1890109010455899.996449996
    react-php-reboot3480307012559199.72448753

    Monitoring


    cpu median (%)cpu max (%)memory median (MB)memory max (MB)
    php-fpm5.5779.35984.47998.78
    php-fpm-8085.0592.19936.64943.93
    php-ppm66.8682.411,089.311,097.41
    nginx unit86.1493.941,067.711,069.52
    road-runner73.4182.721,129.481,134.00
    road-runner-reboot80.3286.29982.69984.80
    react-php73.7682.181,101.711,105.06
    react-php-reboot85.7791.92975.85978.42


    Chart 4.1 Average response time per second



    Graph 4.2 Average response time per second (without php-fpm, php-ppm)



    Chart 4.3 Average CPU Load per second



    Graph 4.4 Average memory consumption per second


    Results


    Here are collected graphs showing the change in the characteristics of services depending on the load. When viewing charts, it should be borne in mind that not all services answered 100% of requests.



    Graph 5.1 95% response time percentile



    Graph 5.2 95% response percentile percentage (without php-fpm)



    Chart 5.3 Maximum processor load



    Chart 5.4 Maximum Memory Consumption


    The best solution (without changing the code), in my opinion, is the Nginx Unit process manager. He shows good results in speed of response and has the support of the company.


    In any case, the development approach and tools need to be chosen individually, depending on your workload, server resources, and developer capabilities.


    UPD
    The php-fpm-80 service has been added to the 1000/1000 rps test. The php-fpm
    configuration has been used for it:


    pm = dynamic
    pm.max_children = 80

    Also popular now: