The Notes explain at length that no visitor's IP address reaches the log and why both loggers are hand-written to keep it that way. Nothing showed it. A reader had to take the claim on faith or read haproxy.cfg. Add a "the log" section to Usage in both READMEs: a real `docker compose logs haproxy` excerpt from a running node, the two log formats broken down field by field, and the point the sample exists to make — the first field is the X-Client-Id pseudonym, and it is all the log knows about who connected.
24 KiB
Общие сведения
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
Журнал
docker compose logs haproxy на работающем узле:
haproxy-1 | XFF_HMAC_KEY was not set — generated one for this container.
haproxy-1 | [NOTICE] (1) : Automatically setting global.maxconn to 32745.
haproxy-1 | rlj13BvDLpJLzzHbutTiC5fjK0OR4YRlPrYnJIHpO7Y= [18/Aug/2026:10:25:25.196] http 1/1 Success
haproxy-1 | wgM6JB1uN2AGFKkl67+O7SCx+DHrGttufQw0HDQMSIU= [18/Aug/2026:10:45:10.700] https~ 1/1 SSL handshake failure
haproxy-1 | 5056er7Wogz59zFJfaNNelKpEnxzEISztzbgn/omnHg= [18/Aug/2026:10:54:38.337] https~ 1/1 Connection closed during SSL handshake
haproxy-1 | 6TPbf9FXhRxBoy+SSpFrOuTvYjZF3RjTQmdCteZWH3s= [19/Aug/2026:04:13:17.521] https~ default-backend-http/main 400 289 0/497 SSTP_DUPLEX_POST /sra_{BA195980-CD49-458b-9E23-C84EE0ADCD75}/
Ни в одной строке нет IP-адреса, и появиться ему неоткуда. Первое поле —
псевдоним из X-Client-Id, HMAC-SHA256 от адреса посетителя на ключе
XFF_HMAC_KEY в base64, — и это всё, что журнал знает о том, кто подключился.
Оба логгера выписаны вручную именно поэтому: их умолчания начинаются с
%ci:%cp, то есть с адреса и порта. См. «Замечания».
Четыре строки обращений — это два формата:
- уровень соединения,
%[var(sess.cid)] [%tr] %ft %ac/%fc %[fc_err_str]: псевдоним, время, фронтенд, счётчики соединений, чем соединение кончилось. Сюда попадает всё, что умирает до появления запроса: клиент, не предлагающий ничего выше TLS 1.1, сканер, обрывающий рукопожатие на середине, проба, которая открыла порт и ушла. - уровень транзакции,
… %ft %b/%s %ST %B %TR/%Ta %HM %HP: псевдоним, время, фронтенд, бэкенд/сервер, статус, байты, таймеры, метод, путь. Последняя строка — 400 в ответ на случайную SSTP-пробу. Путь заканчивается там, где начинается строка запроса: её в журнале нет никогда.
Успешные запросы не пишутся вовсе (option dontlog-normal), поэтому настолько
тихий журнал — нормальное состояние работающего узла, а не признак того, что
логгеры настроены неверно.
Сборка и публикация
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. IP посетителя заменяется на HMAC от него в X-Client-Id и дальше не передаётся. Не задавай — точка входа сгенерирует ключ на каждый запуск контейнера; задавай, только если псевдонимы должны пережить перезапуск или совпадать на двух прокси, см. «Замечания» |
Маршрутизация, тайм-ауты и настройки 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осознанно, если нужен полный журнал обращений: он же удерживает объём. - Логгеров два, и забытый второй сдаёт IP-адреса.
option httplogздесь не используется: его формат по умолчанию начинается с%ci:%cp, то есть IP-адреса посетителей попали бы вdocker logsи свели бы на нет псевдоним, который выставляют фронтенды. Собственныйlog-formatставит в это первое поле псевдоним. Ловушка —error-log-format: он покрывает то, что происходит до появления транзакции (отклонённое TLS-рукопожатие, а нижняя граница теперь TLS 1.2), и его умолчание начинается так же. Здесь заданы оба. Поэтому псевдоним вычисляется правиломtcp-request connectionна приёме соединения и в областиsess: правило http-фазы к моменту провала рукопожатия ещё не выполнялось бы. - В журнал идут только метод и путь, никогда не строка запроса.
%{+Q}rунёс бы и её, и токен, однажды оказавшийся в URL, был бы записан на всё время хранения журнала. - IP-адрес посетителя дальше не идёт.
option forwardforне задан:X-Forwarded-Forв обоих фронтендах удаляется и никогда не заполняется, поэтому ничто за этим прокси не может записать в журнал IP, которого ему не давали. Вместо IP-адреса передаётся псевдоним вX-Client-Id— HMAC-SHA256 от IP-адреса на ключеXFF_HMAC_KEY. Он взаимно однозначен с IP, то есть как ключ ограничения частоты ничем не хуже, и без ключа необратим. Именно HMAC, а не просто хеш: IPv4 — это 2³² значений, и хеш IP-адреса без ключа перебирается за секунды. XFF_HMAC_KEYне оставляется пустым, а генерируется. Пустой ключ выключает функцию:X-Client-Idне отправляется вовсе, и ограничение частоты ниже по цепочке вырождается в одну корзину на всех посетителей — состояние, которого никто не выбирает нарочно и в которое проще всего попасть, забыв строку в.env. Поэтому точка входа подставляетopenssl rand -base64 32, если ключа нет. Его значение никто не выбирает, снаружи контейнера оно никому не нужно, и двум развёртываниям не требуется одинаковое. Новый ключ на каждый запуск контейнера стоит сброса корзин ниже по цепочке — на минутном окне это незаметно — и делает несвязываемыми псевдонимы до и после, а это свойство, ради которого ключ и существует, а не потеря. Задавать значение явно стоит лишь тогда, когда псевдонимы должны пережить перезапуск или совпадать на двух прокси. Конфигурация по-прежнему умеет работать с пустым ключом:haproxy.cfgможно запустить и вне этого образа. Значение, не являющееся корректным base64, по-прежнему останавливает контейнер при разборе — тихо испортиться оно не может, и поэтому же генерируется base64, а не hex: hex здесь был бы принят и молча раскодирован как base64 во что-то другое.- Оба удаления безусловны.
X-Forwarded-ForиX-Client-Idудаляются независимо от того, задан ключ или нет, — чтобы присланный клиентом заголовок ниже по цепочке нельзя было принять за выставленный этим прокси. То же сX-Forwarded-Proto: каждый фронтенд выставляет собственную схему, а не передаёт дальше клиентское утверждение. - Потребителя всё равно нужно научить этим пользоваться. Ограничение
частоты, построенное на IP-адресе сокета —
$binary_remote_addrу nginx,request.client.hostу ДС, — видит IP этого прокси на каждом запросе и вырождается в общую корзину. Ключом должен бытьX-Client-Id, и доверять ему следует только с IP этого прокси; готовый пример —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:<версия>.