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 pardon, но данной серией статей я все же хочу популяризовать Lighttpd. acute

Есть еще один момент, связанный с поддержкой FastCGI в HAProxy. Казалось бы, вот оно – веб сервер не нужен, но увы. Балансировщик не умеет в статику, поэтому в крайнем случае это мог бы быть сайт на чистом (если не сказать голом) PHP, то есть без всяких там фреймворков и графики (если только svg как-то генерировать или раздавать файлы через интерпретатор). Впрочем иначе получился бы Nginx, а постоянные читатели в курсе моего скептического отношении к оному. В мире, где есть Caddy, Nginx стал очень специфическим инструментом опытнейших администраторов, когда важна тончайшая настройка. А я все же погромист и когда появилась возможность вместо нескольких экранов конфига писать пару строк, я забыл про Nginx как про страшный сон.

Вот и настройка HAProxy для режима, собственно, обратного прокси не выглядит устрашающей, хотя формат файлов конфигурации здесь довольно специфический (для какой-никакой подсветки синтаксиса я указал nginx, но разумеется общего у них немного).

Основы настройки HAProxy

Блоки. Обратный прокси

Базово файл конфигурации состоит из четырех блоков:

  1. global – глобальные настройки (уровня процесса);
  2. defaults – настройки по умолчанию;
  3. frontend – настройка прослушивания и обработки входящего трафика;
  4. 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 мегабит в секунду (если правильно подсчитал fool).

Кроме того, с помощью фильтров реализуется, например, кэширование и распределенная трассировка (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 тоже является обязательным, завел для него переменную окружения. В конце как раз объявлено то самое соответствие (которое хэш-таблица), необходимое для проведения испытания. Уфф. tired

Дорабатываем определение сервиса 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. Хм scratch, получились очень похожие перенаправления, объединим их:

    # 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_errthe last error of the first SSL error stack that was raised on the connection from the frontend's perspective – чего? crazy – последняя ошибка первого стека ошибок SSL, возникшая при подключении (с точки зрения фронтенда) pardon
    • 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.


Комментарии, обсуждение