From 464e12abdbc88308dcf41af34f7ea2d1d37cb095 Mon Sep 17 00:00:00 2001 From: gilfanovr Date: Sun, 11 Oct 2026 15:06:53 +0300 Subject: [PATCH] =?UTF-8?q?rpi:=20=D0=B2=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BF=D0=B8=D1=8F=20/mnt/backup=20=D0=BD=D0=B0=201?= =?UTF-8?q?=20=D0=A2=D0=91=20=D0=B4=D0=B8=D1=81=D0=BA=20=D0=B2=20=D1=80?= =?UTF-8?q?=D0=BE=D1=83=D1=82=D0=B5=D1=80=D0=B5=20(rclone=20SFTP)=20+=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=BD=D0=B8=D1=82=D0=BE=D1=80=D0=B8=D0=BD=D0=B3=20?= =?UTF-8?q?=D0=BC=D0=BE=D0=BD=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F=20=D0=B8=20=D0=B7=D0=B0=D0=BF=D0=BE=D0=BB=D0=BD?= =?UTF-8?q?=D0=B5=D0=BD=D0=B8=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- servers/rpi.md | 11 + .../provisioning/dashboards/rpi-disk.json | 193 ++++++++++++++++++ stacks/monitoring/prometheus/rules/alerts.yml | 18 +- stacks/rpi-backup/bin/router-backup | 58 ++++++ stacks/rpi-backup/bin/smart-metrics | 11 + .../rpi-backup/systemd/router-backup.service | 14 ++ stacks/rpi-backup/systemd/router-backup.timer | 10 + 7 files changed, 314 insertions(+), 1 deletion(-) create mode 100644 stacks/rpi-backup/bin/router-backup create mode 100644 stacks/rpi-backup/systemd/router-backup.service create mode 100644 stacks/rpi-backup/systemd/router-backup.timer diff --git a/servers/rpi.md b/servers/rpi.md index d074467..982604e 100644 --- a/servers/rpi.md +++ b/servers/rpi.md @@ -35,6 +35,17 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg - ⚠️ Архив без ротации: растёт вечно. Скрипт выходит с кодом 2 при заполнении >90 % (→ алерт BackupFailed), плюс HostDisk* если когда-то поставим node-exporter. - Первая полная выгрузка (368 ГБ) запущена 2026-10-11 ~13:13, ~27 МБ/с (упор в домашний канал). +## Вторая копия — 1 ТБ диск в роутере (с 2026-10-11) +- Диск воткнут в роутер Huawei (`192.168.10.254`), а не в малину — **не ест питание USB малины** (два HDD от неё не тянут, см. «Диски»). Доступ — SFTP роутера на порту **8022**, пользователь `root`, remote `router:` в `~rus/.config/rclone/rclone.conf` (пароль только там), host key — `~rus/.ssh/known_hosts_router`. +- ⚠️ Роутер не считает хэши (`shell_type = none`, `md5sum_command = none`) — rclone сравнивает по размеру+mtime, целостность проверяется перечиткой. +- `mnt-router.service` — `rclone mount router: /mnt/router` (user rus, `--vfs-cache-mode writes`, кэш в `/mnt/backup/.rclone-cache`). Для просмотра/ручной работы; бэкап пишет **напрямую в remote**, не через FUSE (коды ошибок, без кэша). +- Скорость (замер 2026-10-11, параллельно с выгрузкой OneDrive): запись ~8 МБ/с, чтение ~11 МБ/с — первичная копия ~370 ГБ ≈ 13 ч. Диск 932 ГБ. +- `router-backup` (`stacks/rpi-backup/`, таймер 06:00, `flock`): в `router:rpi-backup/` — `onedrive/{archive,manifests,logs}` только `copy` (там ничего не удаляется), затем зеркало `onedrive/current` с `--max-delete 1000`, затем зеркало зашифрованных `servers/` с `--max-delete 50`. Порядок важен: удалённое из current к этому моменту уже скопировано в archive. Логи — `/mnt/backup/router-logs/`. +- ⚠️ `--max-delete` — предохранитель: если локально «пропадёт» много файлов (сбой диска/монтирования), копия на роутере не зачистится, а прогон упадёт с ошибкой → алерт `BackupFailed`. +- По воскресеньям `verify`: сверка размеров деревьев + перечитать с роутера последний daily каждого сервера и сравнить sha256. +- Мониторинг: `router_mount_up` и заполнение `/mnt/router` (в `smart-metrics`, `df` под `timeout 20`), итог прогона — `backup_*{job="backup_router"}`; алерты `RouterDiskMountDown` (30 мин), `BackupDiskFull` (>90 %, любой диск бэкапа), `BackupStale` (>30 ч); ряд «Вторая копия» на дашборде rpi-disk. +- ⚠️ 1 ТБ меньше основного диска: сейчас влезает (~370 ГБ OneDrive + ~100 ГБ серверов), но архив версий растёт вечно — следить за `BackupDiskFull`. + ## Бэкап серверов fr1/ru1 (с 2026-10-11, код — `stacks/server-backup/`) - Малина — внешняя площадка для VPS: `servers-backup.timer` (02:15) забирает по SSH выгрузки, которые **сервер сам шифрует открытым ключом age** (`tar | zstd | age`). Закрытого ключа на малине нет — ни она, ни Astra, ни взломщик архивы не прочитают. Ключ у владельца: Mac `~/.config/age/servers-backup.key`. - Хранилище `/mnt/backup/servers//{daily,weekly,monthly}` (root 700): 14 / 8 / 12, monthly — хардлинк первого daily месяца. fr1 daily ~2.1 ГБ (5.5 мин), ru1 ~17 МБ, fr1 weekly (вс) — тома ботов и imgbot. diff --git a/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json b/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json index 1a54974..1a09626 100644 --- a/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json +++ b/stacks/monitoring/grafana/provisioning/dashboards/rpi-disk.json @@ -1096,6 +1096,199 @@ "overrides": [] }, "options": {} + }, + { + "type": "row", + "title": "Вторая копия — диск в роутере", + "collapsed": false, + "gridPos": { + "x": 0, + "y": 31, + "w": 24, + "h": 1 + }, + "id": 200, + "panels": [] + }, + { + "id": 201, + "type": "stat", + "title": "Диск в роутере (SFTP)", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 0, + "y": 32, + "w": 6, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "router_mount_up{instance=\"$instance\"}", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "mappings": [ + { + "type": "value", + "options": { + "1": { + "text": "доступен", + "color": "green" + }, + "0": { + "text": "НЕДОСТУПЕН", + "color": "red" + } + } + } + ] + }, + "overrides": [] + }, + "description": "rclone-монтирование /mnt/router (Huawei SFTP :8022). Диск питается от роутера, не от малины.", + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center" + } + }, + { + "id": 202, + "type": "stat", + "title": "С последней копии на роутер", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 6, + "y": 32, + "w": 9, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "time() - backup_last_success_timestamp{instance=\"$instance\",job=\"backup_router\"}", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "s", + "noValue": "ещё не было", + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 93600, + "color": "orange" + }, + { + "value": 108000, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "description": "Ежедневно 06:00: OneDrive (архив — copy, current — зеркало с --max-delete) и зашифрованные бэкапы серверов.", + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center" + } + }, + { + "id": 203, + "type": "stat", + "title": "Заполнение диска в роутере", + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "gridPos": { + "x": 15, + "y": 32, + "w": 9, + "h": 4 + }, + "targets": [ + { + "refId": "A", + "expr": "1 - backup_disk_avail_bytes{instance=\"$instance\",mountpoint=\"/mnt/router\"} / backup_disk_size_bytes{instance=\"$instance\",mountpoint=\"/mnt/router\"}", + "instant": true, + "range": false + } + ], + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "min": 0, + "max": 1, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 0.8, + "color": "orange" + }, + { + "value": 0.9, + "color": "red" + } + ] + } + }, + "overrides": [] + }, + "description": "", + "options": { + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "colorMode": "background", + "graphMode": "none", + "textMode": "value", + "justifyMode": "center" + } } ] } \ No newline at end of file diff --git a/stacks/monitoring/prometheus/rules/alerts.yml b/stacks/monitoring/prometheus/rules/alerts.yml index e2b9bc2..39b6301 100644 --- a/stacks/monitoring/prometheus/rules/alerts.yml +++ b/stacks/monitoring/prometheus/rules/alerts.yml @@ -90,13 +90,29 @@ groups: annotations: summary: "{{ $labels.instance }}: {{ $labels.job }} завершился с кодом {{ $value }} — см. journalctl на хосте" - # sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней; бэкап серверов ежедневно → 26ч + # sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней; бэкап серверов ежедневно → 26ч; копия на роутер ежедневно → 30ч (первичная ~13ч) - alert: BackupStale expr: | (time() - backup_last_success_timestamp{job="backup_onedrive"} > 12 * 3600) or (time() - backup_last_success_timestamp{job="backup_onedrive_verify"} > 8 * 86400) or (time() - backup_last_success_timestamp{job="backup_servers"} > 26 * 3600) + or (time() - backup_last_success_timestamp{job="backup_router"} > 30 * 3600) for: 10m labels: {severity: critical} annotations: summary: "{{ $labels.instance }}: {{ $labels.job }} не было успешного прогона {{ $value | humanizeDuration }}" + + # /mnt/backup (HDD 4 ТБ) и /mnt/router (1 ТБ диск в роутере) — заполнение + - alert: BackupDiskFull + expr: 1 - backup_disk_avail_bytes / backup_disk_size_bytes > 0.9 + for: 15m + labels: {severity: warning} + annotations: + summary: "{{ $labels.instance }}: диск бэкапа {{ $labels.mountpoint }} заполнен на {{ $value | humanizePercentage }}" + + - alert: RouterDiskMountDown + expr: router_mount_up == 0 + for: 30m + labels: {severity: warning} + annotations: + summary: "{{ $labels.instance }}: диск в роутере (/mnt/router, SFTP) недоступен 30+ мин — вторая копия бэкапа не пишется" diff --git a/stacks/rpi-backup/bin/router-backup b/stacks/rpi-backup/bin/router-backup new file mode 100644 index 0000000..c804adf --- /dev/null +++ b/stacks/rpi-backup/bin/router-backup @@ -0,0 +1,58 @@ +#!/bin/bash +# Вторая копия /mnt/backup на 1 ТБ диск в роутере Huawei (SFTP :8022, remote `router:` в +# ~rus/.config/rclone/rclone.conf). Диск питается от роутера — не нагружает USB малины. +# +# router-backup run — ежедневно (таймер 06:00) +# router-backup verify — по воскресеньям после run: размеры + перечитать свежие архивы серверов по sha256 +# +# Пишем прямо в remote, а не через FUSE /mnt/router: так надёжнее и есть коды ошибок. +# Роутер не умеет хэши (shell_type=none) → сравнение по размеру+mtime, целостность — перечиткой. +set -uo pipefail +C=/home/rus/.config/rclone/rclone.conf +R=router:rpi-backup +SRC=/mnt/backup +TS=$(date +%F_%H%M) +LOGDIR=$SRC/router-logs; mkdir -p "$LOGDIR" +LOG="$LOGDIR/router-$TS.log" +rc() { rclone --config "$C" --log-file "$LOG" --log-level INFO --stats 10m --stats-log-level NOTICE \ + --transfers 2 --checkers 4 --sftp-concurrency 8 --retries 5 --low-level-retries 20 "$@"; } + +mountpoint -q "$SRC" || { echo "$SRC не смонтирован — пропуск"; exit 1; } +rclone --config "$C" lsd router: >/dev/null 2>&1 || { echo "роутер недоступен по SFTP"; exit 1; } + +run() { + local r=0 + # 1) то, что никогда не удаляется: архив версий, манифесты, логи — только copy + for d in archive manifests logs; do + [ -d "$SRC/onedrive/$d" ] && { rc copy "$SRC/onedrive/$d" "$R/onedrive/$d" || r=1; } + done + # 2) зеркало current. Удалённое из current локально уже лежит в archive (скопированном выше). + # --max-delete: если локально «пропало» много файлов (сбой диска/монтирования) — стоп, а не зачистка копии + rc sync "$SRC/onedrive/current" "$R/onedrive/current" --exclude '*.partial' --max-delete 1000 || r=1 + # 3) зашифрованные бэкапы серверов — зеркало (ротация 14/8/12 → удаляется пара файлов в день) + rc sync "$SRC/servers" "$R/servers" --max-delete 50 || r=1 + echo "run: $([ $r = 0 ] && echo OK || echo 'ЕСТЬ ОШИБКИ') — лог $LOG" + return $r +} + +verify() { + local r=0 s rel want got + # размеры всего дерева (только листинг, без чтения данных) + rc check "$SRC/onedrive/current" "$R/onedrive/current" --size-only --one-way --exclude '*.partial' || r=1 + rc check "$SRC/servers" "$R/servers" --size-only --one-way || r=1 + # перечитать с роутера последний daily каждого сервера и сверить с sha256 + for s in $(ls -1t "$SRC"/servers/*/daily/*.age.sha256 2>/dev/null | awk -F/ '!seen[$(NF-2)]++'); do + rel=${s#"$SRC/servers/"}; rel=${rel%.sha256} + want=$(cut -d' ' -f1 "$s") + got=$(rclone --config "$C" cat "$R/servers/$rel" 2>>"$LOG" | sha256sum | cut -d' ' -f1) + if [ "$want" = "$got" ]; then echo "read-back OK $rel"; else echo "read-back BAD $rel"; r=1; fi + done + echo "verify: $([ $r = 0 ] && echo OK || echo 'ЕСТЬ РАСХОЖДЕНИЯ')" + return $r +} + +case "${1:-run}" in + run) run; r=$?; [ "$(date +%u)" = 7 ] && { verify || r=1; }; exit $r ;; + verify) verify ;; + *) echo "usage: router-backup run|verify" >&2; exit 2 ;; +esac diff --git a/stacks/rpi-backup/bin/smart-metrics b/stacks/rpi-backup/bin/smart-metrics index 0600480..76e88c0 100755 --- a/stacks/rpi-backup/bin/smart-metrics +++ b/stacks/rpi-backup/bin/smart-metrics @@ -39,4 +39,15 @@ if mountpoint -q /mnt/backup; then echo "backup_disk_size_bytes{mountpoint=\"/mnt/backup\"} $size" >> "$out" echo "backup_disk_avail_bytes{mountpoint=\"/mnt/backup\"} $avail" >> "$out" fi +# диск в роутере (rclone SFTP-монтирование /mnt/router): жив ли и сколько занято. +# timeout — зависший FUSE не должен вешать сбор метрик +echo "# TYPE router_mount_up gauge" >> "$out" +if mountpoint -q /mnt/router && r=$(timeout 20 df -B1 --output=size,avail /mnt/router 2>/dev/null | tail -1) && [ -n "$r" ]; then + read -r size avail <<< "$r" + echo "router_mount_up 1" >> "$out" + echo "backup_disk_size_bytes{mountpoint=\"/mnt/router\"} $size" >> "$out" + echo "backup_disk_avail_bytes{mountpoint=\"/mnt/router\"} $avail" >> "$out" +else + echo "router_mount_up 0" >> "$out" +fi metrics-push smart < "$out" diff --git a/stacks/rpi-backup/systemd/router-backup.service b/stacks/rpi-backup/systemd/router-backup.service new file mode 100644 index 0000000..cc78750 --- /dev/null +++ b/stacks/rpi-backup/systemd/router-backup.service @@ -0,0 +1,14 @@ +[Unit] +Description=Second copy of /mnt/backup to router USB disk (rclone SFTP) +Wants=network-online.target +After=network-online.target +RequiresMountsFor=/mnt/backup + +[Service] +Type=oneshot +ExecStart=/usr/bin/flock -n /run/lock/router-backup.lock /usr/local/bin/router-backup run +ExecStopPost=/usr/local/bin/backup-metrics backup_router +Nice=10 +IOSchedulingClass=idle +# первичная копия ~370 ГБ при ~8 МБ/с — около 13 ч +TimeoutStartSec=infinity diff --git a/stacks/rpi-backup/systemd/router-backup.timer b/stacks/rpi-backup/systemd/router-backup.timer new file mode 100644 index 0000000..e71c3d4 --- /dev/null +++ b/stacks/rpi-backup/systemd/router-backup.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Daily copy of /mnt/backup to router disk + +[Timer] +OnCalendar=*-*-* 06:00 +RandomizedDelaySec=10m +Persistent=true + +[Install] +WantedBy=timers.target