
В январе 2023 мне пришла в голову идея: а почему бы не управлять своими серверами так же, как я управляю своими проектами — через docker compose up.
Довольно быстро стало понятно, что до меня в эту сторону массово не ходили, если кто так уже делает, то делает это молча… т.е. на все детские грабли на этом пути мне придётся наступить лично.
А вот вам повезло: если тоже захотите пойти в эту сторону, то у вас уже есть и эта статья и пример конкретной реализации.
Кому этот подход может подойти? Тут должны сойтись звёзды несколько факторов:
Нужно быть программистом, хорошо знакомым с
docker-compose.yml.Нужно иметь 1-5 личных серверов — не важно, дома или на обычном/облачном хостинге, настоящий ли это сервер или свой десктоп/ноут, выполняющий заодно и функции «сервера».
Нужно иметь достаточно опыта настройки этих серверов вручную по ситуации, чтобы появилось понимание недостатков этого подхода и желание внедрить IaC (перенести конфигурацию серверов в git и сделать её легко воспроизводимой).
Но главное — нужно не быть админом, которому Ansible привычнее. 😄
Что касается IaC, то код проектов мы тоже когда-то давно писали без git, нередко меняя его прямо на сервере, и хорошо если через scp, а не в текстовом редакторе прямо на сервере.
А потом стало очевидно, что это плохая идея, и git действительно нужен даже для личных проектов.
Не буду долго агитировать за IaC, просто спрошу: вот у вас дома «сервер», и там винт умер — вы же сможете легко и быстро настроить новый сервер с нуля так же, как был настроен старый? Если ответ "да", то дальше можете не читать, у вас уже всё хорошо!
Лично меня в этом подходе привлекла его крайняя простота: всё, что для него нужно (помимо Docker Compose) — это тривиальный шелл-скрипт (который будет rsync-ом заливать на сервер каталог с docker-compose.yml, после чего выполнять на сервере через ssh docker compose up) и какой-то способ держать зашифрованные секреты в git.
Основную сложность — приведение сервера в заданное состояние из любого текущего состояния (то, ради чего обычно используют Ansible) — берёт на себя Docker Compose.
Ну, почти — нужно будет ему немного с этим делом помочь, но всё получится!
Сначала я использовал один шелл-скрипт на 20 строк плюс git-secret.
Но недавно добавил mise (исключительно для удобства), и перешёл с git-secret на fnox (для безопасного использования AI-агентов, чтобы они не получили случайно доступ к секрету, просто прочитав файл).
Конкретный пример того, как выглядит полноценная реализация этого подхода, вы можете увидеть в https://github.com/powerman/myservers-template. Это урезанный вариант моей текущей конфигурации, почищенный от личных данных и готовый для использования в качестве шаблона для создания вашего репо. Он содержит пример достаточно сложной настройки (primary и secondary сервера для собственных DNS и email сервисов, VPN для доступа в домашнюю локалку, полноценный мониторинг Netdata с алертами в телеграм). Свои DNS и email вряд ли нужны многим, но вот файрвол/мониторинг/VPN вполне могут пригодиться и для ваших серверов.
Плюс там ещё пример того, как можно вести документацию по такой инфраструктуре, с диаграммой.
Это именно пример как такое настраивается, а не готовый к выкату на ваши сервера проект!
А теперь о собранных граблях — какие проблемы не решил Docker Compose и их пришлось решать мне:
Начальная настройка сервера — тот же docker нужно на сервер как-то установить. Я для этой цели завёл
bootstrap.sh— скрипт, который нужно однократно выполнить на новом сервере (можно передать его как cloud-init user-data script в админке облачного хостинга или запустить вручную). Я старался сделать его как можно меньше, чтобы минимизировать необходимость его изменений в будущем — потому что его изменение эквивалентно необходимости снова настраивать сервер вручную, да ещё и не забывать дублировать эти ручные изменения вbootstrap.sh. Но всё равно нашлось целых две причины его менять: обновление на следующий релиз OS (напр. черезdo-release-upgradeв Ubuntu), и необходимость обновить установленные для удобства моей работы в консоли сервера инструменты (напр. когда я перешёл с Vim на Neovim и мне на всех серверах понадобился мой конфиг в/root/.config/nvim).Идемпотентная настройка файрвола. Учитывая, как докер сам изменяет настройки файрвола, понадобилось придумать подход, состоящий из начальных правил
/etc/nftables.conf(создаваемыхbootstrap.sh) и определённой структуры файла миграции этих правилmigrate.nft(выполняемого внутри контейнера приdocker compose up), позволяющий не затрагивать текущие правила докера и при этом вносить любые необходимые изменения.Идемпотентность для контейнера WireGuard. Учитывая, что WireGuard создаёт сетевой интерфейс через ядро, при отключении контейнера необходимо этот сетевой интерфейс удалить.
Письма от сервисов OS (ошибки systemd, unattended-upgrades, etc.). Поскольку у меня на серверах всё равно используется postfix, то решил через bind-mount
/var/spool/postfixхоста в контейнер postfix, чтобы очередь писем у них стала общая и postfix внутри контейнера доставлял в т.ч. и письма от системных сервисов.Специфика конкретных серверов/OS. Пришлось для каждого сервера написать небольшой скрипт
host-facts.sh, который создаёт переменные окружения, содержащие эту специфику (названия сетевых интерфейсов, IP адреса, UID/GID системных аккаунтов, etc.), которые дальше используются вdocker-compose.yml.HEALTHCHECKкритичен и необходим — иначе при деплое не поймёшь, что сервис не запустился. Многие образы докера из коробки идут безHEALTHCHECK— приходится добавлять самому.Обновления. Да, для пакетов OS обновления ставит unattended-upgrades, но при этом подходе в OS хоста практически ничего нет, все сервисы внутри контейнеров и их тоже нужно обновлять. Renovate умеет обновлять и базовые образы (строку
FROMвDockerfile), и образы вdocker-compose.yml, и пакеты Alpine (RUN apk add), и даже сторонние приложения, установленные произвольным способом (вроде postgrey, скачиваемого по URL из GitHub) — через custom managers с regex. Так что придётся использовать именно его. И держать этот проект в приватном репо на гитхабе.
Ещё одна полезная штука — тестирование на CI и локально перед выкатом. Оно, конечно, не обязательно, но… хочется видеть ошибки не когда уже сервис упал при выкате, а чуть раньше.
В общем, я немного накидал в проект форматировщиков/линтеров (благо mise очень упростил их установку и запуск), плюс сделал поддержку локальной сборки образов в host-facts.sh и без необходимости расшифровки реальных секретов, но тут ещё можно много всякого добавить.
Покажу несколько немного упрощённых примеров реализации, для иллюстрации подхода.
Деплой — это rsync каталога сервера на хост + docker compose up, всё в одном скрипте:
rsync --recursive --links --delete "./$proj/" "${destination}:.myserver" ssh -t "${destination}" 'set -euo pipefail cd ~/.myserver docker compose up --build --detach --remove-orphans --wait'
--wait тут критичен: он ждёт, пока все HEALTHCHECK-и не станут healthy (или не упадут).
Вы видите результат прямо в терминале и можете сразу откатиться: git checkout @~ (ну или git stash) + повторный деплой.
На 2-5 личных серверов автоматический деплой не нужен: важнее видеть что происходит глазами, чем экономить минуту автоматизацией.
Плюс резервирование (primary+secondary для DNS/email) покрывает окно неудачного деплоя.
Специфика хоста определяется через host-facts.sh — скрипт, который при деплое создаёт переменные окружения для docker-compose.yml:
uid() { getent passwd "$1" | cut -d: -f3; } gid() { getent group "$1" | cut -d: -f3; } metadata() { curl -s "http://169.254.169.254/metadata/v1$1"; } set -euo pipefail if test -z "${DEVEL:-}"; then WAN_IP="$(metadata /interfaces/public/0/ipv4/address)" UID_POSTFIX="$(uid postfix)" GID_POSTFIX="$(gid postfix)" # ... else WAN_IP=0.0.0.0 UID_POSTFIX=1001 GID_POSTFIX=1001 # ... fi for _var in \ WAN_IP \ UID_POSTFIX \ GID_POSTFIX; do eval "export $_var=\"\$$_var\""; done
Ветка else с фоллбэками нужна для сборки образов локально и на CI — без реального железа.
Структура скрипта подобрана так, чтобы опечатки и пропущенные переменные ловились линтером или шеллом при выполнении, плюс его было очень просто проверить глазами.
Идемпотентный файрвол — отдельная история. Docker создаёт свои правила nftables в family ip и ip6 (отдельно для каждого протокола), поэтому все «свои» правила нужно держать в family inet (который покрывает и IPv4, и IPv6), чтобы не конфликтовать:
flush ruleset inet # Сбрасываем только свои правила. flush chain ip filter DOCKER-USER # Чистим цепочки для docker forward. flush chain ip6 filter DOCKER-USER table inet filter { chain prerouting-before-docker { type filter hook prerouting priority dstnat - 2; # Фильтруем ДО docker NAT — иначе docker dnat делает невозможной # фильтрацию по оригинальному порту назначения. ... } }
Этот migrate.nft рендерится из шаблона через dockerize (подставляя IP-адреса из переменных) и применяется контейнером при каждом docker compose up — полностью идемпотентно.
WireGuard — ещё один нюанс: контейнер создаёт сетевой интерфейс ядра, и при остановке его нужно явно удалить, иначе повторный запуск сломается:
finish() { wg-quick down "$INTERFACE" } trap finish EXIT wg-quick up "$INTERFACE" sleep inf
Для существующих серверов этот подход тоже возможно применять. Да, там не получится чистой настройки «с нуля» с bootstrap.sh, и, скорее всего, не получится сразу перенести вообще всё, но можно переносить сервисы в контейнеры по одному, постепенно — лучше так, чем никак.
Ещё один плюс этого подхода — появляется очевидное место, где удобно документировать свою инфраструктуру.
В общем, подход «docker compose up как IaC» оказался вполне рабочим.
Грабли есть, но они разовые — наступил, решил, зафиксировал в конфиге.
Дальше серверы живут в git, деплой — одна команда, восстановление сервера с нуля занимает минуты, а не бесконечность.
