Статья посвящена сравнению производительности различных веб-серверов для приложения, построенного на базе laravel. Ниже будет много графиков и параметров конфигурации и мои личные выводы, никак не претендующие на вселенскую истину.
Сам я давно работаю с nginx-unit (+ lumen), однако при получении новых проектов очень часто вижу использование php-fpm. На предложения перейти на nginx-unit, возникает вполне логичный вопрос - "а чем он лучше?". Поиск соответствующих статей в интернете дал мало результатов, в основном это достаточно старые и малоинформативные статьи на зарубежных сайтах, а так же статья от 2019 года на хабре, где проводилось аналогичное сравнение нескольких веб-серверов для приложения на базе symfony. Но, во-первых, php и nginx-unit уже ушли далеко вперед за время, прошедшее после публикации статьи, а во-вторых, меня интересовала именно производительность в связке с laravel и lumen. В связи с этим я собрал простейший тестовый стенд из нескольких веб-серверов и сделал небольшое нагрузочное тестирование, результатами которого и хочу поделиться.
Характеристики тестового стенда:
CPU: AMD Ryzen 9 5900X 12-Core
RAM: DDR4 4000 MHz 32GiB x2
SSD: Samsung SSD 980 PRO 500GB nvme
OS: xubuntu 20.04
Приложения для тестирования:
Laravel 8.69.0
Laravel является одним из наиболее популярных фреймворков для php. Из коробки содержит в себе огромное количество функционала, позволяющего решать почти любые задачи бизнеса в разумные сроки. При этом он достаточно гибок, и почти любую задачу можно было реализовать далеко не 1 способом, ровно как и спроектировать само приложение (правда здесь кроется и минус - задачу можно решить так, что потом еще и куча проблем добавится от поддержки говнокода, до падения производительности и утечек памяти).
Lumen 8.3.1
Облегченная версия Laravel, созданная в основном для реализации api-приложений. В части функциональности сохранил все самые важные компоненты Laravel, а то, что не является критически важным (например, фасады), урезано. При этом он более производителен чем Laravel, но далеко не все задачи на нем решать выгодно. Как это обычно бывает, для каждой конкретной задачи есть свой оптимальный инструмент. Для одних задач выгоднее использовать laravel, для других lumen, для третьих php использовать не выгодно вовсе.
Веб серверы:
Php-fpm
Стандартный менеджер php процессов. Для каждого запроса требуется инициализация фреймворка.
Nginx unit
Веб-сервер приложений, разработанный командой nginx. Для каждого запроса требуется инициализация фреймворка.
Laravel-octane
Строго говоря, это не веб-сервер, а пакет управления приложением от команды laravel. А вот под капотом использует веб-сервер Swoole или RoadRunner . Фреймворк инициализируется при первом запросе, далее хранится в памяти и не реинициализируется при последующих запросах.
Версия php во всех сборках: 8.0.12
Инструменты для тестирования:
Yandex tank для проведения нагрузочного тестирования. Используется самый свежий из доступных docker-образ.
Telegraf (входит в набор инструментов yandex tank) для сбора статистики по используемым приложениями ресурсам компьютера.
Overload (так же из комплекта yandex tank) для построения графиков.
Приложения
Все приложения имеют стандартную конфигурацию. Логирование ошибок отключено. Кэш собирается при первом обращении только для компонентов laravel/lumen (таких, как роутер и т.п.), для контроллеров кэша нет. У приложения 1 эндпоинт, который при обращении обрабатывает реквест, и возвращает встроенный json респонс, содержащий случайное число, параметр конфигурации, статическую строку и заголовки запроса. Пример ответа сервиса:
{
"app_env": "prod",
"type": "unit-lumen",
"number": 1527706674,
"headers": {
"accept-language": [
"ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7"
],
"accept-encoding": [
"gzip, deflate"
],
"accept": [
"text\/html,application\/xhtml+xml,application\/xml;q=0.9,image\/avif,image\/webp,image\/apng,*\/*;q=0.8,application\/signed-exchange;v=b3;q=0.9"
],
"user-agent": [
"Mozilla\/5.0 (X11; Linux x86_64) AppleWebKit\/537.36 (KHTML, like Gecko) Chrome\/93.0.4577.99 Safari\/537.36"
],
"upgrade-insecure-requests": [
"1"
],
"connection": [
"keep-alive"
],
"host": [
"10.100.9.3"
],
"content-length": [
""
],
"content-type": [
""
]
}
}
Все приложения поднимаются в docker-контейнерах. Во всех приложениях используется кастомный php.ini, со следующими параметрами:
upload_max_filesize = 50M
post_max_size = 50M
opcache.enable=1
opcache.interned_strings_buffer=64
opcache.max_accelerated_files=100000
opcache.memory_consumption=128
opcache.save_comments=1
opcache.revalidate_freq=0
opcache.validate_timestamps=0
opcache.max_wasted_percentage=10
apc.enable_cli=1
memory_limit=256M
Отдельные изменения, если таковые имеются, описаны для каждого приложения.
Для тестирования php-fpm используется дополнительный контейнер с nginx, поскольку php-fpm работает через протокол FastCGI. Используется официальный образ nginx:1.19.6-alpine.
nginx.conf
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 2048;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
resolver_timeout 10s;
server_tokens off;
keepalive_timeout 3;
reset_timedout_connection on;
client_body_timeout 2;
send_timeout 1;
server_names_hash_bucket_size 128;
client_max_body_size 32m;
proxy_buffers 4 512k;
proxy_buffer_size 256k;
proxy_busy_buffers_size 512k;
gzip on;
gzip_comp_level 9;
gzip_disable "msie6";
gzip_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript image/svg+xml;
include /etc/nginx/conf.d/*.conf;
}
default.conf
server {
listen 80;
server_name localhost;
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
location / {
try_files $uri /index.php$is_args$args;
}
location ~ ^/index\.php(/|$) {
fastcgi_pass 10.100.9.100:9000;
fastcgi_split_path_info ^(.+\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/public/$fastcgi_script_name;
}
}
Подопытные кролики, они же собранные контейнеры
1. nginx + php-fpm + laravel
Php-fpm: используется официальный образ php:8.0.12-fpm
Изменения php.ini:
memory_limit=512M
pm = static
pm.max_children = 100
pm.max_requests = 1000
Тут сразу стоит оговориться: изначально планировалось что каждому веб-серверу будет выделено 16 воркеров по числу логических ядер (об этом ниже), но с php-fpm возникли проблемы в процессе тестирования, пришлось выдавать ему больше воркеров.
2. nginx + php-fpm + lumen
Конфигурация полностью идентична "nginx + php-fpm + laravel"
3. nginx-unit + laravel
Так вышло, что немногим ранее я уже собирал контейнер с nginx-unit для php8, поскольку нормально работающих контейнеров, отвечающих моим производственным задачам не было. Не долго думая, именно этот контейнер я и использовал как основу, чуть доработав под текущее тестирование. Можно заметить, что сборка производилась на базе ubuntu:hirsute, поскольку с apline я не очень дружу, да и разбираться тогда времени с ним не было. Нужна была сборка здесь и сейчас, при этом не планировалось что она дойдет до прода в первозданном виде. Хотя, учитывая результаты тестов, теперь может и дойти.
Параметры nginx-unit:
{
"listeners": {
"*:80": {
"pass": "routes"
}
},
"routes": [
{
"match": {
"uri": [
"*.manifest",
"*.appcache",
"*.html",
"*.json",
"*.rss",
"*.atom",
"*.jpg",
"*.jpeg",
"*.gif",
"*.png",
"*.ico",
"*.cur",
"*.gz",
"*.svg",
"*.svgz",
"*.mp4",
"*.ogg",
"*.ogv",
"*.webm",
"*.htc",
"*.css",
"*.js",
"*.ttf",
"*.ttc",
"*.otf",
"*.eot",
"*.woff",
"*.woff2",
"/robot.txt"
]
},
"action": {
"share": "/var/www/public"
}
},
{
"action": {
"pass": "applications/php"
}
}
],
"applications": {
"php": {
"type": "php 8.0",
"limits": {
"requests": 1000,
"timeout": 60
},
"processes": {
"max": 16,
"spare": 16,
"idle_timeout": 30
},
"user": "www-data",
"group": "www-data",
"working_directory": "/var/www/",
"root": "/var/www/public",
"script": "index.php",
"index": "index.php"
}
},
"access_log": "/dev/stdout"
}
4. nginx-unit + lumen
Конфигурация полностью идентична "nginx-unit + laravel"
5. laravel-octane (swoole) + laravel
Добавить к тестируемым приложениям octane меня побудило сообщение Тейлора (автора laravel) в твиттере. Меня смутили его утверждения "октан быстрее люмена" и "немного увеличить скорость". В связи с чем к тестам был добавлен наскоро собранный контейнер с laravel-octane, работающим на основе swoole.
Laravel-octane поддерживает помимо swoole еще и roadranner, и изначально я планировал протестировать и его. Но как ни странно, мне не удалось заставить работать последний нормально даже на костылях внутри докера (если быть точнее, удалось, но автодеплой он не переживал, только ручной старт), в связи с чем в последствие я от него отказался. Тем не менее, swoole мне было достаточно. Кроме того, Laravel-octane+swoole добавляют дополнительные инструменты, которые могут пригодиться при решении ряда стоящих передо мной задач.
.env
OCTANE_SERVER=swoole
Все остальные параметры передаются прямо в команде запуска сервера:
php artisan octane:start --port=80 --workers=16 --max-requests=1000 --host=host.docker.internal
Yandex tank
Для тестирование было принято решение использовать 2 различных профиля нагрузки, отражающих 2 ситуации: "стандарт" для ежедневных часто встречающихся нагрузок, "стресс" для понимания границ работоспособности системы.
Задача теста - понять насколько эффективно приложение справится со штатными нагрузками, с которыми я встречаюсь постоянно.
phantom:
address: 10.100.9.101
uris:
- /
load_profile:
load_type: rps
schedule: step(5, 100, 5, 10s) const(100, 2m30s)
timeout: 2s
console:
enabled: true
telegraf:
config: 'monitoring-nginx-unit-laravel.xml'
enabled: true
kill_old: false
package: yandextank.plugins.Telegraf
ssh_timeout: 30s
overload:
enabled: true
package: yandextank.plugins.DataUploader
token_file: 'overload_token.txt'
Идея теста в том, чтобы определить на что способен каждый конкретный веб-сервис и какое количество пользователей он способен выдержать стабильно и без сбоев. Профиль нагрузки:
phantom:
address: 10.100.9.101
uris:
- /
load_profile:
load_type: rps # schedule load by defining requests per second
schedule: line(1, 1000, 10m)
timeout: 2s
console:
enabled: true
telegraf:
config: 'monitoring-nginx-unit-laravel.xml'
enabled: true
kill_old: false
package: yandextank.plugins.Telegraf
ssh_timeout: 30s
overload:
enabled: true
package: yandextank.plugins.DataUploader
token_file: 'overload_token.txt'
Таймаут 2 секунды был выбран не случайно. Во-первых, в в api-приложениях если эндпоинт отвечает слишком долго, чаще всего это бывает фатально для работоспособности приложения (я часто применяю api-gateway в рамках микросервисной архитектуры, и в нем всегда жестко задаю таймаут на запрос в зависимости от задач от 1 до 3 секунд, не более. Но при этом, если эндпоинт действительно ворочает тяжелую логику - я всегда вытаскиваю подобные вещи в джобы). Во-вторых, при тестировании php-fpm yandex tank'у с дефолтным таймаутом 11 секунд не хватало ресурсов для обеспечения нагрузки, т.к. он слишком долго ожидал ответа (об этом далее).
Telegraf
С ним все просто, создана минимальная конфигурация для сбора данных о потребляемых ресурсах
<Monitoring>
<Host address="10.100.9.101" interval="1" username="root">
<CPU/> <Kernel/> <Net/> <System/> <Memory/> <Disk/> <Netstat /> <Nstat/>
</Host>
</Monitoring>
Для тестирования все участники действия были жестко ограничены по количеству логических ядер процессора:
Контейнер с приложением: 16 ядер
Nginx (для тестов с php-fpm): 2 ядра
Yandex tank: 4 ядра (на 2 ядрах нехватало ресурсов для работы с php-fpm даже с уменьшенным таймаутом)
Чрезвычайно важный и крайне приоритетный процесс проталкивания /dev/zero в /dev/null (вопросы излишни, мой личный костыль, чтобы упростить себе жизнь): строго 1 полностью загруженное ядро
Все процессы ОС: оставшееся 1 ядро, фактически свободное, про запас
Все приложения тестировались с "холодным стартом", т.е. эндпоинт не подогревался, кэш не собирался до запуска теста.
Далее пойдут графики тестирования. Если нет графика ошибок - значит их не было.
График нагрузки на память не прикладывал, чтобы не увеличивать объем статьи до бесконечности. В синтетических тестах память почти не потребляется, и разница минимальна. Примерно по тем же соображениям у контейнеров не было ограничений по используемой памяти, благо ее хватает.
Профиль нагрузки "стандарт"
1. nginx + php-fpm + laravel
Детальный отчет: https://overload.yandex.net/479324
2. nginx + php-fpm + lumen
Детальный отчет: https://overload.yandex.net/479325
3. nginx-unit + laravel
Детальный отчет: https://overload.yandex.net/479330
4. nginx-unit + lumen
Детальный отчет: https://overload.yandex.net/479332
5. octane (swoole) + laravel
Детальный отчет: https://overload.yandex.net/479339
Перцентили времени ответа (ms)
99% |
98% |
95% |
90% |
85% |
80% |
75% |
50% |
HTTP OK % |
|
nginx + php-fpm + laravel |
60 |
59 |
56 |
52 |
48 |
46 |
45 |
44 |
100 |
nginx + php-fpm + lumen |
18 |
18 |
17 |
16 |
16 |
15 |
15 |
14 |
100 |
nginx-unit + laravel |
7.6 |
7 |
6.5 |
5.8 |
5.3 |
5.2 |
5.2 |
4.059 |
100 |
nginx-unit + lumen |
1.930 |
1.870 |
1.640 |
1.520 |
1.460 |
1.410 |
1.320 |
1.070 |
100 |
octane (swoole) + laravel |
1.230 |
1.200 |
1.160 |
1.110 |
1.050 |
1.010 |
0.980 |
0.800 |
100 |
Профиль нагрузки "стресс"
1. nginx + php-fpm + laravel
Детальный отчет: https://overload.yandex.net/479202
Тестировать пришлось несколько раз. Первый раз, когда php-fpm было выделено 16 воркеров, а nginx имел настройки по умолчанию, вся эта сборка схлопнулась в черную дыру и на 120 rps начала орать об ошибках и недостаточном количестве воркеров (причем оба одновременно). После этого nginx был перенастроен на адекватную конфигурацию, а php было выделено 100 и 250 воркеров (2 теста, в итоге остановился на 100, для 250 не хватало логических ядер). Поскольку тестирование этим профилем нагрузки я проводил первым, в дальнейшем эта конфигурация и была использована на всех остальных тестах. Кроме того, я столкнулся с тем, что на отметке ~120 rps php-fpm резко перестает справляться с нагрузками, запросы зависают, и yandex tank с дефолтным таймаутом запроса в 11 секунд начинает загибаться, ему банально не хватает ресурсов для обеспечения требуемого количества запросов. В связи с этим я выделил танку 4 ядра (изначально было 2), и снизил таймаут до 2 секунд. На графике хорошо видна эта 2-секундная отсечка, когда php-fpm загнулся. Но выводы будут ниже.
2. nginx + php-fpm + lumen
Детальный отчет: https://overload.yandex.net/479210
3. nginx-unit + laravel
Детальный отчет: https://overload.yandex.net/479184
4. nginx-unit + lumen
Детальный отчет: https://overload.yandex.net/479207
В процессе тестирования произошла 1 ошибка с http кодом 500 именно от самого приложения. В связи с отключенными логами, понять что именно случилось невозможно, и больше она не воспроизводилась при повторных прогонах. Однако, учитывая что на 300299 успешных запросов 1 оказался ошибочным, я посчитал что эту ошибку можно проигнорировать.
5. octane (swoole) + laravel
Детальный отчет: https://overload.yandex.net/479204
Перцентили времени ответа (ms)
* Звездочкой в таблице будут отмечены сборки на базе php-fpm, которые не выдержали нагрузки вообще. У них будет указана отсечка rps, после которой они начали умирать. Как следствие, перцентили даны до этой отсечки. Я решил все же добавить их в таблицу, хотя бы в таком виде.
Сборка на базе laravel-octane указана дважды. Полная статистика, когда сервер не выдержал нагрузок, и статистика до точки смерти. Как и с php-fpm - отмечена звездочкой с указанием порогового rps.
99% |
98% |
95% |
90% |
85% |
80% |
75% |
50% |
HTTP OK % |
|
* nginx + php-fpm + laravel ~120 rps |
62 |
59 |
55 |
52 |
49 |
47 |
45 |
43 |
- |
* nginx + php-fpm + lumen ~400 rps |
34 |
25 |
19 |
17 |
16 |
16 |
15 |
14 |
- |
nginx-unit + laravel |
6.6 |
6 |
5.5 |
5.2 |
4.96 |
4.7 |
4.5 |
3.79 |
100 |
nginx-unit + lumen |
1.77 |
1.56 |
1.4 |
1.25 |
1.17 |
1.13 |
1.08 |
0.91 |
100 |
octane (swoole) + laravel |
18 |
8.3 |
4.85 |
3.88 |
3.45 |
3.1 |
2.85 |
1.87 |
82.807 |
* octane (swoole) + laravel ~600 rps |
3.23 |
3.01 |
2.85 |
2.55 |
2.2 |
2.029 |
1.92 |
0.78 |
- |
Итоги тестирования меня, надо признать, удивили. Я конечно ожидал, что php-fpm будет медленнее чем, nginx-unit, но не настолько: разница во времени генерации ответа почти в 10 раз. Так же меня удивило то, что php-fpm оказался единственным, который под стресс тестом захлебнулся вообще. Возможно, это связано с тем, что я не являюсь экспертом в конфигурации nginx и php, но остальные сервера, работавшие на том же конфиге, стресс тест выдержали, хоть и с потерями. В итоге, я пришел к выводу, что я не зря давно уже пересел на nginx-unit.
Что касается сравнения производительности: если не брать в расчет php-fpm, то nginx-unit единственный, кто выдержал стресс тест без потерь, хотя на octane я возлагал большие надежды. В связи с этим, php-fpm я дальше учитывать в своих рассуждениях не буду.
Скачки времени выполнения для 100% перцентиля наблюдаются и у nginx-unit и у octane+swoole, но в обоих случаях не сказать, чтобы они были катастрофическими, а причины их могут быть вовсе в том, что все тестирование произодилось на "слегка" не предназначенном для этого железе и окружении. Опять же, все тесты были сугубо синтетическими, и в реальных условиях, когда приложение будет помимо всего прочего и бизнес логику ворочать, и в базу и кэш ходить, ситуация будет иная.
Технически, octane будет быстрее в реальных условиях, за счет сохранения состояния приложения и всех соединений. Кроме того, у swoole есть собственные реализации высокопроизводительного кэша и некоторые другие "примочки", которые позволят ускорить работу тяжелой бизнес логики. Но есть и своя цена: во-первых, все упрется в то, что ему нужно больше оперативной памяти (я понимаю, что цена на оперативную память в дата-центрах не высока для бизнеса, но учитывать это стоит), а во-вторых, что еще важнее, octane крайне недружелюбен к говнокоду - ошибок он не прощает. На официальном сайте есть даже раздел с примерами реализации, работающими на других серверах, но которые приведут к неработоспособности приложения конкретно на octane.
С другой стороны, nginx-unit оказался более стабилен при высоких нагрузках. И при этом в синтетических тестах в связке с lumen почти не проигрывает octane, так что я не зря сомневался в словах Тейлора. Lumen еще поживет и продолжит удивлять (во всяком случае меня, я не ожидал от него такой прыти по сравнению с laravel).
В итоге для себя я сделал вывод, что в случае, если требуется предельно стабильный сервис, который будет жить даже там, где другие умирают, то это nginx-unit. А чтобы он жил очень хорошо, с ним все же выгодно использовать lumen, если речь об api. Если же требуется сервис, который должен быть предельно быстрым, и обсчитывать сложную бизнес логику на лету - то это octane, если того позволяют ресурсы (учитывая как оперативную память, так и то, что при ожидании высоких нагрузок, стоит позаботиться о балансировщике и горизонтальном масштабировании заранее). В данном случае octane является тем самым "оружием массового поражения", которое далеко не везде стоит применять, тут Тейлор абсолютно прав. Для каждой задачи - свой инструмент.
Надеюсь, статья кому-либо поможет выбрать инструмент для решения своих задач.
на главную сниппетов