Не краткий обзор HAProxy на примере интеграции с Lighttpd
HAProxy (high availability proxy) - обратный прокси и балансировщик нагрузки, один из старейших и известнейших. В версии 3.2 появилась экспериментальная поддержка протокола ACMEv2, а значит получение сертификатов Let's Encrypt. Пусть это и не совсем то, для чего предназначается балансировщик, но мы можем поставить его перед веб-сервером Lighttpd для терминации SSL.
Введение
При всех своих достоинствах и удобстве использования, Traefik все же довольно объемная программа (исполняемый файл весит более 160 мегабайт), а официальный образ Docker на базе Alpine Linux теоретически может отрицательно влиять на производительность (о чем свидетельствуют некоторые сравнительные тесты). HAProxy напротив, крайне быстр и компактен (справедливости ради, Дебиановский образ все же не самый маленький). Допустим, на сервере работает только один сайт, тогда задействовать Traefik только лишь для решения вопроса сертификации как-то избыточно. А вот «прослойка» из HAProxy предварительно не выглядит чрезмерной.
Казалось бы, зачем в принципе городить огород и не выставить веб-сервер наружу? Как раз из-за сертификатов: Lighttpd может лишь пользоваться готовыми, а для их получения нужны сторонние решения – например certbot. По-моему, последний не слишком сочетается с Docker: при беглом поиске сложилось впечатление, что в основном cron (или таймер systemd) настраивают на хосте. А вообще, если мы говорим о сайте на PHP, то нужно брать FrankenPHP , но данной серией статей я все же хочу популяризовать Lighttpd.
Есть еще один момент, связанный с поддержкой FastCGI в HAProxy. Казалось бы, вот оно – веб сервер не нужен, но увы. Балансировщик не умеет в статику, поэтому в крайнем случае это мог бы быть сайт на чистом (если не сказать голом) PHP, то есть без всяких там фреймворков и графики (если только svg как-то генерировать или раздавать файлы через интерпретатор). Впрочем иначе получился бы Nginx, а постоянные читатели в курсе моего скептического отношении к оному. В мире, где есть Caddy, Nginx стал очень специфическим инструментом опытнейших администраторов, когда важна тончайшая настройка. А я все же погромист и когда появилась возможность вместо нескольких экранов конфига писать пару строк, я забыл про Nginx как про страшный сон.
Вот и настройка HAProxy для режима, собственно, обратного прокси не выглядит устрашающей, хотя формат файлов конфигурации здесь довольно специфический (для какой-никакой подсветки синтаксиса я указал nginx, но разумеется общего у них немного).
Основы настройки HAProxy
Блоки. Обратный прокси
Базово файл конфигурации состоит из четырех блоков:
global
– глобальные настройки (уровня процесса);defaults
– настройки по умолчанию;frontend
– настройка прослушивания и обработки входящего трафика;backend
– определение и настройка серверов приложений.
Блок global может быть только один, в то время как блокам frontend и backend (и даже defaults при необходимости) присваиваются наименования и, таким образом, их может быть произвольное количество. На первый взгляд может быть не совсем понятно, чем отличаются global от defaults. В глобальном блоке определяются такие настройки, как пользователь и группа, под которым должен быть запущен балансировщик, максимальное количество подключений, настройка журналирования и т.д. В блоке (или блоках) defaults определяются настройки по умолчанию для последующих frontend и backend, например режим (http – L7, или tcp – L4), алгоритм балансировки и т.д. Кстати можно объединять «парные» блоки frontend и backend в один listen, но кажется так никто не делает; вот и мы не будем (хотя в нашем-то случае это было бы удобнее).
Проиллюстрируем вышесказанное:
global
user www-data
group www-data
# Set the maximum per-process number of concurrent connections
maxconn 2048
# Log to standard output
log stdout format raw local0
defaults
# Set the proxy mode to http (layer 7)
mode http
# Each server is used in turns, according to their weights
balance roundrobin
frontend web
# Receive HTTP traffic on all IPv4 addresses assigned to the server at port 80
bind :80
# e.g. listen both IPv6 and IPv6
#bind [::]:80 v4v6
# Log HTTP details
option httplog
# Inherit global logging settings
log global
# Choose the default pool of backend servers
default_backend site
backend site
# List servers
server s1 172.16.0.1:8000 check
В глобальном блоке я установил нашего любимого www-data в качестве пользователя, под которым будет работать HAProxy, и для примера ограничил количество подключений. Затем настроил вывод логов в стандартный поток в сыром (текстовом) формате как некую предварительную оптимизацию под контейнеры, где local0 – имя настройки. По большому счету почти всему предлагается присваивать имена, а в данном случае это имеет смысл еще и потому, что у вас может быть множество журналов под различные нужды. Кстати вывод в stdout или stderr – возможность скорее второстепенная, стандартно предлагается взаимодействовать с syslog по UDP (log 127.0.0.1 local0
).
Небольшое отступление по поводу пользователя и порта. Зачастую непривилегированным пользователям недоступны начальные номера портов, решением может быть sysctl net.ipv4.ip_unprivileged_port_start=0
или setcap CAP_NET_BIND_SERVICE=+eip /usr/local/sbin/haproxy
(с учетом реального расположения исполняемого файла). Применительно к Docker проще всего пробросить 80-й порт на какой-нибудь 8000.
Далее идет блок настроек по умолчанию, в котором я задал режим http
и стандартный алгоритм балансировки roundrobin
, то есть по очереди с учетом «веса» серверов. Формально можно балансировать HTTP-трафик на транспортном уровне (tcp
), но тогда мы не сможем настроить HTTPS и много чего еще, да и с точки зрения логирования так себе идея. Обычно режим L4 приводят в пример для проброса трафика к MySQL (?!), но вообще-то нельзя просто так взять и горизонтально масштабировать СУБД, будьте внимательны и осторожны.
Объявляем фронтенд (или точку входа, если угодно) web на 80-м порту. Применительно к Docker достаточно поддерживать IPv4 (bind :80
), но если вдруг HAProxy установлен непосредственно на хосте, то целесообразно воспользоваться альтернативной директивой bind [::]:80 v4v6
для работы по обеим версиям протокола. К вопросу о логировании – благодаря option httplog
мы включаем так называемый http-формат журнала, который добавляет сведения типа исходного запроса, кода ответа, времени обработки и т.д. (примеры и разбор будут позже). Остальные настройки логирования берем из глобального блока (log global
) и назначаем бэк по умолчанию (default_backend site
).
Настраиваем бэкенд site, в нем для примера я написал только один сервер под названием s1. Алгоритм балансировки задан по умолчанию, да и с одним сервером особо не разгуляешься. Разумеется, поддерживаются домены вместо IP-адресов, но с нюансом – разыменовывание происходит при запуске, в связи с чем какую-то динамическую конфигурацию сделать не то, чтобы совсем нельзя, но довольно сложно. С этой точки зрения Traefik выигрывает – благодаря меткам контейнеров он всегда знает, у кого какой адрес.
Проверка состояния
Аргумент check
в объявлении сервера включает активную проверку состояния по TCP, то есть согласно документации сервер должен отвечать пакетами SYN/ACK
в ответ на запросы балансировщика (каждые 2 секунды по умолчанию). Можно реализовать и HTTP-проверку (по кодам или содержимому ответа), но на мой взгляд в нашем случае она не особо нужна. Тем не менее, вот как выглядит проверка по кодам:
backend site
option httpchk
server s1 172.16.0.1:8000 check
В логах веб-сервера можно будет увидеть, что при такой проверке выполняется запрос OPTIONS / HTTP/1.0
.
Похоже, в учебнике не совсем правильно (или я что-то не так понял) описана пассивная проверка, то есть когда анализируется фактическое взаимодействие с сервером. Судя по моим экспериментам, она работает в дополнение к активной проверке, а не вместо нее (как я изначально подумал). Забегая вперед, эксперимент заключался в остановке контейнера с веб-сервером. Даже без каких-либо обращений к сайту балансировщик через какое-то время исключал этот сервер (контейнер) из ротации. Тем не менее, за счет пассивной проверки (и пользователей) можно существенно увеличить интервал активной, чтобы сервер понапрасну не «дергать» (особенно при HTTP-проверке).
Например:
backend site
server lighty php:9000 check inter 1m observe layer4 error-limit 5 on-error mark-down
Здесь я предвосхищаю Docker-окружение в лице имени и адреса сервера, а что касается параметров:
inter 1m
– задает интервал активной проверки (одна минута);observe layer4
– активирует пассивную проверку на 4-м уровне (транспортном, TCP), то есть будет проверяться успешность подключений. Поскольку по умолчанию установлен режим http, то можно указать иlayer7
, тогда будут учитываться коды ответа;error-limit 5
– устанавливает предел количества ошибок, то есть в данном случае сколько раз сервер может не ответить;on-error mark-down
– в случае, если сервер по-прежнему не отвечает (превышен предел), он помечается как недоступный (down).
Можно установить отдельный интервал (fastinter
) проверки в случае, когда сервер «лежит», например inter 5m fastinter 30s
.
В общем, как лучше настроить проверку состояния – вопрос компромиссов, вплоть до того, что не использовать ее вовсе. Хотя такой вариант наверное самый плохой, поскольку прокси будет каждый раз «ломиться» на недоступный сервер. В случае проверки состояния балансировщиком (особенно по HTTP), возможно стоит отключать HEALTHCHECK контейнера.
Маршрутизация
Давайте крайне поверхностно затронем тему контроля прав доступа (ACL) и маршрутизации. Допустим, у нас есть два сервера, API (PHP, Node или т.п.) по пути /api
и статики (к примеру сборка Vue.js):
defaults
mode http
frontend web
bind :80
acl is_api path_beg /api
use_backend api if is_api
default_backend static
backend api
server a1 172.16.0.1:8000 check
backend static
server s1 172.18.0.2:8080 check
Парой директив acl
и use_backend
мы как раз описали правило: если путь в URL начинается с /api
, перенаправлять запросы на 172.16.0.1:8000, иначе (по умолчанию) на 172.18.0.2:8080.
Фильтры
Сжатие (gzip, похоже что zstd и br не поддерживаются) включается с помощью фильтра, например:
backend site
filter compression
compression algo gzip
compression type text/css text/html text/javascript application/javascript text/plain text/xml application/json
# optional: do not pass Accept-Encoding header
#compression offload
server s1 172.16.0.1:8000 check
Еще хотелось бы упомянуть про заинтересовавшую меня возможность замедления трафика (traffik shaping), причем можно ограничивать как входящий (для фронтенда), так и исходящий (от бэка). Фрагменты конфигурации:
frontend
filter bwlim-in default-limit 125k default-period 1s
http-request set-bandwidth-limit
backend
filter bwlim-out default-limit 125k default-period 1s
http-response set-bandwidth-limit
Возможное неудобство в том, что ограничение указывается в байтах (или в данном случае килобайтах), поэтому фактически я поставил 1 мегабит в секунду (если правильно подсчитал ).
Кроме того, с помощью фильтров реализуется, например, кэширование и распределенная трассировка (Jaeger и т.п.). Позвольте мне не углубляться в столь специфические и весьма обширные темы.
Таймауты
Напоследок упомяну возможность настройки различных таймаутов, например:
timeout client
– время бездействия клиента;timeout queue
– в случае достижения максимального количества подключений, сколько времени запрос может ожидать свободного сервера;timeout connect
– время на установление соединения с сервером бэка. В документации предлагается устанавливать 4-5 секунд, но в современных реалиях (ЦОДы, а тем более Docker) значение можно уменьшать до 500 миллисекунд;timeout server
– время бездействия сервера;timeout check
– время ответа на активную проверку состояния. Поскольку проверка состояния по идее намного проще обычной обработки запроса, рекомендуется устанавливать меньшее значение по сравнению с timeout server, чтобы оперативно исключать «лагающие» сервера.
Скорее всего эти настройки будут расположены в секции defaults, но так вышло, что все, кроме timeout client – «задние»:
defaults
# Set the maximum inactivity time on the client side (frontend)
timeout client 30s
# Set the maximum time to wait in the queue for a connection slot to be free (backend)
timeout queue 10s
# Set the maximum time to wait for a connection attempt to a server to succeed (backend)
timeout connect 500ms
# Set the maximum inactivity time on the server side (backend)
timeout server 30s
# Set additional check timeout, but only after a connection has been already established
timeout check 3s
Почти обязательными являются таймауты client, connect и server – в случае их отсутствия встроенная проверка конфигурации будет выдавать предупреждение.
Разумеется, вышеперечисленное – далеко не все возможности HAProxy, но предполагавшийся кратким обзор уже получается не таким уж и кратким, так что на этом пока остановимся.
Docker
Скомпонуем официальный lts-образ HAProxy (то есть версии 3.2 на момент написания) и мой абсолютно неофициальный dmkos/php:lighttpd (без s6 для простоты):
services:
php:
image: dmkos/php:lighttpd
environment:
LIGHTTPD_DOCUMENT_ROOT: /var/www/html/public
volumes:
- ./config/lighttpd:/usr/local/etc/lighttpd/conf.d
- ./src:/var/www/html
web:
image: haproxy:lts
depends_on:
- php
ports:
- "80:8000"
volumes:
- ./config/haproxy:/usr/local/etc/haproxy
Dockerfile пока не нужен, зато нужны файлы конфигурации.
Для Lighttpd предлагаю объединить все в одном – config/lighttpd/custom.conf:
# log to container's log (stderr)
accesslog.filename = "/proc/self/fd/2"
# index.php expects original URL in PATH_INFO
url.rewrite-if-not-file = ( "" => "/index.php${url.path}${qsa}" )
Я включил логирование в stderr для проверки и иллюстрации некоторых возможностей HAProxy и вставил стандартную перезапись URL для фреймворка Symfony. Да, мы опять снова будет терзать демо-приложение.
Для образа HAProxy особых вариантов нет – файл конфигурации обязан называться haproxy.cfg. Разместим его в config/haproxy:
global
log stdout format raw local0
defaults
mode http
timeout client 30s
timeout connect 500ms
timeout server 30s
frontend web
bind :8000
option httplog
log global
default_backend site
backend site
server lighty php:9000 check inter 1m observe layer4 error-limit 5 on-error mark-down
Пользователя в глобальном блоке указывать не надо, ведь контейнер и так будет работать под непривилегированным пользователем haproxy (99:99). Как видите, заодно убрал комментарии и всякие лишние настройки (например roundrobin является стандартным алгоритмом), при этом указал таймауты по умолчанию.
Установим демо-приложение. На хосте подготовим каталог src и однократно запустим контейнер:
mkdir src
chown 33:33 src
docker compose run -it --rm php bash
В контейнере (не забываем про точку в конце):
composer create-project symfony/symfony-demo .
exit
На всякий пожарный можно проверить конфигурацию HAProxy:
docker compose run -t --rm web haproxy -c -f /usr/local/etc/haproxy/haproxy.cfg
По идее ничего не должно вылезти (но при подготовке статьи я проверял конфигурацию без таймаутов и как раз было выдано предупреждение).
Стартуем:
docker compose up
Предлагаю явно, чтобы сразу же видеть журналы как прокси, так и веб-сервера.
Заходим по IP, у меня это пока что виртуальная машина http://192.168.56.103/. Пример соответствующих записей:
web-1 | 192.168.56.1:65068 [15/Oct/2025:12:18:44.437] web site/lighty 0/0/0/138/138 200 55115 - - ---- 1/1/0/0/0 0/0 "GET /ru HTTP/1.1"
php-1 | 172.18.0.3 192.168.56.103 - [15/Oct/2025:12:18:44 +0000] "GET /ru HTTP/1.1" 200 54732 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"
php-1 | 172.18.0.3 192.168.56.103 - [15/Oct/2025:12:25:48 +0000] "GET /ru/blog/posts/lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit HTTP/1.1" 200 78675 "http://192.168.56.103/ru/blog/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"
web-1 | 192.168.56.1:50140 [15/Oct/2025:12:25:48.123] web site/lighty 0/0/0/99/99 200 79015 - - ---- 1/1/0/0/0 0/0 "GET /ru/blog/posts/lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit HTTP/1.1"
Думаю, стоит расшифровать строки от HAProxy (web-1):
- 192.168.56.1:50140 – IP клиента и порт
- [15/Oct/2025:12:25:48.123] – дата и время запроса
- web – фронтенд
- site/lighty – бэкенд и имя сервера
- 0/0/0/99/99 (TR/Tw/Tc/Tr/Ta):
- TR – время (мс) между моментом получения первого байта и запроса целиком (без учета тела)
- Tw – время ожидания в очередях
- Tc – время установления соединения с обрабатывающим запрос сервером (включая повторные попытки)
- Tr – общее время ожидания до получения ответа от сервера (без учета тела)
- Ta – общее время обработки запроса (от получения первого байта запроса до отправки последнего байта ответа)
- 200 – код ответа
- 79015 – количество байт, отправленных клиенту
- первая - (captured_request_cookie) – «захваченная печенька» запроса, в данном случае нет данных (мы не обрабатываем куки)
- вторая - (captured_response_cookie) – «захваченная печенька» ответа
- ---- (termination_state) – состояние потока при закрытии (в данном случае корректная обработка без cookie), каждый символ показывает соответствующие коды:
- событие, вызвавшее закрытие потока
- состояние потока при закрытии
- состояние «печеньки» запроса
- действие над «печенькой»
- 1/1/0/0/0 (actconn/feconn/beconn/srv_conn/retries):
- actconn – количество подключений к HAProxy
- feconn – количество подключений к данному фронтенду
- beconn – количество подключений к бэкенду
- srv_conn – количество активных подключений к данному серверу
- retries – количество попыток повторного подключения к серверу
- 0/0 (srv_queue/backend_queue):
- srv_queue – количество запросов в очереди к серверу
- backend_queue – количество запросов в очереди к бэкенду
В конце идут заголовки. Так как у нас нет правил их обработки, то выводится только HTTP-запрос (в данном случае GET).
Масштабирование
Я все же решил по аналогии с Traefik привести пример с масштабированием и «липкими» сессиями, хотя с фиксированной конфигурацией это уже не столь эффектно. В compose.yaml ставим три реплики сервиса php:
services:
php:
deploy:
replicas: 3
# остальные настройки...
А в haproxy.cfg перенастраиваем backend:
backend site
server php1 haproxy-php-1:9000 check
server php2 haproxy-php-2:9000 check
server php3 haproxy-php-3:9000 check
Я вернул активную проверку состояния по умолчанию и написал адреса серверов в соответствии с именами контейнеров, которые назначает docker compose (сам compose.yaml находится в каталоге haproxy, отсюда и префикс).
Если сейчас перезапустить приложение (down/up) и обновить главную страницу, то мы как раз увидим поочередное обращение к серверам (сократил вывод):
php-1 | 172.18.0.5 192.168.56.103 - [15/Oct/2025:13:41:49 +0000] "GET /ru HTTP/1.1" 200
web-1 | 192.168.56.1:53273 [15/Oct/2025:13:41:48.938] web site/php1 "GET /ru HTTP/1.1"
php-2 | 172.18.0.5 192.168.56.103 - [15/Oct/2025:13:41:49 +0000] "GET /_wdt/styles HTTP/1.1" 200
web-1 | 192.168.56.1:53273 [15/Oct/2025:13:41:49.320] web site/php2 "GET /_wdt/styles HTTP/1.1"
php-3 | 172.18.0.5 192.168.56.103 - [15/Oct/2025:13:41:49 +0000] "GET /_wdt/4d4b1f?XDEBUG_IGNORE=1 HTTP/1.1" 200
web-1 | 192.168.56.1:53273 [15/Oct/2025:13:41:49.567] web site/php3 "GET /_wdt/4d4b1f?XDEBUG_IGNORE=1 HTTP/1.1"
Проверим IP-адрес контейнера haproxy-php-2 (docker inspect haproxy-php-2 | grep IPAddress
– в моем случае 172.18.0.3) и остановим его (docker stop haproxy-php-2
). В логах прокси увидим запись:
[WARNING] (9) : Server site/php2 is DOWN, reason: Layer4 timeout, check duration: 2001ms. 2 active and 0 backup servers left. 0 sessions active, 0 requeued, 0 remaining in queue.
Для очистки совести можно обновить несколько раз страницу и убедиться, что запросы направляются только к серверам site/php1 и site/php3. Включаем контейнер обратно (docker start haproxy-php-2
). К счастью его IP-адрес не поменялся (что могло бы сбить с толку балансировщик), поэтому сервер включается обратно:
[WARNING] (9) : Server site/php2 is UP, reason: Layer4 check passed, check duration: 0ms. 3 active and 0 backup servers online. 0 sessions requeued, 0 total in queue.
Как и в случае с Traefik, пока что авторизация работает только при взведенном флаге «оставаться в системе». Будем заманивать пользователей на конкретные сервера вкусняшками:
backend site
cookie _ha_sticky insert indirect nocache httponly dynamic attr "SameSite=Lax"
dynamic-cookie-key cookie_secret_key_123
server php1 haproxy-php-1:9000 check
server php2 haproxy-php-2:9000 check
server php3 haproxy-php-3:9000 check
Я решил сразу же немного зашифровать пресловутую печеньку (аргумент dynamic и директива dynamic-cookie-key – если что, не забудьте сгенерировать), а заодно поставить атрибуты HttpOnly и SameSite=Lax. Теперь в логах есть привязка к серверу:
web-1 | 192.168.56.1:64914 [15/Oct/2025:14:27:23.296] web site/php1 0/0/0/378/378 200 76455 - - --NI 1/1/0/0/0 0/0 "GET /ru/login HTTP/1.1"
php-1 | 172.18.0.5 192.168.56.103 - [15/Oct/2025:14:27:23 +0000] "GET /ru/login HTTP/1.1" 200 76115 "http://192.168.56.103/ru" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"
web-1 | 192.168.56.1:64914 [15/Oct/2025:14:27:23.780] web site/php1 0/0/0/85/85 200 20918 - - --VN 1/1/0/0/0 0/0 "GET /_wdt/6bf50c?XDEBUG_IGNORE=1 HTTP/1.1"
Другой браузер:
php-2 | 172.18.0.5 192.168.56.103 - [15/Oct/2025:14:30:32 +0000] "GET / HTTP/1.1" 302 258 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 YaBrowser/25.8.0.0 Safari/537.36"
web-1 | 192.168.56.1:49318 [15/Oct/2025:14:30:32.106] web site/php2 0/0/0/301/301 302 1250 - - --NI 1/1/0/0/0 0/0 "GET / HTTP/1.1"
web-1 | 192.168.56.1:49318 [15/Oct/2025:14:30:32.416] web site/php2 0/0/0/71/71 200 55114 - - --VN 1/1/0/0/0 0/0 "GET /ru HTTP/1.1"
Обратите внимание на поле termination_state: при первом обращении оно равно --NI
, а при последующих – --VN
, где:
NI
– клиент не предоставил cookie (NO) и она была добавлена в ответ прокси-сервером (INSERTED);VN
– клиент предоставил корректную cookie (VALID), а в ответе сервера таковой не содержится (NO) и прокси сервер ничего не делал (стандартная ситуация для cookie insert).
Теоретически привязку можно делать по IP-адресу клиента, но в случае IPv4 есть риск разбалансировки серверов, поскольку почти все сидят за NAT.
Но, что называется, поигрались и хватит. Я у себя вернул конфигурацию с одним сервером (контейнером).
URL без index.php
Следующим номером нашей программы, так же как и для правой палочки Traefik, будет принудительное исключение index.php из URL. Редиректы настраиваются в блоке frontend благодаря acl:
frontend web
bind :8000
option httplog
log global
# remove index.php from URL
acl index_php path_beg /index.php
http-request set-var(req.scheme) str(https) if { ssl_fc }
http-request set-var(req.scheme) str(http) if !{ ssl_fc }
http-request redirect location %[var(req.scheme)]://%[hdr(host)]%[path,regsub("/index\.php","")] code 308 if index_php
default_backend site
Оказалось, что это какая-то неочевидная задача. Во-первых, почему-то отсутствует предопределенная переменная схемы, так что появился внезапный пример объявления строковой переменной в области видимости запроса. ssl_fc
– предопределенное правило проверки ssl-терминации (кстати вместо условия с отрицанием if !
можно написать unless
). После настройки https мы это безобразие уберем и жестко пропишем схему. Во-вторых, есть редирект, добавляющий префикс пути, но нет убирающего. Поэтому пришлось разбираться, как заменять часть пути с помощью регулярного выражения (и в целом с подстановками). Так что самым простым оказалось только написание условия, что путь начинается с index.php.
Пример логов:
web-1 | 192.168.56.1:63220 [15/Oct/2025:16:55:47.210] web web/<NOSRV> 0/-1/-1/-1/0 308 163 - - LR-- 1/1/0/0/0 0/0 "GET /index.php/ru/blog/posts/lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit HTTP/1.1"
web-1 | 192.168.56.1:63220 [15/Oct/2025:16:55:47.221] web site/lighty 0/0/0/65/65 200 79013 - - ---- 1/1/0/0/0 0/0 "GET /ru/blog/posts/lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit HTTP/1.1"
web-1 | 192.168.56.1:63220 [15/Oct/2025:16:55:52.251] web web/<NOSRV> 0/-1/-1/-1/0 308 94 - - LR-- 1/1/0/0/0 0/0 "GET /index.php HTTP/1.1"
web-1 | 192.168.56.1:63220 [15/Oct/2025:16:55:52.261] web site/lighty 0/0/0/19/19 302 1067 - - ---- 1/1/0/0/0 0/0 "GET / HTTP/1.1"
web-1 | 192.168.56.1:63220 [15/Oct/2025:16:55:52.287] web site/lighty 0/0/0/30/30 200 55114 - - ---- 1/1/0/0/0 0/0 "GET /ru HTTP/1.1"
И вновь нестандартный termination_state, на сей раз LR
при редиректе – запрос был перехвачен и полностью обработан прокси без обращения к серверам (web/<NOSRV> вместо бэка).
IP-адрес клиента
В случае с Traefik этот вопрос мы (я) отдавали на откуп приложению, в частности задавали доверенные адреса прокси через переменную окружения SYMFONY_TRUSTED_PROXIES
. Но это было возможно благодаря тому, что Traefik добавлял соответствующие заголовки. В принципе аналогично может поступать и HAProxy, для этого надо подсыпать option forwardfor
в бэкенд. Однако я предлагаю воспользоваться другим способом – специальным PROXY протоколом.
В Lighttpd за извлечение IP-адреса клиента отвечает mod_extforward:
# extract the client's "real" IP
server.modules += ( "mod_extforward" )
extforward.forwarder = (
"10.0.0.0/8" => "trust",
"172.16.0.0/12" => "trust",
"192.168.0.0/16" => "trust"
)
extforward.hap-PROXY = "enable"
Подключаем модуль и перечисляем адреса доверенных прокси. К сожалению какого-то макроса для частных диапазонов нет, поэтому проходится писать их самостоятельно. А далее как раз включаем поддержку протокола.
В документации содержится примечание, что для работы с PROXY модуль должен загружаться после mod_openssl. Наверное речь идет о приоритете модулей в случае, если SSL в принципе используется, потому что вроде бы все работает и без оного (да и непонятно, зачем он мог бы понадобиться в данном контексте).
Также необходимо добавить аргумент send-proxy или send-proxy-v2 (в зависимости от версии протокола; Lighttpd поддерживает обе) к описанию сервера в бэкенде:
backend site
server lighty php:9000 send-proxy-v2 check inter 1m observe layer4 error-limit 5 on-error mark-down
Уже на примере журнала видно, что теперь веб-сервер правильно определяет IP-адрес (раньше там красовался адрес балансировщика):
web-1 | 192.168.56.1:51849 [16/Oct/2025:11:33:32.338] web site/lighty 0/0/1/30/31 200 55018 - - ---- 1/1/0/0/0 0/0 "GET /ru HTTP/1.1"
php-1 | 192.168.56.1 192.168.56.103 - [16/Oct/2025:11:33:32 +0000] "GET /ru HTTP/1.1" 200 54732 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:144.0) Gecko/20100101 Firefox/144.0"
На всякий случай, в логах Lighttpd (php-1) 192.168.56.103 – это хост из заголовков.
Let’s Encrypt
Что же, пора перемещаться в интернет, в частности на VDS/VPS с указывающим на него доменом (A-запись в простейшем случае). Точнее основным доменом example.com и субдоменом www.example.com – заодно сделаем редирект с www на без www. Согласно документации, пока что HAProxy поддерживает только один вид испытания: HTTP-01.
Честно говоря, я сначала планировал действовать постепенно, но к сожалению даже для какой-то начальной конфигурации придется внести сразу множество изменений. Начнем с haproxy.cfg:
global
log stdout format raw local0
stats socket 127.0.0.1:9999 level admin
expose-experimental-directives
httpclient.resolvers.prefer ipv4
defaults
mode http
timeout client 30s
timeout connect 500ms
timeout server 30s
crt-store certs
crt-base /etc/letsencrypt
key-base /etc/letsencrypt
load crt "${DOMAIN_NAME}.pem" acme le-staging domains "${DOMAIN_NAME},www.${DOMAIN_NAME}" alias "${DOMAIN_NAME}"
frontend web
bind :8000
bind :8443 ssl
option httplog
log global
# HTTP-01 challenge
acl http_01 path_beg '/.well-known/acme-challenge/'
http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/)].%[path,field(-1,/),map(virt@acme)]\n" if http_01
ssl-f-use crt "@certs/${DOMAIN_NAME}"
# remove index.php from URL
acl index_php path_beg /index.php
http-request set-var(req.scheme) str(https) if { ssl_fc }
http-request set-var(req.scheme) str(http) if !{ ssl_fc }
http-request redirect location %[var(req.scheme)]://%[hdr(host)]%[path,regsub("/index\.php","")] code 308 if index_php
default_backend site
acme le-staging
directory https://acme-staging-v02.api.letsencrypt.org/directory
account-key /etc/letsencrypt/account.key
challenge HTTP-01
contact "${CONTACT_EMAIL}"
map virt@acme
backend site
server lighty php:9000 send-proxy-v2 check inter 1m observe layer4 error-limit 5 on-error mark-down
К сожалению, непосредственно в документации пример довольно странный (зачем-то сделали два сертификата – с ключами RSA и ECDSA, по-моему достаточно какого-то одного; разве что для обратной совместимости) и мало что объясняющий, а в интернетах в основном попадаются только старые статьи про сертификацию через certbot или acme.sh. Так что по сути пока что более-менее понятной инструкцией является анонс релиза 3.2, из которого выяснилось множество удивительных вещей. Что называется, наберите воздух.
В глобальном блоке включаем:
- stats socket 127.0.0.1:9999 level admin – административный «интерфейс», он потребуется для выполнения некоторых команд;
- expose-experimental-directives – поддержку экспериментальных директив, в частности
acme
; - httpclient.resolvers.prefer ipv4 – предпочтение IPv4. Оказывается, HAProxy испытывает трудности при работе с Let’s Encrypt по IPv6.
Далее я решил для порядка отдельно объявить хранилище сертификатов certs
в директории /etc/letsencrypt. При описании конкретного сертификата я задействовал переменную окружения для имени домена (без www), к счастью синтаксис для этого привычный. Связываем этот сертификат с объявленным ниже блоком acme, перечисляем имена доменов (общее без www и альтернативное с www) и присваиваем псевдоним.
Курочим точку входа. Прописал работу по https (а заодно по HTTP/2 – включается по умолчанию) на 8443 порту. В отличие от примеров, для настройки HTTP-01 я решил для наглядности прописать условие отдельной директивой acl. Следующая директива http-request return творит всю магию:
lf-string
говорит о том, что содержимым ответа будет строка с поддержкой подстановок (log format);%[path,field(-1,/)]
извлекает из пути (URL) последнюю часть (токен);%[path,field(-1,/),map(virt@acme)]
по токену извлекает секретную часть (отпечаток аккаунта) из соответствия, объявленного в блоке acme (оно формируется в момент проведения проверки).
Директивой ssl-f-use подключаем сертификат по псевдониму. Здесь важно то, что псевдоним должен быть объявлен до его первого использования, поэтому блок crt-store возможно не слишком красиво стоит до frontend.
В блоке acme я решил оставить большинство значений по умолчанию. directory указывать обязательно, это будет тестовый (staging) каталог Let’s Encrypt. Прописал путь к ключу аккаунта, его лучше создать заранее. Для определенности указал тип испытания, хотя в принципе http-01 и так является значением по умолчанию. Контактный e-mail тоже является обязательным, завел для него переменную окружения. В конце как раз объявлено то самое соответствие (которое хэш-таблица), необходимое для проведения испытания. Уфф.
Дорабатываем определение сервиса web в compose.yaml:
services:
php:
# ...
web:
build:
context: .
dockerfile: hap.dockerfile
environment:
DOMAIN_NAME: $DOMAIN_NAME
CONTACT_EMAIL: $CONTACT_EMAIL
depends_on:
- php
ports:
- "80:8000"
- "443:8443"
volumes:
- ./config/haproxy:/usr/local/etc/haproxy
- ./certs:/etc/letsencrypt
Добавил переменные окружения, проброс 443 порта и каталога certs. В примерах чаще фигурирует /etc/haproxy, но я решил хранить сертификаты в /etc/letsencrypt (с точки зрения контейнера). И вот теперь нам потребовался hap.dockerfile, чтобы установить в образ утилиту socat, необходимую для взаимодействия с сокетом (портом) HAProxy:
FROM haproxy:lts
USER root
# Install required software for HAProxy management
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
socat; \
apt-get dist-clean
USER haproxy
Подготовим директорию certs:
mkdir certs
chown 99:99 certs
В файле .env укажем нужные параметры:
DOMAIN_NAME=example.com
CONTACT_EMAIL=admin@example.com
Вовсе без сертификата HAProxy отказывается запускаться, поэтому придется сначала выпустить самоподписанный. Проще в контейнере, чтобы сразу под нужным владельцем, а заодно можно будет пользоваться переменными окружения:
docker compose run -it --rm web bash
В контейнере:
cd /etc/letsencrypt
openssl req -x509 \
-newkey rsa:2048 \
-keyout cert.key \
-out cert.crt \
-not_before 20240101000000Z -not_after 20250101000000Z \
-nodes \
-subj "/C=US/ST=Ohio/L=Columbus/O=MyCompany/CN=${DOMAIN_NAME}"
cat cert.key cert.crt > ${DOMAIN_NAME}.pem
chmod 600 ${DOMAIN_NAME}.pem
rm cert.*
«Субъект» сертификата я взял прямиком из примера, поэтому штат Огайо и все такое. Не суть, главный трюк заключается в том, что мы делаем заведомо просроченный сертификат (параметры -not_before
и -not_after
)! Дело в том, что HAProxy откладывает продление сертификата «до последнего» (curtime + (notAfter – notBefore) / 12), поэтому даже -days 1
при выпуске – слишком долго. А так, увидев просроченный сертификат, балансировщик пойдет сразу же его обновлять. И, разумеется, важно имя файла объединенного сертификата с ключом.
Для пущей важности ограничиваем доступ к файлу сертификата только для владельца (права 600) и удаляем больше не нужные отдельные файлы ключа и сертификата. В той же директории /etc/letsencrypt генерируем ключ аккаунта:
openssl genrsa -out account.key 2048
Почему лучше его создать заранее? В противном случае он генерируется в оперативной памяти, так что каждый раз заводить новый аккаунт Let’s Encrypt мягко говоря расточительно.
Выходим из контейнера (exit
) и стартуем как положено:
docker compose up -d
Казалось бы, все замечательно, HAProxy следит за сроком сертификата и перевыпускает его при необходимости. Чувствуете подвох? Нет? А он, как тот суслик, есть, причем размером с медведя. В соответствии с логикой работы, программа считывает конфигурацию с диска при запуске (включая сертификат) и больше с диском не работает! Это означает, что полученное от Let’s Encrypt «удостоверение» хранится в оперативке, а следовательно теряется при остановке контейнера.
Это, коллеги, очередное фиаско, хоть и не такое обидное, как неудача с Pages во FreeBSD. Получается, вместо одного таймера (продления условным certbot’ом), нужно делать другой – для выгрузки сертификатов на диск (или не забывать это делать врукопашную). Впрочем, если контейнер перезапускается редко, то вроде как можно закрыть на это глаза, если не считать некоторого начального времени, когда будет использоваться самоподписанный сертификат, да еще и просроченный. Логика логикой, но мне кажется что acme-сертификаты надо сбрасывать на диск хотя бы при корректном завершении работы HAProxy – сервер (хост) обслужить, обновления поставить и т.д. Не будет же балансировщик годами работать.
Как вы уже догадались, именно для этих целей включался административный доступ и устанавливался socat. Заходим в контейнер:
docker compose exec -it web bash
Проверяем состояние сертификатов:
echo "acme status" | socat stdio tcp4-connect:127.0.0.1:9999
Если все в порядке, то вывод будет примерно таким:
# certificate section state expiration date (UTC) expires in scheduled date (UTC) scheduled in
@certs/example.com le-staging Scheduled 2026-01-15T11:39:46Z 89d 23h01m20s 2026-01-07T23:39:47Z 82d 11h01m21s
В случае чего, можно принудительно перевыпустить сертификат:
echo "acme renew @certs/${DOMAIN_NAME}" | socat stdio tcp4-connect:127.0.0.1:9999
И, что нас интересует больше всего, выгрузить в файл:
echo "dump ssl cert @certs/${DOMAIN_NAME}" | socat stdio tcp4-connect:127.0.0.1:9999 > /etc/letsencrypt/${DOMAIN_NAME}.pem
На этом можно выйти из контейнера и забыть о нем примерно на 83 дня.
Доработки
Редиректы
Для начала добавим редирект схемы (http → https) во frontend:
http-request redirect scheme https unless { ssl_fc }
Важно, чтобы он шел после настройки HTTP-01: пути /.well-known/acme-challenge должны быть доступны по 80-му порту (http).
Как я и обещал, убираем безобразие со схемой в редиректе с index.php, раз у нас заведомо https:
# remove index.php from URL
acl index_php path_beg /index.php
http-request redirect location https://%[hdr(host)]%[path,regsub("/index\.php","")] code 308 if index_php
И добавляем редирект с www на без www:
# www to non-www
acl has_www hdr_beg(host) www
http-request redirect location https://%[hdr(host),regsub("^www\.","")]%[capture.req.uri] code 308 if has_www
Здесь в условии проверяется начало заголовка host. Хм , получились очень похожие перенаправления, объединим их:
# remove index.php from URL or www from hostname
acl index_php path_beg /index.php
acl has_www hdr_beg(host) www
http-request redirect location https://%[hdr(host),regsub("^www\.","")]%[capture.req.uri,regsub("/index\.php","")] code 308 if index_php || has_www
Сжатие и h2c для бэка
Я решил добавить компрессию, раз мы о ней упоминали, но на практике так до сих пор и не применили. Типы почистил, в демо-приложении нет xml и json. А поскольку Lighttpd поддерживает HTTP/2 и, в частности, его вариант без шифрования, то мы можем включить этот протокол (proto h2
) для взаимодействия сервера с прокси:
backend site
filter compression
compression algo gzip
compression type text/css text/html text/javascript text/plain application/javascript
server lighty php:9000 proto h2 send-proxy-v2 check inter 1m observe layer4 error-limit 5 on-error mark-down
Если честно, в обычных условиях разницу ощутить сложно. Мне почему-то показалось, что как будто наоборот, чуть медленнее стало по сравнению с HTTP/1.1 (даже без gzip). Но не упомянуть о такой возможности нельзя.
Сразу скажу, что в официальном образе HAProxy скомпилирована без поддержки QUIC, поэтому HTTP/3 для клиента не будет.
Журналирование https
Так как у нас теперь почти всегда защищенное соединение, можно добавить соответствующие сведения в логи доступа. В настройках фронтенда меняем:
option httpslog
Пример записи:
xxx.xxx.xxx.xxx:5135 [16/Oct/2025:16:08:55.914] web~ site/lighty 0/0/1/148/154 200 79023 - - ---- 1/1/0/0/0 0/0 "GET https://example.com/ru/blog/posts/lorem-ipsum-dolor-sit-amet-consectetur-adipiscing-elit HTTP/2.0" 0/0000000000000000/0/0/0 example.com/TLSv1.3/TLS_AES_256_GCM_SHA384
К формату http добавилось:
- 0/0000000000000000/0/0/0 (fc_err/ssl_fc_err/ssl_c_err/ssl_c_ca_err/ssl_fc_is_resumed) – очередная статистика (если верить документации, а то что-то подозрительно много нулей во втором поле):
- fc_err – состояние соединения на стороне фронтенда
- ssl_fc_err – the last error of the first SSL error stack that was raised on the connection from the frontend's perspective – чего?
– последняя ошибка первого стека ошибок SSL, возникшая при подключении (с точки зрения фронтенда)
- ssl_c_err – состояние проверки сертификата клиента
- ssl_c_ca_err – состояние проверки цепочки сертификатов
- ssl_fc_is_resumed – признак возобновления предыдущей TLS-сессии
- example.com/TLSv1.3/TLS_AES_256_GCM_SHA384 (ssl_fc_sni/ssl_version/ssl_ciphers):
- ssl_fc_sni – индикатор имени сервера, проще говоря хост (домен)
- ssl_version – версия SSL (TLS)
- ssl_ciphers – алгоритм шифрования соединения.
Две точки входа
Не уверен, что это характерно для HAProxy, но мне захотелось сделать две точки входа подобно Traefik – web и websecure. Первая из них будет служить для проведения сертификации и редиректа схемы, а вторая займется всем остальным. Заодно в web можно будет вернуть httplog.
frontend web
bind :8000
option httplog
log global
# HTTP-01 challenge
acl http_01 path_beg '/.well-known/acme-challenge/'
http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/)].%[path,field(-1,/),map(virt@acme)]\n" if http_01
http-request redirect scheme https
frontend websecure
bind :8443 ssl
ssl-f-use crt "@certs/${DOMAIN_NAME}"
option httpslog
log global
# remove index.php from URL or www from hostname
acl index_php path_beg /index.php
acl has_www hdr_beg(host) www
http-request redirect location https://%[hdr(host),regsub("^www\.","")]%[capture.req.uri,regsub("/index\.php","")] code 308 if index_php || has_www
default_backend site
Были некоторые опасения, что балансировщик не захочет обрабатывать фронт без бэка, но к счастью все обошлось.
Автоматическая выгрузка сертификата
Все-таки я не могу не привести решение проблемы персистентности. Оно заключается в дампе сертификата при завершении работы, для чего пришлось привлечь s6-overlay. Нам понадобится «длительный» (longrun
) сервис haproxy и «кратковременные» (oneshot
) prepare для автоматизации выпуска самоподписанного сертификата и dumpssl для выгрузки на диск.
Dockerfile
Модифицируем hap.dockerfile:
FROM haproxy:lts
USER root
# Install required software
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
socat \
xz-utils; \
apt-get dist-clean
# Install s6-overlay
ARG S6_OVERLAY_VERSION=3.2.1.0
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /usr/src/
ARG S6_OVERLAY_ARCH=x86_64
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz /usr/src/
RUN set -eux; \
tar -C / -Jxpf /usr/src/s6-overlay-noarch.tar.xz; \
tar -C / -Jxpf /usr/src/s6-overlay-${S6_OVERLAY_ARCH}.tar.xz
# Configure
COPY s6-rc.d/ /etc/s6-overlay/s6-rc.d/
COPY --chmod=755 bin/ /usr/local/bin/
STOPSIGNAL SIGTERM
ENTRYPOINT ["/init"]
USER haproxy
В компанию к socat пришлось добавить установку xz-utils, иначе архивы не распаковывались. Далее идет установка и настройка s6-overlay примерно как в варианте образа Lighttpd, только в отличие от статьи я решил создать иерархию файлов на хосте. А еще я оставил непривилегированный режим контейнера.
Сервис haproxy
Файлы:
- s6-rc.d/haproxy/dependencies.d/prepare – пустой, объявляет зависимость от подготовительных действий;
- s6-rc.d/haproxy/down-signal – сигнал завершения работы –
SIGUSR1
; - s6-rc.d/haproxy/type – тип сервиса –
longrun
; - s6-rc.d/user/contents.d/haproxy – пустой, ставит сервис в «автозагрузку».
s6-rc.d/haproxy/run – основной скрипт – запускает haproxy с переменными окружения контейнера и заданным конфигурационным файлом:
#!/command/execlineb -P
with-contenv
/usr/local/sbin/haproxy -W -db -f /usr/local/etc/haproxy/haproxy.cfg
Также указал флаги -W
(режим главного и дочернего процесса) и -db
(запрет перехода в фоновый режим) как в docker-entrypoint.sh базового образа.
Сервис prepare
Файлы:
- s6-rc.d/prepare/dependencies.d/base – пустой, объявляет зависимость только от базовых сервисов;
- s6-rc.d/prepare/type – тип сервиса –
oneshot
; - s6-rc.d/user/contents.d/prepare – пустой, активирует сервис.
В файле s6-rc.d/prepare/up (действия, выполняемые при запуске контейнера) вызовем скрипт (с доступом к переменным окружения контейнера):
with-contenv
/usr/local/sbin/prepare
Сам скрипт sbin/prepare представляет собой ранее описанные команды по созданию сертификата и ключа аккаунта с добавлением номинальных проверок и выводом сообщений:
#!/bin/sh
if [ ! -s "/etc/letsencrypt/${DOMAIN_NAME}.pem" ]; then
echo "Issue self-signed certficate"
cd /etc/letsencrypt
openssl req -x509 \
-newkey rsa:2048 \
-keyout cert.key \
-out cert.crt \
-not_before 20240101000000Z -not_after 20250101000000Z \
-nodes \
-subj "/C=US/ST=Ohio/L=Columbus/O=MyCompany/CN=${DOMAIN_NAME}"
cat cert.key cert.crt > ${DOMAIN_NAME}.pem
chmod 600 ${DOMAIN_NAME}.pem
rm cert.*
fi
if [ ! -s "/etc/letsencrypt/account.key" ]; then
echo "Generate account key"
openssl genrsa -out /etc/letsencrypt/account.key 2048
fi
Сервис dumpssl
Файлы:
- s6-rc.d/dumpssl/dependencies.d/haproxy – пустой, объявляет зависимость от одноименного сервиса;
- s6-rc.d/dumpssl/type – тип сервиса –
oneshot
; - s6-rc.d/user/contents.d/dumpssl – пустой файл – «автозапуск».
В обязательной команде s6-rc.d/dumpssl/up просто выведем сообщение:
echo "Dumping SSL certificate on exit enabled"
А в s6-rc.d/dumpssl/down (действиях, выполняемых при завершении работы) вызовем скрипт выгрузки сертификата в файл:
with-contenv
/usr/local/sbin/dumpssl
Сутью sbin/dumpssl является ранее приведенная команда дампа в обрамлении вывода сообщений и предварительной проверки подключения:
#!/bin/sh
if socat /dev/null TCP:127.0.0.1:9999 ; then
echo -n "Dump SSL certificate @certs/${DOMAIN_NAME}... "
echo "dump ssl cert @certs/${DOMAIN_NAME}" | socat stdio tcp4-connect:127.0.0.1:9999 > /etc/letsencrypt/${DOMAIN_NAME}.pem
echo "done."
fi
That’s it
Как сказали бы авторы документации Symfony после огромной и абсолютно непонятной инструкции. Фишка заключается в зависимости сервиса dumpssl от haproxy: при завершении работы контейнера сначала вызывается первый из них, благодаря чему есть возможность выгрузить сертификат на диск, и только потом останавливается балансировщик. В свою очередь, автоматизация подготовки контейнера нужна для того, чтобы HAProxy мог хоть как-то загрузиться, иначе условный docker run был бы по сути невозможным.
Актуальный исходный код примера можно посмотреть в репозитории:
https://git.dmkos.ru/containers/php/-/tree/main/linux/lighttpd/examples/haproxy
Или по состоянию на момент публикации:
https://git.dmkos.ru/containers/php/-/tree/17-lighttpd-haproxy/linux/lighttpd/examples/haproxy
Заключение
Да уж, хотел сделать коротенький минут на сорок обзор, а в итоге статья получилась весьма длинной. Это при том, что я описал наверное процентов 20 возможностей HAProxy. К сожалению, с Докером он не совсем дружит, а маленький нюансик с ACME-сертификатами вылился в большущую писанину. Честно говоря, я рассчитывал на полную автоматизацию как в Traefik, а так ну разве что certbot больше не нужен. Опять же, раз в любом случае требуется хоть какой-то сертификат, странно что нет поддержки TLS-ALPN-01 – по моему (не)скромному мнению она более логична в такой ситуации. Посмотрим, изменится ли что-то в будущих версиях, иначе народ так и будет по старинке сертифицироваться через внешние утилиты (есть у меня такое сильное подозрение).
Тем не менее, если как-то специально не ломать статичную конфигурацию манипуляциями с контейнерами, то работать будет. Для пущей важности можно зафиксировать IP-адреса. Или, в теории, управлять серверами через административный сокет. С третьей стороны, есть такой проект, как byjg/docker-easy-haproxy. Там при запуске генерируется конфигурация HAProxy на основе меток контейнеров и различных переменных окружения. Недостатков (или просто нюансов) тоже хватает: Alpine Linux, установка HAProxy из apk, certbot.
Что касается меня лично, то на ус я конечно же HAProxy намотал, но говоря откровенно, вряд ли буду им пользоваться. Все-таки у меня не пресловутый хайлоад на десятках тысяч «оборотов», когда Traefik начинает испытывать трудности. При этом на стороне последнего еще и динамическая конфигурация, сжатие zstd и br и полная автоматизация работы с сертификатами. Впрочем HAProxy лучше с точки зрения некоей изоляции веб-приложений, поскольку не требует доступа к «розетке» Докера и повышенных привилегий. Возможно, балансировщик мог бы себя проявить во FreeBSD с клетками, но и там Caddy вроде как справляется.
Категория: Программирование, веб | Опубликовано 21.10.2025 | Редакция от 22.10.2025
Похожие материалы
Система инициализации s6-overlay. Вариант образа Lighttpd для Docker
Несмотря на то, что веб-сервер Lighttpd умеет самостоятельно запускать процессы FastCGI (в частности php-fpm), такая возможность скорее побочная и злоупотреблять ею не стоит. С точки зрения контейнеризации это означает, что нужна система, которая смогла бы запустить сначала PHP, а затем веб-сервер, после чего корректно завершить эти процессы при остановке контейнера. Одной из таких является s6-overlay, с помощью которой мы и создадим вариант образа Lighttpd для PHP.