🏠: docker

HAProxy + acme.sh в Docker

Попользовавшись реверс-прокси Traefik для своего домашнего сервера, я решил вернуться на HAProxy как более функциональное решение, в частности, в области ограничений на количество запросов от клиентов, а то некоторые адреса и подсети буквально бомбардируют мой сайт запросами, и хочется иметь автоматический механизм, охлаждающий пыл таких товарищей. Вопрос стоял в способе выпуска и обновления SSL-сертификата: Traefik умеет сам этим заниматься, а вот для HAProxy нужен какой-то сторонний механизм.

Сначала я думал сделать второй контейнер с Certbot, который будет заниматься обновлением сертификата и класть результат в общий с HAProxy том, где он и будет подхватываться. Но возникают вопросы:

  1. Как сделать запуск обновления периодическим?
  2. Как дать знать реверс-прокси, что пора перезапуститься или перечитать конфигурацию, чтобы новый сертификат начал использоваться?

Вариант завязываться с кроном на хосте с командами 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 в строке перевыпуска сертификата — это команда, срабатывающая только тогда, когда сертификат перевыпущен. Здесь это команда развёртывания сертификата в каталог, где его увидит реверс-прокси.

Желаю успехов.

Вечер дня сисадмина

После ужина обновил систему на своём сервере-неттопе, а потом решил ещё разик поковырять давно висевшее предупреждение в Nextcloud:

The reverse proxy header configuration is incorrect, or you are accessing Nextcloud from a trusted proxy. If not, this is a security issue and can allow an attacker to spoof their IP address as visible to the Nextcloud. Further information can be found in the documentation.

Сначала я попробовал добавить в параметр trusted_proxies конфигурации Nextcloud не имя контейнера реверс-прокси, а приватный диапазон IP, используемый Докером — 172.16.0.0/12; так много где советуют сделать, но это ничего не дало. В конце концов, проблема решилась заданием того же имени контейнера, но не единым параметром, а первым элементом массива, а также установкой ожидаемого заголовка пересылки.

docker exec -uwww-data nc-php php /var/www/html/cloud/occ config:system:set forwarded_for_headers 0 --value="X-Forwarded-For"
docker exec -uwww-data nc-php php /var/www/html/cloud/occ config:system:set trusted_proxies 0 --value="reverse-proxy"

Наконец-то:

Ободрённый успехом, я решил обновить контейнер с PHP 7.4 на PHP 8.0. Он у меня самосборный — это Alpine c последующей установкой необходимых пакетов и настройкой с помощью командной строки. Я поменял пакеты в Dockerfile на соответствующую версию и поправил пути, пересобрал контейнер, и вроде как заработало, но перестали выполняться команды, вызывающие php, подобные приведённым выше, и остановился cron, выполняющий фоновые задания. Ошибка была такая:

OCI runtime exec failed: exec failed: unable to start container process: exec: ″php″: executable file not found in $PATH: unknown

Полез внутрь контейнера. Команда php8 выполняется, а php — нет. Оказалось, что в дистрибутиве Alpine для php8 не готовы пакеты, поэтому символическая ссылка автоматически при установке не прописывается. Речь об этом шла год назад, не знаю, та же причина сейчас или нет, но результат всё равно один. Добавил ещё одну строчку в Dockerfile, после чего всё заработало:

ln -sf /usr/bin/php8 /usr/bin/php

Cron снова в порядке

Заодно напишу про упомянутый в прошлом тексте выпуск сертификатов Let’s Encrypt для HAProxy. Всё получилось и отлично работает. У Certbot есть каталог /etc/letsencrypt/renewal-hooks/deploy, и если туда положить скрипт, то он будет выполняться после каждого успешного перевыпуска сертификата. В данном случае нужно слепить полную цепочку сертификатов и закрытый ключ в одно целое, положив результат в каталог сертификатов HAProxy:

#!/bin/bash
cat $RENEWED_LINEAGE/{fullchain.pem,privkey.pem} > /etc/ssl/certs/haproxy/$(basename $RENEWED_LINEAGE).pem

# --deploy-hook DEPLOY_HOOK
#    Command to be run in a shell once for each
#    successfully issued certificate. For this command, the
#    shell variable $RENEWED_LINEAGE will point to the
#    config live subdirectory (for example,
#    "/etc/letsencrypt/live/example.com") containing the
#    new certificates and keys; the shell variable
#    $RENEWED_DOMAINS will contain a space-delimited list
#    of renewed certificate domains (for example,
#    "example.com www.example.com") (default: None)

Чтобы сертификат подхватился, HAProxy должен перечитать конфигурацию. Можно, конечно, добавить строку и в скрипт, приведённый выше, но если сертификатов выпускается сразу штук десять, то столько раз дёргать реверс-прокси за несколько секунд вряд ли хорошая идея. Поэтому я сделал отдельный скрипт, который ищет сертификаты с датой изменения меньше суток, и если они есть, перечитывает конфигурацию:

#!/bin/bash
if [[ $(find /etc/ssl/certs/haproxy/* -mtime -1) ]]
then
systemctl reload haproxy.service
fi

В cron нужно добавить запуск этого скрипта раз в сутки, например, в 3 часа ночи:

echo -e "\n# Reload HAProxy if there are new certs\n0 3\t* * *\troot\t/scripts/haproxy-reload-if-new-certs.sh" >> /etc/crontab

Ну, с праздничком всех причастных и сочувствующих, спокойной ночи.

Страничка мониторинга опять в строю

После переезда сайта в Докер я продолжил рассматривать варианты какого-то простенького мониторинга. В основном, мониторинг для Докера представляет собой сбор неимоверного количества метрик, большая часть которых непонятно зачем нужна в мирное время, и передача их куда-то на аккумулирующий сервис. Я нашёл некий паллиатив под названием cAdvisor, у которого есть свой веб-интерфейс, запустил его, и он вывалил мне невероятную кашу из процессов, километровых путей и идентификаторов, к тому же, процессорных ресурсов он кушал больше, чем всё остальное, вместе взятое.

Конечно, такой вариант мне не годился, но я заметил кое-что интересное, а именно — cAdvisor работает с примонтированным на чтение корнем хостовой системы, чтобы получать информацию собственно о хосте. Тогда я полез в гитхаб-репозиторий своего любимого phpsysinfo и обнаружил, что там появилась инструкция по запуску в Докере, что свидетельствовало о развитии этого направления (я года три не следил за новинками в этой программе), а также плагин, получающий информацию о контейнерах. Не хватало только одного — отображения информации о хостовой системе при работе самого сервиса в контейнере, о чём я написал разработчику, сославшись на подход, применяемый в cAdvisor.

Разработчик оказался невероятно отзывчивым и за 3 дня функционал в виде параметра ROOTFS="/rootfs", позволяющий задавать альтернативный путь к корню, был добавлен, и подправлены ошибки реализации. Настройка phpsysinfo в этом случае немного отличается — везде, где в обычных условиях запрос информации шёл в режиме ACCESS="command", теперь это сделать невозможно, так как из контейнера команды на хост, естественно, не передаются; нужно идти путём ACCESS="data" — когда хост периодически сам выполняет запросы и кладёт файл с результатом в подкаталог <phpsysinfo>/data.

Изображение без описания

Например, для параметров SMART и для Docker нужно добавить в /etc/crontab на хосте примерно следующее:

### phpsysinfo ###
# Docker containers
*/30 * * * * root docker stats --no-stream --format 'table {{.Name}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}\t{{.NetIO}}\t{{.BlockIO}}\t{{.PIDs}}' > /var/lib/docker/volumes/home_phpsysinfo/_data/mon/data/docker.tmp
# SMART
*/30 * * * * root smartctl --all /dev/sda > /var/lib/docker/volumes/home_phpsysinfo/_data/mon/data/smart0.tmp

Я не стал использовать идущий в комплекте Dockerfile, а сделал по уже привычной схеме: nginx, php-fpm и один именованный общий том.

Изображение без описания

Красота вернулась!

P. S. Совсем забыл: пару дней назад добавил ещё и Watchtower — сервис автообновления образов Докера.

Переезд в Docker

Устройство этого сайта на сегодняшний день

Наконец-то ковыряние Докера привело к чему-то практическому. Конечно, и раньше я ставил его на работе и поднимал там всякие сервисы, но это были одиночные контейнеры и небольшая настройка. Сейчас реализована задача посложнее — переезд с моего одноплатника Orange Pi PC 2, работавшего веб-сервером без малого 4 года (сколько воды утекло с тех пор!), на неттоп, который я купил в 2016 году, завершивший свою карьеру настольного компьютера, со вставленным туда SSD Samsung 850 EVO 250 ГБ, также освободившийся от настольных задач, и смена парадигмы хостинга с монолита на микросервисы.

Технических подробностей здесь особо не будет (хотя это смешно звучит: ничего, кроме них, тут, в общем-то, и нет), потом добавлю что-то в справочник, а пока просто фиксирую по горячим следам.

На старом сервере стоял Armbian, веб-сервер Apache и база данных MySQL. Был единый каталог /var/www/html, где в корне лежал Wordpress, а в дополнительно созданных подкаталогах — другие сервисы: в /cloud — Nextcloud, в /wiki — Dokuwiki, в /mon — phpSysInfo, позже был добавлен Webtrees в одноимённую подпапку.

Не то чтобы мне нужно было позарез переезжать на новую платформу, но меня немного беспокоило, что хранилищем выступают флешки, и система стоит на карточке microSD. Хоть логи в Armbian и пишутся в память, тем не менее, ресурс флеш-носителей довольно мал — несколько месяцев назад, например, померла флешка для записи резервных копий. Ну и, конечно, хочется освоить что-то новое и быть в курсе современных прикладных направлений в ИТ. Наконец, желательно иметь более переносимую систему, которая не так привязывается к железу и в перспективе будет работать в кластере.

На неттоп, в котором памяти стало 4 ГБ после обмена с ноутбуком, была установлена Ubuntu Server 20.04 LTS с ядром HWE и Docker в качестве платформы. Затем я перенёс туда наработки, которые я делал на тестовых виртуальных машинах, и занялся миграцией данных.

Задача, главным образом, осложнялась тем, что у меня есть только одно доменное имя, и все сервисы должны работать не на поддоменах, что очень просто настраивается, а на путях после доменного имени (префиксах). Многие современные сервисы, упакованные в контейнеры, уже имеют поддержку разных режимов работы через реверс-прокси, но некоторые либо не имеют этой поддержки вовсе, либо она есть, но не работает, как в случае с Nextcloud, где можно указать параметр overwritewebroot, но работать он не будет. Из-за этого пришлось собирать Nextcloud по кускам самому. Но это даже и к лучшему, потому что официальный контейнер Nextcloud, по-хорошему, противоречит самой идее микросервисов, так как там в одном контейнере находится сразу несколько работающих процессов, что больше похоже на виртуальную машину; к тому же, при самостоятельной сборке начинаешь лучше понимать устройство системы.

Я всегда стремлюсь к экономии ресурсов, и по возможности использую либо чистый контейнер Alpine Linux (например, для php-fpm — так уж вышло), либо вариант нужного мне сервиса на базе Alpine. Иногда стремление сэкономить выходит боком — я долго возился с «лёгким» веб-сервером Lighttpd, но в случае с Webtrees не смог решить задачу «красивых ссылок» (pretty URLs) даже с помощью специального форума, и в результате решил остановиться на Nginx как самом модном и распространённом варианте на сегодняшний день, для которого везде есть куча конфигураций.

Порой я упирался в непонимание каких-то вещей, например, как раздавать права на томах, если туда смотрят 2 контейнера — nginx и php-fpm, которые работают от разных пользователей? И как раздать эти разрешения с хоста, где таких пользователей вообще нет? Заводить их там не вариант же.

Заставить работать nginx от учётки www-data у меня не вышло, но потом оказалось, что достаточно раздавать права на том с данными только для php-fpm и nginx можно вообще не трогать, а с хоста можно задавать разрешения даже для несуществующих пользователей, если просто указывать совпадающий ID:

sudo chown -R 82:82 /var/lib/docker/volumes/home_cloud/_data/cloud
# Впрочем, правильнее, наверное, так:
docker exec cloud-php chown -R www-data:www-data /var/www/html/cloud

Отдельная песня с реверс-прокси. Например, рабочий конфиг ярлыков для Nextcloud, чтобы внутри него при проверке получить зелёную галочку, оказался такой:

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.nc.rule=PathPrefix(`/cloud`,`/.well-known`)"
  - "traefik.http.routers.nc.middlewares=nc-dav,nc-wellknown,nc-sts"
  - "traefik.http.middlewares.nc-dav.redirectregex.regex=(.*)/.well-known/ca(rd|l)dav"
  - "traefik.http.middlewares.nc-dav.redirectregex.replacement=$$1/cloud/remote.php/dav/"
  - "traefik.http.middlewares.nc-wellknown.replacepathregex.regex=^(/.well-known.*)"
  - "traefik.http.middlewares.nc-wellknown.replacepathregex.replacement=/cloud/index.php$$1"
  - "traefik.http.middlewares.nc-sts.headers.stspreload=true"
  - "traefik.http.middlewares.nc-sts.headers.stsseconds=31536000"

И на это уходят дни и недели. Иногда думаешь — да ну всё это к чёрту, потом опять начинаешь долбить эту стену, пока, наконец, не пробьёшся.

Помимо тех сервисов, которые у меня были, я добавил новые:

  • Photoprism — фото- и видеогалерея с распознаванием лиц, геолокацией, распознаванием дубликатов, доступом по ссылкам и т. п.
  • Bepasty — аналог Pastebin, но не только для текста. Можно выкладывать всё, что угодно.
  • Alltube — веб-морда для старого доброго youtube-dl.

Конечно, сервисы эти не то чтобы мне позарез нужны, но так как они место в квартире не занимают, пусть будут, тем более, что пригодиться они вполне могут.

Есть и убытки — не переехала страничка мониторинга, так как в контейнере она хоста не увидит, а на самом хосте поднимать ради этого веб-сервис глупо. Тандем Prometheus + Grafana — это довольно громоздко, трудоёмко и не очень-то осмысленно ради такого мелкого результата. Посмотрим позже, пока нужно хотя бы наладить какое-то резервное копирование.

Продолжение следует.