настройка vps, часть 2: reverse proxy, мониторинг и защита сервисов

Содержание:

Это вторая часть гайда по настройке VPS. В первой части мы поставили минимально необходимые утилиты, подняли Docker для наших сервисов, сделали базовую настройку безопасности (UFW, fail2ban, настроили ssh) и настроили минимальный мониторинг средствами DigitalOcean.

В этой части поднимем полноценный мониторинг на стеке Prometheus + Grafana, а также настроим домен для него.

Ожидается, что у читателя уже куплен домен. Подойдет абсолютно любой, у меня Hostinger.


Финальный чеклист

DNS и reverse proxy

Структура и конфиги

Сеть и UFW

Запуск

Защита Grafana (опционально)


Grafana - это дашборды, а значит она должна смотреть наружу. Для этого необходимы два условия:

Главное


Этап 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']

У нас перечислено 3 таргета:

Этап 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 нужно поменять на свое значение.

Краткое пояснение:

Этап 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.

Алгоритм:

  1. Закрываем права на Caddyfile, чтобы его могли читать только root и сам Caddy.
  2. Добавляем basic_auth в конфиг Caddy для целевого сервиса, который смотрит наружу - в нашем случае Grafana. Попытки логина будут видны в логах.
  3. Через 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 caddy

basic_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 пароль. Шаги те же:

  1. Генерим новый хеш: caddy hash-password
  2. Меняем хеш в /etc/caddy/Caddyfile
  3. Релоадим сервис: 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
    }
}

Валидируем конфиг:

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 =

Проверим, что фильтр действительно ловит 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

Как понять, что парсинг логов работает:

Создаем 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

Перезапускаем и проверяем работу бана. Открываем 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.

У меня такие:

Но, естественно, можно рисовать свои. После импорта ждем минут 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 по аналогии:

  1. Директория ~/services/<имя>/ со своим docker-compose.yml
  2. Сервис слушает на 127.0.0.1:port
  3. DNS-запись <имя>.example.com -> IP_VPS
  4. Блок в Caddyfile с reverse_proxy localhost:port
  5. 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 конфига.