rpi: вторая копия /mnt/backup на 1 ТБ диск в роутере (rclone SFTP) + мониторинг монтирования и заполнения

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
gilfanovr
2026-10-11 15:06:53 +03:00
co-authored by Claude Opus 5.5
parent 3811483577
commit 464e12abdb
7 changed files with 314 additions and 1 deletions
+11
View File
@@ -35,6 +35,17 @@ OneDrive на внешний HDD. Мониторится с fr1 через pushg
- ⚠️ Архив без ротации: растёт вечно. Скрипт выходит с кодом 2 при заполнении >90 % (→ алерт BackupFailed), плюс HostDisk* если когда-то поставим node-exporter. - ⚠️ Архив без ротации: растёт вечно. Скрипт выходит с кодом 2 при заполнении >90 % (→ алерт BackupFailed), плюс HostDisk* если когда-то поставим node-exporter.
- Первая полная выгрузка (368 ГБ) запущена 2026-10-11 ~13:13, ~27 МБ/с (упор в домашний канал). - Первая полная выгрузка (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/`) ## Бэкап серверов 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`. - Малина — внешняя площадка для VPS: `servers-backup.timer` (02:15) забирает по SSH выгрузки, которые **сервер сам шифрует открытым ключом age** (`tar | zstd | age`). Закрытого ключа на малине нет — ни она, ни Astra, ни взломщик архивы не прочитают. Ключ у владельца: Mac `~/.config/age/servers-backup.key`.
- Хранилище `/mnt/backup/servers/<host>/{daily,weekly,monthly}` (root 700): 14 / 8 / 12, monthly — хардлинк первого daily месяца. fr1 daily ~2.1 ГБ (5.5 мин), ru1 ~17 МБ, fr1 weekly (вс) — тома ботов и imgbot. - Хранилище `/mnt/backup/servers/<host>/{daily,weekly,monthly}` (root 700): 14 / 8 / 12, monthly — хардлинк первого daily месяца. fr1 daily ~2.1 ГБ (5.5 мин), ru1 ~17 МБ, fr1 weekly (вс) — тома ботов и imgbot.
@@ -1096,6 +1096,199 @@
"overrides": [] "overrides": []
}, },
"options": {} "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"
}
} }
] ]
} }
+17 -1
View File
@@ -90,13 +90,29 @@ groups:
annotations: annotations:
summary: "{{ $labels.instance }}: {{ $labels.job }} завершился с кодом {{ $value }} — см. journalctl на хосте" summary: "{{ $labels.instance }}: {{ $labels.job }} завершился с кодом {{ $value }} — см. journalctl на хосте"
# sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней; бэкап серверов ежедневно → 26ч # sync каждые 4ч → 12ч = три пропуска подряд; verify раз в неделю → 8 дней; бэкап серверов ежедневно → 26ч; копия на роутер ежедневно → 30ч (первичная ~13ч)
- alert: BackupStale - alert: BackupStale
expr: | expr: |
(time() - backup_last_success_timestamp{job="backup_onedrive"} > 12 * 3600) (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_onedrive_verify"} > 8 * 86400)
or (time() - backup_last_success_timestamp{job="backup_servers"} > 26 * 3600) or (time() - backup_last_success_timestamp{job="backup_servers"} > 26 * 3600)
or (time() - backup_last_success_timestamp{job="backup_router"} > 30 * 3600)
for: 10m for: 10m
labels: {severity: critical} labels: {severity: critical}
annotations: annotations:
summary: "{{ $labels.instance }}: {{ $labels.job }} не было успешного прогона {{ $value | humanizeDuration }}" 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+ мин — вторая копия бэкапа не пишется"
+58
View File
@@ -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
+11
View File
@@ -39,4 +39,15 @@ if mountpoint -q /mnt/backup; then
echo "backup_disk_size_bytes{mountpoint=\"/mnt/backup\"} $size" >> "$out" echo "backup_disk_size_bytes{mountpoint=\"/mnt/backup\"} $size" >> "$out"
echo "backup_disk_avail_bytes{mountpoint=\"/mnt/backup\"} $avail" >> "$out" echo "backup_disk_avail_bytes{mountpoint=\"/mnt/backup\"} $avail" >> "$out"
fi 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" metrics-push smart < "$out"
@@ -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
@@ -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