Сравнение php-fpm, nginx-unit и laravel-octane

Статья посвящена сравнению производительности различных веб-серверов для приложения, построенного на базе laravel. Ниже будет много графиков и параметров конфигурации и мои личные выводы, никак не претендующие на вселенскую истину.

Сам я давно работаю с nginx-unit (+ lumen), однако при получении новых проектов очень часто вижу использование php-fpm. На предложения перейти на nginx-unit, возникает вполне логичный вопрос — «а чем он лучше?». Поиск соответствующих статей в интернете дал мало результатов, в основном это достаточно старые и малоинформативные статьи на зарубежных сайтах, а так же статья от 2019 года на хабре, где проводилось аналогичное сравнение нескольких веб-серверов для приложения на базе symfony. Но, во-первых, php и nginx-unit уже ушли далеко вперед за время, прошедшее после публикации статьи, а во-вторых, меня интересовала именно производительность в связке с laravel и lumen. В связи с этим я собрал простейший тестовый стенд из нескольких веб-серверов и сделал небольшое нагрузочное тестирование, результатами которого и хочу поделиться.

О тестовом стенде

Характеристики тестового стенда:

  • CPU: AMD Ryzen 9 5900×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

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

С ним все просто, создана минимальная конфигурация для сбора данных о потребляемых ресурсах

Конфигурация


  
           
  

Нагрузочное тестирование и результаты

Для тестирования все участники действия были жестко ограничены по количеству логических ядер процессора:

  • Контейнер с приложением: 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 является тем самым «оружием массового поражения», которое далеко не везде стоит применять, тут Тейлор абсолютно прав. Для каждой задачи — свой инструмент.

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

© Habrahabr.ru