Files
haproxy/README.ru-RU.md
T
bitdeals a66944077d docs: add README in English and Russian
Same structure as the bitmessage and bitcoind repositories: intro, usage with
compose and cli examples, a parameter table, and notes carrying the traps —
why the certificate must exist before start-up, why the runtime API is a unix
socket, why there are two loggers, and why HSTS is one day rather than a year.
2026-08-07 13:38:54 +00:00

20 KiB
Raw Blame History

Общие сведения

English version: README.md

HAProxy — балансировщик и обратный прокси для TCP и HTTP. Здесь он служит публичным краем сайта BitDeals: терминирует TLS на 443, передаёт всё остальное веб-контейнеру и направляет ACME-проверки в certbot.

HAProxy, работающий в docker-контейнере с конфигурацией, вшитой в образ.

Репозиторий описывает только развёртывание в docker. Образ — bitnami/haproxy, в который скопирован один файл.

Использование

У контейнера два порта, 80 и 443, и оба — публичный сайт.

Третий канал портом не является: runtime API HAProxy слушает unix-сокет /var/lib/haproxy/admin.sock на томе, разделяемом с контейнером certbot, — тот через него устанавливает обновлённый сертификат в работающий процесс без перезапуска. API имеет уровень admin и не защищён аутентификацией, поэтому кто может его открыть, определяют права на файл, — см. «Замечания».

Переменная окружения одна — XFF_HMAC_KEY, и она необязательна. Всё остальное задано в docker/haproxy.cfg, который копируется в образ при сборке, поэтому изменение маршрутизации означает пересборку и повторное развёртывание.

Сертификат читается из /usr/local/etc/haproxy/certificates/site.pem, том подключён только на чтение и разделяется с certbot. Файл обязан существовать до старта контейнера — см. «Замечания».

docker-compose

services:
  haproxy:
    build:
      context: https://git.bitdeals.org/private/haproxy.git
      dockerfile: ./docker/Dockerfile
    image: registry.bitdeals.org/haproxy
    restart: unless-stopped
    depends_on:
      - nginx
      - certbot
    volumes:
      - certificates:/usr/local/etc/haproxy/certificates:ro
      - haproxy_admin:/var/lib/haproxy   # сокет runtime API — только для certbot
    ports:
      - "80:80"
      - "443:443"

volumes:
  certificates:
  haproxy_admin:

Оба бэкенда названы по сервисам, к которым обращаются: nginx:80 — сайт, certbot:380 — ACME-проверки. Эти имена должны разрешаться внутри compose-проекта, то есть названные сервисы обязаны быть с этим в одной сети.

docker cli

docker run -d \
  -p 80:80 \
  -p 443:443 \
  -v certificates:/usr/local/etc/haproxy/certificates:ro \
  registry.bitdeals.org/haproxy

Всё, что указано после имени образа, заменяет собственные аргументы демона, поэтому проверка подключённого файла конфигурации не требует нового образа:

docker run --rm -v "$PWD/docker/haproxy.cfg:/tmp/haproxy.cfg:ro" \
  registry.bitdeals.org/haproxy -c -f /tmp/haproxy.cfg

Сборка и публикация

Push в main собирает и публикует образ (.gitea/workflows/build.yaml) с тремя тегами: <версия>.<sha7> — для развёртывания, <версия> — для чтения и latest — для compose и Watchtower. Ночной cron пересобирает образ из тех же исходников. Вручную, если под рукой учётные данные реестра:

docker build . --file docker/Dockerfile --tag registry.bitdeals.org/haproxy
docker push registry.bitdeals.org/haproxy

Контекст сборки — корень репозитория, а не docker/: Dockerfile копирует ./docker/haproxy.cfg, поэтому при контексте ./docker этот файл не виден и сборка падает на COPY.

Параметры

Образы контейнера настраиваются параметрами, передаваемыми при запуске.

Параметр Назначение
-p 80 Обычный HTTP. Перенаправляет на HTTPS кодом 301, кроме пути ACME-проверки: он обязан оставаться доступным здесь, иначе перевыпуск не проходит
-p 443 HTTPS. Требует наличия site.pem в томе сертификатов до старта контейнера
-v /usr/local/etc/haproxy/certificates Каталог сертификатов, только на чтение. Читается лишь site.pem и лишь при связывании портов. certbot пишет его через тот же том, подключённый на запись как /etc/certificates
-v /var/lib/haproxy Сокет runtime API (admin.sock, уровень admin, без аутентификации). Подключайте этот том к certbot и больше никуда — см. «Замечания»
-e XFF_HMAC_KEY Необязательная, base64. Задана — адрес посетителя заменяется на HMAC от него в X-Client-Id и дальше не передаётся; пуста — такой заголовок не отправляется. Создать: openssl rand -base64 32, см. «Замечания»

Маршрутизация, тайм-ауты и настройки TLS параметрами не являются: они находятся в docker/haproxy.cfg и поставляются внутри образа.

Замечания

  • site.pem обязан существовать до старта контейнера. bind ... ssl crt разбирается при чтении конфигурации, поэтому пустой том — это фатальная ошибка запуска, а не предупреждение: HAProxy завершается и без политики перезапуска больше не поднимается. certbot при первом старте создаёт самоподписанную заглушку именно чтобы разорвать этот круг, — поэтому сервис поставляется с restart: unless-stopped; в проекте, где certbot определён, добавьте ещё и depends_on.
  • Сертификат, установленный через runtime API, живёт только в памяти. Именно поэтому том здесь подключён только на чтение: set ssl cert и commit ssl cert на диск ничего не пишут. Файл на томе — копия certbot, и именно её HAProxy перечитывает после перезапуска, так что оба пути согласуются без права записи у HAProxy.
  • Runtime API — полноценный административный канал без пароля. Любой, кто способен его открыть, может установить другой сертификат с другим приватным ключом, перенаправить бэкенд на иной адрес или вывести серверы из обслуживания — то есть незаметно устроить сайту «человека посередине». Считайте доступ к admin.sock равноценным владению приватным ключом TLS и подключайте этот том к certbot и больше никуда. expose-fd listeners, которая вдобавок отдала бы клиенту сокета сами слушающие сокеты, намеренно не задана: она нужна для бесшовной перезагрузки, которой этот образ не выполняет.
  • Unix-сокет — потому что порт ограничить нельзя. expose: наружу ничего не публикует, но и не ограничивает, а правил по портам у docker-сетей нет: значит runtime API на TCP открыт любому контейнеру в общей сети, включая nginx, — ведь HAProxy обязан дозваниваться до него. Сокет на томе доступен только тем контейнерам, которые этот том подключают, и это исчерпывающее описание разграничения доступа. Заодно приватный ключ, идущий по этому каналу при каждом перевыпуске, больше не покидает пределы тома.
  • HAProxy нужны права на запись в каталог сокета, а не только на сам файл. Он связывается с сокетом, создавая <путь>.<pid>.tmp и переименовывая его поверх целевого, — поэтому образ создаёт /var/lib/haproxy с владельцем 1001, а docker переносит это владение на пустой именованный том, подключённый сюда. Переименование заодно объясняет, почему оставшийся от прошлого запуска сокет безвреден. certbot подключается от root, и mode 660 его не касается.
  • У редиректа на HTTPS есть одно исключение, и оно несущее. Порт 80 отвечает 301 на всё, кроме /.well-known/acme-challenge/, — этот путь Let's Encrypt проверяет по обычному HTTP, и его перенаправление останавливает любой перевыпуск. Правило записано выше use_backend, потому что в этом порядке оно и выполняется: правила http-request вычисляются до выбора бэкенда независимо от порядка в файле, и HAProxy предупреждает, когда одно расходится с другим.
  • HSTS выставлен на сутки, а не на привычный год. Это дверь в одну сторону: браузер, увидевший заголовок, отказывается ходить по обычному HTTP на этот хост до истечения срока, и отменить это с сервера нельзя. Сутки оставляют просроченный сертификат исправимым. Поднимать ступенями — 86400, 2592000, 31536000, — когда перевыпуск устоится. includeSubDomains и preload намеренно отсутствуют: первый связывает имена, которых этот прокси не обслуживает, второй практически необратим.
  • Адреса бэкендов перечитываются, и по умолчанию это не так. Обе строки server несут resolvers docker, поэтому имена nginx и certbot разрешаются заново по ходу работы. Без этого имя разрешается один раз при загрузке и держится всё время жизни процесса, а пересозданный с новым IP контейнер — то, что Watchtower делает при каждом развёртывании, — остаётся незамеченным. init-addr libc,none — вторая половина: она позволяет HAProxy стартовать, когда бэкенд ещё не поднят, вместо отказа разобрать неразрешимое имя.
  • Журнал идёт в stdout, а option dontlog-normal оставляет в нём только ошибки. log stdout format raw local0 не требует syslog-демона — вывод забирает docker logs. Успешный запрос не пишется ничем; 503, бэкенд без сервера, отклонённое рукопожатие — пишутся. Убирайте dontlog-normal осознанно, если нужен полный журнал обращений: он же удерживает объём.
  • Логгеров два, и забытый второй сдаёт адреса. option httplog здесь не используется: его формат по умолчанию начинается с %ci:%cp, то есть адреса посетителей попали бы в docker logs и свели бы на нет псевдоним, который выставляют фронтенды. Собственный log-format ставит в это первое поле псевдоним. Ловушка — error-log-format: он покрывает то, что происходит до появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому псевдоним вычисляется правилом tcp-request connection на приёме соединения и в области sess: правило http-фазы к моменту провала рукопожатия ещё не выполнялось бы.
  • В журнал идут только метод и путь, никогда не строка запроса. %{+Q}r унёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время хранения журнала.
  • Адрес посетителя дальше не идёт. option forwardfor не задан: X-Forwarded-For в обоих фронтендах удаляется и никогда не заполняется, поэтому ничто за этим прокси не может записать в журнал адрес, которого ему не давали. Вместо адреса передаётся псевдоним в X-Client-Id — HMAC-SHA256 от адреса на ключе XFF_HMAC_KEY. Он взаимно однозначен с адресом, то есть как ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а не просто хеш: IPv4 — это 2³² значений, и хеш адреса без ключа перебирается за секунды.
  • XFF_HMAC_KEY необязателен, и пустой ключ выключает функцию, а не ослабляет её. Не задан — X-Client-Id не отправляется вовсе, и ограничение частоты ниже по цепочке вырождается в одну корзину на всех посетителей; задан — у каждого посетителя своя. Чего не происходит никогда, так это псевдонима, выведенного на пустом ключе. Значение, не являющееся корректным base64, останавливает контейнер при разборе конфигурации — тихо испортиться оно не может. Ротация ключа сбрасывает корзины (пользователь этого не видит) и меняет все псевдонимы, поэтому активность до и после ротации связать нельзя.
  • Оба удаления безусловны. X-Forwarded-For и X-Client-Id удаляются независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок ниже по цепочке нельзя было принять за выставленный этим прокси. То же с X-Forwarded-Proto: каждый фронтенд выставляет собственную схему, а не передаёт дальше клиентское утверждение.
  • Потребителя всё равно нужно научить этим пользоваться. Ограничение частоты, построенное на адресе сокета — $binary_remote_addr у nginx, request.client.host у ДС, — видит этот прокси на каждом запросе и вырождается в общую корзину. Ключом должен быть X-Client-Id, и доверять ему следует только с адреса этого прокси; готовый пример — frontend/docker/rate-limit.conf в репозитории bitdeals-ng.
  • TLS задан в global, а не отдан на усмотрение OpenSSL. Нижняя граница — TLS 1.2, список шифров только ECDHE и в вариантах ECDSA и RSA (certbot выпускает ECDSA, а самоподписанная заглушка — RSA), билеты сессий выключены, чтобы совершенная прямая секретность не сводилась на нет долгоживущим ключом билета. alpn h2,http/1.1 в строке bind предлагает браузерам HTTP/2; бэкенд остаётся на HTTP/1.1, преобразованием занимается HAProxy. Заголовок HSTS не отправляется, и это намеренно: он был бы преждевременным, пока порт 80 отдаёт сайт вместо перенаправления, а отменить его после того, как браузеры запомнили политику, трудно.
  • Фазу чтения заголовков ограничивает timeout http-request 10s, и timeout client его не заменяет: тот является таймаутом бездействия и сбрасывается на каждом полученном байте, поэтому клиент, шлющий по байту, держит соединение открытым сколько угодно. Этот — абсолютный.
  • Процесс работает под uid 1001 и всё же занимает порты 80 и 443. Это работает потому, что docker по умолчанию выставляет в контейнерах net.ipv4.ip_unprivileged_port_start=0; хост или среда выполнения, вернувшие традиционное значение, приведут к тому, что контейнер не сможет занять порты.
  • Базовый образ не зафиксирован. FROM bitnami/haproxy означает :latest, а ночной cron пересборки берёт то, на что этот тег указывает сейчас, — минорная версия HAProxy может смениться в сборке, которую никто не запускал, после чего Watchtower выкатит её. Для воспроизводимых сборок фиксируйте FROM bitnami/haproxy:<версия>.