HAProxy: промышленная сборка, деплой и CI/CD
Артур Хайбуллин
Артур Хайбуллин 15 июн 2026, 12:30
build_haproxy_cicd.jpg

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, обсудим.

0
168
Комментарии (0)
Комментировать
Пока нет комментариев

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