Домашний сервер задумывался как тестовый стенд рабочих сайтов, поэтому схему взяли с боевого сервера один в один: обратный прокси traefik принимает все запросы, у каждого сайта свой набор контейнеров nginx + PHP + MariaDB, крон запускает ofelia по меткам контейнеров. «Просто поднять так же» не вышло: часть схемы на свежем Docker не заработала вовсе, а часть заработала, но вела себя иначе, чем мы думали годами.
Это пятая часть цикла «Домашняя инфраструктура».
traefik 2 не видит Docker 29
Первый же запуск: прокси поднялся, а ни одного сайта не видит. В журнале — провайдер Docker не может подключиться. Причина в версиях API: traefik 2 (и 2.10, и последняя 2.11) обращается к Docker по API 1.24, а Docker 29 принимает только 1.40 и новее. Переменная DOCKER_API_VERSION не помогает: версия зашита в клиент.
Поставили traefik 3. Статический конфиг от второй версии он принял без правок. А вот правила маршрутов пришлось переписать.
Если на вашем сервере traefik 2 — обновите его до того, как обновится Docker. Иначе после обновления пакетов прокси перестанет видеть контейнеры, и лягут все сайты разом.
HostRegexp в traefik 3 — обычное регулярное выражение
Во второй версии поддомены ловились шаблоном с именованной группой {subdomain:[a-z]+}. В третьей этот синтаксис удалён, теперь это обычное регулярное выражение Go:
labels:
- 'traefik.http.routers.site.rule=HostRegexp(`^([a-z0-9-]+\.)?example\.ru$`)'
- 'traefik.http.routers.site.tls.certresolver=letsEncrypt'
- 'traefik.http.routers.site.tls.domains[0].main=example.ru'
- в YAML такое правило — только в одинарных кавычках: в двойных
\.даёт ошибку «unknown escape»; - по регулярному выражению traefik не может сам понять, на какое имя выпускать сертификат, поэтому нужен явный
tls.domains[0].main.
Имя базы данных — это домен сайта
В наших конфигах сайтов DB_HOST равен доменному имени сайта, а не localhost и не имени контейнера. Работает это потому, что контейнер MariaDB получает в сети Docker псевдоним, совпадающий с доменом. Из этого вытекают два следствия, которые стоит знать заранее:
- вешать тот же домен псевдонимом ещё и на прокси нельзя: встроенный DNS Docker начнёт отдавать два контейнера вперемешку;
- запрос PHP к собственному сайту по домену попадёт не на веб-сервер, а в базу. Поэтому крон запускается командой
php -f wp-cron.phpвнутри контейнера, а не HTTP-запросом к сайту.
Без имени проекта compose сносит соседей
Файлы compose у нас лежат в каталоге docker/ каждого проекта. Если не задать COMPOSE_PROJECT_NAME, имя проекта берётся из имени каталога — то есть у всех будет «docker». Прокси и служебные контейнеры становятся одним проектом, и docker compose down в одном каталоге гасит контейнеры другого. Имя проекта — обязательная строка в .env каждого.
stop не выключает сайт
Чтобы тестовая копия не ела ресурсы, я гасил её через docker compose stop. После очередного systemctl restart docker все девять остановленных контейнеров поднялись сами: у них restart: always, а эта политика срабатывает при старте демона, даже если контейнер останавливали вручную.
# выключить надолго: контейнеры удаляются, данные остаются
docker compose -p site-test down # без -v: именованные тома и bind-mount базы целы
# включить обратно
docker compose -p site-test up -d
unless-stopped переживает ручную остановку, always — нет. Если хотите, чтобы stop держал контейнер выключенным, нужна именно unless-stopped.
ofelia не замечает новые сайты
Ofelia читает метки контейнеров только при своём старте. Подняли новый сайт или пересоздали контейнер — задания крона для него не появятся, пока не перезапустить саму ofelia. Перечитать метки на лету она не умеет. Хуже того: после перезапуска Docker ofelia стартует раньше сайтов, не видит ни одного задания, и крон молча не работает нигде.
Написал маленький сторож: он слушает события Docker и перезапускает ofelia, когда запускается или падает контейнер с её метками.
#!/bin/bash
# ofelia-watch: перезапуск ofelia при старте/падении контейнеров с метками крона
sleep 25 && docker restart ofelia >/dev/null # стартовая синхронизация после подъёма docker
last=0
docker events --filter label=ofelia.enabled=true --filter event=start --filter event=die \
--format '{{.Actor.Attributes.name}}' |
while read -r name; do
[ "$name" = "ofelia" ] && continue # у самой ofelia та же метка — иначе бесконечный цикл
now=$(date +%s); [ $((now - last)) -lt 8 ] && continue # пачка событий — один перезапуск
last=$now; docker restart ofelia >/dev/null
done
Docker 29 считает образы по-другому
В Docker 29 по умолчанию хранилище образов containerd. IMAGE ID теперь — дайджест манифеста, а не конфигурации, и размеры учитываются иначе: один и тот же образ на старом сервере показывал 2,44 ГБ, на новом — 1,74. Сравнивать, тот ли образ стоит на двух машинах, можно только по RepoDigests или версиям внутри, но не по ID и не по размеру. И образы лежат в /var/lib/containerd, а не в /var/lib/docker — учитывайте это, когда ищете, куда делось место.
Сборка фронта без Node на сервере
Node ставить на хост не стал: версия из репозитория Ubuntu — 22, а наша сборка требует 20 (в 22 удалён синтаксис импорта JSON, на котором держится один из плагинов). Вместо этого обёртка node-docker запускает команды в контейнере node:20 от моего пользователя, с HOME=/tmp, лимитом памяти и --init.
Сборщик пишет результат выше текущего каталога (../assets-dist). Если смонтировать в контейнер только текущую папку, файлы уйдут в никуда внутри контейнера, а в журнале будет «compiled successfully». Монтировать надо корень проекта, по тому же абсолютному пути, что на хосте.
Переносите схему с одного сервера на другой?
Пришлите compose и конфиг прокси — проверю, что сломается на новых версиях, до того как это случится.
// загружаю комментарии…