🏠: сайт

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

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

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

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

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

Подкручиваем HTTPS

Больше трёх лет назад я делал подобную заметку, настало время дальнейшего пересмотра настроек. Я мало смыслю в безопасности, но зато люблю, когда онлайн-проверки светятся зелёным. За прошедшее время появился протокол HTTP/2, который я включил в конце 2017-го, и TLS 1.3, а версии TLS 1.0 и 1.1 уже признаются устаревшими с марта этого года, и отправляются на заслуженную пенсию составлять компанию уже находящемуся там SSL.

Итак, чтобы выключить все протоколы, кроме самых новых и надёжных, нужно добавить в файл конфигурации Апача, например, /etc/apache2/sites-available/default-ssl.conf (у меня другой файл, который создаёт Let’s Encrypt), следующее:

# Set Forward Secrecy
SSLProtocol -all +TLSv1.2 +TLSv1.3
SSLHonorCipherOrder on
SSLCipherSuite HIGH:!aNULL:!MD5:!3DES

# Strict transport security
<IfModule mod_headers.c>
 Header always set Strict-Transport-Security "max-age=15768000; includeSubDomains"
 Header always set Referrer-Policy "no-referrer-when-downgrade"
</IfModule>

С TLS 1.3, по ощущениям, действительно работает быстрее — ведь для установки защищённого соединения ему нужно меньше согласований. SSLHonorCipherOrder on — это включение расстановки приоритета алгоритмов шифрования самим сервером, что рекомендуется.

Также, я включил в Апаче поддержку OCSP Stapling — вроде бы полезная вещь — и, забавы ради, HSTS preloading, но это уже совсем необязательно.

Результат обстоятельного теста на Ssllabs после настройки:

Список других полезных тестов:

Переехал на новый сервер

Полторы недели назад наконец-то перенёс свой сайт на новый сервер. «Сервер» — это звучит гордо: это такой же, что и раньше, размером с сигаретную пачку пластиковый корпус, в котором заключена маленькая печатная плата. Тем не менее, это полноценный сервер, чему я не перестаю удивляться.

Модель — Orange Pi PC 2, от предыдущей модели с почти таким же названием (Orange Pi PC Plus) он отличается более мощным процессором, гигабитным сетевым интерфейсом и отсутствием wi-fi, который мне и не нужен в данном случае.

Orange Pi PC 2

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

Несколько недель я вообще не трогал плату, устав наблюдать внезапные перезагрузки. Тем временем, ядро Linux обновлялось, вбирая в себя поддержку всё новых и новых устройств и избавляясь от ошибок, а ребята из Armbian постоянно допиливали свой дистрибутив. Где-то в начале ноября я решил попробовать новую сборку Armbian — и с удивлением заметил, что перезагрузки прекратились. Выждав примерно неделю, установив несколько обновлений системы и убедившись, что всё работает стабильно, я перевёз сайт на новый одноплатник.

Нужно сказать, что на этом компьютере и сейчас всё небезоблачно — например, выключить его командой shutdown из консоли вообще невозможно, только выдёргиванием из розетки, а из-за лицензионных ограничений драйверы под Linux для видеоускорителя Mali-450 могут вообще никогда не увидеть свет, но для моих целей это не нужно, так что я доволен результатом. Из дополнительных плюсов — процессор меньше греется. Безо всякого охлаждения, температура процессора в покое 33-40°, на старом сервере было 44-50°.

Также, я поменял страничку мониторинга — eZ Server Monitor заменил на phpSysInfo, описанный в предыдущей публикации, потому что он умеет определять имя процессора, в отличие от предшественника, пишущего «нет данных». А ещё у него есть мобильное приложение для телефона, непонятно зачем сделанное, но сам по себе факт забавный. Вот как это выглядит:

phpSysInfo