haiodo.blog
Бенчмарк, который померил не то

Бенчмарк, который померил не то

Перепроверил статью про HAProxy, Envoy, nginx, Traefik и Caddy. Весь их рейтинг переворачивается от одной строчки конфига, а nginx по дороге потерял половину скорости на смене дефолта.

С чего началось


Мы держим Traefik. Живём с ним давно, он стабилен, и никаких причин его трогать не было.

Потом мне попалась статья на Хабре от апреля 2025-го. Пять реверс-прокси на 50 000 запросов в секунду. Итог такой:

HAProxyEnvoynginxTraefikCaddy
RPS49 72849 7499 1584 6593 354
ошибок0%0%10.2%32.8%35.9%
p951.16 ms3.34 ms3.82 s8.46 s17.03 s
CPU9 ядер16 ядер30 ядер30 ядер30 ядер

Traefik “не справился с нагрузкой”. Тот самый, который у нас в проде не жалуется.

Дальше можно было пойти двумя путями. Поверить и начать мигрировать. Или проверить.


Первое, что царапнуло

Открываем конфиги из статьи. Вот nginx:

location / {
    proxy_pass http://10.0.0.4:8080;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    ...
}

Вот HAProxy целиком:

defaults
    mode http

frontend main
    bind *:443 ssl crt /usr/local/etc/haproxy/certs/haproxy.pem
    default_backend test_backend

backend test_backend
    server test_backend_server 10.0.0.8:8080 check

Ни у кого не настроен пул соединений до бэкенда. Ни у кого. И это выглядит честно - все в равных условиях, все на дефолтах.

Только дефолты у них разные:

проксипул до бэкенда по умолчанию
HAProxyпереиспользование включено
Envoyпул без явного лимита
TraefikmaxIdleConnsPerHost = 200
Caddykeepalive_idle_conns_per_host = 32
nginxкэша нет, если не завёл upstream-блок

Сравните этот столбик с рейтингом из статьи. Это один и тот же порядок.

Совпадение? Надо мерить.


Стенд

Собрал стенд, всё в Docker на одной машине. Бэкенд, прокси и генератор нагрузки разведены по непересекающимся наборам ядер через --cpuset-cpus - иначе меряется не прокси, а драка трёх процессов за планировщик.

рольядра
бэкенд (Go + fasthttp, отдаёт hello world)0-2
прокси3-6
генератор нагрузки7-11

Железо и окружение:

машинаMacBook Pro, Apple M4 Max, 16 ядер (12 performance + 4 efficiency), 48 GB
ОСmacOS 26.5.2 (25F84), arm64
DockerDocker Desktop 29.7.2, Apple Virtualization Framework + VirtioFS
VM12 vCPU, 12 GiB RAM, swap 4 GiB, ядро 7.0.12-linuxkit, overlay2, aarch64
сборка Go-бинарейgolang:1.25-alpine, go1.25.14
обработка результатовPython 3.14.4

Образы - официальные, по тегу latest на 23 августа 2026:

образчто внутри
haproxy:latestHAProxy 3.4.3, OpenSSL 3.5.6
envoyproxy/envoy:distroless-v1.36-latestEnvoy 1.36.9, BoringSSL
nginx:latestnginx 1.31.4, OpenSSL
traefik:latestTraefik 3.7.11, Go 1.26.6
caddy:latestCaddy 2.11.4
grafana/k6:latestk6 2.2.0
alpine:3wrk 4.x, curl, openssl

Для сравнения с апрелем 2025 брал haproxy:3.1.6, nginx:1.27.4, envoyproxy/envoy:v1.34.0, traefik:v3.3.5, caddy:2.9.1. Для бисекта nginx - все теги от 1.27.4 до 1.31.4. Полный список с sha256-дайджестами лежит в репозитории.

Честная оговорка: 12 vCPU виртуалки ложатся на 16 физических ядер M4 Max, где ядра разные - performance и efficiency. Какой vCPU куда попадёт, я не контролирую. Поэтому абсолютные RPS тут - цифры этой конкретной машины, а не паспортные характеристики прокси. Сравнивать между собой их можно (все прогоны в одинаковых условиях, повторы сходятся в пределах 2-5%), переносить на свой кластер - нет.

Главное решение - не мерить RPS как основную метрику. RPS зависит от того, сколько ядер вы дали и не упёрся ли генератор. Мерю микросекунды CPU прокси на один запрос. Это свойство прокси и его конфига, а не стенда. CPU беру кумулятивно из Docker API, а не усреднённым процентом из docker stats.

Весь стенд лежит на github.com/haiodo/reverse-proxy-bench. Три команды - и вы повторяете всё сами.


Замер первый: те же конфиги

Клиент с keep-alive, ECDSA P-256, 200 одновременных соединений.

проксиRPSp99µs CPU/запрос
HAProxy223 2201.51 ms17.8
Envoy121 9928.73 ms32.7
Traefik94 1026.49 ms41.5
Caddy53 20512.75 ms71.2
nginx32 419354 ms121.8

Первое, что бросается в глаза: HAProxy на четырёх ядрах ноутбука выдал 223 тысячи. В статье он на 32 vCPU показал 50 тысяч и загрузил 9 ядер. Разница в эффективности - в десять раз. Значит, в той статье HAProxy не проксировал, а занимался чем-то другим.

Второе: Traefik на втором-третьем месте с нулём ошибок. Никакого “не справился”.

Порядок при этом ровно тот же, что в статье. Причину я уже назвал.


Замер второй: одна строчка

Добавляю каждому пул до бэкенда. Больше ничего не трогаю.

проксибылосталоµs CPU/запрос
nginx32 419205 308121.8 → 17.9
Caddy53 20564 89771.2 → 56.9
Traefik94 10298 84341.5 → 39.6
HAProxy223 220228 181уже был включён
Envoy121 992119 418уже был включён

nginx - в шесть раз. Хвост латентности p99 с 354 ms до 6.5 ms.

А если довести настройку до конца, nginx выдаёт 261 515 RPS и становится первым. Тот самый nginx, который в статье последний с 10% ошибок.


Замер третий: ручка в обратную сторону

Это важнее предыдущего. Беру тех, у кого пул включён по умолчанию, и выключаю его руками.

проксиручкабылосталоµs CPU/запрос
Envoymax_requests_per_connection: 1121 99231 97032.7 → 124.7
TraefikmaxIdleConnsPerHost: -194 10227 49741.5 → 131.7
Caddykeepalive off53 20534 46371.2 → 109.4
HAProxyoption http-server-close223 22082 38317.8 → 48.1

Все сваливаются в одну яму: 27-34 тысячи и 110-132 микросекунды. Ровно туда, где сидел nginx с дефолтом.

Вот и ответ. Разница между продуктами на порядок меньше, чем разница между “пул есть” и “пула нет” внутри любого из них. Рейтинг из статьи - это не рейтинг прокси. Это рейтинг их дефолтных настроек пула, отсортированный по размеру.

Кстати, отдельная засада: у HAProxy http-reuse never не выключает keep-alive. Он запрещает делить соединение между клиентами, но внутри сессии соединение живёт. Замер с never дал 233 тысячи, то есть ничего не изменилось. Реально выключает только option http-server-close.


Куда делся Traefik

Осталось объяснить, почему в статье Traefik не просто медленный, а именно разваливается: 33% ошибок, p95 восемь секунд.

Смотрим на конфиг Traefik из статьи. Он получает сертификат сам, через ACME:

--certificatesresolvers.myresolver.acme.httpchallenge=true
--certificatesresolvers.myresolver.acme.email=example@yandex.com

А остальные четверо берут готовый файл от certbot.

$ docker run --rm traefik:latest traefik --help | grep -A1 keytype
--certificatesresolvers.<name>.acme.keytype  (Default: "RSA4096")

RSA-4096. Caddy по умолчанию берёт ec256. certbot с версии 2.0 - тоже ECDSA P-256. Traefik остаётся единственным, кто подписывает каждый TLS-хендшейк самым дорогим ключом из существующих.

Померил стоимость полного хендшейка, микросекунды CPU:

проксиEC256RSA-2048RSA-4096
Envoy (BoringSSL)1644992 367
nginx (OpenSSL)2666142 544
HAProxy (OpenSSL)2836152 496
Traefik (Go)2821 0364 827
Caddy (Go)3551 1124 914

Дефолт Traefik стоит 4 827 µs. Дефолт Caddy - 355 µs. Разница в 13.6 раза, и она не про Traefik, а про выбор ключа.

Ну и до кучи: Go-шная криптография на RSA примерно вдвое дороже OpenSSL, а на эллиптике идёт вровень. То есть выбор RSA4096 дефолтом бьёт по Go-прокси дважды.

Проверяем в лоб. Тот же бинарь, тот же конфиг, открытая модель нагрузки на 15 000 запросов в секунду, меняется только файл сертификата:

Traefik, ключRPSp95VU у генератора
EC25614 9870.26 ms508
RSA-204814 9140.29 ms1 197
RSA-40962 38213.6 s20 000 (потолок)

Вот он, обвал из статьи. Воспроизвёлся один в один. Traefik тут ни при чём.

Если вам интересно, RSA-4096 валит всех, просто в порядке стоимости их хендшейка:

прокси на RSA-4096RPSp95VU у генератораядер прокси (из 4)
HAProxy14 9080.20 ms9840.70
Envoy13 9926.5 ms6 5341.36
nginx10 6593.5 s20 0002.36
Caddy3 8196.5 s20 0003.75
Traefik2 38213.6 s20 0003.70

Как именно оно разваливается

Посмотрите на столбик VU в двух последних таблицах. Там вся механика.

Генератор работает по открытой модели: “выдавай 15 000 запросов в секунду, чего бы это ни стоило”. Пока прокси отвечает быстро, хватает пятисот виртуальных пользователей, соединения переиспользуются, хендшейков почти нет.

Стоит латентности подрасти - генератор поднимает новых VU, чтобы держать заданный темп. Каждый новый VU открывает новое соединение. Каждое новое соединение - это TLS-хендшейк. На дешёвом ключе прокси это переваривает. На RSA-4096 он захлёбывается, латентность растёт ещё, генератор поднимает ещё VU, и так до упора в maxVUs.

Это положительная обратная связь. Она меряет не потолок прокси, а скорость схлопывания стенда.

Отдельно откалибровал сам генератор - HAProxy, дешёвый ключ, растущий заказанный темп:

заказанодостигнутоядер прокси (из 4)p95потеряно итераций
10 0009 9940.450.21 ms110
20 00019 9560.820.20 ms856
40 00039 8751.472.5 ms2 468
80 00069 6932.0224 ms205 481

На 80 тысячах тест “проваливается” - а прокси занят на половину. Тот же HAProxy на закрытой модели спокойно отдаёт 223 тысячи.

В статье, напомню, у “победителя” HAProxy тоже потеряно 16 192 итерации. Генератор уже упирался в себя на первом же тесте.


nginx, который потерял половину

Тут я собирался закончить, но решил прогнать ещё и те же версии, что были в статье. Апрель 2025-го против августа 2026-го, один и тот же стенд, один и тот же конфиг.

проксиапрель 2025август 2026дельта
HAProxy3.1.6 - 237 4573.4.3 - 223 220-6%
Envoy1.34.0 - 117 1961.36.9 - 121 992+4%
Traefik3.3.5 - 99 6893.7.11 - 94 102-6%
Caddy2.9.1 - 54 5042.11.4 - 53 205-2%
nginx1.27.4 - 76 5301.31.4 - 32 419-58%

Четверо стоят на месте в пределах разброса. nginx на том же конфиге просел в 2.4 раза.

Прогнал все версии подряд:

версияRPSµs/запросp99
1.27.4 - 1.29.673 686 - 76 25051 - 5312-18 ms
1.29.732 390121.5425 ms
1.29.8 - 1.31.432 333 - 32 417~121~360 ms

Обвал ровно на 1.29.7, 24 марта 2026. Открываем CHANGES:

*) Change: now the "keepalive" directive in the "upstream" block is enabled by default.
*) Change: now ngx_http_proxy_module supports keepalive by default; the default value
   for "proxy_http_version" is "1.1"; the "Connection" proxy header is not sent by
   default anymore.

nginx включил keep-alive до бэкенда по умолчанию. Изменение, задуманное как ускорение, дало замедление вдвое.

Разбираемся. Изолирующие замеры на 1.31.4, базовая линия - конфиг из статьи, 32 342 RPS:

конфигRPSµs/запрос
обернуть в upstream-блок, ни строчки больше191 79819.0
upstream + keepalive 128209 10717.7
upstream + keepalive 32197 73918.7
upstream + keepalive 32 local190 57719.2
upstream + keepalive 297 33940.3
конфиг из статьи + вернуть Connection: close74 41652.6

Дефолт - keepalive 32 local. Но явные 32 дают 198 тысяч, а дефолтные 32 - 32 тысячи. Параметр local тоже ни при чём (190 577 против 190 266 на повторе).

Решает наличие upstream-блока. Дефолтный кэш соединений работает только для именованного upstream. При proxy_pass http://host:port с литеральным адресом кэша нет - но Connection: close бэкенду nginx с 1.29.7 больше не отправляет. Соединение открывается по HTTP/1.1, используется один раз и закрывается самим nginx.

Худшее из двух миров. Раньше закрывал бэкенд, теперь закрывает nginx - и платит за это.

Вывод простой: заводите upstream-блок, даже если бэкенд один. Одно это, без единой дополнительной директивы, даёт шестикратный прирост.


Вебсокеты

Раз уж стенд стоял, добавил вебсокеты - у нас на них живёт весь продукт.

Эхо-сообщения, 200 соединений:

проксисообщений/сp99RSS
nginx (с Upgrade)338 7364.78 ms103 MB
HAProxy299 5292.38 ms97 MB
Caddy297 8282.54 ms71 MB
Traefik295 1902.50 ms96 MB
Envoy284 1027.01 ms44 MB
nginx (конфиг из статьи)0-200 отказов из 200

Все в пределах 15% друг от друга - релеинг вебсокетов дёшев и одинаков. Кроме nginx с конфигом из статьи: там нет проброса Upgrade, и вебсокеты не проходят вообще. У остальных четверых работают из коробки.

Держим 5000 соединений:

проксиустановленоp99RSS
HAProxy5000/50002-3 ms371 MB
Caddy5000/50004.5 ms698 MB
Traefik5000/50005.0 ms632 MB
nginx5000/500097-155 ms375 MB
Envoy1024/5000-3976 отказов

Envoy упёрся ровно в 1024 - это дефолтный порог circuit breaker max_connections у кластера. Каждый вебсокет держит соединение к бэкенду. Поднимаем порог - 5000 из 5000.

nginx на длинных простаивающих соединениях стабильно хуже всех по хвостам: 97 и 155 ms в двух прогонах против 2-3 ms у HAProxy.


Что я забрал себе

Мерить надо CPU на запрос, а не RPS. RPS говорит про ваш стенд. Микросекунды CPU на запрос говорят про прокси. Первое нельзя переносить между машинами, второе можно.

Открытая модель нагрузки не меряет потолок. Она меряет, как система разваливается. Это отдельный полезный тест, но потолок ищут закрытой моделью с фиксированной конкурентностью. Иначе вы публикуете скорость схлопывания собственного генератора.

“Все на дефолтах” - это не равные условия. Это пять разных конфигураций, про которые вы не знаете, чем они отличаются. Если сравниваете - сначала выпишите дефолты в таблицу и посмотрите, не совпадает ли она с вашим будущим рейтингом.

Сертификат всем один и тот же файл. Не три разных ACME-клиента с тремя разными дефолтами типа ключа. Разница между EC256 и RSA-4096 - тринадцатикратная, она перекрывает любую разницу между продуктами.

Дефолты меняются, и не всегда в вашу пользу. nginx поменял поведение keep-alive, целясь в ускорение, и для конфигов без upstream-блока получилось замедление вдвое. Ваш конфиг работает не так, как когда вы его писали.

А Traefik мы оставили. Он всё это время был вторым-третьим с нулём ошибок, а тестировали не его.


Повторить

Стенд полностью в репозитории: github.com/haiodo/reverse-proxy-bench.

./gencerts.sh && ./build.sh && ./runall.sh && python3 parse.py

Сырые данные этого прогона лежат в baselines/2026-08-23/, все находки по конфигурированию - в FINDINGS.md. Прогоните через год и сравните - собственно, для этого я его и оформил отдельно.