В январе 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, деплой — одна команда, восстановление сервера с нуля занимает минуты, а не бесконечность.