Тестирование производилось с помощью 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:
- включен OPcache;
- настроен bootstrap кеш с помощью composer;
- настройки php.ini соответствуют лучшим практикам Symfony.
Логи пишутся в 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 FPMNginx UnitRoad RunnerReact 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 AppKernel;
use SymfonyComponentDebugDebug;
use SymfonyComponentHttpFoundationRequest;
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 = ReactEventLoopFactory::create();
$kernel = new Kernel($env, $debug);
$kernel->boot();
$rebootKernelAfterRequest = in_array('--reboot-kernel-after-request', $argv);
/** @var PsrLogLoggerInterface $logger */
$logger = $kernel->getContainer()->get('logger');
$server = new ReactHttpServer(function (PsrHttpMessageServerRequestInterface $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 SymfonyComponentHttpFoundationRequest(
$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 SymfonyComponentHttpFoundationResponse('Internal server error', 500);
} catch (Throwable $e) {
$logger->error('Internal server error', ['error' => $e->getMessage(), 'trace' => $e->getTraceAsString()]);
$sfResponse = new SymfonyComponentHttpFoundationResponse('Internal server error', 500);
}
$kernel->terminate($sfRequest, $sfResponse);
if ($rebootKernelAfterRequest) {
$kernel->reboot(null);
}
return new ReactHttpResponse(
$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 ReactSocketServer('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 AppKernel;
use SpiralGoridgeSocketRelay;
use SpiralRoadRunnerPSR7Client;
use SpiralRoadRunnerWorker;
use SymfonyBridgePsrHttpMessageFactoryDiactorosFactory;
use SymfonyBridgePsrHttpMessageFactoryHttpFoundationFactory;
use SymfonyComponentDebugDebug;
use SymfonyComponentHttpFoundationRequest;
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 МБ
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)
Ссылки с детальным отчетом
- php-fpm https://overload.yandex.net/150666
- php-ppm https://overload.yandex.net/150670
- nginx-unit https://overload.yandex.net/150675
- road-runner https://overload.yandex.net/150681
- road-runner-reboot https://overload.yandex.net/151961
- react-php https://overload.yandex.net/150697
- react-php-reboot https://overload.yandex.net/152063
Перцентили времени ответа
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)
Ссылки с детальным отчетом
- php-fpm https://overload.yandex.net/150705
- php-ppm https://overload.yandex.net/150710
- nginx-unit https://overload.yandex.net/150711
- road-runner https://overload.yandex.net/150715
- road-runner-reboot https://overload.yandex.net/152011
- react-php https://overload.yandex.net/150717
- react-php-reboot https://overload.yandex.net/152064
Перцентили времени ответа
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)
Ссылки с детальным отчетом
- php-fpm https://overload.yandex.net/150841
- php-ppm https://overload.yandex.net/150842
- nginx-unit https://overload.yandex.net/150843
- road-runner https://overload.yandex.net/150844
- road-runner-reboot https://overload.yandex.net/152068
- react-php https://overload.yandex.net/150846
- react-php-reboot https://overload.yandex.net/152065
Перцентили времени ответа
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 |
Графики
График 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)
Ссылки с детальным отчетом
- php-fpm https://overload.yandex.net/150849
- php-ppm https://overload.yandex.net/150874
- nginx-unit https://overload.yandex.net/150876
- road-runner https://overload.yandex.net/150881
- road-runner-reboot https://overload.yandex.net/152069
- react-php https://overload.yandex.net/150885
- react-php-reboot https://overload.yandex.net/152066
Перцентили времени ответа
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 |
График 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. Он показывает хорошие результаты в скорости ответа и имеет поддержку компании.
В любом случае подход к разработке и инструменты нужно выбирать индивидуально в зависимости от ваших нагрузок, ресурсов сервера и возможностей разработчиков.
Автор: mrsuh