Попользовавшись реверс-прокси Traefik для своего домашнего сервера, я решил вернуться на HAProxy как более функциональное решение, в частности, в области ограничений на количество запросов от клиентов, а то некоторые адреса и подсети буквально бомбардируют мой сайт запросами, и хочется иметь автоматический механизм, охлаждающий пыл таких товарищей. Вопрос стоял в способе выпуска и обновления SSL-сертификата: Traefik умеет сам этим заниматься, а вот для HAProxy нужен какой-то сторонний механизм.
Сначала я думал сделать второй контейнер с Certbot, который будет заниматься обновлением сертификата и класть результат в общий с HAProxy том, где он и будет подхватываться. Но возникают вопросы:
- Как сделать запуск обновления периодическим?
- Как дать знать реверс-прокси, что пора перезапуститься или перечитать конфигурацию, чтобы новый сертификат начал использоваться?
Вариант завязываться с кроном на хосте с командами docker run
мне не нравится, хотелось бы всё держать внутри Докера. Другой вариант, когда в контейнере запущен бесконечный цикл в терминале, также не представляется мне удовлетворительным:
while :; do
<команда обновления сертификата>
sleep 24h
done
Некоторое время назад я прочёл статью HAProxy and Let’s Encrypt: Improved Support in acme.sh о настройке совместной работы Хапрокси и скрипта по работе с выпуском и обновлением сертификатов acme.sh. По сравнению с моим прошлым решением здесь новшеством является то, что после выпуска сертификата вообще не нужно ни перечитывать конфигурацию, ни тем более перезапускать сервер — сертификат начинает работать сразу же «на горячую». Для запуска Хапрокси в Докере я использую образ haproxy:lts-alpine
, а в образах Alpine есть свой crond. По умолчанию он отключён и умеет работать только от пользователя root, но это не проблема — crond я уже использую в других контейнерах, например, для выполнения фоновых заданий в Nextcloud и для напоминаний о днях рождения родственников в Webtrees. В этом направлении я и решил действовать — контейнер с реверс-прокси будет заниматься также и обновлением сертификата. Да, это не очень правильно, потому что в одном контейнере, по-хорошему, должен быть запущен только один процесс; но остальные пути решения выглядят либо хуже, либо слишком громоздко.
В вышеупомянутой статье сначала создаётся отдельный пользователь acme, но здесь я не вижу в этом особого смысла. В исходном образе уже есть учётка haproxy с UID 99, её и будем использовать. Что касается самого скрипта acme.sh, то в этом случае проще всего поставить его из пакета, а не возиться с ручной установкой из git-репозитория. Ещё дополнительно пригодится утилита su-exec, которая нужна для выполнения команд от имени haproxy из-под root. Почему su-exec — она занимает всего 10 КБ и, в отличие от sudo, который я по привычке попробовал использовать, хорошо работает в контейнере, не требуя какой-то дополнительной настройки. Sudo давал мне ошибку
haproxy | haproxy is not in the sudoers file.
haproxy | This incident has been reported to the administrator.
После установки утилит надо ещё прописать в cron задание по обновлению сертификата. В файл скрипта прописывается шебанг и команда на обновление сертификата (о ней чуть позже), дальше скрипт делается исполняемым. Размещается скрипт в каталоге /etc/periodic/daily
, что означает, что он будет выполняться раз в сутки.
Вот весь Dockerfile, собирающий образ для запуска. Строка CMD
активирует крон и запускает реверс-прокси при старте контейнера.
FROM haproxy:lts-alpine
WORKDIR /var/lib/haproxy
ARG renew_script=/etc/periodic/daily/cert_renew
USER root
RUN apk add acme.sh su-exec && \
echo '#!/bin/sh' > $renew_script && \
echo "su-exec haproxy acme.sh --renew -d example.com --renew-hook \"acme.sh --deploy -d example.com --deploy-hook haproxy\"" >> $renew_script && \
chmod ug+x $renew_script
CMD crond && su-exec haproxy haproxy -f /usr/local/etc/haproxy/haproxy.cfg
Acme.sh можно настраивать с помощью переменных окружения, что для контейнеров является наиболее предпочтительным способом (см. The twelve-factor app, глава «Конфигурация»). Пропишем эти переменные в docker-compose.yml
:
services:
haproxy:
# Здесь лежит Dockerfile
build: ./haproxy
container_name: haproxy
hostname: haproxy
restart: always
sysctls:
net.ipv4.ip_unprivileged_port_start: 0
environment:
# Настройка развёртывания сертификатов для HAProxy
DEPLOY_HAPROXY_HOT_UPDATE: yes
DEPLOY_HAPROXY_STATS_SOCKET: "/tmp/api.sock"
DEPLOY_HAPROXY_PEM_PATH: "/certs"
# Рабочий каталог acme.sh, где хранятся настройки, сертификаты и т. д.
LE_WORKING_DIR: "/acme.sh"
ports:
- 80:80
- 443:443
- 8404:8404
volumes:
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
- ./haproxy/cfg:/usr/local/etc/haproxy
- ./haproxy/certs:/certs
- ./haproxy/acme.sh:/acme.sh
Необходимо дать права на каталоги хоста группе или пользователю haproxy (UID/GID 99), чтобы acme.sh мог записывать туда свои данные изнутри контейнера.
sudo chown -R $USER:99 ./haproxy/{certs,acme.sh}
Дальше нужно запустить контейнер с реверс-прокси и зарегистрировать учётку для работы acme.sh с Let’s Encrypt. Все команды docker exec
обязательно нужно выполнять, указывая пользователя haproxy, потому что в нашем обновлённом докер-образе, если не указано иначе, все команды будут выполняться под суперпользователем root.
docker compose up -d --build
docker exec -u haproxy haproxy acme.sh --register-account --server letsencrypt -m youremail@example.com
Полученное значение ACCOUNT_THUMBPRINT необходимо скопировать и прописать его в свой файл haproxy.cfg как переменную, а также строку в веб-фронтенд. Всё это позволит Хапрокси корректно отвечать на запрос проверки принадлежности сервера перед выдачей сертификата (ACME challenge).
global
stats socket /tmp/api.sock user haproxy group haproxy mode 660 level admin expose-fd listeners
setenv ACCOUNT_THUMBPRINT 'lCufto4sDRTHdmWL0EugFywGV54hBCuTTXvwifi65R4'
frontend web
bind :80
bind :443 ssl crt /certs strict-sni
http-request return status 200 content-type text/plain lf-string "%[path,field(-1,/)].${ACCOUNT_THUMBPRINT}\n" if { path_beg '/.well-known/acme-challenge/' }
Перечитываем конфигурацию Хапрокси и переходим к выпуску и установке сертификата.
docker kill -s HUP haproxy
# Выпуск сертификата. Лог полезен для выяснения причин потенциальных проблем.
docker exec -u haproxy haproxy acme.sh --issue --stateless --server letsencrypt -d example.com --log /acme.sh/debug.log
# После успешного выпуска развёртываем сертификат в каталог для Хапрокси.
# Так как переменные DEPLOY уже заданы в docker-compose.yml, больше никаких параметров задавать не нужно.
docker exec -u haproxy haproxy acme.sh --deploy -d example.com --deploy-hook haproxy
[Mon Jul 14 14:34:02 MSK 2025] The domain 'example.com' seems to already have an ECC cert, let's use it.
[Mon Jul 14 14:34:02 MSK 2025] Deploying PEM file
[Mon Jul 14 14:34:02 MSK 2025] Moving new certificate into place
[Mon Jul 14 14:34:02 MSK 2025] Update existing certificate '/certs/example.com.pem' over HAProxy stats socket.
[Mon Jul 14 14:34:02 MSK 2025] Success
Всё, новый сертификат установлен и работает! Теперь про его перевыпуск, который был прописан в крон в докерфайле. Строка выглядит так:
su-exec haproxy acme.sh --renew -d example.com --renew-hook "acme.sh --deploy -d example.com --deploy-hook haproxy"
Т. е., от root запускается скрипт, а дальше команда уже выполняется от пользователя haproxy. Если сертификату больше 60 дней, то он перевыпускается; если меньше, то пишется следующее:
[Mon Jul 14 10:00:40 MSK 2025] The domain 'example.com' seems to already have an ECC cert, let's use it.
[Mon Jul 14 10:00:40 MSK 2025] Renewing: 'example.com'
[Mon Jul 14 10:00:40 MSK 2025] Switching back to https://acme-v02.api.letsencrypt.org/directory
[Mon Jul 14 10:00:40 MSK 2025] Renewing using Le_API=https://acme-v02.api.letsencrypt.org/directory
[Mon Jul 14 10:00:40 MSK 2025] Skipping. Next renewal time is: 2025-09-10T14:21:52Z
[Mon Jul 14 10:00:40 MSK 2025] Add '--force' to force renewal.
Параметр --force
, принудительно перевыпускающий сертификат вне зависимости от его срока действия, я рекомендую никогда не использовать. Вас могут заблокировать за чрезмерно частые обращения к сервису, да и смысла часто перевыпускать сертификаты никакого. Если нужно проводить какие-то тесты и эксперименты, пользуйтесь тестовыми серверами Let’s Encrypt (staging servers), где ограничения гораздо мягче. В acme.sh тестовые сервера настраиваются указанием --server letsencrypt_test
при регистрации учётки и выпуске сертификата.
Параметр --renew-hook
в строке перевыпуска сертификата — это команда, срабатывающая только тогда, когда сертификат перевыпущен. Здесь это команда развёртывания сертификата в каталог, где его увидит реверс-прокси.
Желаю успехов.