Всем привет! В этой статье я поделюсь своим взглядом на инфраструктуру, которая помогает эффективно решать следующие задачи:
Доставлять запросы до бэкенда и фронтенда при переходе по купленному домену
Работать с сервером через терминал
Входить в контейнер через множество действий
Отлаживать через терминал
Мониторить состояние железа сервера
Заново изучать, как настроить мониторинг Docker-приложений
Использовать готовые решения вместо изобретения «велосипедов»
Раньше я регулярно сталкивался с этими задачами, пока они не надоели, и я не решил, что наступать на одни и те же грабли — это неэффективно и лишено смысла.
В итоге я собрал сетап, который позволяет сосредоточиться на бизнес-логике, не тратя время на продумывание инфраструктуры.
В конце статьи вы найдёте ссылку на репозиторий с шаблоном.
Основные принципы моего подхода:
Минимум ручных действий на сервере
Переиспользование готовых решений
Задел под «фабрику» бизнес-логики
Система, близкая к Production
Изоляция разработчиков от сервера

Стек

Конечный вариант с бизнес-логикой и базой данных будет выглядеть так:

В качестве прокси я также пробовал Nginx и Nginx Proxy Manager.
Альтернативы Caddy
Nginx Proxy Manger
Nginx + Certbot
Nginx Proxy Manager — удобное решение, но мне показалось, что оно достаточно ограничено в функционале.
Использование Nginx в паре с Certbot требует дополнительной настройки SSL. Кроме того, добавление и управление новыми поддоменами требует действий.
Caddy оказался универсальным решением — золотой серединой между гибкостью Nginx и простотой управления SSL, то есть он покрывает обе задачи. Именно поэтому я остановился на Caddy.
Общий конвейер
Основные шаги:
-
Настройка VPS сервера:
Установка Docker и Docker Сompose
Ручное создание Docker Network
-
Установка Gitlab Runner:
Создание в Gitlab группы репозиториев
Подключение Group Runner
-
Настройка прокси и SSL для доменов:
Развёртывание Caddy через Runner
-
Настройка мониторинга Docker:
Развёртывание Portainer через Runner
-
База мониторинга для приложений:
Развёртывание через Runner Monitoring (Grafana, Loki, Prometheus)
Написание бизнес-логики, не возвращаясь к пунктам 1—4 и не заходя на сервер.
Структура репозитория
Caddy
Portainer
Monitoring
App-1
App-n

0. Покупаем домен на хостинге
1. Настраиваем Docker и Gitlab Runner

2. Ставим Caddy через Gitlab Runner

Организация структуры репозитория для Caddy:

Один поддомен = один
<sub_domain>.caddy.
Все наши поддомены храним здесь: ./caddy/conf.d .
В Caddyfile указываем host:2019/metrics для дальнейшего мониторинга и подключаем конфиги из conf.d.
{ admin 0.0.0.0:2019 # Включаем встроенный сбор метрик для Prometheus servers { metrics } } # Подгружаем настройки ваших доменов из подпапки import /etc/caddy/conf.d/*.caddy
Настраиваем домены для мониторинга
grafana.my-site.ru { reverse_proxy vps_grafana:3000 }
prometheus.my-site.ru { reverse_proxy vps_prometheus:9090 }
Для восприятия просто, конфиг пишется легко.
docker-compose.yml для проекта будет такой:
version: '3.8' services: caddy: image: caddy:2.8 container_name: vps_caddy_proxy restart: unless-stopped ports: - "80:80" # Для выпуска SSL и редиректа с HTTP на HTTPS - "443:443" # Для защищенного HTTPS трафика ваших сайтов volumes: # Монтируем всю папку конфигурации (Caddyfile и папку conf.d внутри) - ./caddy:/etc/caddy - ./caddy/html:/usr/share/caddy/html # Тома для хранения SSL-сертификатов Let's Encrypt на хосте - caddy_data:/data - caddy_config:/config logging: driver: json-file # Автоматическое обнаружение для Prometheus и Promtail работает прямо на Caddy! labels: prometheus.scrape: "true" prometheus.port: "2019" # Порт Caddy, где лежат встроенные метрики prometheus.interval: "10s" environment: "production" logging.scrape: "true" # Включает сбор логов запросов Caddy в Grafana Loki networks: - vps_common_network volumes: caddy_data: name: vps_caddy_data caddy_config: name: vps_caddy_config networks: vps_common_network: external: true
Ничего сверхъестественного — обычный docker-compose.yml. Тему Labels пока опустим, и вернёмся к ней позже.
3. Ставим Portainer

Что получаем:

Например, если вашему B2B-AI-SaaS-стартапу будет нужен блог, вы cделаете это в два клика:

docker-compose.yml
version: "3.8" services: twportainer: image: portainer/portainer-ce:latest container_name: twportainer environment: - TZ=Europe/Moscow volumes: - /var/run/docker.sock:/var/run/docker.sock - portainer_data:/data networks: - vps_common_network restart: always volumes: portainer_data: name: vps_portainer_data networks: vps_common_network: external: true
Этого вполне достаточно. Делаем один раз и не дорабатываем.
4. Ставим мониторинг

Теперь подробнее рассмотрим docker-compose.yml :
version: '3.8' services: prometheus: image: prom/prometheus:v3.13.1 container_name: vps_prometheus user: root ports: - "9090:9090" volumes: - ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro - /var/run/docker.sock:/var/run/docker.sock:ro - prometheus_data:/prometheus command: # Указывает путь к главному файлу конфигурации внутри контейнера. - '--config.file=/etc/prometheus/prometheus.yml' # Задает директорию, куда Prometheus будет сохранять собранные метрики (базу данных TSDB). - '--storage.tsdb.path=/prometheus' # Путь к библиотекам консольных шаблонов (встроенная фича Prometheus для кастомных HTML-панелей). - '--web.console.libraries=/etc/prometheus/console_libraries' - '--web.console.templates=/etc/prometheus/consoles' # Время хранения данных. Метрики старше 200 часов (примерно 8.3 дня) будут автоматически удаляться. - '--storage.tsdb.retention.time=200h' # Это позволяет обновлять конфиг prometheus.yml без перезапуска контейнера (через curl-запрос). - '--web.enable-lifecycle' # curl -X POST http://localhost:9090/-/reload logging: driver: json-file networks: - vps_common_network depends_on: - loki grafana: image: grafana/grafana:13.1.1 container_name: vps_grafana environment: - GF_SECURITY_ADMIN_PASSWORD=admin # В продакшене лучше вынести в .env файл - GF_USERS_ALLOW_SIGN_UP=false logging: driver: json-file volumes: - grafana_data:/var/lib/grafana - ./grafana/provisioning:/etc/grafana/provisioning networks: - vps_common_network depends_on: - prometheus - loki loki: image: grafana/loki:3.7.4 container_name: vps_loki command: -config.file=/etc/loki/local-config.yaml logging: driver: json-file volumes: - loki_data:/loki networks: - vps_common_network promtail: image: grafana/promtail:3.6.11 container_name: vps_promtail user: root volumes: - /var/log:/var/log:ro - /var/run/docker.sock:/var/run/docker.sock:ro - ./promtail/promtail-config.yml:/etc/promtail/config.yml:ro command: -config.file=/etc/promtail/config.yml logging: driver: json-file networks: - vps_common_network depends_on: - loki volumes: prometheus_data: grafana_data: loki_data: networks: vps_common_network: external: true
В этом конфиге можно ничего не менять.

Итак, datasources.yml описывает, откуда Grafana может брать данные:
apiVersion: 1 datasources: - name: Prometheus type: prometheus access: proxy url: http://prometheus:9090 isDefault: true - name: Loki type: loki access: proxy url: http://loki:3100
А prometheus.yml отвечает за сбор метрик с Docker-контейнеров:
global: scrape_interval: 5s # Интервал по умолчанию для всех evaluation_interval: 5s scrape_configs: # 1. Сам Prometheus мониторит себя локально - job_name: 'prometheus' static_configs: - targets: ['localhost:9090'] # 2. Автоматическое обнаружение всех остальных контейнеров - job_name: 'docker-containers' docker_sd_configs: - host: unix:///var/run/docker.sock relabel_configs: # Фильтр: собираем метрики только с контейнеров, где указано prometheus.scrape: "true" - source_labels: [__meta_docker_container_label_prometheus_scrape] regex: 'true' action: keep # Формируем адрес контейнера (имя_контейнера:порт_из_метки) - source_labels: [__meta_docker_container_host, __meta_docker_container_label_prometheus_port] regex: '([^;]+);([^;]+)' replacement: '${1}:${2}' target_label: __address__ # Создаем красивый лейбл "app" из имени контейнера (удаляем начальный слэш) - source_labels: [__meta_docker_container_name] regex: '/(.*)' target_label: app # Автоматически прокидываем лейбл "environment" из Docker-метки прямо в метрики - source_labels: [__meta_docker_container_label_environment] target_label: environment # Динамический scrape_interval: если у контейнера есть метка prometheus.interval, применяем её - source_labels: [__meta_docker_container_label_prometheus_interval] regex: '(.+)' target_label: __scrape_interval__ # Динамический путь: если у контейнера есть метка prometheus.path, подменяем стандартный /metrics - source_labels: [__meta_docker_container_label_prometheus_path] regex: '(.+)' target_label: __metrics_path__
Этот инструмент нацелен на использование Labels в Docker-контейнерах. Хотя эта концепция не является новинкой, она хорошо подходит для общего понимания.
Labels:
Prometheus.scrape
Prometheus.port
Environment
Interval
Path
Их использование позволяет настроить мониторинг один раз и не изменять конфигурацию впоследствии. При добавлении нового бизнес-домена или сервиса подключение к системе мониторинга сводится к простому добавлению необходимых меток в контейнер.
promtail-config.yml
server: http_listen_port: 9080 grpc_listen_port: 0 positions: filename: /tmp/positions.yaml clients: - url: http://loki:3100/loki/api/v1/push scrape_configs: - job_name: system static_configs: - targets: - localhost labels: job: varlogs __path__: /var/log/*log - job_name: docker-containers docker_sd_configs: - host: unix:///var/run/docker.sock # Подключаемся к сокету Docker relabel_configs: # Фильтр: собираем логи только если у контейнера есть лабель logging.scrape=true - source_labels: [__meta_docker_container_label_logging_scrape] regex: 'true' action: keep # Автоматически создаем лейбл container_name (Promtail сам уберет начальный слэш) - source_labels: [__meta_docker_container_name] target_label: container_name # Автоматически добавляем имя Docker-образа - source_labels: [__meta_docker_container_image] target_label: image_name # Пробрасываем лейбл окружения из Docker-меток (как делали в Prometheus) - source_labels: [__meta_docker_container_label_environment] target_label: environment # Задаем фиксированный лейбл для Grafana Loki, чтобы отделять логи контейнеров от системных - replacement: containerlogs target_label: job
Labels:
Logging_scrape
Это требуется для подключения сервиса к журналам.


Ссылка на дашборд: https://grafana.com/grafana/dashboards/22870-caddy/
Метки

Каждому Docker-контейнеру можно назначить метки, которые будут считываться в мониторинге. Это позволяет управлять видимостью на уровне приложения, а не мониторинга.
services: my-django-backend: build: context: . dockerfile: Dockerfile container_name: django_app logging: driver: json-file labels: prometheus.scrape: "true" # Сбор метрик (Prometheus) prometheus.port: "8000" # Внутренний порт приложения внутри контейнера prometheus.path: "/metrics" # Необязательно, так как /metrics используется по умолчанию logging.scrape: "true" # Сбор логов (Promtail) environment: "production" # Общий маркер для фильтрации в Grafana networks: - vps_common_network
Резюме

В результате мы получили систему, в которой:
Разработка бизнес-логики ведётся независимо
Управление кодом происходит без постоянного подключения к серверу
Добавление новой функциональности и SSL-сертификатов для поддоменов не требует больших усилий
Публичная группа с необходимыми репозиториями доступна по ссылке: https://gitlab.com/simple-infrastructure-template
Это шаблон можно переиспользовать и адаптировать под свои задачи.
Разумеется, у такого подхода есть свои недостатки, и по мере развития проекта потребуются изменения. Однако стоит подчеркнуть, что акцент делался именно на первые этапы развития стартапов и личных проектов. По опыту, этот подход к инфраструктуре закрывает большинство задач и значительно упрощает работу. Благодаря ему можно быстро собрать рабочее решение всего за пару дней.
Apalin
Спасибо, забрал идею с host:2019/metrics и conf.d, у нас Caddyfile до сих пор лежит одним куском.
Про restart расскажу, как это вышло у нас, потому что в ваших примерах caddy идёт с unless-stopped, а portainer с always.
На прошлой неделе перезапустили демон docker руками, обычный systemctl restart docker. Сайты отвалились почти сразу. Не все: часть вернулась сама, часть нет, и общего у упавших сперва не находилось. Нашлось в политиках перезапуска. Все упавшие стояли на unless-stopped, все выжившие на always.