Всё началось с того, что меня перестал устраивать мой подход к развертыванию личных сервисов. Я решил переписать всё с нуля. К тому же это был отличный повод получить новые навыки и попробовать инструменты, до которых давно не доходили руки.
Арендовать мощные VPS под ресурсоемкие задачи в текущих реалиях выходит неоправданно дорого. При этом дома у меня уже был выделенный неттоп (мини-ПК) с 16 ГБ оперативной памяти на базе энергоэффективного процессора Intel N95.
Пройдя путь от простых Bash-скриптов и сторонних туннелей до полностью автоматизированной инфраструктуры, я создал проект ServeHub-2. В этой статье я подробно разберу, как эволюционировала сеть проекта, почему декларативный подход победил императивный и с какими неочевидными багами пришлось столкнуться в процессе автоматизации.
История настройки сетевой архитектуры
Использование Tuna
В самом начале проекта я еще не думал о VPN как о способе пробития NAT и основе, на которой будет строиться вся система. Первое, к чему я пришел — сервис Tuna. Если кратко, это аналог Cloudflare Tunnel за условные 300 рублей. У него очень приятный веб-интерфейс и невероятно простая настройка. Однако для полноценной независимой архитектуры он не подошел по двум причинам:
-
Сервера Tuna находятся вне контроля пользователя.
-
На удаленном сервере нельзя развернуть свои сопутствующие сервисы.
В целом сервис действительно удобный, но для моих задач он оказался слишком ограничивающим.
Вот пример конфига для tuna (все максимально просто, создаем контейнеры для ssh туннеля, чтобы был доступ ssh, а также основной http туннель с привязкой к nginx или любому другому прокси если такой есть):
tuna_matrix: container_name: tuna_matrix image: yuccastream/tuna:latest restart: always environment: - TUNA_TOKEN=${TUNA_TOKEN} - SYNAPSE_SERVER_NAME=${SYNAPSE_SERVER_NAME} command: http nginx:80 --domain=${SYNAPSE_SERVER_NAME} --https-redirect depends_on: - nginx tuna_ssh: container_name: tuna_ssh image: yuccastream/tuna:latest restart: always network_mode: "host" environment: - TUNA_TOKEN=${TUNA_TOKEN} command: tcp 22 --port=${TUNA_SSH_PORT}
В поисках гибкости: тесты Pangolin и переход к FRP
После Tuna я решил двигаться в сторону собственного контроля и попробовал Pangolin. Однако решение быстро отвалилось: для дешёвого сервера оно оказалось слишком «тяжёлым» и избыточным. Главные минусы — ощутимый оверхед по ресурсам и лишний слой принудительной веб-аутентификации перед самими сервисами. Это ломало нормальное взаимодействие с родными мобильными клиентами (вроде Element для Matrix или Bitwarden для паролей), где повторная авторизация в браузере просто не нужна.
На смену пришел FRP (Fast Reverse Proxy). Он выполнял ту же функцию проброса, но хостил я его уже на собственном арендованном VPS в режиме Layer 4 (TCP). Это дало абсолютную гибкость: внешний VPS не заглядывал внутрь пакетов и ничего не расшифровывал, а просто пересылал сырой поток домой. Кроме того, это отлично оптимизировало расходы: вместо 300 рублей за Tuna и ещё 300 рублей за отдельный VPN, я стал платить всего 500 рублей за один стойкий VPS, который мог настраивать как хочу. (к тому же можно было обойтись даже дешевле, так как VPS с 4 гб оперативки загружен всего лишь на 40%, процессор всего на 20%-40%)
FRP состоит из 2 конфигурационных файлов (один на удаленном сервере frp server, другой на локальном frp client), все также довольно просто, однако его можно использовать на разных слоях. У меня он брал трафик за стандартный TCP и передавал его на локальный сервер.
Также доп. фишка в том что основная конфигурация происходит именно в frpc, который, в свою очередь, передает часть настроек на frp на удаленном сервере.
frpc.toml:
serverAddr = "{{ .Envs.FRP_SERVER_IP }}"serverPort = 7000auth.method = "token"auth.token = "{{ .Envs.FRP_TOKEN }}"[[proxies]]name = "http_traffic"type = "tcp"localIP = "nginx_proxy"localPort = 80remotePort = 8080transport.proxyProtocolVersion = "v2"[[proxies]]name = "https_traffic"type = "tcp"localIP = "nginx_proxy"localPort = 443remotePort = 8443transport.proxyProtocolVersion = "v2"[[proxies]]name = "secure_ssh"type = "tcp"localIP = "host.docker.internal"localPort = 22remotePort = 2222
frps.toml:
bindPort = 7000auth.method = "token"auth.token = "{{ .Envs.FRP_TOKEN }}"maxPortsPerClient = 10
Полноценный переезд на WireGuard (AmneziaWG) и 8 часов отладки
Со временем архитектура эволюционировала в сторону полноценной VPN-сети на базе WireGuard, а точнее — его модификации AmneziaWG. Причин для этого шага было несколько:
-
Безопасность: FRP всё же открывал внутренние ресурсы в публичный интернет, оставляя их доступными для сканеров портов.
-
Удобство маршрутизации: Все участники сети стали равноправными узлами в одной виртуальной локальной подсети. Больше не нужно было настраивать постоянные односторонние пробросы.
-
Дополнительный бонус: Поскольку удаленный VPS был куплен в Нидерландах, через этот же VPN я автоматически получил безопасный доступ ко всем зарубежным ресурсам.
Для реализации схемы на удаленном VPS был развернут контейнер wg-easy с поддержкой AmneziaWG, а на домашнем мини-ПК — клиентский контейнер Amnezia (сборка из Dockerfile с использованием amnezia-tools и модулем ядра хоста).
И именно здесь я поймал самый изнурительный баг проекта. После развертывания трафик упорно шёл только в одну сторону. Часов 8 ушло на диагностику iptables, маршрутов и чтение зарубежных форумов (что бесполезно, учитывая специфику наших блокировок). Оказалось, провайдер просто дропал обратный трафик стандартного WireGuard, так как пакеты шли без маскировки. Причина крылась в docker-compose.yml: у меня было прописано image: ghcr.io/wg-easy/wg-easy:latest. Как выяснилось, тег latest на Docker Hub намертво прилип к старой 14-й версии, а поддержка параметров AmneziaWG появилась только в ветке 15.x. Изменение тега на конкретную версию (15.3) решило проблему за секунду.
Благодаря переходу конфигурация получилась максимально простой, основные отличия от стандартной документации выделил в коде: (в основном то, на что пришлось долго рыть информацию)
wg-easy: image: ghcr.io/wg-easy/wg-easy:15.3 # самая последняя версия container_name: wg-easy environment: - LANG=en - WG_HOST=${VPS_PUBLIC_IP} - PORT=51821 - WG_PORT=51820 - WEBROOT_BIND_ADDRESS=127.0.0.1 - WG_DEFAULT_ADDRESS=10.8.0.x - INIT_DNS=10.8.0.1 # это важно для adguard home (об этом позже), так как он именно на этом ip - WG_PERSISTENT_KEEPALIVE=25 - WG_ALLOWED_IPS=0.0.0.0/0 - EXPERIMENTAL_AWG=true # включаем поддержку amnezia, нужно будет установить модуль ядра на хост - OVERRIDE_AUTO_AWG=awg # чтобы не использовался стандартный модуль wg - INSECURE=true # оставили INSECURE, так как есть прокси - WG_DEVICE=eth0 - INIT_ENABLED=true - INIT_USERNAME=${ADMIN_USER} - INIT_PASSWORD=${ADMIN_PASSWORD} - INIT_HOST=${VPS_PUBLIC_IP} - INIT_PORT=51820 volumes: - ./apps-data/wg-easy:/etc/wireguard - /lib/modules:/lib/modules:ro network_mode: "host" restart: unless-stopped cap_add: - NET_ADMIN - SYS_MODULE healthcheck: test: ["CMD", "awg", "show", "wg0"] interval: 30s timeout: 10s retries: 3 start_period: 10s
Настройка Nginx
Чтобы сервисы были доступны исключительно внутри подсети VPN, я задействовал Nginx. Доступ к приложениям был жестко ограничен на уровне конфигурации — веб-сервер принимает запросы только из диапазона IP-адресов 10.8.0.0/24. Любые попытки постучаться на сервер из внешнего интернета без активного VPN-туннеля автоматически сбрасываются Nginx.
Логика распределения завязана на proxy-протоколе: Nginx на удаленном VPS выступает основным входным узлом, принимает зашифрованный трафик, заворачивает его в заголовки с реальным IP-адресом клиента и через туннель перекидывает на локальный Nginx домашнего сервера. Локальный веб-сервер уже сам расшифровывает SSL и распределяет трафик по конечным Docker-контейнерам, сохраняя реальные IP в логах безопасности.
Вставлю один кусок кода из nginx на удаленной машине для примера, так все остальное примерно похоже (конфиги использовались в виде .template):
map $http_upgrade $connection_upgrade { default upgrade; '' close;} # здесь умная обработка websockets, чтобы соединение апгрейдилось только когда нужноserver { listen 443 ssl; # указываем ssl server_name adguard.${SERVER_NAME}; # использую шаблоны ssl_certificate /etc/letsencrypt/live/${SERVER_NAME}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/${SERVER_NAME}/privkey.pem; client_max_body_size 2G; # решил указать для всех сервисов, так как некоторые требуют перекачки больших файлов add_header Strict-Transport-Security "max-age=15552000; includeSubDomains; preload" always; set_real_ip_from 172.16.0.0/12; # ограничиваем доверенные ip set_real_ip_from 10.0.0.0/8; set_real_ip_from 127.0.0.1; real_ip_recursive on; # это нужно там где nginx перебрасывает трафик на локальный сервер, чтобы другой сервер видел ip allow 127.0.0.1; # здесь разрешаются только пользователи из определенных диапазонов адресов allow 172.16.0.0/12; allow 10.8.0.0/24; deny all; location / { proxy_pass http://127.0.0.1:8082; # пересылает трафик в контейнер proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https; }}
SSL-сертификаты
Для получения валидных SSL-сертификатов я настроил работу через автоматический Certbot по challenge-валидации DNS-01 c API Webnames. Сам домен привязан к внутреннему IP-адресу 10.8.0.1. Проверка через DNS позволила выпустить единый wildcard-сертификат на весь домен и его поддомены без необходимости держать открытым 80-й порт веб-сервера наружу.
Уточню, что certbot запускается автоматически во время выполнения Ansible плейбука, поэтому самому кроме указания переменных ничего делать не нужно.
# =========================================================================# 3. НАСТРОЙКА ПЛАГИНА WEBNAMES DNS ДЛЯ CERTBOT# =========================================================================- name: "[Certbot] Клонирование плагина certbot-dns-webnames" ansible.builtin.git: repo: "https://github.com/regtime-ltd/certbot-dns-webnames.git" dest: "/home/{{ deploy_user }}/ServeHub-2/certbot/certbot-dns-webnames" version: master update: no- name: "[Certbot] Скачивание конфигурации Certbot через API Webnames" ansible.builtin.get_url: url: "https://www.webnames.ru/scripts/json_domain_zone_manager.pl?action=get_config_certbot&domain={{ server_name }}&apikey={{ webnames_apikey }}" dest: "/home/{{ deploy_user }}/ServeHub-2/certbot/certbot-dns-webnames/config.sh" mode: "0755"- name: "[Certbot] Валидация: Проверка, что полученный файл конфигурации не пустой" ansible.builtin.stat: path: "/home/{{ deploy_user }}/ServeHub-2/certbot/certbot-dns-webnames/config.sh" register: webnames_config_stat failed_when: webnames_config_stat.stat.size == 0- name: "[Certbot] Установка прав на исполнение для всех скриптов плагина" ansible.builtin.shell: cmd: "chmod +x *.sh" chdir: "/home/{{ deploy_user }}/ServeHub-2/certbot/certbot-dns-webnames" changed_when: true
В итоге получилась схема, при которой все сервисы доступны по красивым доменным именам с HTTPS, но абсолютно невидимы для внешнего интернета.
Вот настройка certbot:
certbot: build: context: . dockerfile: Dockerfile.certbot container_name: certbot logging: *default-logging volumes: - ./certbot/certs:/etc/letsencrypt - ./certbot/certs-data:/var/lib/letsencrypt - ./certbot/certbot-dns-webnames:/opt/certbot-dns-webnames environment: - SERVER_NAME=${SERVER_NAME} command: > certonly --manual --manual-auth-hook /opt/certbot-dns-webnames/authenticator.sh # использую проверку по DNS, чтобы не нужно было ничего открывать в интернет --manual-cleanup-hook /opt/certbot-dns-webnames/cleanup.sh --preferred-challenges dns-01 --agree-tos --no-eff-email --non-interactive --force-renewal -d "${SERVER_NAME}" -d "*.${SERVER_NAME}" --email "${EMAIL}" deploy: resources: limits: memory: 1G
История софта
Параллельно с сетевой структурой развивался и сам набор приложений. Изначально я хотел собрать в одном месте утилиты, которыми пользуюсь каждый день, но в процессе селфхостинга быстро понимаешь: нельзя просто накидать контейнеров и надеяться, что мини-ПК справится, а конфигурационные файлы не превратятся в кашу.
Первый стек и оптимизация
Первыми на домашнем сервере прижились медиа-сервисы: Navidrome для стриминга музыки и Audiobookshelf для аудиокниг и подкастов. Они легковесные, имеют отличные мобильные клиенты с синхронизацией прогресса и полностью закрывают мои потребности. Позже к ним добавился Nextcloud как единое независимое облако для файлов, контактов и семейных документов.
Затем встал вопрос безопасного хранения паролей. Сначала я смотрел в сторону оригинального Bitwarden, но в итоге я выбрал Vaultwarden — альтернативный сервер на Rust, полностью совместимый с API Bitwarden. Он потребляет считанные мегабайты оперативной памяти и работает идеально. Дополнительно для удобства управления всей этой распределенной Docker-инфраструктурой в локальный стек был добавлен Portainer. (+ Portainer Agent на удаленный сервер)
Из интересных моментов, где мне пришлось немного больше возиться, чем с остальными сервисами это nextcloud настройка:
nextcloud: image: nextcloud:34.0.0-apache container_name: nextcloud logging: *default-logging restart: unless-stopped expose: - 80 depends_on: nextcloud-db: condition: service_healthy environment: - POSTGRES_HOST=nextcloud-db - POSTGRES_DB=${POSTGRES_DB_NEXTCLOUD} - POSTGRES_USER=${POSTGRES_USER} - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} - NEXTCLOUD_ADMIN_USER=${ADMIN_USER} - NEXTCLOUD_ADMIN_PASSWORD=${ADMIN_PASSWORD} - OVERWRITEWEBROOT=/nextcloud # важно для работы nextcloud не в корне сервера - OVERWRITEPROTOCOL=https - HTACCESS_OVERRIDE_CURRENT=1 - NEXTCLOUD_TRUSTED_DOMAINS=${SERVER_NAME} - TRUSTED_PROXIES=172.16.0.0/12 10.8.0.0/24 - NC_maintenance_window_start=2 # задаем окно обслуживания приложения, когда запускать фоновые задачи - NC_default_phone_region=RU - NC_filelocking.enabled=true # Блокировка файлов во время их изменения - 'NC_memcache.local=\OC\Memcache\APCu' # Включает локальное кэширование данных с помощью APCu, возможно потом добавлю Redis volumes: - ./apps-data/nextcloud/html:/var/www/html - /home/${LOCAL_USER}/PersonalData/NextcloudData:/var/www/html/data deploy: resources: limits: memory: 3G healthcheck: test: [ "CMD-SHELL", "test -f /var/www/html/config/config.php && php occ status", ] interval: 1m30s timeout: 30s retries: 5 start_period: 30s
Ошибки проектирования: почему я удалил Matrix (Synapse)
Не все решения прошли проверку временем. На этапе использования Tuna и FRP я развернул сервер Matrix (Synapse) для защищенного обмена сообщениями. Мне казалось это крутой идеей, но когда я окончательно перешел на AmneziaWG, целесообразность мессенджера внутри закрытого туннеля сошла на нет.
Synapse требовал слишком много ресурсов, впустую расходовал оперативку домашнего ПК и усложнял конфиг Nginx. При этом реальной пользы для семьи он не приносил. Для критических алертов инфраструктуры и повседневного общения проще и эффективнее оказалось использовать Telegram. (плюс алертинг настроен именно через него) В итоге я полностью выпилил Synapse из стека, освободив ресурсы.
Вместо него я добавил в связку к wg-easy локальный AdGuard Home. Теперь он работает прямо внутри VPN-сети: очищает весь трафик от рекламы и трекеров на лету, кэширует DNS-запросы и не дает истории веб-серфинга улетать внешним провайдерам.
Основной проблемой с которой я столкнулся при использовании AdGuard Home, так это то что я так и не понял как заставить использовать AmneziaVPN клиент AdGuard как основной DNS, при этом AmneziaWG работает прекрасно. Как я понимаю дело в том что AmneziaWG работает намного проще на уровне ip и у него нету никаких доп фильтров, настроек и подобного, поэтому он просто берет данные из конфига.
От костылей на Bash к декларативному Ansible
Весь стек на обоих серверах разворачивался через bash скрипты, что было уж очень плохо с точки зрения идемпотентности. Во первых из-за bash мне постоянно приходилось очищать сервера, так как нормальных проверок у меня не было и писать я их не хотел, а также было много костылей с импортом переменных, записями в файлы и подобным.
Так я пришел к декларативному подходу и Ansible. Теперь вся конфигурация описывается в виде плейбуков и ролей, отражающих конечное желаемое состояние серверов. Конфиденциальные данные перенесены в файл secrets.yml, а хрупкие конструкции автоматизированы через шаблоны Jinja2. Проект стал идемпотентным: если шаги уже выполнены, Ansible их просто пропускает.
В итоге получилось несколько yaml файлов для стандартной настройки системы (bootstrap_os.yml) и для настройки каждого из хостов (setup_local.yml и setup_remote.yml). В итоге теперь все что нужно чтобы полностью с нуля развернуть проект — скачать Ansible, несколько других зависимостей на свой рабочий пк и запустить один manage_deploy.sh, в котором можно будет выбрать сценарий как будет вести себя Ansible и спокойно дождаться разворачивания сервисов.
Также благодаря Ansible я удобно реализовал переносимость проекта. Так как я решил не использовать тома docker, и вместо этого храню все в папках, чтобы перенести старые данные проекта нужно просто скопировать папку apps-data и положить ее в нужное место и все само заработает после повторного развертывания проекта. В Ansible выделяется отдельная пауза для этого.
Вот пример основного плейбука deploy.yml:
- name: "[Bootstrap] Проверка: доступен ли root?" hosts: vps, local gather_facts: no vars_files: - vars/secrets.yml tags: bootstrap vars: ansible_password: "{{ user_pass }}" ansible_ssh_pass: "{{ user_pass }}" pre_tasks: - name: "[SSH] Проверка: доступен ли root?" ansible.builtin.ping: ignore_errors: yes ignore_unreachable: yes vars: ansible_user: root register: root_auth_check - name: "[SSH] Логика: настройка подключения под root" set_fact: ansible_user: root ansible_become: no when: root_auth_check.ping is defined - name: "[SSH] Логика: настройка sudo для обычного пользователя" set_fact: ansible_become: yes ansible_become_method: sudo ansible_become_password: "{{ user_pass }}" when: root_auth_check.ping is not defined tasks: - name: "[Python] Установка Python3 (необходим для работы Ansible)" raw: | if [ "$USER" != "root" ]; then SUDO="sudo"; else SUDO=""; fi if command -v apt-get >/dev/null; then $SUDO apt-get update && $SUDO env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l apt-get install -y python3 elif command -v pacman >/dev/null; then $SUDO pacman -Sy --noconfirm --overwrite "*" archlinux-keyring && $SUDO pacman -Syu --noconfirm --overwrite "*" python3 else echo "Unsupported distribution" >&2 exit 1 fi changed_when: false - name: "[Facts] Сбор системных фактов после установки Python" setup: - name: "[Общее] Проверка поддерживаемой ОС (Debian/Ubuntu или Arch)" fail: msg: "Неподдерживаемая ОС. Требуется Debian/Ubuntu или Arch Linux" when: - ansible_facts['os_family'] != 'Debian' - ansible_facts['os_family'] != 'Archlinux' - name: "[Bootstrap] Запуск сценария базовой настройки ОС" include_tasks: tasks/bootstrap_os.yml handlers: - name: Restart SSH ansible.builtin.systemd: name: "{{ 'ssh' if ansible_facts['os_family'] == 'Debian' else 'sshd' }}" state: restarted- name: "[Git] Синхронизация репозитория ServeHub-2" hosts: vps, local vars_files: - vars/secrets.yml become: yes tasks: - name: "[Git] Клонирование или обновление репозитория ServeHub-2" ansible.builtin.git: repo: "{{ project_repo_url }}" dest: "/home/{{ deploy_user }}/ServeHub-2" clone: yes update: yes force: yes become: true become_user: "{{ deploy_user }}" - name: "[Общее] Генерация файла .env из шаблона env.j2" ansible.builtin.template: src: env.j2 dest: "/home/{{ deploy_user }}/ServeHub-2/.env" owner: "{{ deploy_user }}" group: "{{ deploy_user }}" mode: "0600"- name: "Настройка удалённой инфраструктуры (VPS)" hosts: vps vars_files: - vars/secrets.yml become: yes tasks: - name: "[VPS] Запуск нативных задач настройки удалённого сервера" include_tasks: tasks/setup_remote.yml- name: "Настройка локальной инфраструктуры (Local Server)" hosts: local vars_files: - vars/secrets.yml become: yes tasks: - name: "[Local] Запуск нативных задач настройки локального сервера" include_tasks: tasks/setup_local.yml
Тестирование мультидистрибутивности с помощью Vagrant
Проект изначально затачивался под работу на трех дистрибутивах: Ubuntu, Debian и Arch Linux. Тестировать Ansible-плейбуки прямо на рабочей локальной машине (в моем случае — EndeavourOS) слишком рискованно, а создавать виртуальные машины руками — долго и неудобно.
Решением стал Vagrant, позволяющий за пару минут развернуть чистые ОС в VirtualBox из готовых образов. Но в процессе настройки мультивендорного стенда в режиме сетевого моста (public_network) всплыли две критические проблемы:
-
Конфликт DNS: По умолчанию Vagrant создает NAT-интерфейс для управления нодой. При включении второго (публичного) интерфейса для локальной сети ломался дефолтный DNS-резолвер. Проблему пришлось решать принудительной очисткой и перезаписью файла
/etc/resolv.confчерез inline-скрипт автоматизации Vagrant. -
Проблема с GRUB на Debian: В используемом базовом образе
generic/debian12конфигурация GRUB сохраняла жесткую привязку к конкретному имени диска из окружения сборщика. При повторном развертывании плейбуков на тестовом стенде это приводило к сбоям загрузчика. Чтобы автоматизировать очистку, пришлось внедрить скрипт, который на лету определяет имя системного диска черезlsblkи автоматически передает правильные параметры в загрузчик через утилитуdebconf-set-selections.
Вот код Vagrantfile: (в node.vm.provision происходит основное решение ошибок)
Vagrant.configure("2") do |config| boxes = { "arch-node" => { box: "generic/arch" }, "debian-node" => { box: "generic/debian12" }, "ubuntu-node" => { box: "generic/ubuntu2204" } } boxes.each do |name, cfg| config.vm.define name do |node| node.vm.box = cfg[:box] node.vm.network "public_network", use_dhcp_assigned_default_route: true node.vm.provision "shell", inline: <<-SHELL echo "=== [Vagrant Fix] Настройка стабильного DNS для моста ===" rm -f /etc/resolv.conf echo -e "nameserver 8.8.8.8\nnameserver 1.1.1.1" > /etc/resolv.conf if [ -f /etc/debian_version ]; then echo "=== [Vagrant Fix] Исправление привязки GRUB к диску ===" PRIMARY_DISK=$(lsblk -ndrio NAME,TYPE | awk '$2=="disk" {print "/dev/"$1; exit}') echo "grub-pc grub-pc/install_devices string $PRIMARY_DISK" | debconf-set-selections fi SHELL node.vm.provider "virtualbox" do |vb| vb.memory = "8192" vb.cpus = 4 vb.name = "servehub-test-#{name}" vb.customize ["modifyvm", :id, "--graphicscontroller", "vmsvga"] end end endend
Надежная система бэкапов на базе Borgmatic
Для создания резервных копий я внедрил Borgmatic (удобную надстройку над дедуплицирующим инструментом Borg Backup). Весь процесс автоматизирован с помощью связки системных юнитов borgmatic.service и borgmatic.timer. В бэкап уходят две ключевые директории: apps-data (конфигурации приложений и баз данных) и PersonalData (медиатека: музыка, книги, подкасты, файлы Nextcloud).
Развертывание системы бэкапов полностью берет на себя Ansible. Мне достаточно указать UUID внешнего жесткого диска — скрипт сам проверит его наличие в системе, примонтирует в нужную директорию, создаст зашифрованный репозиторий и настроит политику ротации (хранение 7 ежедневных, 4 еженедельных и 6 ежемесячных копий). Также в Prometheus выведен мониторинг самого репозитория .borg для отслеживания его размера и статуса успешности архивации.
Главная проблема при бэкапе работающих Docker-контейнеров — риск скопировать базу данных в «битом» или неконсистентном состоянии, если в момент создания архива в нее шла активная запись. Чтобы решить эту проблему, я задействовал механизм хуков в конфигурации Borgmatic.
Перед началом резервного копирования автоматически срабатывает команда остановки контейнеров проекта (docker compose down), а после успешного завершения (или в случае возникновения непредвиденной ошибки) контейнеры автоматически поднимаются обратно в фоновом режиме. Для удобного просмотра архивов и быстрого восстановления файлов я развернул веб-интерфейс Borg UI.
Конфигурация borgmatic: (использую как Jinja2 шаблон, чтобы Ansible в плейбуках сам подставил переменные)
source_directories: # не использую тома docker для легкой переносимости и бэкапов - /home/{{ local_user }}/ServeHub-2/apps-data/ - /home/{{ local_user }}/PersonalData/Music - /home/{{ local_user }}/PersonalData/NextcloudData - /home/{{ local_user }}/PersonalData/Books - /home/{{ local_user }}/PersonalData/Podcasts - /home/{{ local_user }}/PersonalData/Audiobooksrepositories: - path: /mnt/backup_storage/server_backup.borgkeep_daily: 7keep_weekly: 4keep_monthly: 6compression: lz4encryption_passphrase: {{ borgmatic_encryption_passphrase }}# Тут просто перед бэкапом все останавливается, потом стартует зановоcommands: - before: action when: - create run: - echo "Starting backup pipeline..." - cd /home/{{ local_user }}/ServeHub-2 && docker compose -f docker-compose.local.yaml down - after: action when: - create states: - finish run: - cd /home/{{ local_user }}/ServeHub-2 && docker compose -f docker-compose.local.yaml up -d - echo "Backup completed successfully!" - after: error run: - cd /home/{{ local_user }}/ServeHub-2 && docker compose -f docker-compose.local.yaml up -d
Наблюдаемость (Observability) уровня Enterprise
В последних релизах (v1.2.0 и v1.3.0) фокус проекта сместился на мониторинг и работу с логами.
Эволюция алертинга и переход на Gatus
Изначально для мониторинга доступности я смотрел на Uptime Kuma, но отказался из-за отсутствия удобной декларативной настройки через конфиги. Затем я развернул связку Blackbox Exporter и Alertmanager с уведомлениями в Matrix. Но тут крылась логическая несостыковка: весь алертинг был завязан на локальном ПК, и в случае его аппаратного отказа я бы просто лишился уведомлений.
Тогда я решил вернуть Uptime Kuma, но развернуть его на удаленном VPS в качестве внешнего «сторожа» и автоматизировать его настройку через Python-скрипт с библиотекой uptime-kuma-api. Но и тут ждали «грабли» — библиотека не обновлялась три года и намертво ломалась на свежих версиях Kuma.
В итоге идеальным решением стал Gatus. Он изначально проектировался под управление через YAML-конфиги и поддерживает отправку алертов, если сервер не отвечает. Из-за специфики фронтенда Gatus (он не умеет работать из подкаталога типа /gatus), мне пришлось перенести его и wg-easy на полноценные субдомены gatus. и wireguard..
Для Gatus получился простой конфиг:
web: address: "127.0.0.1" port: 51822alerting: # использую телеграм для алертов telegram: token: "${TELEGRAM_TOKEN}" id: "${TELEGRAM_CHAT_ID}"endpoints: - name: "Is Local Server Alive?" url: "http://host.docker.internal:8080/health-check" interval: 60s conditions: - "[STATUS] == 200" alerts: - type: telegram failure-threshold: 3 success-threshold: 2 send-on-resolved: true description: "Локальный домашний сервер недоступен!" - name: "Is VPS Server Alive?" url: "http://host.docker.internal:8080/vps-health-check" interval: 60s conditions: - "[STATUS] == 200" alerts: - type: telegram failure-threshold: 3 success-threshold: 2 send-on-resolved: true description: "Веб-окружение VPS не отвечает!" - name: SSL Certificate url: "https://${SERVER_NAME}" interval: 1h conditions: - "[CERTIFICATE_EXPIRATION] > 720h" alerts: - type: telegram description: "SSL сертификат скоро истекает" failure-threshold: 3 success-threshold: 2 send-on-resolved: true
Для мониторинга аппаратных ресурсов удаленного и локального серверов была развернута связка node-exporter и cAdvisor. Все уведомления теперь приходят мгновенно в Telegram-бота. Чтобы избежать лавины одинаковых сообщений (например, при перезагрузке хоста), в Alertmanager настроена жесткая группировка и дедупликация событий. Также добавлен экспортер для AdGuard Home, выводящий статистику заблокированных запросов в Grafana.
Централизованные логи: Loki + Grafana Alloy
В релизе v1.3.0 в стек была добавлена централизованная система сбора логов Loki. Вместо устаревшего Promtail в качестве агента сбора я применил Grafana Alloy.
Он эффективно собирает логи со всех запущенных Docker-контейнеров, парсит их и передает в Loki. Теперь вся история событий, ошибок веб-сервера Nginx или падений внутренних приложений доступна в едином интерфейсе Grafana с возможностью удобной фильтрации через LogQL, что значительно упрощает отладку.
Конфиг Loki:
auth_enabled: falseserver: http_listen_port: 3100 grpc_listen_port: 9096common: instance_addr: 0.0.0.0 path_prefix: /loki storage: filesystem: chunks_directory: /loki/chunks rules_directory: /loki/rules replication_factor: 1 ring: kvstore: store: inmemoryschema_config: configs: - from: 2024-01-01 store: tsdb object_store: filesystem schema: v13 index: prefix: index_ period: 24hlimits_config: allow_structured_metadata: true reject_old_samples: true reject_old_samples_max_age: 168h retention_period: 30dcompactor: working_directory: /loki/compactor compaction_interval: 10m retention_enabled: true delete_request_store: filesystem retention_delete_delay: 2h retention_delete_worker_count: 150
Конфиг Grafana Alloy: (локальный конфиг)
discovery.docker "local_containers" { host = "unix:///var/run/docker.sock" # cканируем локальный docker-демон для поиска запущенных контейнеров} discovery.relabel "docker_logs_relabel" { targets = discovery.docker.local_containers.targets rule { source_labels = ["__meta_docker_container_name"] regex = "/(.*)" target_label = "container_name" } # Убираем ведущий слэш "/" из имени контейнера (например, /nextcloud -> nextcloud). Это было очень важно, без этого ничего не работало rule { target_label = "server" replacement = "local-minipc" } # некоторые сервисы на локальном и удаленном серверах имеют одинаковые имена, поэтому добавил пометки чтобы можно было понять с какого сервера логи}# собираем логи из Docker на основе подготовленного списка контейнеровloki.source.docker "docker_logs" { host = "unix:///var/run/docker.sock" targets = discovery.relabel.docker_logs_relabel.output forward_to = [loki.write.loki_storage.receiver]}# отправляем логи в Lokiloki.write "loki_storage" { endpoint { url = "http://loki:3100/loki/api/v1/push" # для удаленного сервера тут просто ip 10.8.0.2 }}
Интерфейс: переход на Homepage
Изначально для домашней страницы я написал кастомную минималистичную HTML-панель с Glassmorphism-дизайном. Выглядело это красиво, но добавлять новые сервисы вручную через постоянную правку исходного кода было крайне неудобно.
В итоге я заменил самописную страницу на полноценный комьюнити-проект Homepage. Это дало некоторую гибкость: вся панель настраивается через простые YAML-файлы и поддерживает встроенные виджеты интеграции. Пока что она простенькая, но возможно в будущем сделаю что-то более продвинутое.
Заключение
Постарался подробно рассказать о структуре проекта и как я к этому пришел, очень много я не добавил, если пост зайдет, то я обязательно более подробно пройдусь по некоторым моментам: как я настраивал Matrix и на каком моменте я решил его убрать, как собирал локальный amnezia контейнер-клиент, что нового я добавил в релизе 1.4.0 и другое.
Также я планирую перейти с manage_deploy.sh на отдельный GUI клиент, который будет полностью автоматизировать процесс подготовки к deploy. Собираюсь использовать Go + Wails). Про это тоже возможно сделаю статью.
Вся кодовая база проекта, подробная документация, инструкции по развертыванию открыты и доступны для сообщества:
🔗 GitHub-репозиторий: https://github.com/canntstand/ServeHub-2
Это моя первая статья и в то же время первый личный проект, которому я отдал так много времени (3 месяца). Буду рад вашему фидбеку в комментариях!
ссылка на оригинал статьи https://habr.com/ru/articles/1058580/