HAProxy стоит на передовой почти каждого моего проекта. И каждый раз, когда дело доходит до установки, я слышу одно и то же: «ставь из пакетного менеджера, чего ты мучаешься». Возможно, если Вам нужен балансировщик для Pet-проекта на одном сервере - да, установка пакета из репозитория это нормально. Но если у вас кластер, если вам нужен HTTP/3 и специфичный дополнительный функционал и т.п., если вы хотите понимать, что происходит внутри - дистрибутивный пакет не даст вам этого.
Расскажу, как я собираю HAProxy из исходников с полным контролем, почему статическая линковка это не паранойя, и как автоматизировать весь процесс через GitLab CI.
Проблема: дистрибутивный пакет - это лотерея
Возьмём типичную Ubuntu 22.04 и посмотрим, что даёт apt install haproxy. Картина любопытная, так например, базовые модули на месте, но HTTP/3 отсутствует как класс. Причина проста - системный OpenSSL слишком старый. В Ubuntu 22.04 стоит 3.0.2, а для QUIC нужен 3.0+. Звучит неплохо, но нет - даже в 3.0.2 QUIC не завезён полноценно. Идём дальше: Prometheus exporter выключен, GeoIP недоступен, Lua старый. И это на свежей Ubuntu. А если у вас CentOS 7 с OpenSSL 1.0.2? Там даже TLS 1.3 нет из коробки.
Мораль простая: системный пакет - это компромисс. Он работает «из коробки», но вы не контролируете версии, не знаете точно, какие фичи включены, и привязаны к версии дистрибутива. Когда у вас десять серверов на разных ОС - это превращается в проблему.
Решение: собираем сами, контролируем всё
Я написал build.sh - скрипт, который собирает HAProxy и все зависимости из исходников. Конфигурация - через простой build.conf, где каждая строка это осознанный выбор:
HAPROXY_VERSION="3.4.0"
USE_THREAD=1
USE_CPU_AFFINITY=1
USE_OPENSSL=1
USE_QUIC=1
USE_LUA=1
USE_PCRE2=1
USE_PCRE2_JIT=1
USE_ZLIB=1
USE_PROMEX=1
USE_GEOIP=1
USE_SYSTEMD=1Один файл - и вы знаете ровно что будет в бинарнике. Никаких сюрпризов «а почему этого нет на сервере B».
Статическая линковка: зачем и почему
Первый раз, когда я собрал HAProxy динамически (с .so-зависимостями), всё заработало на Ubuntu. Но при деплое в Alpine-контейнер бинарник отказался запускаться - Alpine использует musl libc, а не glibc. После этого я перешёл на статическую линковку и закрыл для себя целый класс проблем.
Что даёт статика? Во-первых, переносимость. Один бинарник работает на Ubuntu, CentOS, Alpine, Debian - везде, где есть ядро Linux. Собрали один раз - деплоим куда угодно. Во-вторых, предсказуемость. Системный OpenSSL могут обновить завтра, и ваш HAProxy сломается на ровном месте. Со статикой версии зафиксированы - обновляете их только тогда, когда сами этого хотите. В-третьих, безопасность. С динамической линковкой злоумышленник может подменить .so-библиотеку через LD_PRELOAD. Со статикой это невозможно в принципе.
Подводные камни: как я наступил на грабли с Makefile
Казалось бы, всё просто - передаём библиотеки через ADDLIB и -Wl,-Bstatic. Но HAProxy Makefile оказался коварным: он добавляет флаги библиотек в OPTIONS_LDFLAGS до ADDLIB, причём без -Wl,-Bstatic. Результат - линковщик создаёт NEEDED-записи для .so, и бинарник тянет системные библиотеки при запуске.
После сборки смотрю в ldd и вижу:
libssl.so.3 => /lib/x86_64-linux-gnu/libssl.so.3
libcrypto.so.3 => /lib/x86_64-linux-gnu/libcrypto.so.3Это не статическая сборка. Бинарник зависит от системного OpenSSL.
Решение - переопределить _LDFLAGS для каждой библиотеки:
SSL_LDFLAGS_OVERRIDE="SSL_LDFLAGS=-L${prefix}/lib -Wl,-Bstatic -lssl -lcrypto -Wl,-Bdynamic"
LUA_LDFLAGS_OVERRIDE="LUA_LDFLAGS=-L${prefix}/lib -Wl,-Bstatic -llua5.4 -Wl,-Bdynamic"
ZLIB_LDFLAGS_OVERRIDE="ZLIB_LDFLAGS=-Wl,-Bstatic -lz -Wl,-Bdynamic"После этого ldd показывает только libc. Итого в runtime: libcrypt, libm, libc - всё остальное вкомпилено.
Ещё одни грабли: OpenSSL --libdir
OpenSSL 3.x при ./config сам определяет архитектуру и ставит библиотеки в lib64/ на 64-битном Linux. HAProxy Makefile ищет в lib/. Итог - линкуется системный OpenSSL вместо нашего. Решение:
./config --prefix="$prefix" --libdir=lib no-shared no-testsТеперь библиотеки оказываются там, где их ожидает Makefile.
Патч для OpenSSL 3.2.x
HAProxy 3.4.0 использует SSL_get0_group_name для согласования групп в TLS 1.3. Функция появилась в OpenSSL 3.3, а мы собираем 3.2.3. Мелкий патч в ssl_sample.c:
sed -i 's/>= 0x3020000fL/>= 0x30300000L/' src/ssl_sample.cБез этого HAProxy падает при попытке HTTPS-соединения.
Зависимости из исходников: почему не системные пакеты
Для сборки нужны: OpenSSL, Lua, PCRE2, ZLIB, опционально - libmaxminddb для GeoIP. Почему не установить хотя бы это из готовых дистрибутивов?
Причин три:
Первая - версии. Для HTTP/3 нужен OpenSSL 3.2+. В Ubuntu 22.04 - 3.0.2, в CentOS 7 - 1.0.2. Ни один не подходит.
Вторая - статические
.a-файлы. Системные пакеты часто содержат только.so, а нам нужны.aдля статической линковки.Третья - контроль флагов. Для PCRE2 я включаю JIT, для OpenSSL отключаю всё лишнее. Системная сборка не даёт такой гибкости.
Кэширование: sentinel-файл вместо make-кэша
Каждая библиотека собирается разными инструментами: ./configure, ./config, make напрямую. Универсальный механизм - sentinel-файл:
if [[ -f "${prefix}/.done" ]]; then
DEP_PREFIX="$prefix"
return 0
fi
# ...сборка...
echo "$ver" > "${prefix}/.done"${prefix}/.done хранит версию. Если файл существует и версия совпадает - пропускаем сборку. Просто, надёжно, работает для любого инструмента.
Shared-модули: in-binary или .so?
HAProxy поддерживает два типа модулей. In-binary компилируются в основной бинарник через USE_* флаги - проще, модуль всегда доступен. Shared (.so) загружаются в runtime - удобны для проприетарных SDK вроде DeviceAtlas или когда модуль обновляется чаще HAProxy. У shared-модулей есть неприятная особенность: версия SDK должна совпадать с версией HAProxy. OpenTelemetry SDK 1.5 работает с HAProxy 2.8, а 1.16 - с 3.4. Ошибся с версией - модуль просто не загрузится.
Совместимость храню в плоском массиве:
declare -a MODULE_VERSIONS=()
MODULE_VERSIONS+=("3.4 3.99 otel 1.16.0 https://...")
MODULE_VERSIONS+=("3.2 3.3 otel 1.14.0 https://...")
MODULE_VERSIONS+=("2.8 2.8 otel 1.5.0 https://...")Плоский массив с read - минималистично, не требует jq, легко добавлять записи.
systemd: -Ws, а не -W
Для работы Type=notify в systemd нужен master-worker режим с sd_notify. Правильный флаг - -Ws, не просто -W:
ExecStart=/opt/haproxy/haproxy -Ws -f /opt/haproxy/conf/haproxy.cfg \
-p /opt/haproxy/run/haproxy.pidБез -Ws systemd получает сигнал запуска, но HAProxy никогда не вызывает sd_notify() о готовности. Результат - таймаут и result='protocol'.
systemd также не пробрасывает HOSTNAME автоматически. Решение:
Environment=HOSTNAME=%H
Environment=HAPROXY_USER=haproxyТеперь %[env(HOSTNAME)] возвращает имя сервера, а не пустую строку.
Структура /opt/haproxy/: единое дерево вместо FHS
Классический FHS раскидывает HAProxy по всему дереву: бинарник в /usr/sbin, конфиг в /etc, PID в /var/run, модули в /usr/lib. Для кастомной сборки это неудобно - при миграции или бэкапе нужно собирать файлы из пяти мест.
Я собираю всё в единое дерево:
/opt/haproxy/
├── haproxy # бинарник
├── conf/haproxy.cfg # конфиг
├── errors/ # error pages
├── modules/ # .so модули
├── ssl/ # сертификаты
├── run/ # PID, admin.sock
├── lib/ # server-state
└── geoip/ # GeoIP базыБэкап = tar czf /opt/haproxy.tar.gz /opt/haproxy/. Миграция = scp -r /opt/haproxy/ new-server:/opt/. Никаких конфликтов с системным apt install haproxy.
CI/CD: от пуша до релиза меньше чем за 10 минут
Весь процесс автоматизирован через GitLab CI: build → test → package. Build запускается на каждый push в master и на теги. Test - smoke-тест: запускаю HAProxy, делаю HTTP-запрос, проверяю код 200. Package - только для тегов: пакую артефакт, генерирую release notes, публикую GitLab Release.
Создаёте тег - через 5 минут архив доступен для скачивания:
git tag v3.4.0 && git push origin v3.4.0На целевом сервере одна команда:
sudo ./install.shСкрипт идемпотентен, он создаёт пользователя, копирует файлы, валидирует конфиг, запускает сервис, можно запускать сколько угодно раз - система не сломается.
Workflow: от пуша до продакшена
Процесс простой и понятный:
Developer: правит build.conf, пушит в master
|
v
CI: собирает бинарник -> тестирует -> кладёт в output/
| (тег v*)
v
CI: архивирует -> release notes -> GitLab Release
|
v
DevOps: скачивает релиз -> install.sh на сервер
|
v
Сервер: готов принимать трафикОдин тег - один релиз. Каждый коммит в master проходит полный цикл тестов.
Welcome page с полезной информацией
После установки открываем http://<сервер>/ и видим таблицу с информацией о запросе: дата, IP клиента, какой фронтенд обработал, hostname сервера, IP сервера, пользователь. Это не шаблонизатор - HAProxy сам подставляет значения через http-request return ... lf-file. Полезно для отладки: сразу видно, что балансировщик работает.
BUILD_INFO: что внутри бинарника
При сборке генерируется файл BUILD_INFO:
HAProxy 3.4.0
Built: 2026-06-15 10:30:00 UTC
=== In-binary modules ===
[+] USE_OPENSSL
[+] USE_QUIC
[+] USE_LUA
[+] USE_PCRE2_JIT
[+] USE_PROMEX
=== Dependencies ===
[+] OpenSSL 3.2.3
[+] Lua 5.4.7
[+] PCRE2 10.44
[+] ZLIB 1.3.1Когда на сервере что-то идёт не так, вы открываете BUILD_INFO и сразу видите: «собран без USE_GEOIP, а мы пытаемся использовать geoip-конвертер». Или: «OpenSSL 3.0.8, нашли уязвимость - нужно пересобрать с 3.2.3». Этот файл заменяет вопросы «а какой версии библиотека?» на чтение одного файла.
Резюме
Таким образом я получил: бинарник без зависимостей (работает на любом Linux), фиксированные версии (OpenSSL 3.2.3, Lua 5.4.7, PCRE2 10.44), полный контроль над модулями, CI/CD из коробки (тег → релиз за 5 минут) и один install.sh для деплоя.
Код: GitLab repository, MIT license. Форкайте, переписывайте под себя, делитесь находками. Если есть вопросы или идеи - открывайте issue, обсудим.

Станьте первым, кто прокомментирует эту запись