Содержание:
- Настройка DNS
- Настройка реверс-прокси Caddy
- Структура директорий для сервисов
- Настройка Prometheus
- Настройка docker-compose для мониторинга
- UFW-правило для node_exporter
- Запуск
- Настройка домена Grafana в Caddy
- Защита Grafana: права на Caddyfile, basic_auth, fail2ban
- Подключение Prometheus к Grafana
- Импорт дашбордов
- Версионирование конфигов в Git
Это вторая часть гайда по настройке VPS. В первой части мы поставили минимально необходимые утилиты, подняли Docker для наших сервисов, сделали базовую настройку безопасности (UFW, fail2ban, настроили ssh) и настроили минимальный мониторинг средствами DigitalOcean.
В этой части поднимем полноценный мониторинг на стеке Prometheus + Grafana, а также настроим домен для него.
Ожидается, что у читателя уже куплен домен. Подойдет абсолютно любой, у меня Hostinger.
Финальный чеклист
DNS и reverse proxy
-
A-запись
@ -> IP_VPSдобавлена - A-запись для каждого нужного вам поддомена
-
dig +shortвозвращает корректный IP - Caddy установлен
-
/etc/caddy/Caddyfileзаменен на свой конфиг с email -
caddy validateпроходит -
HTTPS работает на корне домена:
curl -I https://example.comотдаетHTTP/2 200 -
В логах Caddy есть
certificate obtained successfully
Структура и конфиги
-
Директория
~/services/observability/создана -
prometheus.ymlс тремя job (prometheus,node,cadvisor) -
docker-compose.ymlс четырьмя сервисами -
Все порты привязаны к
127.0.0.1: -
mem_limitвыставлен для каждого сервиса - Конфиги мониторинга трекаются git’ом
Сеть и UFW
-
UFW-правило
from 172.16.0.0/12 to any port 9100 - UFW снаружи открыт только 22, 80, 443
Запуск
-
docker compose up -dотработал без ошибок -
docker compose psпоказывает все сервисы какrunning -
Все три таргета в Prometheus
up -
Grafana открывается по
https://grafana.example.com -
Datasource Prometheus подключен по URL
http://prometheus:9090 - Импортированы дашборды 1860 и 193
Защита Grafana (опционально)
-
Пароль Grafana изменен с дефолтного
changeme -
Caddyfileимеет права640 root:caddy -
Хеш сгенерирован через
caddy hash-password -
basic_authдобавлен в блокgrafana.example.com - При входе в инкогнито браузер спрашивает basic_auth логин до открытия Grafana
-
Access-лог Caddy пишется в
/var/log/caddy/grafana-access.log, ротация логов настроена -
Фильтр
caddy-grafana-authв/etc/fail2ban/filter.d/настроен - fail2ban-regex ловит фейлы
-
Jail
caddy-grafana-authактивен (sudo fail2ban-client statusпоказывает его) - Тест: 5 неверных попыток приводят к бану IP в UFW
Grafana - это дашборды, а значит она должна смотреть наружу. Для этого необходимы два условия:
Главное
- Обязательный HTTPS. У нас будет открыт сервис с Grafana - поэтому нам необходим сертификат Let’s Encrypt.
- Главный принцип из первой статьи - минимизация поверхности атаки. Любой сервис слушает
127.0.0.1. Наружу доступен только реверс-прокси (в прошлой статье опционально открывали порты 80 и 443 ровно для этого случая). В качестве реверс-прокси используем Caddy (но можно взять любой, хоть Nginx). - Изоляция сервисов. Каждый сервис (кроме Caddy в нашем случае) живет в своем контейнере.
Этап 1. Настройка DNS
Реверс-прокси - это сервер, который стоит перед бэкенд-сервисами, принимает запросы от клиентов и проксирует на них. Клиент общается с реверс-прокси и не видит сам сервис. Назначений у реверс-прокси несколько, но в нашем случае важна именно маршрутизация.
Прежде чем запускать Caddy, домены должны резолвиться на IP нашего VPS. Иначе Let’s Encrypt не сможет проверить владение и не выдаст сертификаты.
В панели регистратора домена нужно добавить A-записи (где IP_VPS - IP вашего VPS):
A @ IP_VPS TTL 60-300
A grafana IP_VPS TTL 300
@ - это обозначение корня домена, то есть это сам домен без точки слева. Т.е. example.com - это корень, а www.example.com, grafana.example.com и т.д. - это поддомены.
Каждый поддомен - это отдельная A-запись (в нашем случае grafana). DNS не наследует записи, т.е. если прописана @ запись для корня, это не значит, что grafana.example.com тоже будет резолвиться на указанный в @ IP. Конкретно для Grafana нам нужна только вторая запись, первую добавляю для блога.
Проверка через минуту-две (где example.com - ваш домен):
dig +short grafana.example.com
Он должен вернуть IP VPS.
Этап 2. Настройка реверс-прокси Caddy
Дока Caddy: https://caddyserver.com/docs/install#debian-ubuntu-raspbian
Установка:
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | \
sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | \
sudo tee /etc/apt/sources.list.d/caddy-stable.list
sudo apt update
sudo apt install -y caddy
После установки Caddy будет работать на 80 и 443 с дефолтным “Welcome”. UFW для них уже открыт (как открыть, если еще не - в предыдущей статье).
Заменим /etc/caddy/Caddyfile (где example.com - ваш домен):
{
email username@example.com
}
example.com {
respond "Hello from Caddy"
}
www.example.com {
redir https://example.com{uri}
}
На email в глобальном блоке Let’s Encrypt пришлет уведомление, если с сертификатом будут какие-то проблемы.
Валидируем и применяем конфиг:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
sudo journalctl -u caddy -n 30 --no-pager
В логах должно быть certificate obtained successfully и Valid configuration. Если открыть в браузере https://example.com, то увидим “Hello from Caddy”.
Этап 3. Структура директорий для сервисов
Все docker-сервисы будут храниться в ~/services/:
mkdir -p ~/services/observability/prometheus
cd ~/services/observabilityЭтап 4. Настройка Prometheus
Prometheus работает по pull-модели - он сам ходит по каждому таргету и забирает метрики по HTTP с пути /metrics. В конфиге ниже перечислен список адресов, к которым он стучится.
~/services/observability/prometheus/prometheus.yml:
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
- job_name: 'node'
static_configs:
- targets: ['172.17.0.1:9100']
- job_name: 'cadvisor'
static_configs:
- targets: ['cadvisor:8080']
scrape_interval- интервал парсинга метрик.evaluation_interval- Prometheus по этой частоте проверяет alerting rules. При срабатывании условия Prometheus пушит алерт в Alertmanager, который занимается рассылкой. У нас алерты будут через Grafana (в будущем), поэтому не обязательно. Если хотите через Alertmanager, то оставьте.scrape_configs- список джобов. Каждая джоба - это группа таргетов одного типа.job_name- это просто метка, по нему потом можно посмотреть метрики через PromQL, например:up{job="node"}.static_configs- список статично заданных адресов таргетов (еще можно получать адреса через service discovery, но в статье это не рассматривается).targets- массив адресов, откуда Prometheus пойдет пуллить метрики по/metrics.
У нас перечислено 3 таргета:
prometheus- Prometheus собирает свои собственные метрики (про самого себя). Стучится вlocalhost:9090внутри своего контейнера.node- метрики хоста: CPU, RAM, диски, сеть. node_exporter запущен вnetwork_mode: host, поэтому биндится на0.0.0.0:9100и слушает на всех IP-адресах хоста. Prometheus идет до хоста через172.17.0.1:9100(дефолтный IP мостаdocker0) и получает его метрики.cadvisor- метрики контейнеров. cadvisor - обычный контейнер в той же compose-сети, что и Prometheus. Они общаются по DNS-имени сервиса (cadvisor), которое Docker Compose автоматически резолвит в текущий IP контейнера. Делаем по доменному имени, потому что IP контейнера может смениться при пересоздании.
Этап 5. docker-compose для мониторинга
~/services/observability/docker-compose.yml:
services:
prometheus:
image: prom/prometheus:latest
container_name: prometheus
restart: unless-stopped
ports:
- "127.0.0.1:9090:9090"
volumes:
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- prometheus_data:/prometheus
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--storage.tsdb.retention.time=30d'
- '--storage.tsdb.retention.size=5GB'
- '--web.enable-lifecycle'
mem_limit: 512m
grafana:
image: grafana/grafana:latest
container_name: grafana
restart: unless-stopped
ports:
- "127.0.0.1:3000:3000"
volumes:
- grafana_data:/var/lib/grafana
environment:
- GF_SECURITY_ADMIN_USER=admin
- GF_SECURITY_ADMIN_PASSWORD=changeme
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=https://grafana.example.com
mem_limit: 512m
node_exporter:
image: prom/node-exporter:latest
container_name: node_exporter
restart: unless-stopped
pid: host
network_mode: host
command:
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
mem_limit: 64m
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
container_name: cadvisor
restart: unless-stopped
privileged: true
devices:
- /dev/kmsg
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
ports:
- "127.0.0.1:8080:8080"
mem_limit: 256m
volumes:
prometheus_data:
grafana_data:
changeme мы заменим на этапе 10, когда зайдем в Grafana из веба.
grafana.example.com нужно поменять на свое значение.
Краткое пояснение:
- Для
imageя поставилlatest, поскольку это не критичные сервисы. Для сервисов, где важна стабильность и воспроизводимость, рекомендуется ставить конкретную версию. ports: "127.0.0.1:9090:9090"- если без префикса127.0.0.1:, то порт откроется на0.0.0.0. С префиксом он будет слушать только на loopback, доступен только локально и через Caddy.network_mode: hostдляnode_exporterнужен, чтобы он видел сетевые интерфейсы и метрики хоста.mem_limit- чтобы контейнер не съел всю память. Если контейнер превышает лимит, срабатывает OOM-киллер. Здесь я подобрал лимиты с запасом под 2GB RAM, можете поставить под свои ресурсы и потребности.retention.time=30d,retention.size=5GB- политика удаления старых метрик. Данные хранятся не дольше 30 дней и не превышают 5 ГБ. Аналогично - ставьте под свои задачи и доступные ресурсы.privileged: trueдляcadvisor- нужен для чтения cgroup других контейнеров.restart: unless-stopped- перезапускать при падении контейнера или рестарте хоста, кроме случаев ручной остановки.- В разделе
volumesмонтируем конфиг Prometheus и именованные томаprometheus_dataиgrafana_data- в них хранится база метрик Prometheus и данные Grafana (настройки, дашборды и т.д.). Это нужно, чтобы данные сохранялись после пересоздания контейнеров.
Этап 6. UFW-правило для node_exporter
Поскольку node_exporter в network_mode: host, Prometheus стучится на 172.17.0.1:9100. UFW по дефолту блокирует трафик с docker-сетей на хост. Разрешаем трафик с 172.16.0.0/12 (все стандартные docker-подсети) на порт 9100 хоста:
sudo ufw allow from 172.16.0.0/12 to any port 9100 proto tcp
sudo ufw reload
Без этого правила Prometheus вернет context deadline exceeded, так как не сможет получить метрики.
Этап 7. Запуск
cd ~/services/observability
docker compose up -d
docker compose ps
Все четыре контейнера должны быть running. Проверим таргеты:
curl -s http://localhost:9090/api/v1/targets | jq '.data.activeTargets[] | {scrapeUrl, health}'
Вывод:
{
"scrapeUrl": "http://cadvisor:8080/metrics",
"health": "up"
}
{
"scrapeUrl": "http://172.17.0.1:9100/metrics",
"health": "up"
}
{
"scrapeUrl": "http://localhost:9090/metrics",
"health": "up"
}
Все три должны быть up. Если node - down, проверьте UFW из этапа 6, и что в docker-compose.yml из этапа 5 для node_exporter указан network_mode: host.
Этап 8. Настройка домена Grafana в Caddy
Чтобы дашборды с нашими метриками были доступны в браузере, Caddy нужно знать, куда проксировать запросы с адреса grafana.example.com. Настроим проксирование на наш localhost:3000, куда мапится порт 3000 контейнера с Grafana из этапа 5. В /etc/caddy/Caddyfile нужно добавить:
grafana.example.com {
reverse_proxy localhost:3000
}
Дальше валидируем конфиг и делаем reload сервиса:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Caddy сам получит TLS-сертификат (кому интересно, как он это делает: https://caddyserver.com/docs/automatic-https). Теперь Grafana доступна по https://grafana.example.com.
Этап 9. Защита Grafana: права на Caddyfile, basic_auth, fail2ban
Опциональный шаг.
На этапе 10 мы зайдем в Grafana под ее собственным admin-логином. Перед этим стоит добавить еще один слой защиты на уровне реверс-прокси: тогда брутфорсить логин Grafana через интернет не получится - запрос не дойдет до Grafana, пока не пройдена авторизация на Caddy.
Алгоритм:
- Закрываем права на Caddyfile, чтобы его могли читать только root и сам Caddy.
- Добавляем basic_auth в конфиг Caddy для целевого сервиса, который смотрит наружу - в нашем случае Grafana. Попытки логина будут видны в логах.
- Через fail2ban настраиваем бан IP при превышении порога неудачных попыток - как это делали в первом гайде для ssh.
Права на Caddyfile
Проверим, что Caddy запускается под своими юзером и группой:
systemctl cat caddy | grep -E '^(User|Group)='
Должно быть:
User=caddy
Group=caddy
Если у вас другие юзер и группа - не выполняем шаги из этого подраздела, иначе сервис упадет при перезапуске или при перепрочтении конфига.
Теперь, если файл доступен на чтение всем (644 root:root), как здесь:
ls -l /etc/caddy/Caddyfile
# -rw-r--r-- 1 root root
То меняем владельца группы на caddy и закрываем чтение для остальных:
sudo chown root:caddy /etc/caddy/Caddyfile
sudo chmod 640 /etc/caddy/Caddyfile
Будет так:
ls -l /etc/caddy/Caddyfile
# -rw-r----- 1 root caddy
Теперь читать файл могут только root и сервис caddy (по группе), писать может только root. Любой другой юзер на VPS получит Permission denied. Делаем релоад и смотрим статус (должно быть Active: active (running)):
sudo systemctl reload caddy
sudo systemctl status caddybasic_auth для Grafana (и любого сервиса, который смотрит наружу, но доступ к которому хотим ограничить)
basic_auth - это доп. слой авторизации на уровне нашего реверс-прокси. До сервиса запрос не дойдет, пока не пройдена эта авторизация.
Нам нужно сгенерировать хеш, который затем положим в Caddyfile:
caddy hash-password
Задаем и подтверждаем пароль. Сохраняем его куда-нибудь себе. Команда вернет хеш - его нужно скопировать в /etc/caddy/Caddyfile в блок grafana.example.com -> basic_auth:
grafana.example.com {
reverse_proxy localhost:3000
basic_auth {
<USER> <HASH>
}
}
В конфиге выше <USER> - это ваш логин для basic_auth, <HASH> - хеш пароля.
Валидируем и перезагружаем:
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
Открываем https://grafana.example.com (лучше в инкогнито). Браузер спросит логин и пароль до загрузки Grafana.
Раз в несколько месяцев или при подозрении на утечку стоит менять basic_auth пароль. Шаги те же:
- Генерим новый хеш:
caddy hash-password - Меняем хеш в
/etc/caddy/Caddyfile - Релоадим сервис:
sudo systemctl reload caddy
fail2ban для защиты от перебора basic_auth
Если помните первую часть, то fail2ban - это сервис, который читает логи и банит IP по определенным параметрам. Сейчас мы настроим правило (jail) для basic_auth Caddy.
Для начала включим access-логи Caddy. Access-лог - это запись об HTTP-запросе (IP клиента, URL, статус ответа и т.д.). Access-логи же надо включать отдельно, и их будет читать fail2ban.
Добавляем в /etc/caddy/Caddyfile секцию log:
grafana.example.com {
reverse_proxy localhost:3000
basic_auth {
admin $2a$14$qwerty...
}
log {
output file /var/log/caddy/grafana-access.log {
roll_size 10mb
roll_keep 5
}
format json
}
}
roll_size 10mb- ротация логов при превышении 10 МБ. Без ротации логи съедят весь диск.roll_keep 5- количество архивов при ротации.
Валидируем конфиг:
sudo caddy validate --config /etc/caddy/Caddyfile
Создаем директорию для логов из конфига выше, выдаем права и релоадим, чекаем статус (ожидается Active: active (running)):
sudo mkdir -p /var/log/caddy
sudo chown -R caddy:caddy /var/log/caddy
sudo systemctl reload caddy
sudo systemctl status caddy
Зайдем на https://grafana.example.com, введем заведомо неправильный пароль. В логе будет запись с status: 401:
sudo tail /var/log/caddy/grafana-access.log
Создаем фильтр /etc/fail2ban/filter.d/caddy-grafana-auth.conf:
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"status":401.*$
datepattern = "ts":{EPOCH}
ignoreregex =
failregex- регулярка для порядка полей в логе Caddy.<HOST>- плейсхолдер fail2ban для IP-кандидата на бан.datepattern- паттерн для извлечения времени фейла из строки лога.{EPOCH}- встроенный плейсхолдер fail2ban для unix timestamp. Фигурные скобки обязательны - без них fail2ban не распознает шаблон.
Проверим, что фильтр действительно ловит 401-е ошибки и распознает даты:
sudo fail2ban-regex -v /var/log/caddy/grafana-access.log /etc/fail2ban/filter.d/caddy-grafana-auth.conf | head -60
В выводе будет что-то вроде:
Running tests
=============
Use failregex filter file : caddy-grafana-auth, basedir: /etc/fail2ban
Use datepattern : "ts":{EPOCH} : "ts":{EPOCH}
Use log file : /var/log/caddy/grafana-access.log
Use encoding : UTF-8
Results
=======
Failregex: 34 total
|- #) [# of hits] regular expression
| 1) [34] ^.*"remote_ip":"<HOST>".*"status":401.*$
| <IP> Sun May 10 01:39:10 2026
| <IP> Sun May 10 01:39:14 2026
`-
Ignoreregex: 0 total
Date template hits:
|- [# of hits] date format
| [67] "ts":{EPOCH}
`-
Lines: 67 lines, 0 ignored, 34 matched, 33 missed
Как понять, что парсинг логов работает:
Failregex: N total- число записей по 401 в логе. Для каждой строки IP-дата - реальные даты.Date template hits:-> число записей (у меня 67) больше нуля при Failregex > 0. Это значит, что даты корректно парсятся.
Создаем jail для Grafana-логов на 401: sudo nano /etc/fail2ban/jail.d/caddy-grafana-auth.conf
Содержимое:
[caddy-grafana-auth]
enabled = true
filter = caddy-grafana-auth
logpath = /var/log/caddy/grafana-access.log
maxretry = 5
findtime = 10m
bantime = 1h
banaction = ufw
findtime = 10m- при параметреmaxretry = 5читаем как “если в течение 10 минут было 5 неудачных попыток - баним IP”bantime = 1h- бан на 1 час.banaction = ufw- правило deny from IP через UFW.
Перезапускаем и проверяем работу бана. Открываем grafana.example.com (лучше в инкогнито) и вводим 5 раз неверный пароль. На шестую попытку браузер не откроет страницу вообще.
sudo systemctl restart fail2ban
sudo fail2ban-client status caddy-grafana-auth
sudo ufw status numbered
В UFW будет видно примененное правило by Fail2Ban after 5 attempts against caddy-grafana-auth:
status caddy-grafana-auth
Status for the jail: caddy-grafana-auth
|- Filter
| |- Currently failed: 0
| |- Total failed: 0
| `- File list: /var/log/caddy/grafana-access.log
`- Actions
|- Currently banned: 1
|- Total banned: 1
`- Banned IP list: <IP>
Status: active
To Action From
-- ------ ----
[ 1] Anywhere REJECT IN <IP> # by Fail2Ban after 5 attempts against caddy-grafana-auth
[ 2] 22/tcp ALLOW IN Anywhere
[ 3] 80/tcp ALLOW IN Anywhere
[ 4] 443/tcp ALLOW IN Anywhere
[ 5] 9100/tcp ALLOW IN 172.16.0.0/12
[ 6] 22/tcp (v6) ALLOW IN Anywhere (v6)
[ 7] 80/tcp (v6) ALLOW IN Anywhere (v6)
[ 8] 443/tcp (v6) ALLOW IN Anywhere (v6)
Чтобы не ждать, можно разбанить себя:
sudo fail2ban-client unban <IP>Этап 10. Подключение Prometheus к Grafana
Заходим в Grafana по https://grafana.example.com. Если делали этап 9 - сначала браузер спросит basic_auth логин (от Caddy), затем откроется страница логина самой Grafana. Логинимся под admin и паролем changeme из compose-файла (этап 5), сразу меняем пароль.
Добавляем data-source: Connections -> Data sources -> Add new data source -> Prometheus.
Connection -> Prometheus server URL: http://prometheus:9090 (Grafana и Prometheus в одной compose-сети, общаются по DNS-имени контейнера).
Остальное по дефолту. Save & Test -> “Successfully queried the Prometheus API”.
Этап 11. Импорт дашбордов
Dashboards -> New -> Import
Готовые дашборды: https://grafana.com/grafana/dashboards/
Достаточно просто вставить ID -> Load.
У меня такие:
- 1860 - Node Exporter
- 193 - метрики Docker (сервис cAdvisor)
Но, естественно, можно рисовать свои. После импорта ждем минут 15, чтобы накопились данные.
Этап 12. Версионирование конфигов в Git
(Настраивали Git в первой части)
Конфиги кладем в git, состояние уже хранится в docker volumes (в первой части мы настроили бэкапы DO, их хватит):
cd ~/services/observability
git init
echo "*.log" > .gitignore
git add .
git status
git commit -m "Initial observability setup"
Можно запушить в приватную репу. Тогда при пересоздании VPS достаточно git clone && docker compose up -d.
Что дальше?
Можно будущие сервисы поднимать в ~/services/<name со своим docker-compose.yml по аналогии:
- Директория
~/services/<имя>/со своимdocker-compose.yml - Сервис слушает на
127.0.0.1:port - DNS-запись
<имя>.example.com -> IP_VPS - Блок в Caddyfile с
reverse_proxy localhost:port sudo caddy validate --config /etc/caddy/Caddyfile && sudo systemctl reload caddy
Если у сервиса есть свой Prometheus-эндпоинт и хочется собирать его метрики, то нужно его добавить в scrape_configs в prometheus.yml и попросить Prometheus перечитать свой конфиг без рестарта контейнера:
docker compose exec prometheus kill -HUP 1
Команда посылает SIGHUP процессу с PID 1 внутри контейнера Prometheus - это его собственный сигнал на reload конфига.