FreeBSD: Home NAS, part 15: автоматизація бекапів – скрипти, rsync, rclone
0 (0)

7 Березня 2026

Фактично, це вже остання велика задача – налаштувати автоматичне створення бекапів.

В пості FreeBSD: Home NAS, part 13: планування зберігання даних та бекапів описав загальну ідею детальніше – як і що бекапиться, де, що, як зберігається, а сьогодні – вже суто технічна частина про саму реалізацію.

Про що буде йти мова в цьому пості – як робилась автоматизація збору даних з Linux-хостів на NAS, трохи про підводні камені rsync, і як всі бекапи з самого NAS синхронізуються в Rclone remotes.

Всі описані тут скрипти і приклади конфігураційних файлів є в GitHub setevoy2/nas-backup.

Всі частини серії по налаштуванню домашнього NAS на FreeBSD:

Короткий опис ідеї та реалізації

Взагалі, спочатку ідея була все робити з restic і NFS: мати на NAS окрему NFS share, яка б підключалась до хостів, потім на хостах в цю шару з restic робити бекапи, і після цього з rclone копіювати дані в Google Drive та/або AWS S3.

Але чим більше думав – тим більше розумів, що це не найкраще рішення:

  • по-перше – зав’язуватись на NFS, яка має бути постійно підключена – треба перевіряти, чи вона є, чи активна, ну і взагалі – це прив’язка до постійного network connection
  • по-друге – сам restic, як система бекапу для домашнього використання – трошки overkill:
    • snapshots – да, круто, але ті самі снапшоти робляться із ZFS
    • другий момент – це те, що restic працює виключно з crypted data у своїх репозиторіях – а я хотів мати можливість просто зайти в каталог бекапів, і подивитись що там є

Тому врешті-решт замість restic вирішив робити зі звичайним rsync, а замість restic remotes – взяти rclone, і заливати в клауди дані з ним.

Але і тут виникли свої нюанси і зміни в планах.

Спершу ідея була мати shell-скрипти з викликом rsync на Linux-хостах, ці скрипти запускати по cron, робити бекапи і заливати їх на NAS – і навіть написав такі скрипти на робочому ноутбуці.

Втім, коли вже почав збирати всю систему, то постало питання – а коли саме з NAS запускати rclone, щоб вже оновлені з rsync бекапи залити в клауд?

Власне, тоді і прийшло розуміння, що потрібен якийсь “control loop”, який буде і запускати копіювання даних з інших хостів, і на самому NAS, і після завершення копіювання даних – буде заливати апдейти в Google Drive та Backblaze, ще і виконувати якісь додаткові дії.

Тобто загальна схема тепер така: запускати rsync прямо з NAS, з цього “control loop бекап-скрипта”, з rsync по SSH підключатись до віддалених хостів, збирати дані, і в кінці, точно знаючи, що всі дані зібрані – вже можна спокійно запускати rclone.

Коротко про мою мережу і хости взагалі – детальніше було в пості FreeBSD: Home NAS, part 13:

Архітектура реалізації

Є три системи, які керують даними:

  • Syncthing: синхронізує частину даних з /home/setevoy між ноутбуками, NAS і телефоном (див. FreeBSD: Home NAS, part 12: синхронізація даних з Syncthing)
  • Rsync: основна “робоча лошадка” для копіювання даних між хостами – збирає з Linux, Raspberry PI, DigitalOcean, та з самого NAS/FreeBSD
  • Rclone: займається синхронізацією даних в клауди

rclone робить sync в Google Drive та Backblaze з опцією --backup-dir – тому навіть якщо Syncthing щось наламає і видалить, а потім ці зміни синхронізуються в клауд – то все одно залишаться копії видалених даних.

І плюс в самому Syncthing для всіх shared директорій включена “Trash Can Versioning”.

Загальна схема виглядає так:

Як писав в попередньому пості – є кілька різних “класів даних” які зберігаються в окремих датасетах, і кожен датасет мапиться на власний rclone remote з власними налаштуваннями шифрування.

Якщо спростити схему і відобразити тільки потік даних – то це виглядає так:

Структура каталогів і файлів

Взагалі, в чорнетці був розписаний весь процес створення “утиліти”, але вирішив вже просто описати фінальне рішення (і то вийшло нічого собі тексту).

Всі операції виконуються кількома shell-скриптами, всі потрібні налаштування – описані в конфіг-файлах.

Структура файлів та каталогів:

root@setevoy-nas:~ # tree -L 3 /opt/nas_backup/
/opt/nas_backup/
├── backup.sh
├── config
│   ├── hosts.conf
│   └── rclone-remotes.conf
├── excludes
│   └── global.exclude
├── includes
│   ├── setevoy-nas
│   │   ├── etc.include
│   │   ├── opt.include
│   │   ├── root.include
│   │   ├── user-home-Data.include
│   │   ├── usr-local-bin.include
│   │   ├── usr-local-etc.include
│   │   └── var.include
│   ├── setevoy-pi
│   │   ├── opt.include
│   │   └── system.include
│   ├── setevoy-work
│   │   ├── user-home-Films.include
│   │   ├── user-home-Media.include
│   │   └── user-home-Vault.include
│   └── template.include
├── validate-config.sh
├── vmbackup-backup.sh
└── web-backup.sh

Скрипти тут:

  • backup.sh: основний скрипт, який виконує перевірки, запускає rsync, запускає інші скрипти
  • validate-config.sh: перевіряє синтаксис файлів конфігурації з каталога config/
  • vmbackup-backup.sh: виконує бекап бази VictoriaMetrics з vmbackup
  • web-backup.sh: виконує бекап локального WordPress – файлів та бази даних MariaDB

Про каталог config/ – трохи далі, а каталоги excludes/ та includes/ містять файли для rsync, і для кожного хоста власний каталог з власними налаштуваннями include/exclude.

Тепер трохи про файли і організацію, а потім вже до скриптів.

rsync, include та exclude

Наприклад, файл includes/setevoy-work/user-home-Media.include описує дані, які треба скопіювати з хоста work.setevoy (робочий ноутбук) і каталогу /home/setevoy:

root@setevoy-nas:~ # cat /opt/nas_backup/includes/setevoy-work/user-home-Media.include

# Syncthing:
# - Books/     => /nas/media
# - Documents/ => /nas/media
# - Music/     => /nas/media
# - Photos/    => /nas/media
# - Pictures   => /nas/media
# - Videos     =>/nas/media
# Rsync:
# - Vault/     => /nas/vault/ !
# - Films/     => /nas/private/ !
# - Drobox/    => /nas/media
# - Ops/       => /nas/media
# - Projects/  => /nas/media
# - To-Sort    => /nas/media
# - VMs        => /nas/media
# - Work       => /nas/media
# - Backups/   => /nas/media

############
### ROOT ###
############

/home/
/home/setevoy/

### Backups ###

/home/setevoy/Backups/
/home/setevoy/Backups/**

...

### Work ###

/home/setevoy/Work/

# <COMPANY_NAME>
/home/setevoy/Work/<COMPANY_NAME>/
/home/setevoy/Work/<COMPANY_NAME>/**

..

Файл exclude – один на всіх із загальними шаблонами того, що треба виключити з даних, які включені в include:

root@setevoy-nas:~ # cat /opt/nas_backup/excludes/global.exclude
######################
### Exclude Global ###
######################

# Syncthing
**/.stversions/

**/.git/
**/logs/
**/log/

# Vim temp files
**/*.swp
**/*.swo
**/*.swx
**/.*.sw?

...

Сам rsync запускається з exclude=all, але про це детальніше буде далі, бо там є свої нюанси.

Каталог config та файли з налаштуваннями

Тут два файли: один для rsynchosts.conf, другий, для rclonerclone-remotes.conf.

Файли перевіряються валідатором – validate-config.sh, а потім парсяться основним скриптом backup.sh.

hosts.conf – параметри для rsync

Файл hosts.conf виглядає так:

root@setevoy-nas:~ # cat /opt/nas_backup/config/hosts.conf
##############
### Syntax ###
##############

# hostname|user|include_file|exclude_file|destination|delete=yes/no

# Notes:
# - include/exclude files can be in subdirectories (e.g., 'setevoy-work/user-home-Vault.include')
# - multiple lines for the same host are allowed (different sources to different destinations)
# - destination directories will be created automatically if they don't exist
# - delete field format: delete=yes or delete=no (explicit format required!)
#   - delete=yes: rsync will use --delete-delay --delete-excluded (removes files on destination that don't exist on source)
#   - delete=no: rsync will only copy/update files (no deletion)

# IMPORTANT! For system backups and multiple hosts with same username:
# - Always include hostname/machine identifier in the destination path
# - Example: /nas/systems/work.setevoy/thinkpad-t14-g5/ (not just /nas/systems/)
# - This prevents mixing configs from different machines

#############################
### work.setevoy - laptop ###
#############################

### HOME ###

# Syncthing:
# - Books/
# - Documents/
# - Music/
# - Photos/
# - Pictures
# - Videos
# Rsync:
# - Vault/    => /nas/vault/
# - Films/    => /nas/private/
# - Drobox/   => /nas/media
# - Ops/      => /nas/media
# - Projects/ => /nas/media
# - To-Sort   => /nas/media
# - VMs       => /nas/media
# - Work      => /nas/media

# '/home/setevoy/ALL' => '/nas/media/home/setevoy/ALL/'
work.setevoy|setevoy|setevoy-work/user-home-Media.include|global.exclude|/nas/media/|delete=yes
...

#################################
### pi.setevoy - Raspberry PI ###
#################################

# '/opt/' => '/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/opt/'
pi.setevoy|root|setevoy-pi/opt.include|global.exclude|/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/|delete=yes
...

#############################
### nas.setevoy - FreeBSD ###
#############################

# '/opt/' => '/nas/systems/setevoy-nas/thinkcentre-10SUSCF000/opt/'
nas.setevoy|root|setevoy-nas/opt.include|global.exclude|/nas/systems/setevoy-nas/thinkcentre-10SUSCF000/|delete=yes
...

Власне, в ньому параметри для запуску rsync:

  • ім’я хоста, з якого буде виконуватись бекап
  • ім’я юзера для підключення – бо не всюди один, і до деяких взагалі треба root – коли бекапляться якісь системні файли
  • третім – відносний шлях до файлу include
  • четвертий параметр – exclude, якщо треба задати окремий
  • п’ятий – локальний каталог на самому NAS, в який будуть копіюватись дані (і який буде використовуватись для створення ZFS snapshots)
  • останній параметр – чи включати опцію rsync --delete, якщо треба в бекапах на NAS видаляти дані, які були видалені на source

rclone-remotes.conf – параметри для rclone

Синтаксис rclone-remotes.conf аналогічний:

root@setevoy-nas:~ # cat /opt/nas_backup/config/rclone-remotes.conf
# used for rclone sync only

##############
### Syntax ###
##############

# set as:

# dataset|rclone_remote

# - no leading and closing slashes on the 'dataset'
# - no closing ":" on the rclone_remote

# use commands:
# - rclone listremotes
# - rclone listremotes nas-google-drive-crypted-test
# - rclone config show nas-google-drive-crypted-test:

#############
### Media ###
#############

# Google
nas/media|nas-google-drive-media

# Backblaze
nas/media|nas-backblaze-crypted-media
...

Тут:

  • першим заданий ZFS dataset, з якого будуть копіюватись дані
  • другим – rclone remote config name, в який дані заливаються

Скрипти

Скрипти 4, поділені по функціональності:

  • backup.sh: основний скрипт, головний “control loop” – запускає всі інші скрипти та rsync && rclone
  • validate-config.sh: перевіряє синтаксис файлів конфігурації, про які писав вище
  • vmbackup-backup.sh: запускає vmbackup для VictoriaMetrics
  • web-backup.sh: створює архів файлів мого щоденнику на WordPress та mysqldump його бази

Скрипт backup.sh розглянемо останнім, аби спочатку подивитись на те, що він запускає, а вже потім – як він це запускає.

Скрипт validate-config.sh: перевірка синтаксису config-файлів

Запускається з backup.sh самим першим і виконує такий собі “preflight check”.

В глобальних змінних має два конфіг-файли, які йому треба перевірити.

Перевірки для hosts.conf та rclone-remotes.conf трохи відрізняються, бо у них вочевидь різний зміст:

  • для hosts.conf:
    • перевіряє чи в ньому вказані всі необхідні поля
    • виконує перевірку, що include/exclude файли, задані для хостів, реально існують
    • хост пінгується (якщо ні – просто видає WARNING, а не ERROR)
    • важливо – виконує перевірку синтаксису поля delete=yes/no, бо це найбільш “болюча” опція (хоча ще є zfs destroy :trollface: )
  • для rclone-remotes.conf:
    • перевіряє наявність ZFS dataset
    • перевіряє наявність rclone remote в його конфігу

Цей скрипт ніяких алертів не шле – це виконується в самому backup.sh, якщо валідатор повернув помилку.

Валідація файлу hosts.conf

Перевірка наявності всіх необхідних параметрів доволі проста – маємо файл, читаємо кожен рядок, маємо список полів.

Поля в файлі конфігурації розділені символом “|” – використовуємо його в while IFS='|'.

IFS – це вбудована змінна shell, Internal Field Separator, якій можна перевизначити символ, за яким буде розбиватись зміст строки чи файлу.

Якщо поле пусте – повертаємо помилку:

...
while IFS='|' read -r hostname user include_file exclude_file destination delete_field; do
  LINE_NUM=$((LINE_NUM + 1))

  # Skip comments and empty lines
  case "$hostname" in
    \#*|'') continue ;;
  esac

  echo "Validating line $LINE_NUM: $hostname"

  # Check if all fields are present
  if [ -z "$hostname" ] || [ -z "$user" ] || [ -z "$include_file" ] || [ -z "$exclude_file" ] || [ -z "$destination" ] || [ -z "$delete_field" ]; then
    echo "  ERROR: Missing field(s) in line $LINE_NUM"
    ERRORS=$((ERRORS + 1))
    continue
  fi
...

Перевірка опції delete=yes/no розбита на дві окремі перевірки:

  1. спершу перевіряємо, що опція задана саме як delete=, а не просто “yes” чи просто “delete
  2. потім перевіряємо значення після “=“, має бути або саме “yes“, або “no

Виглядає це так:

...
  # Validate delete field format
  if ! echo "$delete_field" | grep -q '^delete='; then
    echo "  ERROR: Invalid delete field format. Expected 'delete=yes' or 'delete=no', got: $delete_field"
    ERRORS=$((ERRORS + 1))
  else
    delete_value=$(echo "$delete_field" | cut -d'=' -f2)
    if [ "$delete_value" != "yes" ] && [ "$delete_value" != "no" ]; then
      echo "  ERROR: Invalid delete value. Expected 'yes' or 'no', got: $delete_value"
      ERRORS=$((ERRORS + 1))
    fi
  fi
...

Валідація файлу rclone-remotes.conf

Тут аналогічно: читаємо файл, перевіряємо, що отримали саме дві опції, які розділені символом “|“.

Потім перевіряємо ZFS dataset із zfs list "$dataset", і перевіряємо rclone remote з rclone listremotes:

...
  while IFS='|' read -r dataset remote; do
    ...

    # Check if all fields are present
    if [ -z "$dataset" ] || [ -z "$remote" ]; then
      echo "  ERROR: Missing field(s) in rclone config line $RCLONE_LINE_NUM"
      ERRORS=$((ERRORS + 1))
      continue
    fi

    # Check if dataset exists
    if ! zfs list "$dataset" > /dev/null 2>&1; then
      echo "  ERROR: Dataset $dataset does not exist"
      ERRORS=$((ERRORS + 1))
    fi

    # Check if rclone remote exists
    if ! rclone listremotes | grep -q "^${remote}:$"; then
      echo "  ERROR: Rclone remote $remote not found"
      ERRORS=$((ERRORS + 1))
    fi
...

В кінці скрипта рахуємо помилки і виходимо з помилкою, якщо $ERRORS більше нуля:

...

if [ $ERRORS -gt 0 ]; then
  echo "=== Validation FAILED with $ERRORS error(s) ==="
  exit 1
else
  echo "=== Validation PASSED ==="
  exit 0
fi

Скрипт vmbackup-backup.sh

“Під капотом” використовує власну утиліту VictoriaMetrcis vmbackup. Єдиний мінус утиліти – поки що не підтримує VictoriaLogs, але PR є, скоро, мабуть, додадуть.

Хоча особисто мені бекап логів і непотрібний, а от бекап бази – треба, бо в мене там дані мого “Self Monitoring Project”, де я записую дані по тому, як спав, який настрій – і ці дані записую з 2023 року, втратити їх не хочеться.

Скрипт виконує два типи бекапів – інкрементальний по буднях, і повний – в неділю, плюс видаляє старі бекапи.

На відміну від валідатора – тут вже свій обробник алертів, який шле нотифікації на ntfy.sh.

ntfy.sh – дуже класний сервіс для таких випадків, дуже простий, і, сподіваюсь, я таки запущу self hosted версію і напишу про нього окремо.

Для алертів в скрипті описана окрема функція, яка просто з curl шле POST-запит до сервісу:

...
# Alerts configuration (same as backup.sh)
NTFY_TOPIC="my-alerts"
NTFY_URL="https://ntfy.sh/$NTFY_TOPIC"
NTFY_TOKEN_FILE="/root/ntfy.token"
HOSTNAME=$(hostname)

...

NTFY_TOKEN=$(cat "$NTFY_TOKEN_FILE" | tr -d '\n')

send_alert() {
  TITLE="$1"
  MESSAGE="$2"
  TAGS="${3:-warning,backup}"

  curl -s \
    -H "Authorization: Bearer $NTFY_TOKEN" \
    -H "Title: $TITLE" \
    -H "Tags: $TAGS" \
    -d "$MESSAGE" \
    "$NTFY_URL" >/dev/null
}
...

В параметрах vmbackup задаються дві опції:

...
# VictoriaMetrics settings
VM_DATA_PATH="/var/db/victoria-metrics"
VM_SNAPSHOT_URL="http://localhost:8428/snapshot/create"
...

VM_DATA_PATH використовується для того, щоб, власне, скопіювати дані, а через ендпоінт VM_SNAPSHOT_URLvmbackup передає команду до VictoriaMetrics на “заморозку” операцій, аби створити консистентний snapshot.

Запуск самих бекапів і відправка алертів виглядають так:

...
vmbackup \
  -storageDataPath="$VM_DATA_PATH" \
  -snapshot.createURL="$VM_SNAPSHOT_URL" \
  -dst="fs://$BACKUP_BASE/latest" >> "$LOGFILE" 2>&1
INCREMENTAL_EXIT=$?

if [ $INCREMENTAL_EXIT -ne 0 ]; then
  echo "ERROR: Daily incremental backup failed with exit code $INCREMENTAL_EXIT" | tee -a "$LOGFILE"
  send_alert "VMBackup: Incremental backup failed" "❌ VictoriaMetrics incremental backup failed on $HOSTNAME
Exit code: $INCREMENTAL_EXIT
Log: $LOGFILE"
  FAILED=$((FAILED + 1))
else
  echo "Daily incremental backup completed successfully" | tee -a "$LOGFILE"
fi
...

В результаті є кілька директорій – latest для інкрементальних бекапів, та <DATE> для weekly:

root@setevoy-nas:~ # tree -d -L 2 /nas/services/victoriametrics/
/nas/services/victoriametrics/
├── 20260222
│   ├── data
│   ├── indexdb
│   └── metadata
├── 20260301
│   ├── data
│   ├── indexdb
│   └── metadata
└── latest
    ├── data
    ├── indexdb
    └── metadata

Видалення старих бекапів виконується з find, як і в інших скриптах.

В цьому прикладі спеціально залишаю першу, тестову версію – без реального rm -rf:

...

# Calculate cutoff date (RETENTION_WEEKS weeks ago, in YYYYMMDD format)
CUTOFF=$(date -v-${RETENTION_WEEKS}w +%Y%m%d 2>/dev/null || date -d "${RETENTION_WEEKS} weeks ago" +%Y%m%d)

find "$BACKUP_BASE" -maxdepth 1 -type d -name '[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]' | while read dir; do
  DIR_DATE=$(basename "$dir")
  if [ "$DIR_DATE" -lt "$CUTOFF" ]; then
    echo "Deleting old weekly backup: $dir" | tee -a "$LOGFILE"
    # TODO: uncomment when tested
    #rm -rf "$dir"
    echo "[DRY-RUN] would delete: $dir"
  fi
done

...

Скрипт web-backup.sh

Тут задача – створити архів файлів та зробити дамп бази даних.

Дуже простий, бекапить тільки один сайт, але мені поки більше і не треба.

Також має власний алертинг.

Бекап файлів створюється з tar:

...

SITE_DIR="/usr/local/www/blog.setevoy"
DB_NAME="nas_blog_setevoy_production_db"
DB_CREDENTIALS="/root/.my.cnf.blog-setevoy"
FILES_DEST="$BACKUP_BASE/setevoy/files/${DATE}-blog-setevoy.tar.gz"
DB_DEST="$BACKUP_BASE/setevoy/databases/${DATE}-blog-setevoy.sql"

# Backup files
echo "Archiving files: $SITE_DIR -> $FILES_DEST" | tee -a "$LOGFILE"
tar -czf "$FILES_DEST" --exclude="$SITE_DIR/wp-content/updraft" "$SITE_DIR" >> "$LOGFILE" 2>&1
TAR_EXIT=$?
if [ $TAR_EXIT -ne 0 ]; then
  echo "ERROR: Failed to archive blog.setevoy files" | tee -a "$LOGFILE"
  send_alert "Web Backup: Failed" "❌ Failed to archive blog.setevoy files on $HOSTNAME
Log: $LOGFILE"
  FAILED=$((FAILED + 1))
else
  echo "Files archived successfully" | tee -a "$LOGFILE"
fi

...

А база MariaDB – з mysqldump:

...

DB_CREDENTIALS="/root/.my.cnf.blog-setevoy"

...
# Backup database
echo "Dumping database: mysqldump --defaults-file="$DB_CREDENTIALS" "$DB_NAME" > "$DB_DEST" 2>> "$LOGFILE""
mysqldump --defaults-file="$DB_CREDENTIALS" "$DB_NAME" > "$DB_DEST" 2>> "$LOGFILE"
DB_EXIT=$?

if [ $DB_EXIT -ne 0 ]; then
  echo "ERROR: Failed to dump database $DB_NAME" | tee -a "$LOGFILE"
  send_alert "Web Backup: Failed" "❌ Failed to dump database $DB_NAME on $HOSTNAME
Log: $LOGFILE"
  FAILED=$((FAILED + 1))
else
  echo "Database dumped successfully" | tee -a "$LOGFILE"
fi

...

Тут mysqldump без додаткових опцій, бо це чисто мій власний щоденник, де окрім мене нікого не буває.

Але взагалі варто мати на увазі такі параметри:

  • --single-transaction: тільки для InnoDB – виконати дамп одною транзакцією без блокування таблиць, бо це може заафектити юзерів
  • --routines та --triggers: бекапити процедури і тригери – це не WordPress case, але можуть бути корисним
  • --add-drop-table: дефолтне значення true, додає в sql-дампі DROP TABLE IF EXISTS перед кожним CREATE TABLE – спрощує відновлення в існуючу базу даних

В скрипті використовується опція --defaults-file, через яку передається шлях до файлу з юзером та паролем:

root@setevoy-nas:~ # cat /root/.my.cnf.blog-setevoy
[mysqldump]
user=mysql-username
password="mysql-password"
host=localhost

Видалення старих бекапів – аналогічно до попереднього скрипту, просто з find:

...

find "$BACKUP_BASE" -type f \( -name "*.tar.gz" -o -name "*.sql" \) -mtime "+$RETENTION_DAYS" | while read f; do
  echo "Deleting old backup: $f" | tee -a "$LOGFILE"
  # TODO: uncomment when tested
  #rm -f "$f"
  ls -l "$f"
done

...

І самі бекапи виглядають так:

root@setevoy-nas:/opt/nas_backup # tree -L 3 /nas/services/web/
/nas/services/web/
└── setevoy
    ├── databases
        ...
    │   ├── 2026-03-05-03-00-blog-setevoy.sql
    │   ├── 2026-03-06-03-00-blog-setevoy.sql
    │   └── 2026-03-07-03-00-blog-setevoy.sql
    └── files
        ...
        ├── 2026-03-05-03-00-blog-setevoy.tar.gz
        ├── 2026-03-06-03-00-blog-setevoy.tar.gz
        └── 2026-03-07-03-00-blog-setevoy.tar.gz

Скрипт backup.sh

Ну і, нарешті, основний скрипт backup.sh, який, власне, і займається “оркестрацією” всього процесу.

Тут по черзі виконуються всі необхідні дії і запускаються скрипти, про які говорили вище.

Логіка виконання

  1. створюємо lock-файл: корисно, якщо попередній запуск скрипта завис – щоб не запустити одночасно два процеси виконання
  2. скриптом validate-config.sh виконується перевірка файлів hosts.conf та rclone-remotes.conf
  3. по черзі запускаємо скрипти бекапів:
    1. з web-backup.sh – бекапиться WordPress
    2. з vmbackup-backup.sh – бекапиться VictoriaMetrics
  4. далі читаємо конфіг hosts.conf для rsync, для кожного хоста визначаємо потрібні параметри, і в циклі для кожного хоста:
    1. виконуємо rsync – спершу з --dry-run, потім вже реальний запуск
    2. якщо rsync виконався без помилок – то створюємо ZFS снапшот
  5. вже не в циклах – видаляємо старі ZFS снапшоти
  6. читаємо конфіг rclone-remotes.conf для rclone
    1. в циклі запускаємо rclone sync для кожного заданого в конфігу ZFS dataset та відповідного rclone remote
  7. і в кінці з ntfy.sh відправляємо результат виконання

Step 1: створення lock file

...
LOCKFILE="/var/run/nas-backup.lock"
...

# Check if another instance is running
if [ -f "$LOCKFILE" ]; then
  echo "ERROR: Another backup is already running (lock file exists: $LOCKFILE)" | tee -a "$LOGFILE"
  send_alert "NAS Backup: Already running" "⚠️ Another backup instance is already running on $HOSTNAME
Lock file: $LOCKFILE"
  exit 1
fi

# Create lock file
echo $$ > "$LOCKFILE"

# Remove lock file on exit
trap 'echo ""; echo "Caught interrupt, cleaning up..."; kill $(jobs -p) 2>/dev/null; rm -f $LOCKFILE; exit 130' INT TERM
trap 'rm -f $LOCKFILE' EXIT

..

Тут:

  • перевіряємо, що файлу зараз нема – тобто попередній запуск скрипта вже завершено
  • створюємо файл /var/run/nas-backup.lock, з $$ в файл записуємо PID процесу
  • запускаємо trap, який перехопить Ctrl+C (Interrupt) або SIGTERM і видалить lock file

Step 2: запуск валідатора validate-config.sh

Тут все просто – після створення lock file запускаємо validate-config.sh, з if перевіряємо його код виконання:

...

# Run validator first
echo "Running configuration validator..." | tee -a "$LOGFILE"
if ! /opt/nas_backup/validate-config.sh >> "$LOGFILE" 2>&1; then
  echo "ERROR: Configuration validation failed" | tee -a "$LOGFILE"
  send_alert "NAS Backup: Config validation failed" "❌ Config validation failed on $HOSTNAME
Script: backup.sh
Log: $LOGFILE"
  exit 1
fi

echo "" | tee -a "$LOGFILE"

...

Steps 3 та 4: запуск бекапів Web та VictoriaMetrics

Аналогічно – запускаються з if:

...

echo "=== Starting web backups ===" | tee -a "$LOGFILE"
if ! /opt/nas_backup/web-backup.sh >> "$LOGFILE" 2>&1; then
  echo "WARNING: web_backup.sh failed, continuing..." | tee -a "$LOGFILE"
  send_alert "NAS Backup: Web backup failed" "⚠️ web_backup.sh failed on $HOSTNAME, continuing with rsync\nLog: $LOGFILE"
  exit 1
fi

echo "" | tee -a "$LOGFILE"

# Step 2: VictoriaMetrics backup
echo "=== Starting VictoriaMetrics backup ===" | tee -a "$LOGFILE"
if ! /opt/nas_backup/vmbackup-backup.sh >> "$LOGFILE" 2>&1; then
  echo "WARNING: vmbackup-backup.sh failed, continuing..." | tee -a "$LOGFILE"
  send_alert "NAS Backup: VMBackup failed" "⚠️ vmbackup-backup.sh failed on $HOSTNAME, continuing with rsync\nLog: $LOGFILE"
fi

echo "" | tee -a "$LOGFILE"

...

Step 5: запуск циклу з hosts.conf

Сама, мабуть, важлива і цікава частина – тут починається процес збору даних з усіх хостів, які задані в hosts.conf.

Спершу в циклі читається файл конфігу, заповнюються всі “локальні” змінні:

...
while IFS='|' read -r hostname user include_file exclude_file destination delete_field; do
  # Skip comments and empty lines
  case "$hostname" in
    \#*|'') continue ;;
  esac

  # Parse delete option
  delete_value=$(echo "$delete_field" | cut -d'=' -f2)
...

Далі хост пінгується, і, якщо ping не пройшов – то цикл while переходить до наступного рядка з файлу конфігурації.

Реалізовано це за допомогою оператора continue, який є в тому числі в “\#*|'') continue ;;“: якщо в hosts.conf строка – це коментар, то скіпаємо її і переходимо до наступної.

Аналогічно continue використовується для ping:

...

  # Check if host is reachable
  if ! ping -c 3 "$hostname" > /dev/null 2>&1; then
    echo "WARNING: Host $hostname is not reachable, skipping" | tee -a "$LOGFILE"
    send_alert "NAS Backup: Host unreachable" "⚠️ Host $hostname is not reachable on $HOSTNAME
Skipping backup
Log: $LOGFILE"
    echo "" | tee -a "$LOGFILE"
    continue
  fi

...

Тут – якщо ping повернув не success – то шлемо алерт і через continue переходимо до наступного хоста.

Аналогічно в наступній дії – перевіряється локальна директорія, в яку будуть копіюватись дані, якщо її нема – вона створюється, якщо створення завершилось з помилкою – то переходимо до наступного хоста:

...

  # Create destination directory if it doesn't exist
  if [ ! -d "$destination" ]; then
    echo "Creating destination directory: $destination" | tee -a "$LOGFILE"
    mkdir -p "$destination" >> "$LOGFILE" 2>&1
    if [ $? -ne 0 ]; then
      echo "ERROR: Failed to create destination directory" | tee -a "$LOGFILE"
      send_alert "NAS Backup: Failed to create destination" "❌ Failed to create destination directory on $HOSTNAME
Host: $hostname
Destination: $destination
Log: $LOGFILE"
      echo "" | tee -a "$LOGFILE"
      continue
    fi
  fi

...

Step 6: запуск rsync

Після цього, власне, починається процес копіювання даних з кожного хоста – і тут є кілька підводних каменів.

rsync та опції –delete

Дуже важлива – бо небезпечна – опція: чи видаляти на NAS дані, які були видалені на source.

В hosts.conf вона задається в кінці строки:

...
work.setevoy|setevoy|setevoy-work/user-home-Media.include|global.exclude|/nas/media/|delete=yes
...

В самому backup.sh перевіряється її значення і, якщо delete=yes – то в змінну $RSYNC_DELETE_OPTS задається значення --delete-delay:

...

  # Build rsync options based on delete setting
  # default is empty, i.e. no delete
  # IMPORTANT: DON NOT SET '--delete-excluded' if using multiply .includes: `rsync` is running with the `--exclude='*'` and will wipe all other data
  RSYNC_DELETE_OPTS=""
  if [ "$delete_value" = "yes" ]; then
    RSYNC_DELETE_OPTS="--delete-delay"
  fi

...

Тут в коментарі до перевірки записав, і ще раз підкреслю окремо – бо я з цим трохи мав проблему:

  • rsync запускається  опцією --exclude='*' (про це трохи далі)
  • якщо в $RSYNC_DELETE_OPTS вказати --delete-excluded – то, відповідно, rsync на NAS почне видаляти всі дані, які явно не задані в include-файлі

А так як файли include можуть бути різними для різних даних на source, але при цьому на destination – тобто самому NAS, каталог може бути єдиним – то rsync при кожній ітерації видалить дані іншої строки з конфігу.

Ось приклад з Raspberry PI:

...
# '/opt/' => '/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/opt/'
pi.setevoy|root|setevoy-pi/opt.include|global.exclude|/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/|delete=yes

# '/etc/systemd/' => '/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/etc/'
pi.setevoy|root|setevoy-pi/system.include|global.exclude|/nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/|delete=yes
...

І тут rsync:

  • візьме все, що дозволено в opt.include
  • скопіює в /nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/
  • перейде до наступної строки, візьме все з system.include
  • почне копіювати в /nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/ – і видалить звідти те, що скопіював при запуску з opt.include

rsync та –exclude=’*’

Чому rsync запускається з --exclude='*'?

Бо, по-перше, особисто я віддаю перевагу підходу “заборонити все і копіювати тільки те, що дозволено явно“.

По-друге – простіший конфіг hosts.conf та сам скрипт backup.sh – достатньо передати тільки ім’я хоста, а далі rsync рекурсивно від / файлової системи проходиться по каталогам які явно дозволені в include-файлі, і копіює дані тільки з них.

Без exclude='*' довелось би або додавати виключення в файл global.exclude, або в include через “-“.

Виконання та опції rsync

Власне запуск самого rsync виглядає так – спочатку --dry-run, потім “running real backup” – те саме, тільки без --dry-run:

...

  RSYNC_DELETE_OPTS=""
  if [ "$delete_value" = "yes" ]; then
    RSYNC_DELETE_OPTS="--delete-delay"
  fi

  echo "Rsync command: rsync -avh $RSYNC_DELETE_OPTS --prune-empty-dirs --itemize-changes --progress --exclude-from=$EXCLUDES_DIR/$exclude_file --include-from=$INCLUDES_DIR/$include_file --exclude='*' $user@$hostname:/ $destination" | tee -a "$LOGFILE"
  echo "" | tee -a "$LOGFILE"

  # Run rsync with dry-run first
  echo "Running dry-run..." | tee -a "$LOGFILE"
  rsync -avh \
    --dry-run \
    $RSYNC_DELETE_OPTS \
    --prune-empty-dirs \
    --itemize-changes \
    --progress \
    --exclude-from="$EXCLUDES_DIR/$exclude_file" \
    --include-from="$INCLUDES_DIR/$include_file" \
    --exclude='*' \
    "$user@$hostname:/" "$destination" >> "$LOGFILE" 2>&1

  EXIT_CODE=$?

  if [ $EXIT_CODE -ne 0 ]; then
    echo "=== Dry-run FAILED with exit code $EXIT_CODE, skipping real backup ===" | tee -a "$LOGFILE"
    send_alert "NAS Backup: Dry-run failed" "❌ Rsync dry-run failed on $HOSTNAME
Host: $hostname
Exit code: $EXIT_CODE
Log: $LOGFILE"
    BACKUP_FAILED=$((BACKUP_FAILED + 1))
    echo "" | tee -a "$LOGFILE"
    continue
  fi

  echo "Dry-run successful, running real backup..." | tee -a "$LOGFILE"

  # Run REAL rsync
  rsync -avh \
    $RSYNC_DELETE_OPTS \
    --prune-empty-dirs \
    --itemize-changes \
    --progress \
    --exclude-from="$EXCLUDES_DIR/$exclude_file" \
    --include-from="$INCLUDES_DIR/$include_file" \
    --exclude='*' \
    "$user@$hostname:/" "$destination" >> "$LOGFILE" 2>&1

  EXIT_CODE=$?

...

З корисних опцій тут:

  • -a (archive): зберігає права, власника, сімлінки, timestamps
  • -v (verbose): виводить в лог інформацію що саме виконується
  • -h (human): відображати розмір як 1G замість байт
  • --delete-delay: видаляти дані по завершенню передачі даних, а не в процесі
  • --prune-empty-dirs: якщо на source каталог пустий – не копіювати його
  • --itemize-changes: детальна інформація в лог що саме змінилось в файлі, які перезаписуються/видаляються
  • --progress: показує прогрес передачі кожного файлу

Порядок передачі опцій –include-from та –exclude-from

І окремо про exclude та include.

Має значення в якому порядку параметри передаються до rsync:

  • першим йде --exclude-from – аби rsync перед запуском копіювання вже “знав” що треба пропускати
  • далі через --include-from передаємо список каталогів та файлів, які дозволено прочитати та скопіювати
  • і останнім з --exclude='*' виключаємо  бекапу все, що явно не задано в --include-from

Формат файлів –include-from та –exclude-from

Exclude-файл поки що один глобальний:

######################
### Exclude Global ###
######################

# Syncthing
**/.stversions/

**/.git/
**/logs/
**/log/

# Vim temp files
**/*.swp
**/*.swo
**/*.swx
**/.*.sw?

**/node_modules/

# Python
**/.venv/
**/venv/
**/__pycache__/

...

Тут через “**” вказуємо “без різниці, де саме цей файл чи каталог буде знайдено“, тобто виключаємо і /root/some-dir/.git/ – і /home/setevoy/some-dir/.git/.

Приклад одного з include-файлів – тут трохи цікавіше:

############
### ROOT ###
############

/home/
/home/setevoy/

### Books ###

/home/setevoy/Books/
/home/setevoy/Books/**

### Backups ###

/home/setevoy/Backups/
/home/setevoy/Backups/**

### Downloads ###

/home/setevoy/Downloads/
/home/setevoy/Downloads/Books/
/home/setevoy/Downloads/Books/**

...

Так як rsync запускається з --exclude='*' – то в include йому треба явно дозволити “зайти” в корневий каталог.

Тобто, при виконані rsync -avh [email protected]:/rsync зайде в корінь, “/“, потім – маючи /home/ в include-from – зможе “заглянути” в /home/, а далі вже завітати до /home/setevoy/.

І далі аналогічно дозволяємо доступ в /home/setevoy/Books/, де з “**” вказуємо “взяти тут все, що знайдеш” – окрім того, що було задано в exclude-file.

При цьому дані з, наприклад, каталогу /home/setevoy/Bob/rsync пропустить, бо не має явного дозволу їх читати і копіювати.

Step 7: створення ZFS snapshots

Після того як rsync для хоста завершився без помилок – запускається наступний if/else:

...

  EXIT_CODE=$?

  if [ $EXIT_CODE -eq 0 ]; then
    echo ""
    echo "=== Backup from $hostname completed successfully ===" | tee -a "$LOGFILE"
    BACKUP_SUCCESS=$((BACKUP_SUCCESS + 1))

    # Create ZFS snapshot
    SNAPSHOT_NAME="nas-backup-$(date +%Y-%m-%d-%H-%M-%S)"

    # Get dataset name from destination path
    DATASET=$(zfs list -H -o name "$destination" 2>/dev/null | head -1)

    if [ -z "$DATASET" ]; then
      echo "ERROR: Could not determine ZFS dataset for $destination" | tee -a "$LOGFILE"
      send_alert "NAS Backup: Snapshot failed" "❌ Could not determine ZFS dataset on $HOSTNAME
      ...
    else
      echo ""
      echo "Creating ZFS snapshot: $DATASET@$SNAPSHOT_NAME" | tee -a "$LOGFILE"

      zfs snapshot "$DATASET@$SNAPSHOT_NAME" >> "$LOGFILE" 2>&1

      if [ $? -eq 0 ]; then
        echo "ZFS snapshot created successfully" | tee -a "$LOGFILE"
      else
        echo "ERROR: Failed to create ZFS snapshot" | tee -a "$LOGFILE"
        send_alert "NAS Backup: Snapshot failed" "❌ Failed to create ZFS snapshot on $HOSTNAME
        ...
      fi
    fi

..

В BACKUP_SUCCESS=$((BACKUP_SUCCESS + 1)) просто інкрементиться значення, яке використовується виключно для фінального повідомлення через ntfy.sh.

Далі формуємо ім’я снапшоту, і в змінну $DATASET записуємо ім’я датасету.

Для цього беремо параметр $destination, який в hosts.conf заданий як повний шлях – /nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/, а потім із zfs list отримуємо mountpoint:

root@setevoy-nas:/opt/nas_backup # zfs list -H -o name /nas/systems/setevoy-pi/raspberry-pi-cm4-rev11/
nas/systems

А потім викликаємо zfs snapshot для, власне, створення снапшоту.

Step 8: видалення старих ZFS snapshots

Тут теж потенційно небезпечна операція, бо викликається zfs destroy – яка може дропнути повний ZFS dataset:

...

CUTOFF_DATE=$(date -v-${SNAPSHOT_RETENTION_DAYS}d +%Y-%m-%d 2>/dev/null || date -d "${SNAPSHOT_RETENTION_DAYS} days ago" +%Y-%m-%d)

zfs list -H -t snapshot -o name | grep '@nas-backup-' | while read snapshot; do
  SNAP_DATE=$(echo "$snapshot" | sed 's/.*@nas-backup-\([0-9-]*\)-.*/\1/')

  if [ "$SNAP_DATE" \< "$CUTOFF_DATE" ]; then
    echo "Deleting old snapshot: $snapshot" | tee -a "$LOGFILE"
    zfs destroy "$snapshot" >> "$LOGFILE" 2>&1
  fi
done

...

Як працює:

  • в змінну $CUTOFF_DATE вносимо дату “сьогодні мінус 30 днів” – бо $SNAPSHOT_RETENTION_DAYS заданий в 30.
  • із zfs list -H -t snapshot -o name відображаємо список всіх наявних снапшотів і вибираємо тільки ті, які робились цим скриптом – grep '@nas-backup-'
  • потім в циклі для кожного снапшоту із zfs list -t snapshot отримуємо дату, коли цей снапшот був створений, записуємо в змінну $SNAP_DATE
  • порівнюємо $SNAP_DATE та $CUTOFF_DATE
  • і, якщо $SNAP_DATE старша за $CUTOFF_DATE – то виконуємо zfs destroy

Step 9: запуск rclone

Тут в цілому підхід аналогічний – читаємо кожну строку з конфігу:

...

while IFS='|' read -r dataset remote; do

...

В $dataset записуємо ім’я ZFS dataset, в $remote – ім’я rclone remote.

Ще раз приклад конфігу:

# used for rclone sync only

##############
### Syntax ###
##############

# set as:

# dataset|rclone_remote

# - no leading and closing slashes on the 'dataset'
# - no closing ":" on the rclone_remote

# use commands:
# - rclone listremotes
# - rclone listremotes nas-google-drive-crypted-test
# - rclone config show nas-google-drive-crypted-test:

#############
### Media ###
#############

# Google
nas/media|nas-google-drive-media

# Backblaze
nas/media|nas-backblaze-crypted-media

...

Тобто – беремо dataset nas/media – і копіюємо його зміст до nas-google-drive-media, а потім його ж – але до nas-backblaze-crypted-media.

Приклад rclone remote для Backblaze:

root@setevoy-nas:/opt/nas_backup # rclone config show nas-backblaze-crypted-media
[nas-backblaze-crypted-media]
type = crypt
remote = nas-backblaze-root-media:setevoy-nas-media
filename_encryption = off
directory_name_encryption = false
password = *** ENCRYPTED ***

Весь цикл виглядає так:

...

RCLONE_CONF="/opt/nas_backup/config/rclone-remotes.conf"

if [ ! -f "$RCLONE_CONF" ]; then
  echo "WARNING: rclone config not found at $RCLONE_CONF, skipping cloud sync" | tee -a "$LOGFILE"
else
  TS=$(date +%F-%H-%M)

  while IFS='|' read -r dataset remote; do
    # Skip comments and empty lines
    case "$dataset" in
      \#*|'') continue ;;
    esac

    echo "Syncing dataset $dataset to $remote" | tee -a "$LOGFILE"

    # Get mount point for dataset
    MOUNT_POINT=$(zfs get -H -o value mountpoint "$dataset" 2>/dev/null)

    if [ -z "$MOUNT_POINT" ] || [ "$MOUNT_POINT" = "-" ]; then
      echo "ERROR: Could not get mount point for dataset $dataset" | tee -a "$LOGFILE"
      RCLONE_FAILED=$((RCLONE_FAILED + 1))
      continue
    fi

    ...

    rclone sync "$MOUNT_POINT/" "${remote}:data" \
      --backup-dir "${remote}:_archive/$TS" \
      --progress \
      --stats=30s \
      --log-level INFO >> "$LOGFILE" 2>&1

    EXIT_CODE=$?

    if [ $EXIT_CODE -eq 0 ]; then
      echo "Rclone sync for $dataset completed successfully" | tee -a "$LOGFILE"
      RCLONE_SUCCESS=$((RCLONE_SUCCESS + 1))
    else
      echo "ERROR: Rclone sync for $dataset failed with exit code $EXIT_CODE" | tee -a "$LOGFILE"
      send_alert "NAS Backup: Rclone sync failed" "❌ Rclone sync failed on $HOSTNAME
      ...
      RCLONE_FAILED=$((RCLONE_FAILED + 1))
    fi

    echo "" | tee -a "$LOGFILE"

  done < "$RCLONE_CONF"

...

Сам rclone sync виконує саме синхронізацію: якщо на NAS файл або каталог був видалений – то він видалиться і на rclone remote.

Тому, для більш спокійного сну, rclone запускається з --backup-dir, куди копіює дані, які під час виконання sync видаляються або змінюються.

Як це виглядає на remote:

root@setevoy-nas:/home/setevoy # rclone tree --dirs-only --level 4 nas-backblaze-crypted-media:
/
├── _archive
...
│   ├── 2026-03-05-03-07
│   │   └── home
│   │       └── setevoy
│   └── 2026-03-07-03-07
│       └── home
│           └── setevoy
└── data
    └── home
        └── setevoy
            ├── Backups
            ├── Books
            ...
            ├── Videos
            └── Work

Ну і, власне, на цьому все. Останнім виконується відправка повідомлення про те, як пройшов бекап:

...

# Send summary
if [ $BACKUP_FAILED -eq 0 ] && [ $RCLONE_FAILED -eq 0 ]; then
  send_alert "NAS Backup: Completed successfully" "✅ All backups completed successfully on $HOSTNAME
Rsync successful: $BACKUP_SUCCESS
Rsync failed: $BACKUP_FAILED
Rclone successful: $RCLONE_SUCCESS
Rclone failed: $RCLONE_FAILED
Log: $LOGFILE" "white_check_mark,backup"
else
  send_alert "NAS Backup: Completed with errors" "⚠️ Backups completed with errors on $HOSTNAME
Rsync successful: $BACKUP_SUCCESS
Rsync failed: $BACKUP_FAILED
Rclone successful: $RCLONE_SUCCESS
Rclone failed: $RCLONE_FAILED
Log: $LOGFILE"
fi

Запускається скрипт з crontab:

root@setevoy-nas:~ # crontab -l
...
0 3 * * * /opt/nas_backup/backup.sh

Приклад результату виконання

Як це все щастя виглядає в лог-файлі та повідомлення ntfy.sh.

Початок – робота валідатора:

Завершення – виконання rclone sync:

Повідомлення в ntfy.sh:

І на телефоні:

Що можна покращити

Скрипт(и), звісно, не ідеальні, і можна було б зробити ще:

  • запуск rsync та rclone для загальної картини можна винести окремим скриптами, як це зроблено для validate-config.sh та vmbackup-backup.sh
  • зараз весь цикл виконання виконується без можливості вказати “зроби мені тільки web” або “зроби мені тільки rclone” – можна було б додати getopt чи getopts, парсити аргументи, з якими запускається скрипт та вибирати, що саме виконувати
  • додати в аргументи можливість окремого запуску rsync чисто з --dry-run
  • для rclone зараз не використовується --ignore-from – можна було б додати
  • ну і “вішенка на торті” – писати метрики в VictoriaMetrics про те, скільки байт передано, скільки місця на диску було витрачено або звільнено – щось таке

Все.

Поки працює, як є – вже кілька тижнів, поки що без проблем.

Loading

FreeBSD: Home NAS, part 14 – логи з VictoriaLogs і алерти з VMAlert
0 (0)

28 Лютого 2026

Продовження серії по налаштуванню домашнього NAS.

Моніторинг в цілому вже налаштований в попередніх частинах, але залишилось налаштувати роботу з логами – бо робити це в консолі з tail -f /var/log/messages, звісно, можна – але є і більш зручні інструменти.

Використаємо VictoriaLogs – тим більш для метрик на моїй FreeBSD вже є стек VictoriaMetrics + VMAlert + Alertmanager.

Всі частини серії по налаштуванню домашнього NAS на FreeBSD:

Установка VictoriaLogs

Є в репозиторії, просто встановлюємо з pkg:

root@setevoy-nas:~ # pkg install -y victoria-logs

Глянемо, які файлу додасть в систему:

root@setevoy-nas:~ # pkg info -l victoria-logs
victoria-logs-1.43.1_2:
  /usr/local/bin/victoria-logs
  /usr/local/bin/vlogscli
  /usr/local/etc/rc.d/victoria-logs
...

Глянемо, що в скрипті rc.d:

root@setevoy-nas:~ # cat /usr/local/etc/rc.d/victoria-logs
...
rcvar="victoria_logs_enable"
...
victoria_logs_user="victoria-logs"
...

Додаємо до /etc/rc.conf:

root@setevoy-nas:~ # sysrc victoria_logs_enable="YES"
victoria_logs_enable:  -> YES

Запускаємо сервіс:

root@setevoy-nas:~ # service victoria-logs start

Перевіряємо порт:

root@setevoy-nas:~ # sockstat -4 -l | grep logs
victoria-logs victoria-l 33088 5  tcp4 *:9428              *:*

Відкриваємо в браузері на порту 9428:

Поки тут пусто – додаємо збір логів.

Установка Fluent Bit

Хотів взяти Vector.dev – але його нема в репозиторії і портах FreeBSD, і нема навіть в списку підтримуваних систем.

Є відкрита GitHub issue – ще у 2020 році.

Що є з інших рішень:

  • Promtail: від Grafana – не хочу, і вони його наче вже депрікейтять
  • Filebeat: від Elastic, на Go – але пам’ятаю, що трохи важкуватий по ресурсам
  • Fluent Bit: на C, швидкий, легкий, хоча конфіг може показатись незручним
  • Logstash: Java – nuff said
  • Rsyslog: ну от де конфіг справді незручний, тому ні (див. rsyslog: добавление наблюдения за файлом в конфигурацию – 2014 рік)

Отже, візьмемо Fluent Bit.

Глянемо, чи є в репозиторії:

root@setevoy-nas:~ # pkg search fluent
fluent-bit-4.2.2_2             Fast and lightweight data forwarder

Встановлюємо:

root@setevoy-nas:~ # pkg install -y fluent-bit

Перевіряємо, які файли додає в систему:

root@setevoy-nas:~ # pkg info -l fluent-bit | grep etc
  /usr/local/etc/fluent-bit/fluent-bit.conf.sample
  /usr/local/etc/fluent-bit/parsers.conf.sample
  /usr/local/etc/fluent-bit/plugins.conf
  /usr/local/etc/rc.d/fluent-bit

Дефолтний конфіг /usr/local/etc/fluent-bit/fluent-bit.conf.

Перевіряємо, як запускається:

root@setevoy-nas:~ # cat /usr/local/etc/rc.d/fluent-bit
...
# fluent_bit_enable (bool):	Set to YES to enable fluent-bit
# 				Default: NO
# fluent_bit_config (str):	config files to use
#				Default: /usr/local/etc/fluent-bit/fluent-bit.conf
# fluent_bit_flags (str):	Extra flags passed to fluent-bit
# fluent_bit_user (str):	Default run as user nobody
# fluent_bit_group (str):	Default run as group nogroup
...

: ${fluent_bit_enable:="NO"}
: ${fluent_bit_user:="nobody"}
: ${fluent_bit_group:="nogroup"}
: ${fluent_bit_config:="/usr/local/etc/fluent-bit/fluent-bit.conf"}

pidfile=/var/run/${name}.pid
procname="/usr/local/bin/fluent-bit"
command="/usr/sbin/daemon"
command_args="-H -p ${pidfile} -o /var/log/${name}/${name}.log -t ${name} ${procname} --quiet --config ${fluent_bit_config} ${fluent_bit_flags}"
...

Що нам треба буде – додати fluent_bit_enable в /etc/rc.conf. І звертаємо увагу на fluent_bit_user та fluent_bit_group.

Створюємо каталог для його бази – fluent-bit буде сюди записувати позиції в файлах логів:

root@setevoy-nas:~ # mkdir -p /var/db/fluent-bit
root@setevoy-nas:~ # chown nobody:nogroup /var/db/fluent-bit/

Видаляємо (переносимо) дефолтний конфіг:

root@setevoy-nas:~ # mv /usr/local/etc/fluent-bit/fluent-bit.conf /usr/local/etc/fluent-bit/fluent-bit.conf-default

Пишемо свій файл /usr/local/etc/fluent-bit/fluent-bit.conf, поки додаємо збір тільки /var/log/messages:

[SERVICE]
    flush        5
    daemon       Off
    log_level    info
    parsers_file parsers.conf
    plugins_file plugins.conf

[INPUT]
    name        tail
    path        /var/log/messages
    tag         freebsd.messages
    db          /var/db/fluent-bit/messages.db

[OUTPUT]
    name        loki
    match       *
    host        localhost
    port        9428
    uri         /insert/loki/api/v1/push?_msg_field=log&_time_field=date
    labels      job=fluent-bit, host=setevoy-nas, logfile=messages

В полі uri вказуємо адресу VictoriaLogs, задаємо поле для _msg, в labels вказуємо набір тегів, які будуть додаватись до логів.

Запускаємо для тесту:

root@setevoy-nas:~ # vim /usr/local/etc/fluent-bit/fluent-bit.conf ^C
root@setevoy-nas:~ # fluent-bit -c /usr/local/etc/fluent-bit/fluent-bit.conf
Fluent Bit v4.2.2
* Copyright (C) 2015-2025 The Fluent Bit Authors
* Fluent Bit is a CNCF graduated project under the Fluent organization
* https://fluentbit.io

______ _                  _    ______ _ _             ___   _____
|  ___| |                | |   | ___ (_) |           /   | / __  \
| |_  | |_   _  ___ _ __ | |_  | |_/ /_| |_  __   __/ /| | `' / /'
|  _| | | | | |/ _ \ '_ \| __| | ___ \ | __| \ \ / / /_| |   / /
| |   | | |_| |  __/ | | | |_  | |_/ / | |_   \ V /\___  |_./ /___
\_|   |_|\__,_|\___|_| |_|\__| \____/|_|\__|   \_/     |_(_)_____/

             Fluent Bit v4.2 – Direct Routes Ahead
         Celebrating 10 Years of Open, Fluent Innovation!
...
[2026/02/28 16:38:18.797199771] [ info] [output:loki:loki.0] configured, hostname=localhost:9428
...

Записуємо повідомлення в /var/log/messages:

root@setevoy-nas:~ # logger "test message from fluent-bit"

Перевіряємо ім’я:

root@setevoy-nas:~ # cat /usr/local/etc/rc.d/fluent-bit | grep name
name="fluent_bit"
rcvar=${name}_enable
...

Додаємо в автостарт:

root@setevoy-nas:~ # sysrc fluent_bit_enable="YES"

Запускаємо:

root@setevoy-nas:~ # service fluent-bit start
Starting fluent_bit.

VictoriaLogs та робота з логами з консолі

VictoriaLogs прям дуже зручна в плані роботи з даними з консолі, і, думаю, юзери FreeBSD це оцінять.

Для роботи у нас два варіанти – або робити запити з curl, а потім їх парсити – або використати vlogscli.

Запити з curl

Приклад з curl:

root@setevoy-nas:~ # curl -s 'http://localhost:9428/select/logsql/query?query=*'
{"_time":"2026-02-28T14:42:00.20468201Z","_stream_id":"0000000000000000782dd9afdaaf4d53bfb843de46a3d91b","_stream":"{host=\"setevoy-nas\",job=\"fluent-bit\",logfile=\"messages\"}","_msg":"Feb 28 16:42:00 setevoy-nas setevoy[36200]: test message from fluent-bit 2","host":"setevoy-nas","job":"fluent-bit","logfile":"messages"}

Результат отримуємо в JSON, тому можна передати в jq:

І робити всякі пайпи:

root@setevoy-nas:~ # curl -s http://localhost:9428/select/logsql/query -d 'query=test' | jq -r '._time + " " + ._msg'
2026-02-28T14:42:00.20468201Z Feb 28 16:42:00 setevoy-nas setevoy[36200]: test message from fluent-bit 2
2026-02-28T14:53:49.481172045Z Feb 28 16:48:41 setevoy-nas setevoy[36663]: test message from fluent-bit 3
2026-02-28T14:54:05.981200313Z Feb 28 16:54:05 setevoy-nas setevoy[37055]: test message from fluent-bit 3
2026-02-28T15:06:21.481220267Z Feb 28 17:06:21 setevoy-nas setevoy[37991]: test message from fluent-bit
2026-02-28T15:12:46.231202127Z Feb 28 17:12:46 setevoy-nas setevoy[38385]: test message from fluent-bit
2026-02-28T15:14:42.731213928Z Feb 28 17:14:42 setevoy-nas setevoy[38502]: test message for vlogscli
2026-02-28T15:15:48.981198786Z Feb 28 17:15:48 setevoy-nas setevoy[38569]: test message for vlogscli
2026-02-28T15:17:59.731198268Z Feb 28 17:17:59 setevoy-nas setevoy[38684]: test message for vlogscli

Запити з vlogscli

Запускаємо vlogscli:

root@setevoy-nas:~ # vlogscli
sending queries to -datasource.url=http://localhost:9428/select/logsql/query
type ? and press enter to see available commands
;>

І, наприклад, запустити \tail:

;> \tail *;
executing [*]...; duration: client 9.003s
{
  "_msg": "Feb 28 17:17:59 setevoy-nas setevoy[38684]: test message for vlogscli",
  "_stream": "{host=\"setevoy-nas\",job=\"fluent-bit\",logfile=\"messages\"}",
  "_stream_id": "0000000000000000782dd9afdaaf4d53bfb843de46a3d91b",
  "_time": "2026-02-28T15:17:59.731198268Z",
  "host": "setevoy-nas",
  "job": "fluent-bit",
  "logfile": "messages"
}

Або використовувати різні LogsQL filters та pipes, наприклад – Time filter:

;> _time:5m;
executing [_time:5m]...; duration: server 0.000s
{
  "_msg": "Feb 28 17:17:59 setevoy-nas setevoy[38684]: test message for vlogscli",
  "_stream": "{host=\"setevoy-nas\",job=\"fluent-bit\",logfile=\"messages\"}",
  "_stream_id": "0000000000000000782dd9afdaaf4d53bfb843de46a3d91b",
  "_time": "2026-02-28T15:17:59.731198268Z",
  "host": "setevoy-nas",
  "job": "fluent-bit",
  "logfile": "messages"
}

Або включити compact mode:

;> \c
compact output mode

І тоді результат буде таким:

vmalert та алерти з логів

Для vmalert можна створити Recodring Rules – читати логи, генерувати метрики, а потім з цих метрик або можемо малювати графіки в Grafana – або створювати алерти.

Див. VictoriaLogs: створення Recording Rules з VMAlert.

Але для цього vmalert треба робити запити до двох datasources:

  • до VictoriaLogs на порт 9428 і URI /select/logsql/ – аби прочитати логи
  • до VictoriaMetrics на порт 8428 – аби записати метрики і виконати запити для створення алерту

Але два --datasource.url для vmalert задати не можна – але можна зробити базовий роутинг через vmauth, як я робив на робочому проекті, де в мене це все працює в Kubernetes – а потім для vmalert в --datasource.url вказати адресу vmauth.

Див. VictoriaMetrics: VMAuth – проксі, аутентифиікація та авторизація.

Налаштування vmauth

vmauth в мене вже встановлена з пакету vmutils, зараз треба просто додати конфіг з роутами та rc.d скрипт, бо в комплекті vmutils його нема:

root@setevoy-nas:~ # pkg info -l vmutils | grep vmauth
  /usr/local/bin/vmauth
  /usr/local/share/doc/vmutils/vmauth.md
  /usr/local/share/doc/vmutils/vmauth_flags.md

Створюємо конфіг /usr/local/etc/vmauth.yml з двома роутами – для VictoriaLogs та VictoriaMetrics:

unauthorized_user:
  url_map:
    - src_paths:
        - "/select/logsql/.*"
      url_prefix: "http://127.0.0.1:9428"
    - src_paths:
        - "/.*"
      url_prefix: "http://127.0.0.1:8428"

Пишемо rc.d скрипт – /usr/local/etc/rc.d/vmauth:

#!/bin/sh

# PROVIDE: vmauth
# REQUIRE: LOGIN
# KEYWORD: shutdown

. /etc/rc.subr

name="vmauth"
rcvar="vmauth_enable"

load_rc_config $name

: ${vmauth_enable:="NO"}
: ${vmauth_user:="victoria-metrics"}
: ${vmauth_logfile:="/var/log/vmauth.log"}
: ${vmauth_args:="-auth.config=/usr/local/etc/vmauth.yml -httpListenAddr=:8427"}

pidfile="/var/run/${name}.pid"
command="/usr/sbin/daemon"
procname="/usr/local/bin/vmauth"

command_args="-f -o ${vmauth_logfile} -p ${pidfile} ${procname} ${vmauth_args}"

start_cmd="vmauth_start"
stop_cmd="vmauth_stop"

vmauth_start()
{
  echo "Starting vmauth"
  touch ${vmauth_logfile}
  chown ${vmauth_user} ${vmauth_logfile}
  ${command} ${command_args}
}

vmauth_stop()
{
  echo "Stopping vmauth"
  kill `cat ${pidfile}`
}

run_rc_command "$1"

Задаємо execution права:

root@setevoy-nas:~ # chmod +x /usr/local/etc/rc.d/vmauth

Додаємо запуск в /etc/rc.conf:

root@setevoy-nas:~ # sysrc vmauth_enable="YES"
vmauth_enable:  -> YES

Запускаємо:

root@setevoy-nas:~ # service vmauth start
Starting vmauth

Перевіряємо порт:

root@setevoy-nas:~ # sockstat -4 -l | grep vmauth
root     vmauth     42277 4   tcp4   *:8427                *:*

Додаємо vmalert_args:

root@setevoy-nas:~ # sysrc vmalert_args="--datasource.url=http://127.0.0.1:8427 --notifier.url=http://127.0.0.1:9093 --rule=/usr/local/etc/vmalert/*.yml --remoteWrite.url=http://127.0.0.1:8428"

Тут:

  • --datasource.url=http://127.0.0.1:8427: адреса vmauth, який буде роутити запити по URI до VictoriaLogs або VictoriaMetrics
  • --notifier.url=http://127.0.0.1:9093: адреса Alertmanager
  • --remoteWrite.url=http://127.0.0.1:8428: адреса VictoriaMetrics, в яку будемо писати згенеровані метрики

Створення vmalert Recording Rule та алерту

І приклад метрики та алерту – файл /usr/local/etc/vmalert/freebsd-system-alerts.yml:

groups:

  - name: freebsd-logs-records
    type: vlogs
    interval: 1m
    rules:
      - record: freebsd:messages:errors_per_minute
        expr: 'error | stats count() as errors_count'

  - name: freebsd-logs-alerts
    rules:
      - alert: FreeBSDTooManyErrors
        expr: freebsd:messages:errors_per_minute > 1
        for: 1m
        labels:
          severity: warning
        annotations:
          summary: "Too many errors in logs"

Перезапускаємо vmalert:

root@setevoy-nas:~ # service vmalert restart
Stopping vmalert
Starting vmalert

Запускаємо для тесту запис “error” до /var/log/messages:

root@setevoy-nas:~ # while true; do logger "error test message"; sleep 1; done

Перевіряємо в VictoriaMetircs:

Перевіряємо в VMAlert:

Отримуємо алерт в Alertmanager:

І алерт в Telegram (бот робився для алертів EcoFlow, тому ім’я таке):

Готово.

Loading

FreeBSD: Home NAS, part 13: планування зберігання даних та бекапів
0 (0)

28 Лютого 2026

Коли тільки починав робити свій NAS і думав про бекапи, то все здавалось доволі простим: є робочий ноутбук з даними, є сервер з FreeBSD під NAS – треба просто взяти, і скопіювати дані.

Тому перша задумка була мати backup-скрипт/и на Linux-хостах, які б з rsync заливали дані на NAS, а потім з NAS іншим скриптом заливати дані до Rclone remotes.

Але…

Але коли почав вже робити, то виникло питання:

  • rsync з хоста setevoy-work заливає дані на NAS
  • в цей жеж час rsync з хоста setevoy-home заливає свої дані

А коли запускати rclone => Google Drive? Як знати, що всі дані з хостів вже готові для копіювання?

І це була тільки верхівка айсбергу задачі по організації процесу.

Всі частини серії по налаштуванню домашнього NAS на FreeBSD:

“Технічне завдання”: складність організації даних та бекапів

Отже, виявилось, що тут є купа нюансів:

  • хостів, з яких треба робити бекапи – не один, і не два, і навіть не три:
    • є робочий ноутбук з Arch Linux
    • є домашній ноутбук з Arch Linux
    • є ігровий ПК, на якому Windows для ігор і Arch Linux для роботи – бо раніше (до “сезону блекаутів”) це був мій робочий комп
    • є власне сервер з FreeBSD під NAS
    • нещодавно з’явився Raspberry Pi (див. Raspberry Pi: перший досвід і установка Raspberry Pi OS Lite)
    • а ще і телефон з власними даними типу фоток
  • на кожному хості є різні типи даних:
    • відносно статичні та однакові дані для ноутбуків/ПК – фото, відео, документи
    • часто змінюються, але в цілому однакові на ноутбуках/ПК, хоча можуть дещо відрізнятись – робочі дані по проектам, якісь власні скрипти
    • конфіденційні дані, але однакові на всіх хостах – SSH-ключі, бекапи KeePass/1Password, Recovery Codes тощо
    • колекція home video 😉
    • системні бекапи – різні конфіги з /etc, /usr/local/etc, ~/.ssh/config, всякі dotfiles налаштувань OpenBox або KDE Plasma
  • всі ці дані треба синхронізувати на декілька зовнішніх ресурсів (далі – просто “клауди”):
    • ZFS datasets на самому NAS
    • зберігати копії в клаудах – Google та/або Proton Drive, AWS S3 чи Backblaze
  • і до всього цього – мати резервні копії резервних копій на випадок випадкового видалення, для чого є:
    • ZFS snapshots – резервні копії на самому NAS
    • Syncthing Trash – резервні копії Syncthing
    • rclone --backup-dir – при копіюванні змін з NAS в клауди використовується окремий каталог для зберігання видалених або змінених даних
  • і все діло треба ще і автоматизувати, аби копіювання з NAS до Google Drive не виконувалось одночасно з копіюванням з хостів на NAS
    • тобто, просто запустити rsync з робочого ноута можна – але як знати, коли запускати rclone з NAS в Google Drive?

Відчуваєте, як в голові починається каша? 😉

Далі в цьому пості я всі remotes буду писати просто як “Google Drive” або “клауди” – але сюди ж входять і Proton Drive, і AWS S3, і, можливо, якісь інші, які почну використовувати пізніше – бо Rclone дає просто безліч варіантів (UPD: замість AWS S3 взяв собі Backblaze, див. Backblaze: знайомство з B2 Cloud Storage – перші враження).

Просто поки що Google Drive основний, хоча, можливо, я буду взагалі зав’язувати роботу з Google, бо їхній “AI”, який вони пхають в кожну дірку вже починає зайо*вати.

Схема моїх хостів та мереж

Для загальної картини схематично всі хости та мережі можна відобразити так:

В офісі знаходиться MikroTik RB4011, який грає таку собі роль “VPN-хаба” – на ньому налаштований WireGuard, який об’єднує офісну та домашню мережі і до якого підключений сервер в Digital Ocean, на якому зараз працює rtfm.co.ua.

Про MikroTik писав в пості MikroTik: перше знайомство та Getting Started, про WireGuard на ньому – MikroTik: налаштування WireGuard та підключення Linux peers, і там жеж є більш детальна схема мережі.

Отже, питання мережі вирішено – тепер треба з усіх цих хостів почати збирати дані і якось продумати організацію їхнього збереження на NAS та процес бекапу з хостів на NAS – і з NAS в клауди.

Типи та класи даних для зберігання і бекапів

Коли я почав планувати організацію збереження даних на NAS, то в мене сформувався такий підхід:

  • всі дані ділимо на класи
  • для кожного класу маємо окремий ZFS dataset  – маємо можливість налаштовувати власні квоти, окремі політики снапшотів та їхніх retention
  • кожен датасет має власний remote backend в Rclone – маємо можливість налаштувати власні параметри шифрування

Головний “source of truth” даних – це робочий ноутбук: тут 1 терабайт диск, тому на ньому зберігаються всі дані, які потім вже бекапляться на NAS з rsync, а частина даних синхронізується з домашнім ноутбуком та NAS через Syncthing.

Всі свої дані виділив в такі класи:

  • User Data: дані з /home/setevoy на ноутбуках на ПК:
    • Shared Static Unencrypted Data:
      • ці дані однакові на всіх хостах
      • сюди входять всякі ~/Books, ~/Music, ~/Photos
      • в Google зберігаються просто plaintext, аби можна було через Web щось подивитись або скачати без Rclone
      • синхронізуються між NAS, setevoy-work та setevoy-home з Syncthing (див. FreeBSD: Home NAS, part 12: синхронізація даних з Syncthing)
    • Shared Dynamic Unencrypted Data:
      • дані на всіх хоста – однакові або з невеликими відмінностями
      • часто оновлюються та/або можуть мати багато “мусору” типу каталогів .git
      • в Google Drive зберігаються просто plaintext
      • сюди входять всякі ~/Work, ~/Projects (власні скрипти), ~/Opt (MikroTik WinBox, якісь локальні Prometheus Exporters, etc)
      • для таких даних source of truth – робочий ноут, а на NAS – mirror даних з робочої машини з rsync під час виконання бекапів
      • на домашній комп, при потребі, просто синхронізувати вручну з NAS
    • Shared Static Crypted Data:
      • дані, однакові на всіх хостах
      • але конфіденційні, тому в Google Drive будуть шифруватись
      • сюди входять ~/Vault (Recovery Codes, KeePass/1Pass, etc)
      • синхронізуються/бекапляться на NAS з rsync з робочого ноута
    • Non-Shared Static Crypted Data:
      • колекція приватних відео – зберігається тільки на робочому ( 🙂 ) ноуті та на самому NAS
      • в Google Drive будуть шифруватись і імена каталогів, і імена файлів, і самі дані
      • сюди входить ~/Films/Private/
      • синхронізуються/бекапляться на NAS з rsync з робочого ноута
  • System Data: різні dotfiles, конфіги з /etc, бекапи сервісів та блогів:
    • System Backups:
      • /boot, /etc, /usr/local/etc, ~/.ssh/config, /root, etc – з усіх хостів на NAS
      • різні конфіги і скрипти з самого FreeBSD
    • Services Backups:
      • тут в основному локальні дані самого NAS, на якому в мене WordPress з моїм приватним щоденником, VictoriaMetrics з метриками всіх хостів і моїм Self-Monitoring (див. InfluxDB: запуск на Debian з NGINX і підключення Grafana, але дані з Influx мігрував до VictoriaMetrics)
      • /usr/local/www – файли блогів
      • mysqldump – бекапи баз даних
      • vmbackup – дані VictoriaMetrics
      • бекапи блогу rtfm.co.ua – але в нього власна (дуже стара) система бекапів, яку поки не міняю – вона заливає дані в AWS S3, а вже звідти архіви нової системою зберігаються на NAS

Data Layers: діаграми збереження та передачі даних

Для кращого уявлення про те, як дані зберігаються та передаються – сформував такий собі концепт “Data Layers“:

  • Storage Layer: ZFS pool, datasets – тут визначаємо що і як зберігається
    • Snaphost Policy Layer: можна ввести додатковий рівень – тут визначаємо локальні ZFS snaphotting policy
  • Transport Layer: Rclone, Rsync – тут визначаємо що, чим, як і куди копіюється
  • Cloud Policy Layer: тут визначаємо які у нас є Rclone Remotes та encryption policy директорій і даних в них

Схема Transport Layer: передача та синхронізація даних

Тепер можна продумати та відобразити весь flow даних.

Про саму автоматизацію бекапів буду писати окремо, інакше пост буде кілометровий – там кілька shell-скриптів, які запускаються на NAS.

Але поки що можна схематично відобразити всі дані та процес збереження і передачі такою от діаграмою:

  • Syncthing: контролює дані, які змінюються не надто часто, мають мало “мусору”, та загальні для всіх хостів – постійно запущений процес на ноутбуках та телефоні
  • Rsync: для решти даних, має описані правила з include та exclude файлах – запускається з NAS, підключається до хостів, збирає з них дані
  • Rclone: займається копіювання даних в remotes, виконує шифрування – запускається з NAS після того, як rsync зібрав всі дані та сам backup-скрипт створив локальні (на NAS) бекапи WordPress та VictoriaMetrics

Схема Cloud Policy Layer: шифрування даних в Google Drive та Backblaze

Тут – для кращого розуміння самим собою – зібрав такий собі “mapping” даних із ZFS-датасетів на NAS до Rclone Remotes:

В Google Drive є окремий каталог Backups/Rclone, в якому створені окремі каталоги для кожного типу зберігання, і для кожного з них на NAS для Rclone налаштовані власні remotes:

  • /nas/services та /nas/systems: шифруємо самі дані, але імена каталогів та файлів plaintext, аби простіше було шукати
  • /nas/media: тут просто все відкритим текстом, бо нічого sensetive не маємо
  • /nas/vault та /nas/private: максимальний рівень конфіденційності – шифруються і імена каталогів та файлів, і їхній зміст

А для Backblaze – просто окремі корзини. Про Rclone remotes буде трохи далі.

NAS ZFS datasets: організація збереження даних

Далі для кожного класу даних визначив датасети:

  • Shared Static Unencrypted Data (~/Pictures, ~/Photos):
    • ZFS dataset: nas/media
  • Shared Dynamic Unencrypted Data (~/Work, ~/Projects, ~/Opt):
    • ZFS dataset: nas/media
  • Non-Shared Static Crypted Data (~/Films/Private/):
    • ZFS dataset: nas/private
  • Shared Static Crypted Data (~/Vault):
    • ZFS dataset: nas/vault
  • System Backups (/boot, /etc, /usr/local/etc):
    • ZFS dataset: nas/systems
  • Services Backups (WordPress databases та файли, VictoriaMetrics):
    • ZFS dataset nas/services

Всі ZFS datasets на NAS зараз виглядають так:

root@setevoy-nas:~ # zfs list
NAME                 USED  AVAIL  REFER  MOUNTPOINT
nas                 2.24T  1.27T   128K  /nas
nas/backups-manual   593G  1.27T   593G  /nas/backups-manual
nas/jellyfin        9.20G  1.27T  9.20G  /nas/jellyfin
nas/media            369G  1.27T   341G  /nas/media
nas/mobile          52.3G  1.27T  52.3G  /nas/mobile
nas/private          208G  1.27T   208G  /nas/private
nas/services         133G  1.27T   133G  /nas/services
nas/systems         4.82G  1.27T  3.78G  /nas/systems
nas/to-sort          560G  1.27T   560G  /nas/to-sort
nas/vault           56.2G  1.27T  56.2G  /nas/vault

Тут є кілька додаткових датасетів, які не пов’язані бекапами:

  • /nas/backups-manual: просто окремі ручні бекапи з хостів або з самого NAS, коли роблю якісь потенційно небезпечні операції з даними
  • /nas/jellyfin: фільми для Jellyfin (про нього є чорнетка, може якось допишу) – це чисто фільми-серіали, тому в бекапи не включено і зберігається окремо
  • /nas/mobile: датасет для даних з мобільного телефона, які копіюються сюди з Syncthing на телефоні
  • /nas/to-sort: копії даних зі старих компів, які треба перебрати та включити в загальні бекапи

Приклад організації даних системних бекапів – все по окремим каталогам:

root@setevoy-nas:~ # tree -d -L 3 /nas/systems/
/nas/systems/
├── setevoy-nas
│   └── thinkcentre-10SUSCF000
│       ├── boot
│       ├── etc
│       ├── home
│       ├── opt
│       ├── root
│       ├── usr
│       └── var
├── setevoy-pi
│   └── raspberry-pi-cm4-rev11
│       ├── etc
│       └── opt
└── setevoy-work
    └── thinkpad-t14-g5-21ML003URA
        ├── etc
        ├── home
        ├── root
        ├── usr
        └── var

І дані сервісів:

root@setevoy-nas:~ # tree -d -L 3 /nas/services/
/nas/services/
├── victoriametrics
│   ├── 20260222
│   │   ├── data
│   │   ├── indexdb
│   │   └── metadata
│   └── latest
│       ├── data
│       ├── indexdb
│       └── metadata
└── web
    └── setevoy
        ├── databases
        └── files

База VictoriaMetrics бекапиться з vmbackup, дані в web – скриптом, який створює tar.gz з файлами та запускає mysqldump для бекапів баз даних.

Дані на диску виглядають так:

root@setevoy-nas:~ # tree -L 4 /nas/services/
/nas/services/
├── victoriametrics
│   ├── 20260222
│   │   ├── backup_complete.ignore
│   │   ├── backup_metadata.ignore
│   │   ├── data
│   │   │   ├── indexdb
│   │   │   └── small
│   │   ├── indexdb
│   │   │   └── 1882B20388AAC712
│   │   └── metadata
│   │       └── minTimestampForCompositeIndex
│   └── latest
│       ├── backup_complete.ignore
│       ├── backup_metadata.ignore
│       ├── data
│       │   ├── indexdb
│       │   └── small
│       ├── indexdb
│       │   └── 1882B20388AAC712
│       └── metadata
│           └── minTimestampForCompositeIndex
└── web
    └── setevoy
        ├── databases
        │   ├── 2026-02-19-18-05-blog-setevoy.sql
        │   ├── 2026-02-21-18-47-blog-setevoy.sql
        │   ├── 2026-02-22-00-00-blog-setevoy.sql
        ...
        └── files
            ├── 2026-02-19-18-05-blog-setevoy.tar.gz
            ├── 2026-02-21-18-47-blog-setevoy.tar.gz
            ├── 2026-02-22-00-00-blog-setevoy.tar.gz
        ...
...

А дані в /nas/media – просто всі в одному каталозі:

root@setevoy-nas:~ # tree -L 3 /nas/media/
/nas/media/
└── home
    └── setevoy
        ├── Backups
        ├── Books
        ├── Documents
        ├── Downloads
        ├── Dropbox
        ├── Music
        ├── Opt
        ├── Photos
        ├── Pictures
        ├── Projects
        ├── VMs
        ├── Videos
        └── Work

Rclone remotes та пов’язані ZFS datasets

Для Rclone створені remotes під кожен датасет, з якого треба бекапити дані в клауди:

root@setevoy-nas:~ # rclone listremotes
nas-aws-s3-setevoy-backups-root:
nas-google-drive-total-root:
nas-google-drive-root:
nas-google-drive-media:
nas-google-drive-mobile:
nas-google-drive-crypted-systems:
nas-google-drive-crypted-services:
nas-google-drive-crypted-vault:
nas-google-drive-crypted-private:
nas-backblaze-root-media:
nas-backblaze-crypted-media:
nas-backblaze-root-mobile:
nas-backblaze-crypted-mobile:
nas-backblaze-root-systems:
nas-backblaze-crypted-systems:
nas-backblaze-root-services:
nas-backblaze-crypted-services:
nas-backblaze-root-vault:
nas-backblaze-crypted-vault:
nas-backblaze-root-private:
nas-backblaze-crypted-private:

Кожен remote “мапиться” на ZFS datasets:

  • nas-google-drive-media та nas-backblaze-crypted-media: сюди заливаються дані з /nas/media
  • nas-google-drive-crypted-systems та nas-backblaze-crypted-systems: сюди – з /nas/systems

І так далі.

Кожен Rclone Remote має власну директорію в Google Drive або Backblaze бакет.

В Google Drive це виглядає так:

root@setevoy-nas:~ # rclone tree -d --max-depth 2  nas-google-drive-total-root:Backups/Rclone
/
└── nas
    ├── media
    ├── mobile
    ├── private
    ├── services
    ├── systems
    └── vault

І приклад одного з бакетів в Backblaze:

root@setevoy-nas:~ # rclone tree -d --max-depth 4 nas-backblaze-crypted-media:
/
├── _archive
│   ├── 2026-02-22-14-53
│   ├── 2026-02-24-03-06
│   │   └── home
│   │       └── setevoy
│   ├── 2026-02-25-03-07
│   │   └── home
│   │       └── setevoy
│   └── 2026-02-27-12-18
│       └── home
│           └── setevoy
└── data
    └── home
        └── setevoy
            ├── Backups
            ├── Books
            ├── Documents
            ├── Downloads
            ├── Dropbox
            ├── Music
            ├── Opt
            ├── Photos
            ├── Pictures
            ├── Projects
            ├── VMs
            ├── Videos
            └── Work

Ну, наче все описав.

Наступна частина – про сам shell-скрипт, який запускає rsync, створює бекапи WordPress та VictoriaMetrics, потім створює ZFS snapshots, а потім всі дані синхронізує в Rclone remotes.

І потім вже остання частина всієї цієї серії постів по Home NAS – з повним описом того, як це все будувалось, які сервіси, як все це моніториться, та як виглядає:

Loading

ilert: альтернатива Opsgenie – перше знайомство, Alertmanager, Slack
5 (1)

24 Лютого 2026

Думаю, всі юзери Opsgenie в курсі, що Atlassian вбиває закриває проект.

Я користувався Opsgenie з 2018 року, до нього звик, і він, в цілому, мав все те, що мені треба було від системи алертинга – хоч місцями і кривувато, але потрібні інтеграції працювали та налаштовувались достатньо легко.

Коли почав шукати альтернативи – натрапив на пост на Reddit – Anyone using Opsgenie? What’s your replacement plan, де дуже багато писали про incident.io – але це саме той випадок, коли мільйони мух таки можуть помилятись, бо більш ущєрбної системи я не бачив.

До речі, зрозумів одну річ: якщо знайомство з системою приводить тебе до думки піти на Youtube, аби глянути як люди цю систему налаштовують – то у цієї у системи явні проблеми або з UI/UX, або з документацією – і це 100% випадок incident.io, при чому по обом пунктам.

Прої… Провозившись з нею кілька днів в спробах таки змусити її відправляти текст в Slack так, як я того хочу – знов почав шукати альтернативи, і в тому ж треді на Reddit натрапив на ilert, і… Боже – це любов з першого погляду.

Все завелось просто за 15 хвилин і без всякого додаткового геморою з налаштуванням того, як повідомлення будуть виглядати в Slack.

Якісь косяки/незручності 100% зустрінуться, але поки що система виглядає саме так, як це має бути – без зайвих свістопєрдєлок, з простим, зручним і інтуїтивно зрозумілим (інтуїтивно зрозумілим, блт – чуєш, incident.io?!) інтерфейсом.

Отже сьогодні подивимось на основні можливості і налаштуємо ilert на відправку алертів.

Власне – що особисто мені треба від системи алертів? Ну… алерти. Відправка алертів. Зручний UI для перегляду алертів, і можливість налаштування шаблонів для повідомлень в Slack, бо це у нас основний канал доставки.

З інтеграцій треба вміти приймати алерти від стандартного Alertmanager та від AWS SNS.

Ну і наявність адекватної (адекватної, incident.io!) документації.

Все!

Поїхали.

Посилань на документацію ilert буде багато, почати можна з Opsgenie to ilert Migration Guide або VictoriaMetrics Integration.

ilert overview та основні можливості

Після реєстрації попадаємо в дашборд – і ви просто зацініть, як тут все класно виглядає – просто, функціонально, зрозуміло:

Основні можливості:

  • є Terraform provider
    • є експорт конфігурації відразу в Terraform
  • алертинг – SMS, Voice calls, Slack, Telegram, web-hooks і упасібоже MS Teams
  • стандартний On-call management  – ротації, розклад, ескалації
  • ChatOps – управління алертами зі Slack
  • AI SRE – ще не трогав, але спробую, хоча це наче ще в Beta
  • postmortems, incidents
  • REST API
  • десь бачив можливість збирати метрики з Prometheus/VictoriaMetrics, але це ще тестив
  • MCP для LLM
  • мобільна апка (теж ще не дивився)
  • Status Pages

Ну і овер 100 інтеграцій – All Integrations.

Pricing

Див. Pricing.

Є Free Plan, в який до того ж входить Heaerbeat, є Status Page, On-call/voice/SMS – з обмеженнями, але включено.

Є в дашборді:

Початок роботи

Основне – це, звісно, алерти, тому глянемо що тут є.

Головні концепти ilert по алертам – це Alert sources та Alert actions:

  • Alert sources: власне, джерела алертів – Alertmanager, AWS SNS, etc
  • Alert actions: правила того, що з алертами робити – відправити повідомлення, оновити статус Status Pages, пушнути webhook

З чого почнемо, і що мені треба з основного:

  • почати приймати алерти від Alertmanager
  • налаштувати відправку в Slack
    • подивитись на роутинг алертів – аби в Slack відправляти по різним каналам
    • подивитись на шаблони повідомлень

Підключення Alertmanager

Переходимо в Alert sources, додаємо новий:

Вибираємо Prometheus – це, власне, і буде Alertmanager:

Документація – Prometheus Integration, і взагалі посилання на документацію є майже всюди, та і сама документація чудова.

Задаємо ім’я:

Задаємо Esclation Policy – далі трохи про них ще поговоримо:

Grouping – none, в мене цим займається Alertmanager:

Тут жеж можна налаштувати Templaing, але поки не робимо – далі буде трохи детальніше:

Тут жеж можна задати фільтри – при яких умовах алерт буде відправлений сюди, але поки теж не включаємо – теж буде трохи далі:

Клікаємо внизу Finish setup, отримуємо ключ і повний URL:

Налаштування Alertmanager

Переходимо до конфіга Alertmanager, додаємо новий роут:

...
      routes:

        - matchers:
            - component="devops"
          receiver: ilert-notifications
          continue: true
...

Та reciever:

...
    receivers:

      - name: 'ilert-notifications'
        webhook_configs:
        - url: 'https://api.ilert.com/api/v1/events/prometheus/il1prom***c94'
...

Можна відправити тестовий алерт з curl:

$ curl -X POST https://api.ilert.com/api/v1/events/prometheus/*** -H "Content-Type: application/json" -d '{"receiver":"ilert-default","status":"firing","alerts":[{"status":"firing","labels":{"alertname":"Test","severity":"warning"},"annotations":{"summary":"Test alert"},"startsAt":"2026-02-24T00:00:00Z","endsAt":"0001-01-01T00:00:00Z","fingerprint":"test123"}],"groupLabels":{"alertname":"Test"},"commonLabels":{"alertname":"Test","severity":"warning"},"commonAnnotations":{"summary":"Test alert"},"externalURL":"http://localhost:9093","version":"4","groupKey":"test"}'

І дуже корисні логи:

В яких буде видно весь payload, і як його розпарсив ilert:

Ну і простий і дуже зручний UI з алертами:

Налаштування алертів в Slack

Переходимо в Alert Actions, додаємо новий:

Вибираємо Slack, не включаємо галочку “Use webhook“:

Підключаємо Alert source, який буде сюди слати алерти, вибираємо на які події слати повідомлення:

Ну і фільтри – але поки пропускаємо:

Задаємо ім’я Action, вибираємо канал, в який будуть йти повідомлення:

Можна тицнути Test:

І посміятись 🙂

Чекаємо на алерт з Alertmanager – і ви просто подивіться на цю красу з коробки!

Всі Connectors трохи сховали – шукаємо в Settings:

Роутинг повідомлень в Slack channels

Тепер, як загальний алертинг працює – починаємо тюнити.

Перше, що треба – це роутинг алертів:

  • є кілька оточень – dev, staging, prod, ops
  • є різні команди – devops, backend, web, data

Для кожної команди у нас є окремі Slack channels – #alerts-backend-prod, #alerts-devops-ops і так далі.

Що треба зробити – щоб алерти Backend Prod йшли в канал #alerts-backend-prod, а алерти для девопсів, відповідно, в канал #alerts-devops-ops.

Для Opsgenie це в мене було реалізовано через роутинг в самому Opsgenie:

А labels задаються в алертах:

...
      - alert: Kubernetes Pod UnHealthy
        expr: k8s:pod:unhealthy{namespace="prod-backend-api-ns"} > 0
        for: 15m
        labels:
          severity: warning
          component: backend
          environment: prod
...

І вже по ним Opsgenie  вибирає через яку Slack-інтеграцію слати алерт.

Власне, в ilert можна зробити таким самим чином, бо аналогічно до Opsgenie – кожен Slack Connector прив’язується до конкретного каналу.

А тому:

  • маємо Alert Source: Alertmanager
  • для нього створюємо кілька Alert actions:
    • slack-alerts-backend-prod: використовує Slack connector, налаштований на #alerts-backend-prod
    • slack-alerts-devops-ops: використовує Slack connector, налаштований на #alerts-devops-ops

Ще є дуже прикольна штука з динамічними роутами через Escalation Policy – далі подивимось.

Static alert rounting

Спершу дивимось, в якому вигляді ilert отримує алерт – переходимо в Alert source > Alert logs, відкриваємо якийсь алерт:

Далі переходимо в Alert actions і додаємо новий (чи редагуємо старий) Action.

Тут все аналогічно до того, як підключали Slack вище – але тепер включаємо Conditional execution і задаємо умову:

Або пишемо кодом – див. ICL – ilert condition language:

(alert.labels.component in ["devops"])

Задаємо канал:

Фільтр готовий:

Тепер алерти з label component="devops" підуть в канал #alerts-devops-ops.

Dynamic alert routing

Інший прикольний варіант – через Dynamic escalation policy routing.

Суть його в тому, що створюється кілька Escalation policices, яким задається Routing key – просто якесь string значення.

Далі в Alert source вмикається Dynamic routing та вказується поле алерта, з якого отримується значення – і, використовуючи це значення, до нового алерта автоматично підключається Escalation policy.

А в Alert Action використовується значення не (alert.labels.component in ["devops"]), як робили вище – а з roting_id, через який підключається відповідна політика.

Цей підхід краще в плані того, що відразу налаштовується не тільки куди слати алерт – а і як його escalate.

Пробуємо.

Переходимо в On-Call > Escalation policies, створюємо нову політику:

Задаємо Routing key:

Аналогічно для Backend:

Тепер переходимо в Alert sources > Alert actions і задаємо фільтр по alert.escalationPolicy.id:

І для бекенду:

Дал редагуємо сам Alert source, включаємо Dynamic routing вказуємо label з алерта, яку треба читати – в моєму прикладі це {{ alerts[0].labels.routing }}:

Тепер:

  • Alert source розпарсить {{ alerts[0].labels.routing }}
  • отримає значення “devops
  • по цьому значенню динамічно підключить Escalation policy
  • передать алерт в Alert Actions
  • а кожен екшен з Alert Actions перевірить свій фільтр alert.escalationPolicy.id – і спрацює той Action і його Connector, який “підключений” (чи “замаплений”) саме до slack-alerts-backend-prod або slack-alerts-devops-ops

В алертах додаємо нову лейблу – routing: alerts-devops-ops та routing: alerts-backend-prod:

...

      - alert: Route Test => alerts-devops-ops (alertname) 5
        expr: sum(kube_pod_info{namespace="ops-monitoring-ns", pod=~".*grafana.*"}) by (cluster, namespace, pod) >= 0
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
          routing: alerts-devops-ops
        annotations:
          summary: "Route Test (summary) 5"
          description:

      - alert: Route Test => alerts-backend-prod (alertname) 5
        expr: sum(kube_pod_info{namespace="ops-monitoring-ns", pod=~".*grafana.*"}) by (cluster, namespace, pod) >= 0
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
          routing: alerts-backend-prod
        annotations:
          summary: "Route Test (summary) 5"
          description:

...

І отримуємо алерти в різні канали.

Девопси:

Бекенди:

Блін – я в захваті від системи 🙂

Slack messages templating

Документація – Alert template.

Тут, в принципі, все доволі просто – задаємо поля з alert payload, можемо використовувати різні Functions – наприклад, [{{ commonLabels.severity.upperCase() }}].

В полях маленька кнопочка “play” справа внизу дозволяє відразу перевірити, як шаблон буде працювати:

Додамо новий алерт:

...
      - alert: Template Test (alertname) 1
        expr: sum(kube_pod_info{namespace="ops-monitoring-ns", pod=~".*grafana.*"}) by (cluster, namespace, pod) >= 0
        for: 1s
        labels:
          severity: warning
          component: devops
          environment: ops
          routing: alerts-devops-ops
        annotations:
          summary: "Template Test (summary) 1"
          description: "Template Test (description)"
          grafana_pod_overview_url: 'https://{{ .Values.monitoring.root_url }}/d/kubernetes-pod-overview/kubernetes-pod-overview?orgId=1&var-namespace={{ "{{" }} $labels.involvedObject_namespace }}&var-pod={{ "{{" }} $labels.pod }}'
...

Результат:

Heartbeat monitors

Heartbeat monitors – теж прикольна штука, але тут, в принципі, все просто – створюємо Alert source, отримуємо URL, і періодично до нього шлемо запит.

Як тільки пропустили відправку сигналу – спрацьовує алерт.

Пробуємо з curl:

$ curl -v https://beat.ilert.com/api/pings/ih2***
* Host beat.ilert.com:443 was resolved.
...
> GET /api/pings/ih2:25*** HTTP/1.1
...

І через заданий таймаут він стане Expired, і спрацює Alert action.

Deployment events – не тестив, але виглядає цікаво.

Summary або загальні враження

Просто система алертів, якою вона має бути.

Чим мені сподобався Backblaze – це простим і інтуїтивно зрозумілим інтерфейсом (див. Backblaze: знайомство з B2 Cloud Storage – перші враження).

Чому я просто закохався в ilert – простий інтерфейс, інтуїтивно зрозумілий інтерфейс і чудова документація.

Можливо, ще випливуть якісь проблеми вже під час реального використання, але після першого дня налаштувань – враження виключно позивні.

Loading

Backblaze: знайомство з B2 Cloud Storage – перші враження
5 (1)

23 Лютого 2026

По факту, цей пост – частина серій по сетапу домашнього NAS на FreeBSD (див. початок тут – FreeBSD: Home NAS, part 1 – налаштування ZFS mirror), але винесу його окремо.

В мене вже налаштована автоматизація бекапів (про неї теж будуть пости), і зараз дані з NAS раз на добу заливаються до Google Drive.

Але, по-перше – з сервісів Google я потроху позбавляюся, по-друге – хочеться мати друге місце для бекапів в клауді, а не тримати всі яйки в одній корзині.

Взагалі планував другим сторейджем взяти AWS S3, але потім подивився на альтернативи та відкрив для себе Backblaze, який мені настільки сподобався, що я прямо в той жеж день оформив підписку і налаштував Rclone на копіювання Backblaze теж.

Про Rclone детальніше писав у FreeBSD: Home NAS, part 9 – backup даних з rclone до AWS S3 та Google Drive, сьогодні тут теж про нього згадаємо, ну і познайомимось з Backblaze в цілому – подивимось на можливості, прайсінг, налаштуємо Rclone remotes та глянемо на швидкість.

Backblaze overview

Загальна документація – Backblaze Documentation, та по Cloud Storage – Get Started with the Web Console.

У Backblaze є два основні продукти – Computer Backup та B2 Cloud Storage.

І якщо до Computer Backup у багатьох є питання (див., наприклад Reddit) – то Cloud Storage прям дуже класна штука.

Головна перевага Backblaze – ціна за зберігання і простота UI (при цьому з усіма необхідними можливостями), а до мінусів можна віднести хіба що доволі невеликий вибір регіонів – але USA та Europe є.

Ну і ще з мінусів, мабуть, відсутність можливості перегляду файлів – але це і не Google Drive, а просто сторейдж, тому ОК.

За вартість детальніше поговоримо трохи нижче, але головне – ціна зберігання: у Backblaze це 6 USD за терабайт, тоді як в AWS S3 Standart Storage це було б 23.55 бакси, плюс ще і вартість за скачування даних з корзин.

Коротко про можливості та плюшки Backblaze:

  • дуже простий в використанні – просто той базовий набір утиліт, які потрібні Cloud Storage – без зайвого ускладнення
  • є можливість налаштувати реплікацію бакетів між регіонами
  • для аутентифікації є Application keys, яким можна здавати окремі scope
  • алерти – базові, по костам і використанню ресурсів
  • дашборда зі статистикою API-запитів і використанню storage
  • можна включити server-side encryption
  • можливість створення снапшотів даних (але тільки якщо не включений SSE)
  • трафік upload в бакет – безкоштовний, download – дуже великий безкоштовний ліміт
  • підтримка базових lifecycle політик
  • є офіційний мобільний клієнт – дуже простенький

Створення тестової корзини

Реєстрація проста, через пошту, після логіну бачимо чудову мінімалістичну дашборду:

Створимо тестовий бакет – звертаємо увагу на попередження про шифрування:

Тобто, як писав вище – не буде можливості створювати снапшоти.

Хоча трохи незрозуміло чому, бо в тому ж попередженні пишуть, що “Backblaze creates, manages key“.

Але для мене це в будь-якому випадку не дуже актуально, бо шифруванням буде займатись Rclone.

Документація по шифруванню – Enable Encryption on a Bucket.

Завершуємо створення бакету – буквально пара кліків:

Створення Application key

В моєму кейсі працювати з Backblaze буде Rclone, тому для нього створимо окремий ключик, див. Create and Manage App Keys:

Задаємо ліміт на конкретну корзину:

Відразу зберігаємо, бо більше його не побачимо:

Налаштування rclone B2 remote

Документація Rclone – Backblaze B2.

Запускаємо rclone config, створюємо новий remote:

$ rclone config
...
e) Edit existing remote
n) New remote
d) Delete remote
...
Enter name for new remote.
name> setevoy-backblaze-testing
...
Option Storage.
Type of storage to configure.
Choose a number from below, or type in your own value.
...
 5 / Backblaze B2
   \ (b2)
...
Account ID or Application Key ID.
Enter a value.
account> 003***001
...
Application Key.
Enter a value.
key> K00***MXQ
...
Option hard_delete.
Permanently delete files on remote removal, otherwise hide files.
Enter a boolean value (true or false). Press Enter for the default (false).
hard_delete>

Edit advanced config?
y) Yes
n) No (default)
y/n>

Configuration complete.
Options:
- type: b2
- account: 003f07593a16f1d0000000001
- key: K003+sr5NBQhJvsTdlCnfdt9UYHqMXQ
Keep this "setevoy-backblaze-testing" remote?
y) Yes this is OK (default)
...

Тут hard_delete залишив в дефолтному значенні, відключеним, але в prodution включив – бо інакше файли будуть не видалятись, а переміщатись в таку собі trash, ховатись.

Мені це не потрібно, бо цим всім займається rclone через --backup-dir.

Перевіряємо як новий ремоут спрацює – створюємо файл, копіюємо в бакет:

[setevoy@setevoy-work ~]  $ echo "test" > /tmp/test-b2.txt
[setevoy@setevoy-work ~]  $ rclone copy  /tmp/test-b2.txt setevoy-backblaze-testing:setevoy-test-1

[setevoy@setevoy-work ~]  $ rclone ls setevoy-backblaze-testing:setevoy-test-1
        5 test-b2.txt

І він в UI – для цього бакету я включав SSE, відразу відображає, що файл зашифрований:

І створення снапшоту для цього файлу недоступне:

Налаштування rclone crypt remote

Тут аналогічно до AWS S3 або Google Drive – створюємо новий remote з типом crypt, підключаємо його до основного remote.

Ще раз запускаємо rclone config, тут приклад з шифруванням тільки даних – імена файлів і директорій будуть plaintext:

$ rclone config
...

name> setevoy-backblaze-testing-crypted
...

Option Storage.
Type of storage to configure.
...
Storage> crypt

Option remote.
Remote to encrypt/decrypt.
...
Enter a value.
remote> setevoy-backblaze-testing:setevoy-test-1

Option filename_encryption.
How to encrypt the filenames.
...
Press Enter for the default (standard).
   / Encrypt the filenames.
 1 | See the docs for the details.
   \ (standard)
 2 / Very simple filename obfuscation.
   \ (obfuscate)
   / Don't encrypt the file names.
 3 | Adds a ".bin", or "suffix" extension only.
   \ (off)
filename_encryption> 3

Option directory_name_encryption.
...
Press Enter for the default (true).
 1 / Encrypt directory names.
   \ (true)
 2 / Don't encrypt directory names, leave them intact.
   \ (false)
directory_name_encryption> 2

Option password.
...
y) Yes, type in my own password
g) Generate random password
y/g> y
Enter the password:
password:
Confirm the password:
password:

Option password2.
Password or pass phrase for salt.
...
n) No, leave this optional password blank (default)
y/g/n>

...
Configuration complete.
Options:
- type: crypt
- remote: setevoy-backblaze-testing:setevoy-test-1
- filename_encryption: off
- directory_name_encryption: false
- password: *** ENCRYPTED ***

...

Копіюємо тестовий файл в цей ремоут:

$ rclone copy  /tmp/test-b2.txt setevoy-backblaze-testing-crypted

І маємо цей жеж файл, але вже як .bin:

Реальні дані та помилка “Cannot upload files, storage cap exceeded”

Взагалі думав на цьому і закінчувати, але не втримався – налаштував вже всю систему повністю, і тут ще є трохи шо показати.

Перше – коли почав заливати вже великі обсяги даних, то спіймав помилку “storage cap exceeded“:

2026/02/22 12:39:37 NOTICE: Failed to sync with 4 errors: last error was: Cannot upload files, storage cap exceeded. See the Caps & Alerts page to increase your cap. (403 storage_cap_exceeded)
ERROR: Rclone sync for nas/vault failed with exit code 7

Бо на безкоштовному акаунті маємо ліміт на 10 ГБ upload:

Додаємо картку – отримуємо повний доступ.

Backblaze pricing

Ну і раз вже дійшло до платежів – то трохи про вартість Backblaze.

Платимо тільки за сам storage – $6 за кожен терабайт, і за завантаження даних з бакетів – але тільки за той обсяг, який в 3 рази перевищує розмір даних, який зберігається в бакетах.

Тобто, зберігаємо 1 терабайт – можемо на місяць безкоштовно скачати 3 терабайти. Вище цього обсягу буде рахуватись по $0.01/GB.

Дательніше див. Backblaze Product and Pricing Updates.

Крім того, оплачується частина API-операцій, при цьому операції поділені на три окремі класи:

  • Class A: безкоштовні – створення бакетів, завантаження файлів (upload), видалення
  • Class B: скачування файлів (download), сюди входить b2_download_file_*, b2_get_file_info, наприклад – rclone sync з бакета до себе на машину
    • $0.004 за 10,000 операцій.
  • Class C: listing файлів, перевірка метаданих – сюди входять b2_list_file_names, b2_list_file_versions, наприклад – коли ми робимо rclone sync від себе в бакет
    • $0.004 за 1,000 операцій

Але при цьому маємо 2500 бескшотовних API-викликів на день.

Див. Backblaze B2 Cloud Storage Frequent Questions.

Для моєї схеми найбільше буде Class C транзакцій, бо rclone при кожному sync перевіряє всі файли і їх modification time, тобто читає метадані (хоча це наче тюниться).

Ну і можна використати опцію rclone --fast-list – менше операції, але більше RAM.

Подивимось на суму, коли прийде перший рахунок 🙂

В Reports є повна інформація по кількості викликів кожного типу:

Власне, додаємо картку, через пару хвилин запускаємо копіювання даних – і все пройшло:

Але через помилку залишилось кілька incomplete uploads (“started large file” на скріншоті) – можна почистити з rclone cleanup або просто видалити вручну:

Upload speed і порівняння з Google Drive та AWS S3

Коли запустив завантаження свої бекапів швидкість виглядала так – але тут частково були невеликі файли:

Пізніше зробив окремий “бенчмарк” – завантаження файлу в 50 гігабайт з rclone copy.

Перший результат – Google Drive, максимум було 322 Mb/s, Backblaze розкачався до 417 Mb/s, а AWS видав цілих 516 Mb/s:

Ну а результат завантаження взагалі всього мого бекапу виглядає так:

В цілому враження – принаймні поки що – чудові.

Подивимось, як це буде працювати.

Loading

MikroTik: налаштування WireGuard та підключення Linux peers
0 (0)

17 Лютого 2026

Ще одна з (багатьох) приємних можливостей MikroTik – вбудована підтримка WireGuard (хоча вона є навіть на дешевому TP-Link Archer).

В моєму сетапі MikroTik RB4011 грає роль такого собі “VPN Hub” – всі клієнти підключаються до нього і об’єднуються в єдину мережу, і роль VPN трохи перебільшена тут дійсно важлива – бо це такий собі gateway, через які всі хости комунікують один з одним, і саме через VPN-тунелі з NAS мій скрипт для створення бекапів (про автоматизацію бекапів буде окремим великим постом, вже є в чернетках) підключається до всіх хостів, аби запустити rsync і стягнути до себе дані.

Крім того, Syncthing теж працює виключно в межах внутрішніх мереж і синхронізує дані між FreeBSD/NAS, ноутбуках з Arch Linux та мобільним телефоном – див. FreeBSD: Home NAS, part 12: синхронізація даних з Syncthing.

Власне сьогодні – налаштування WireGuard на самому MikroTik та на Debian (сервер rtfm.co.ua).

На Arch Linux (домашній ноутбук) все аналогічно до Debian, а для телефона є офіційний клієнт WireGuard, і там все в принципі схоже.

Див. також перший пост по MikroTik – MikroTik: перше знайомство та Getting Started, і далі буде ще, мабуть, ціла серія – вже пачка чернеток є.

Що будемо робити:

  • на MikroTik створимо інтерфейс для WireGuard, назначимо йому IP
  • налаштуємо MikroTik Firewall для трафіку між всіма хостами
  • налаштуємо роути – додамо в домашню мережу
  • створимо WireGuard ключі, додамо WireGuard Peer на MikroTik

Хоча пост вийшов довгий – але насправді все доволі просто.

Головне не плутати приватні і публічні ключі в конфігах – в мене на початку з цим було трохи складностей.

Архітектура мереж і хостів

Схематично вся мережа виглядає так:

Тут:

  • 192.168.0.0/24: мережа офісу, основні хости тут:
    • 192.168.0.1: MikroTik RB4011 – основний роутер
    • 192.168.0.2: FreeBSD з NAS
    • 192.168.0.3: робочий ноутбук з Arch Linux
  • 192.168.100.0/24: домашня мережа
    • 192.168.100.100: домашній ноутбук
  • 10.100.0.0/24: WireGuard VPN
    • 10.100.0.1: MikroTik RB4011
    • 10.100.0.3: домашній ноутбук з Arch Linux
    • 10.100.0.10: сервер rtfm.co.ua в DigitalOcean з Debian Linux

Адресацію буду перероблювати, але поки що так.

Конфігурація WireGuard MikroTik

Офіційна документація – WireGuard.

Отже, мережа VPN 10.100.0.0/24, адреса самого MikroTik в ній – 10.100.0.1.

Додаємо інтерфейс, на якому буде працювати WireGuard – задаємо ім’я, порт, задаємо MTU:

/interface wireguard add name=wg0 listen-port=51820 mtu=1420

(місцями вже без скріншотів, бо робив давненько)

MTU задаємо 1420 – бо дефолтний MTU в Ethernet 1500 байт, а WireGuard додає свої заголовки:

  • IP header: 20 байтів (IPv4) – задається операційною системою роутера
  • UDP header: 8 байтів – аналогічно, роутер
  • WireGuard header: 32 байти – задається WireGuard, де вказується тип повідомлення, індекс піра, аутентифікація

Разом під headers 60 байт: 20 (IP) + 8 (UDP) + 32 (WireGuard), що залишає нам 1440 байт для корисних даних (payload), і ще -20 “запас”.

Див. Header / MTU sizes for Wireguard та мій пост TCP/IP: моделі OSI та TCP/IP, TCP-пакети, Linux sockets і порти.

Перевіряємо:

/interface wireguard print detail where name=wg0

Задаємо адресу інтерфейсу:

/ip address add address=10.100.0.1/24 interface=wg0 comment=wg-hub

Перевіряємо:

/ip address print where interface=wg0

Конфігурація MikroTik Firewall

Нам треба додати дозволи на доступ з інтернету до самого WireGuard на MikroTik, а потім налаштувати доступи між мережами.

Детальніше про фаєрвол на MikroTik теж напишу окремо, теж є в чернетках.

Доступ з інтернету до WireGuard

Включаємо доступ з будь-якого хосту, правило в chain=input:

/ip firewall filter add chain=input protocol=udp dst-port=51820 action=accept comment="allow wireguard"

Або, краще, створюємо список дозволених адрес:

/ip firewall address-list add list=wg-allowed address=46.101.201.123 comment=setevoy-rtfm
/ip firewall address-list add list=wg-allowed address=178.***.236 comment=setevoy-home
/ip firewall address-list add list=wg-allowed address=64.***.***.83 comment=kyiv-work-office

Момент з адресами для дроплетів в DigitalOcean: в мене підключений Reserved IP 67.207.75.157, який використовується на DNS – але для WireGuard використовуємо саме “дефолтний” Public IP від DigitalOcean – 46.101.201.123:

Перевіряємо наш новий address-list:

/ip firewall address-list print where list=wg-allowed

Тепер створюємо правило з цим списком в chain=input:

/ip firewall filter add chain=input protocol=udp dst-port=51820 src-address-list=wg-allowed action=accept comment="allow wireguard from whitelist" place-before=0

Перевіряємо:

/ip firewall filter print where src-address-list~"wg-allowed"

Доступ між внутрішніми мережами

Налаштовуємо доступ між мережами – додаємо правила в chain=forward.

Дозволяємо доступ з мережі VPN 10.100.0.0/24 в локальну мережу офісу 192.168.0.0/24:

/ip firewall filter add chain=forward src-address=10.100.0.0/24 dst-address=192.168.0.0/24 action=accept comment="wg to lan"

І обратно – з офісної мережі в мережу VPN:

/ip firewall filter add chain=forward src-address=192.168.0.0/24 dst-address=10.100.0.0/24 action=accept comment="lan to wg"

Далі – доступ з VPN до домашньої мережі 192.168.100.0/24:

/ip firewall filter add chain=forward src-address=10.100.0.0/24 dst-address=192.168.100.0/24 action=accept comment="wg to home"

І обратно, з дому в мережу VPN:

/ip firewall filter add chain=forward src-address=192.168.100.0/24 dst-address=10.100.0.0/24 action=accept comment="home to wg"

Аналогічно – доступ вже між офісною 192.168.0.0/24  домашньою 192.168.100.0/24.

З офісу в домашню:

/ip firewall filter add chain=forward src-address=192.168.0.0/24 dst-address=192.168.100.0/24 action=accept comment="office to home"

І обратно:

/ip firewall filter add chain=forward src-address=192.168.100.0/24 dst-address=192.168.0.0/24 action=accept comment="home to office"

Перевіряємо:

/ip firewall filter print where comment~"wg|office|home"

MikroTik Routes

MikroTik автоматично створює dynamic connected route для мережі WireGuard (10.100.0.0/24) після створення інтерфейсу wg0, і вже має routes для своїх локальних мереж, які безпосередньо підключені до його інтерфейсів (bridge, ether):

/ip route print

Або виберемо тільки ті, що нам зараз цікаві:

/ip route print where dst-address~"192.168.|10.100"

Тут 10.100.0.0/24 – мережа WireGuard, 192.168.0.0/24 – активна мережа DHCP-серверу на MikroTik, 192.168.88.0/24 – дефолтна мережа DHCP-серверу, зараз не використовується.

Але мережа дома – 192.168.100.0/24, і аби ходити з дому в офіс і навпаки – треба додати ще один роут:

/ip route add dst-address=192.168.100.0/24 gateway=wg0 comment="route to home via wg"

І роути тепер:

Можна переходити до підключення клієнтів.

WireGuard Peer на Linux

Сервер rtfm.co.ua хоститься в DigitalOcean, працює на Debian 12.

Сетап на Arch Linux такий самий, тільки пакет встановлюємо wireguard-toolspacman -S wireguard-tools.

На Debian встановлюємо пакет wireguard з apt:

root@setevoy-do-2023-09-02:~# apt update && apt install -y wireguard

Переходимо в /etc/wireguard/, створюємо ключі:

root@setevoy-do-2023-09-02:/etc/wireguard# cd /etc/wireguard/

root@setevoy-do-2023-09-02:/etc/wireguard# wg genkey | tee /etc/wireguard/privatekey | wg pubkey > /etc/wireguard/publickey

Тут privatekey будемо використовувати для локального інтерфейсу, а publickey потім додамо на MikroTik WireGuard.

На Debian отримуємо значення приватного ключа:

root@setevoy-do-2023-09-02:/etc/wireguard# cat privatekey 
ML+***dmk=

На MikroTik отримуємо public-key:

/interface wireguard print

На Debian створюємо конфіг /etc/wireguard/wg0.conf:

[Interface]
PrivateKey = ML+0***mk=
Address = 10.100.0.10/32

[Peer]
PublicKey = hxz***0o=
Endpoint = 178.***.***.184:51820

AllowedIPs = 10.100.0.0/24,192.168.0.0/24,192.168.100.0/24
PersistentKeepalive = 25

Тут:

  • [Interface]
    • PrivateKey: приватний ключ на самому Debian
    • Address: IP-адреса цього Peer, буде використана для локального інтерфейсу wg0
  • [Peer]
    • PublicKey: публічний ключ з MikroTik
    • Endpoint: зовнішня адреса за якою доступний MikroTik, та порт, на якому WireGuard приймає підключення
    • AllowedIPs: в які мережі може ходити цей peer і для яких будуть створені локальні роути

На Debian отримуємо публічний ключ:

root@setevoy-do-2023-09-02:/etc/wireguard# cat publickey 
x+Pr***0TE=

На MikroTik додаємо новий peer:

/interface wireguard peers add interface=wg0 public-key="x+Pr***0TE=" allowed-address=10.100.0.10/32,192.168.0.0/24,192.168.100.0/24 comment=setevoy-rtfm

В allowed-address на MikroTik задаємо дозвіл на доступ в мережі – перевіряється і для src-addr, і для dst-addr.

Власне – на цьому все.

На Linux запускаємо підключення:

root@setevoy-do-2023-09-02:/etc/wireguard# wg-quick up wg0 
[#] ip link add wg0 type wireguard
[#] wg setconf wg0 /dev/fd/63
[#] ip -4 address add 10.100.0.10/32 dev wg0
[#] ip link set mtu 1420 up dev wg0
[#] ip -4 route add 192.168.100.0/24 dev wg0
[#] ip -4 route add 192.168.0.0/24 dev wg0
[#] ip -4 route add 10.100.0.0/24 dev wg0

Перевіряємо статус:

root@setevoy-do-2023-09-02:/etc/wireguard#wg show
interface: wg0
  public key: x+Pr/***TE=
  private key: (hidden)
  listening port: 59014

peer: hxz***50o=
  endpoint: 178.***.184:51820
  allowed ips: 10.100.0.0/24, 192.168.0.0/24, 192.168.100.0/24
  latest handshake: 1 minute, 51 seconds ago
  transfer: 16.21 KiB received, 21.75 KiB sent
  persistent keepalive: every 25 seconds

На що звертаємо увагу – це latest handshake, який відображає, що підключення активне, і піри один з одним змогли зв’язатись.

Перевіряємо peers на MikroTik:

/interface wireguard peers print where comment="setevoy-rtfm"

Перевіряємо підключення з Linux на офісний ноутбук:

root@setevoy-do-2023-09-02:~# ssh [email protected] -i .ssh/setevoy-work 
[setevoy@setevoy-work ~]$ 

І на домашній ноут – який теж є VPN peer, і у нього дві адреси – 10.100.0.3 в мережі VPN, і 192.168.100.100 в мережі домашнього роутера.

Пробуємо його адресу в VPN:

root@setevoy-do-2023-09-02:~# ping -c 1 10.100.0.3
PING 10.100.0.3 (10.100.0.3) 56(84) bytes of data.
64 bytes from 10.100.0.3: icmp_seq=1 ttl=63 time=116 ms

--- 10.100.0.3 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 115.925/115.925/115.925/0.000 ms

І на його домашню адресу:

root@setevoy-do-2023-09-02:~# ping -c 1 192.168.100.100
PING 192.168.100.100 (192.168.100.100) 56(84) bytes of data.
64 bytes from 192.168.100.100: icmp_seq=1 ttl=63 time=36.2 ms

--- 192.168.100.100 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 36.181/36.181/36.181/0.000 ms

І статус всіх клієнтів на MikriTik зараз:

WireGuard Connection Troubleshooting

Для дебагу може бути корисним додати логування проходження пакетів на фаєрволі MikroTik:

/ip firewall filter add chain=forward src-address=192.168.0.0/24 dst-address=192.168.100.0/24 action=log log-prefix="office->home" place-before=0

/ip firewall filter add chain=forward src-address=192.168.100.0/24 dst-address=192.168.0.0/24 action=log log-prefix="home->office" place-before=0

Потім пінгуємо з офісу додому – тут ще була проблема:

$ ping 192.168.100.100
PING 192.168.100.100 (192.168.100.100) 56(84) bytes of data.
^C
--- 192.168.100.100 ping statistics ---
4 packets transmitted, 0 received, 100% packet loss, time 3090ms

І дивимось логи на MikroTik – тут клієнт вдома на Arch Linux вже підключений, пофіксив:

/log print where message~"office->home|home->office"
 ...
 2026-02-17 14:53:32 firewall,info home->office forward: in:wg0 out:bridge, connection-state:established proto ICMP (type 0, code 0), 192.168.100.100->192.168.0.3, len 84
 2026-02-17 14:53:33 firewall,info office->home forward: in:bridge out:wg0, connection-state:established src-mac c8:a3:62:2e:fd:cb, proto ICMP (type 8, code 0), 192.168.0.3->192.168.100.100, len 84
 2026-02-17 14:53:33 firewall,info home->office forward: in:wg0 out:bridge, connection-state:established proto ICMP (type 0, code 0), 192.168.100.100->192.168.0.3, len 84
 2026-02-17 14:53:34 firewall,info office->home forward: in:bridge out:wg0, connection-state:established src-mac c8:a3:62:2e:fd:cb, proto ICMP (type 8, code 0), 192.168.0.3->192.168.100.100, len 84
 2026-02-17 14:53:34 firewall,info home->office forward: in:wg0 out:bridge, connection-state:established proto ICMP (type 0, code 0), 192.168.100.100->192.168.0.3, len 84

Потім видаляємо, бо буде багато писати:

/ip firewall filter remove [find log-prefix="office->home"]

/ip firewall filter remove [find log-prefix="home->office"]

Або використовуємо tcpdump на хостах.

Наприклад, з офісного ноута пінгуємо домашній:

[setevoy@setevoy-work ~]  $ ping -c 1 192.168.100.100
PING 192.168.100.100 (192.168.100.100) 56(84) bytes of data.
64 bytes from 192.168.100.100: icmp_seq=1 ttl=63 time=107 ms

--- 192.168.100.100 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 106.714/106.714/106.714/0.000 ms

А на домашньому ноуті слухаємо весь ICMP:

root@setevoy-home:~ # tcpdump -i any icmp and host 192.168.100.100
tcpdump: WARNING: any: That device doesn't support promiscuous mode
(Promiscuous mode not supported on the "any" device)
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on any, link-type LINUX_SLL2 (Linux cooked v2), snapshot length 262144 bytes
14:59:17.433334 wg0   In  IP work.setevoy > setevoy-home: ICMP echo request, id 25635, seq 1, length 64
14:59:17.433381 wg0   Out IP setevoy-home > work.setevoy: ICMP echo reply, id 25635, seq 1, length 64

Якщо треба оновити параметри peer – виконуємо через interface wireguard peers set.

Для домашнього ноута при створені не задав 192.168.100.0/24 в allowed-address, треба було оновити параметр:

/interface wireguard peers set [find comment="setevoy-home"] allowed-address=10.100.0.3/32,192.168.100.0/24,192.168.0.0/24

А через те, що не було 192.168.100.0/24 в allowed-address – не було прямого підключення із 192.168.0.0/24 – бо пакет йшов через WireGuard тунель, приходив на домашній ноутбук на інтерфейс wg0, потім відправлявся на інтерфейс WiFi з адресою 192.168.100.100, але так як цього не було в allowed-address – то пакет дропався.

Готово.

Loading

FreeBSD: Home NAS, part 12: синхронізація даних з Syncthing
0 (0)

14 Лютого 2026

Вже потроху наближаюсь до завершення історії з налаштування домашнього NAS на FreeBSD.

Вже є ZFS pool, є датасети, є моніторинг – можна починати налаштування автоматизації бекапів.

Але якщо на початку все здавалось доволі просто – “просто скопіювати потрібні каталоги з робочого ноутбука”, то чим далі – тим цікавішою виявлялась задача.

Більш детальний опис планування та автоматизації бекапів опишу окремо, а сьогодні познайомимось з ще одною класною утилітою – Syncthing.

Всі частини серії по налаштуванню домашнього NAS на FreeBSD:

Syncthing overview

Отже, для чого вона мені: є кілька хостів (робочий та домашній ноутбуки, ігровий ПК), між якими треба синхронізувати загальні дані.

Загальні дані – це каталоги з фотками, музикою, картинками – все те, що змінюється не дуже часто, і де нема “мусора” типу каталогів .git, logs або tmp.

Такі каталоги повинні бути однаковими між ноутами та ПК і самим NAS, і коли я почав думати як жеж це все синхронізувати – то вперся в проблему того, що дані на будь-якому хості можуть і додатись і видалитись – і треба це все діло відстежувати і копіювати всі зміни.

Rsync чи Rclone тут не дуже підходять, бо у них принцип роботи “master-slave” – є один source of truth, і його зміст контролюється з Rsync/Rclone.

А в данному випадку, коли хостів декілька і кожен може робити власні зміни, які треба “відзеркалити” на інші – треба і інший інструмент, який зможе сам моніторити і копіювати всі зміни.

До того ж є і мобільний телефон з фотками, які хочеться бекапити напряму до NAS, а не в Goolge чи Proton Drive.

Власне, тут на сцену і виходить Syncthing:

  • підключається до кількох хостів
  • для кожного хоста налаштовується які саме локальні каталоги синхронізувати з іншими хостами та які каталоги з інших хостів синхронізувати локально
  • передає дані з шифруванням трафіку

До того ж має зручний Web UI, конфіг зберігає в файлі, який легко бекапити та має клієнтів під Android та iOS, і має чудову документацію.

Трохи забігаючи наперед (бо схема с наступного поста FreeBSD: Hone NAS, part 12: планування бекапів) – роль Syncthing в моєму сетапі виглядає так:

Отже, сьогодні установимо Syncthing на NAS з FreeBSD та на ноутбук з Arch Linux, і подивимось як це все працює.

Установка Syncthing на FreeBSD

Syncthing є в репозиторії, встановлюємо його:

root@setevoy-nas:~ # pkg install syncthing

Додаємо до /etc/rc.conf:

root@setevoy-nas:~ # sysrc syncthing_enable="YES"
syncthing_enable:  -> YES
root@setevoy-nas:~ # sysrc syncthing_user="setevoy"
syncthing_user:  -> setevoy

Файл налаштувань – /usr/local/etc/syncthing/config.xml.

Більшість налаштувань виконуються через Web (хоча є і CLI), але по дефолту Syncthing запускається на localhost.

А так як це FreeBSD без X-серверу – то і браузеру там нема.

Тому редагуємо файл і задаємо IP зовнішнього інтерфейсу, в мене це 192.168.0.2 (хоча адресацію буду перероблювати, коли доберусь до MikroTik та його DHCP):

...
    <gui enabled="true" tls="false" sendBasicAuthPrompt="false">
        <address>192.168.0.2:8384</address>
        <metricsWithoutAuth>false</metricsWithoutAuth>
        <apikey>L2P***eAk</apikey>
        <theme>default</theme>
    </gui>
...

Запускаємо сервіс:

root@setevoy-nas:~ # service syncthing start
Starting syncthing.

Перевіряємо порт:

root@setevoy-nas:~ # sockstat -4 -l | grep 8384
setevoy  syncthing  34083 18  tcp4   192.168.0.2:8384   *:*

Відкриваємо дашборду:

Створення ZFS dataset

Поки знайомлюсь з системою – зробив окремий датасет:

root@setevoy-nas:~ # zfs create nas/syncthing-test
root@setevoy-nas:~ # zfs list nas/syncthing-test
NAME                 USED  AVAIL  REFER  MOUNTPOINT
nas/syncthing-test    96K  2.24T    96K  /nas/syncthing-test

Задаємо власника – бо Syncthing запускається від юзера setevoy (який заданий через syncthing_user="setevoy" в /etc/rc.conf):

root@setevoy-nas:~ # chown setevoy:setevoy /nas/syncthing-test
root@setevoy-nas:~ # ls -ld /nas/syncthing-test
drwxr-xr-x  2 setevoy setevoy 2 Feb 13 19:05 /nas/syncthing-test

Додавання каталогу

Тепер додамо локальний каталог, який можна буде зробити доступним для синхронізації на інших хостах:

Вказуємо ім’я та локальний шлях.

Folder ID залишаємо – це просто унікальний ідентифікатор для використання між хостами:

Тут жеж є налаштування Versioning – зберігання копій, далі подивимось детальніше:

І цікаві опції в Advanced – але це вже іншим разом:

Після додавання нового каталогу він буде збережений в .../syncthing/config.xml:

root@setevoy-nas:~ # cat /usr/local/etc/syncthing/config.xml | grep jmw5s-hotah
    <folder id="jmw5s-hotah" label="syncthing-test" path="/nas/syncthing-test" type="sendreceive" rescanIntervalS="3600" fsWatcherEnabled="true" fsWatcherDelayS="10" fsWatcherTimeoutS="0" ignorePerms="false" autoNormalize="true">

Тепер додаємо першого “клієнта” – хоча Syncthing все ж peer-to-peer архітектура, але конкретно в моєму випадку є окремий сервер чи хаб, а інші хости – це клієнти.

Установка Syncthing на Arch Linux

Теж є в репозиторії, встановлюємо:

[setevoy@setevoy-work ~] $ sudo pacman -S syncthing

Можна поки запустити руками – подивитись на його output:

[setevoy@setevoy-work ~]  $ syncthing
2026-02-13 19:14:21 INF syncthing v2.0.14 "Hafnium Hornet" (go1.25.6 X:nodwarf5 linux-amd64) syncthing@archlinux 2026-02-03 09:05:00 UTC [noupgrade] (log.pkg=main)
2026-02-13 19:14:21 INF Generating key and certificate (cn=syncthing log.pkg=syncthing)
2026-02-13 19:14:21 INF Default config saved; edit to taste (with Syncthing stopped) or use the GUI (path=/home/setevoy/.local/state/syncthing/config.xml log.pkg=syncthing)
2026-02-13 19:14:21 INF Archiving a copy of old config file format (path=/home/setevoy/.local/state/syncthing/config.xml.v0 log.pkg=syncthing)
2026-02-13 19:14:21 INF Calculated our device ID (device=2W2JHRW-T***-2TRDAAF log.pkg=syncthing)
2026-02-13 19:14:21 INF Overall rate limit in use (send="is unlimited" recv="is unlimited" log.pkg=connections)
2026-02-13 19:14:21 INF Using discovery mechanism (identity="global discovery server https://discovery-lookup.syncthing.net/v2/?noannounce" log.pkg=discover)
2026-02-13 19:14:21 INF Using discovery mechanism (identity="global discovery server https://discovery-announce-v4.syncthing.net/v2/?nolookup" log.pkg=discover)
2026-02-13 19:14:21 INF Using discovery mechanism (identity="global discovery server https://discovery-announce-v6.syncthing.net/v2/?nolookup" log.pkg=discover)
2026-02-13 19:14:21 INF Using discovery mechanism (identity="IPv4 local broadcast discovery on port 21027" log.pkg=discover)
2026-02-13 19:14:21 INF Using discovery mechanism (identity="IPv6 local multicast discovery on address [ff12::8384]:21027" log.pkg=discover)
2026-02-13 19:14:21 INF Relay listener starting (id=dynamic+https://relays.syncthing.net/endpoint log.pkg=connections)
2026-02-13 19:14:21 INF QUIC listener starting (address="[::]:22000" log.pkg=connections)
2026-02-13 19:14:21 INF Creating new HTTPS certificate (log.pkg=api)
2026-02-13 19:14:21 INF TCP listener starting (address="[::]:22000" log.pkg=connections)
2026-02-13 19:14:21 INF GUI and API listening (address=127.0.0.1:8384 log.pkg=api)
2026-02-13 19:14:21 INF Access the GUI via the following URL: http://127.0.0.1:8384/ (log.pkg=api)
2026-02-13 19:14:21 INF Loaded configuration (name=setevoy-work log.pkg=syncthing)
2026-02-13 19:14:21 INF Measured hashing performance (perf="1978.89 MB/s" log.pkg=syncthing)

Додавання Remote Devices

Тепер треба Syncthing на Linux додати в пул до Syncthing на FreeBSD.

На Linux йдемо в Actions > Show ID:

(QR дуже ручний для підключення мобільних клієнтів – теж вже робив, працює чудово)

Далі на FreeBSD клікаємо Add Remote Device:

Він відразу в мережі побачив клієнта на Linux-хості (див. Syncthing Discovery Server та Security Principles):

Клікаємо Save, але Linux-клієнт поки що в статусі Disconnected:

Повертаємось до Syncthing на Linux – туди приходить запит на підключення:

І тепер маємо два девайси, об’єднані в мережу.

На FreeBSD:

І на ноутбуці з Linux:

Налаштування Folder Sharing

Тепер подивимось, як працює синхронізація.

На FreeBSD у створеному раніше Folder клікаємо Edit:

Переходимо на вкладку Sharing, вибираємо девайси, і якими хочемо зашарити папку:

Аналогічно до процесу додавання Devices – спочатку нам на Linux-клієнт прийде запит на підтвердження:

Клікаємо Add, задаємо локальний шлях на ноутбуці з Linux:

Перевіряємо, як це все діло працює.

Створимо файл на FreeBSD:

root@setevoy-nas:~ # echo "hello from nas" > /nas/syncthing-test/test1.txt

Дивимось Syncthing output на ноуті – пише що і коли змінилось:

...
2026-02-13 19:23:54 INF Synced file (folder.label=syncthing-test folder.id=jmw5s-hotah folder.type=sendreceive file.name=test1.txt file.modified="2026-02-13 19:23:43.432316 +0200 EET" file.permissions=0644 file.size=15 file.blocksize=131072 blocks.local=0 blocks.download=1 log.pkg=model)
...

І файл тепер є на Linux-клієнті:

[setevoy@setevoy-work ~]  $ ll nas/syncthing-test/
total 4
-rw-r--r-- 1 setevoy setevoy 15 Feb 13 19:23 test1.txt

Перевіримо зворотню синхронізацію – додамо файл на Linux:

[setevoy@setevoy-work ~]  $ echo "hello from laptop" > /home/setevoy/nas/syncthing-test/test2.txt

І через декілька секунд – він є і на FreeBSD:

root@setevoy-nas:~ # cat /nas/syncthing-test/test2.txt 
hello from laptop

Тестуємо видалення:

[setevoy@setevoy-work ~]  $ rm /home/setevoy/nas/syncthing-test/test2.txt

І на FreeBSD він теж зникає:

root@setevoy-nas:~ # ll /nas/syncthing-test/
total 3
drwxr-xr-x  2 setevoy setevoy    3B Feb 13 19:09 .stfolder
-rw-r--r--  1 root    setevoy   15B Feb 13 19:23 test1.txt
-rw-r--r--  1 setevoy setevoy   18B Feb 13 19:25 test2.txt
root@setevoy-nas:~ # ll /nas/syncthing-test/
total 2
drwxr-xr-x  2 setevoy setevoy    3B Feb 13 19:09 .stfolder
-rw-r--r--  1 root    setevoy   15B Feb 13 19:23 test1.txt

Налаштування Versioning для бекапів

Тепер про те, як можна бекапити дані – захист від випадкового видалення.

Документація – File Versioning.

Переходимо в Folder > Edit, вкладка File Versioning:

Тут опції:

  • Trash Can: при видаленні файл переноситься в .stversions
  • Simple: зберігає N останніх версій
  • Staggered: зберігає версії з часом (1h, 1d, 1w і т.д.)
  • External: викликати зовнішній скрипт

Спробуємо з Trash Versioning:

На Linux-клієнті створимо новий файл:

[setevoy@setevoy-work ~]  $ echo "hello from laptop" > /home/setevoy/nas/syncthing-test/test-trash.txt

Чекаємо на його появу на FreeBSD-хості:

root@setevoy-nas:/home/setevoy # ll /nas/syncthing-test/
total 3
drwxr-xr-x  2 setevoy setevoy    3B Feb 13 19:09 .stfolder
-rw-r--r--  1 setevoy setevoy   18B Feb 13 19:33 test-trash.txt
-rw-r--r--  1 root    setevoy   15B Feb 13 19:23 test1.txt

Видаляємо на ноутбуці:

[setevoy@setevoy-work ~]  $ rm /home/setevoy/nas/syncthing-test/test-trash.txt

І через кілька секунд він зникає на FreeBSD:

root@setevoy-nas:/home/setevoy # ll /nas/syncthing-test/
total 3
drwxr-xr-x  2 setevoy setevoy    3B Feb 13 19:09 .stfolder
-rw-r--r--  1 setevoy setevoy   18B Feb 13 19:33 test-trash.txt
-rw-r--r--  1 root    setevoy   15B Feb 13 19:23 test1.txt
root@setevoy-nas:/home/setevoy # ll /nas/syncthing-test/
total 3
drwxr-xr-x  2 setevoy setevoy    3B Feb 13 19:09 .stfolder
drwxr-xr-x  2 setevoy setevoy    3B Feb 13 19:34 .stversions
-rw-r--r--  1 root    setevoy   15B Feb 13 19:23 test1.txt

Але збережений в .stversions/:

root@setevoy-nas:/home/setevoy # ll /nas/syncthing-test/.stversions/
total 1
-rw-r--r--  1 setevoy setevoy   18B Feb 13 19:34 test-trash.txt

Крім того, у Web тепер є кнопочка Versions:

Де показані видалені файли, і які звідси можна відновити:

Для NAS, скоріш за все, зроблю Trash на 30 днів, а довготривалі бекапи будуть через ZFS snaphosts + копіювання на Google/Roton Drive та AWS S3.

Наступні кроки

Ну і тепер можна на Linux-клієнт додати Syncthing в автостарт:

[setevoy@setevoy-work ~]  $ systemctl --user enable syncthing.service
Created symlink '/home/setevoy/.config/systemd/user/default.target.wants/syncthing.service' → '/usr/lib/systemd/user/syncthing.service'.
[setevoy@setevoy-work ~]  $ systemctl --user start syncthing.service
[setevoy@setevoy-work ~]  $ systemctl --user status syncthing.service
● syncthing.service - Syncthing - Open Source Continuous File Synchronization
     Loaded: loaded (/usr/lib/systemd/user/syncthing.service; enabled; preset: enabled)
     Active: active (running) since Fri 2026-02-13 19:41:17 EET; 3s ago
...

Можна додати запуск сервісу без user login – корисно для ребутів, див. loginctl:

[setevoy@setevoy-work ~]  $ loginctl enable-linger setevoy

І додати базову перевірку в Uptime Kuma (про неї теж скоріш за все буду писати ще окремо, в мене Kuma крутиться на окремому хості для “міні-моніторинга” на Raspberry PI):

Є у Syncthing і Prometheus метрики, див. Prometheus-Style Metrics – можна буде додати до VictoriaMetrics і створити Grafana dashboard та алерти.

І варто налаштувати бекапи для файлів Syncthing:

[setevoy@setevoy-work ~]  $ ll ~/.local/state/syncthing
total 40
-rw-r--r-- 1 setevoy setevoy   623 Feb 13 19:14 cert.pem
-rw------- 1 setevoy setevoy 11236 Feb 13 20:15 config.xml
...
-rw------- 1 setevoy setevoy   119 Feb 13 19:14 key.pem
...

Далі почитати і поробити Configuration Tuning, налаштувати Firewall Setup, і уважно перечитати Security Principles.

Наостанок – як Syncthing виглядає на телефоні з Syncthing-Fork:

І клієнт телефона в дашборді на FreeBSD:

Готово.

Loading

Raspberry Pi: перший досвід і установка Raspberry Pi OS Lite
0 (0)

12 Лютого 2026

Для тих, хто не слідкує за апдейтами в Telegram-каналі rtfmcoua або просто перший раз зайшов на мій блог – нагадаю, що останні пару місяці збираю такий собі “self-hosted home stack”, в якому вже є пара MikroTik і ThinkCentre з FreeBSD.

На ThinkCentre / FreeBSD в мене NAS на ZFS mirror pool (див. FreeBSD: Home NAS, part 1), і “центральний моніторинг” з VictoriaMetrics + Grafana (див. FreeBSD: Home NAS, part 10 – моніторинг з VictoriaMetrics).

До цього всього щастя вирішив ще додати окрему машинку під mini-monitoring, плюс там захостити системи типу Glance (див. Glance: налаштування self-hosted home page для браузера), бо ThnikCentre під час довгих блекаутів виключаю (хоча там споживання всього близько 20 Вт-год).

Ну і… Колись пробував Arduino – класно штука, але далі “Hello, World” діло не пішло (принаймні поки що), да і якісь системи типу Uptime Kuma на Arduino не захостиш.

Давно хотів спробувати погратись з Raspberry Pi, тільки раніше не міг придумати “а нахуа?” – і ось, нарешті, з’явилась відповідь на це велике питання.

Вибір Raspberry Pi

Чесно – я особо не вибирав 🙂

Точніше, вибирав, бо “вау, кросівєнько!” – десь випадково побачив Raspberry Pi Compute Module 4 PoE Mini-Computer, уявив, як він класно став би в мою серверну шафу – і вирішив взяти його.

Виглядає він ось так:

Є більш нові Compute Module версії 5 – але для моїх цілей, до того ж для першого досвіду, 4 версії вистачить з головою.

Отже, маємо:

Купував в магазині https://minicomp.com.ua – не реклама, але магазин наче нормальний, відправили швидко, підтримка по телефону/Telegram працює, нарікань нуль.

Єдине, що ще окремо довелось купувати кріплення.

Установка операційної системи

Трохи сексу 🙂

Бо перший раз, і це не “вставити USB-флешку з готовим образом”.

Спочатку хотів встановлювати Debian, і, в принципі, вдалося, але…

Я не зміг залогінитись в систему :facepalm:

Тому просто поставив Raspberry Pi OS Lite, і там все пройшло (точніше – увійшло 🙂 ) без проблем.

Втім, так як перший досвід – то збережу тут процес і для Debian теж.

Установка Raspberry Pi Debian

Качаємо на raspi.debian.net:

[setevoy@setevoy-work ~] $ ls ~/Downloads/Rasp/
debian-13-raspi-arm64-daily.tar.xz

Розпаковуваємо (хоча, як виявилось, можна і без цього – див. простіший варіант далі в Установка Raspberry Pi OS Lite):

[setevoy@setevoy-work ~] $ cd ~/Downloads/Rasp/
[setevoy@setevoy-work ~] $ tar xfp debian-13-raspi-arm64-daily.tar.xz 

В архіві лежить disk.raw – це повний образ диска з вже готовим GPT/MBR та boot partition:

[setevoy@setevoy-work ~] $ fdisk -l ~/Downloads/Rasp/disk.raw 
Disk /home/setevoy/Downloads/Rasp/disk.raw: 3 GiB, 3221225472 bytes, 6291456 sectors
...
Disklabel type: gpt
Disk identifier: 580A523C-E6C1-4021-8A56-D664D3C75FA2

Device                                    Start     End Sectors  Size Type
/home/setevoy/Downloads/Rasp/disk.raw1  1048576 6289407 5240832  2.5G Linux root (ARM-64)
/home/setevoy/Downloads/Rasp/disk.raw15    2048 1048575 1046528  511M EFI System

Partition table entries are not in disk order.

Підключення USB до ноутбука

Отуто теж трохи витратив часу, бо дуже незвична і ніфіга не очевидна схема перемикання на завантаження по USB.

Навіть якась ностальгія по часам, коли на HDD перемикав джампери Primary/Slave.

Картинка для тих, хто не бачив це наживо

На моємо CM4 піни для включення завантаження з USB знайшлись отут:

Зайвого джамперу не було, але можна взяти з FAN/VDD:

Перемикаємо джампер, підключаємо звичайним USB-кабелем до ноутбука, перевіряємо девайси – має з’явитись Broadcom:

[setevoy@setevoy-work ~] $ lsusb | grep Broa
Bus 003 Device 024: ID 0a5c:2711 Broadcom Corp. BCM2711 Boot

Встановлюємо rpiusbboot – утиліта підключиться до Raspberry Pi Compute Module та змонтує її eMMC (embedded MultiMediaCard) диск до ноутбука як звичайну флешку:

[setevoy@setevoy-work ~] $ yay -S rpiusbboot

Запускаємо:

[setevoy@setevoy-work ~] $ sudo rpiusbboot 
RPIBOOT: build-date Feb 12 2026 version 20221215~105525 b41ab04a
Waiting for BCM2835/6/7/2711...
Loading embedded: bootcode4.bin
Sending bootcode.bin
Successful read 4 bytes 
Waiting for BCM2835/6/7/2711...
Loading embedded: bootcode4.bin
Second stage boot server
Cannot open file config.txt
Cannot open file pieeprom.sig
Loading embedded: start4.elf
File read: start4.elf
Cannot open file fixup4.dat
Second stage boot server done

Тепер маємо новий диск в системі:

[setevoy@setevoy-work ~] $ lsblk 
NAME          MAJ:MIN RM   SIZE RO TYPE  MOUNTPOINTS
sda             8:0    1  29.1G  0 disk  

Копіюємо образ, який скачали вище:

[setevoy@setevoy-work ~] $ sudo dd if=~/Downloads/Rasp/disk.raw of=/dev/sda bs=4M status=progress conv=fsync

По завершенню – відключаємо живлення Малинки, повертаємо джампер назад до FAN/VDD, і завантажуємось у звичайному режимі.

Але… Як писав вище – я не не зміг залогінитись.

Є такий gist з дефолтними логінами:паролями – жоден не підійшов.

В документації Debian говориться, що root просто без пароля – але не пускало.

Тому я забив на “чистий” Debian, і просто взяв Raspberry Pi OS Lite, тим більш він все одно Debian-based.

І, мабуть, це для Малинки навіть краще.

До того ж – вперше подивився, як робити переустановку системи на eMMC.

Установка Raspberry Pi OS Lite

Знов переключаємо джампер, підключаємо USB до ноутбука, ще раз запускаємо rpiusbboot.

Видаляємо все з диска на Raspberry (УВАЖНО перевіряємо девайс!):

[setevoy@setevoy-work ~] $ sudo wipefs -a /dev/sda
/dev/sda: 8 bytes were erased at offset 0x00000200 (gpt): 45 46 49 20 50 41 52 54
/dev/sda: 2 bytes were erased at offset 0x000001fe (PMBR): 55 aa
/dev/sda: calling ioctl to re-read partition table: Success

Качаємо образ з сайту Raspberry, отримуємо архів 2025-12-04-raspios-trixie-arm64-lite.img.xz.

Тепер робимо той самий dd, але вже просто передаємо до нього образ через xzcat і pipe:

[setevoy@setevoy-work ~] $ xzcat ~/Downloads/ISO/2025-12-04-raspios-trixie-arm64-lite.img.xz | sudo dd of=/dev/sda bs=4M status=progress conv=fsync

Повертаємо джампер, завантажуємось, і – ура!

Створюємо юзера, логінимось – все працює.

Включення SSH

Suprize – але systemctl start sshd тут не варіант 🙂

Хоча systemd в системі є.

Запускаємо raspi-config:

setevoy@raspberrypi:~ $ sudo raspi-config

Переходимо в Interface Options:

Вибираємо і вмикаємо SSH:

Підключаємось:

[setevoy@setevoy-work ~] $ ssh 192.168.0.61
...
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
setevoy@raspberrypi:~ $ 

Запускаємо апгрейд системи:

setevoy@raspberrypi:~ $ sudo apt update && sudo apt full-upgrade -y

Ну і вже налаштовуємо всякі hostname, timezone і решту потрібних параметрів системи.

Static IP на MikroTik

Трохи про мережу, хоча тут все доволі стандартно – NetworkManager та nmcli.

В мене MikroTik, і зараз Raspberry Pi має динамічний IP з пулу DHCP сервера:

Додаємо статичний lease на MAC-адресу Малинки:

/ip dhcp-server lease add address=192.168.0.5 mac-address=2C:CF:67:59:14:9D comment=setevoy-pi

Видаляємо старий:

/ip dhcp-server lease remove 5

Перевіряємо підключення на Raspberry:

setevoy@raspberrypi:~ $ nmcli device status
DEVICE  TYPE      STATE                   CONNECTION         
eth0    ethernet  connected               Wired connection 1 
lo      loopback  connected (externally)  lo

І виконуємо або sudo nmcli device reapply eth0, або sudo nmcli device disconnect eth0 && sudo nmcli device connect eth0, або просто reboot – і тепер можемо підключатись за новою адресою:

[setevoy@setevoy-work ~] $ ssh 192.168.0.5
...
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Thu Feb 12 11:54:41 2026 from 192.168.0.3
setevoy@raspberrypi:~ $ 

Можна відразу на MikroTik додати новий DNS record:

/ip dns static add name=pi.setevoy address=192.168.0.5 ttl=1d

Перевіряємо:

/ip dns static print where name=pi.setevoy

І перевіряємо з робочого ноутбука:

[setevoy@setevoy-work ~]  $ dig pi.setevoy +short
192.168.0.5

В принципі, на цьому все.

Встановлюємо Docker, Docker Compose, запускаємо всякі Glance і Online Kuma.

Далі Online Kuma можна налаштувати на відправку альортів до, наприклад, ntfy.sh – і мати моніторинг свого моніторингу.

А стоїть моя Малинка ось тут:

Про збірку шафи буду писати окремо в заключній частині по налаштуванню Home NAS.

Loading

Glance: налаштування self-hosted home page для браузера
0 (0)

11 Лютого 2026

Є така прикольна штука, як self-hosted home pages.

Колись побачив їх десь на Reddit, зберіг в закладки, і ось тепер, як в мене є всяка self-hosted тема з NAS (див. FreeBSD: Home NAS, part 1), Grafana і іншими корисними в роботі і побуті речами – то подумав, що було б непогано зробити і собі таку дашборду.

З тих, що в мене збережені, і вони, здається, найбільш популярні – це gethomepage/homepage та glanceapp/glance.

Але Homepage якась більш важка, з купою компонентів – фронтенд, бекенд, якісь рендери, а Glance – простіша, і при цьому, в принципі, має все, що я хотів побачити – хоча місцями це робиться через костилі 🙂

Власне – налаштування Glance.

Робити поки буду локально на ноутбуці з Docker Compose, пізніше перенесу конфіг або на FreeBSD/NAS чи на Raspberry PI з Debian.

Налаштування Glance

Див. документацію Pages & Columns.

Основні компоненти – це Pages, Columns та Widgets:

  • pages: власне, сторінки – можна мати кілька вкладок
  • columns: кожна page розбивається на кілька колонок
  • widgets: і в кожній колонці є набір віджетів

Віджети теж можна групувати і робити вкладки – далі побачимо на прикладі Reddit.

Для погратись – легко запускається з Docker, див. Installation.

Окремих тем нема – все налаштовується через стилі, наприклад:

...
theme:
  background-color: 225 14 15
  primary-color: 157 47 65
  contrast-multiplier: 1.1
...

Але є готові стилі – див. Themes.

Окрім дефолтних віджетів є community widgets, наприклад я там собі взяв NextDNS Stats.

Мої Pages та Widgets

Коротко – приклад того, як це все можна налаштувати.

Так як в мене це все хоститься локально і кудись в GitHub я конфіг зберігати не буду – то всякі токени записав прямо в конфіг, але взагалі для них можна використати змінні оточення – див. Other ways of providing tokens/passwords/secrets.

Перша сторінка – Home з трьома колонками:

Clock, Weather, Calendar

Час, погода і календар:

...
pages:
  - name: Home
    columns:
      # ---------- LEFT ----------
      - size: small
        widgets:
          - type: clock
            hour-format: 24h
            timezones:
              - timezone: Asia/Bangkok
                label: Chiang Mai
              - timezone: America/New_York
                label: New York

          - type: weather
            location: Kyiv
            units: metric

          - type: calendar
...

В clock віджеті додав ще дві зони – бо в США у нас частина розробників, а в Chiang Mai – розробник один, але часто з ним спілкуюсь і постійно згадую яка в нього зараз година.

Центрально колонка – з типом full, і для кожної page треба мати як мінімум одну колонку з full.

Search

Тут віджет search:

...
      # ---------- CENTER ----------
      - size: full
        widgets:
          - type: search
            search-engine: duckduckgo
            new-tab: true
            autofocus: true
            bangs:
              - title: YouTube
                shortcut: "!yt"
                url: https://www.youtube.com/results?search_query={QUERY}
...

Bookmarks

Bookmarks – основні для швидкого доступу, розбиті по категоріям:

...
          - type: bookmarks
            groups:
              - title: Local
                color: 120 70 50
                target: _self
                links:
                  - title: MikroTik Gateway
                    url: http://192.168.0.1/webfig/#IP:DHCP_Server.Leases
...
              - title: AI
                color: 260 50 70
                target: _self
                links:
                  - title: ChatGPT
                    url: https://chat.openai.com/chat
...
              - title: RTFM
                color: 200 50 50
                target: _self
                links:
                  - title: RTFM Main
                    url: https://rtfm.co.ua/
...

Для вибору color можна скористатись colorpicker.dev: перша цифра – сам колір, друга – saturation (насиченість), третя – lightness (яскравість).

Group для Reddit

Далі приклад групування з Group – зробив собі окремі вкладки для різних сабредітів, але двома окремими групами – умовний “Reddit Ukraine” і “Reddit IT”:

...
          - type: group
            widgets:
              - type: reddit
                subreddit: finance_ukr
                show-thumbnails: true
              - type: reddit
                subreddit: durka_ukr
                show-thumbnails: true

          - type: group
            widgets:
              - type: reddit
                subreddit: aws
                show-thumbnails: false
              - type: reddit
                subreddit: archlinux
                show-thumbnails: false
...

Split Column для новин

Наступний віджет – Split Column, де більше стисло новини і якісь цікаві матеріали:

...
          - type: split-column
            max-columns: 2
            widgets:
              - type: lobsters
                sort-by: hot
                limit: 15
                collapse-after: 5
              - type: rss
                title: News
                style: vertical-list
                feeds:
                  - url: https://aws.amazon.com/blogs/aws/feed/
                    title: AWS Blogs
                  - url: https://skeletor.org.ua/?feed=rss2
                    title: Skeletor
...

Ну і більш цікава частина – справа, інформація по статусу сервісів.

GitHub Releases

Віджет releases – останні релізи в GitHub:

...
      - size: small
        widgets:
          - type: releases
            token: github_pat_11A***1jF
            repositories:
              - VictoriaMetrics/VictoriaMetrics
              - pdf/zfs_exporter
              - tess1o/go-ecoflow-exporter
...

Мені за zfs_exporter і go-ecoflow-exporter є сенс слідкувати, бо вони в мене деплояться вручну, див. FreeBSD: Home NAS, part 11 – extended моніторинг з додатковими експортерами.

Хоча, звісно, ніхто не відміняє можливість просто підписатись на апдейти в самому GitHub 🙂

Custom API для NextDNS

Приклад кастомного віджета custom-api – інформація по моєму NextDNS:

...
          - type: custom-api
            title: NextDNS Analytics
            title-url: https://api.nextdns.io/profiles/***/analytics/status
            cache: 1h
            url: https://api.nextdns.io/profiles/***/analytics/status
            headers:
              X-Api-Key: 3f8***457
            template: |
              {{ if eq .Response.StatusCode 200 }}
                <div style="display: flex; justify-content: space-between;">
                  {{ $total := 0.0 }}
                  {{ $blocked := 0.0 }}
                  {{ range .JSON.Array "data" }}
                    {{ $total = add $total (.Int "queries" | toFloat) }}
                    {{ if eq (.String "status") "blocked" }}
                      {{ $blocked = add $blocked (.Int "queries" | toFloat) }}
              ...
                <div style="text-align: center; color: var(--color-negative);">
                  Error: {{ .Response.StatusCode }} - {{ .Response.Status }}
                </div>
              {{ end }}
...

І далі ще буде приклад з власним міні-API сервісом.

Monitor – статуси HTTP-сервісів

Віджет monitor – прикольна штука для відображення статусу сервісів, робить простий GET-запит на вказаний URL, ну і працює (принаймні поки що) тільки з HTTP/S:

...
          - type: monitor
            cache: 1m
            title: Services
            sites:
              - title: RTFM
                url: https://rtfm.co.ua
                icon: /assets/rtfm-icon-2.png
              - title: MikroTik RB4011
                url: http://192.168.0.1
                icon: sh:mikrotik-light
              - title: Grafana
                url: http://nas.setevoy:3000
                icon: sh:grafana
              - title: Jellyfin
                url: http://nas.setevoy:8096
                icon: sh:jellyfin
...

На MikroTik RB4011 (див. MikroTik: перше знайомство та Getting Started), наприклад, з локальної мережі доступний web-інтерфейс, тому через нього можна перевіряти статус.

Аби підключити іконки – шукаємо дефолтні на, наприклад, selfh.st або dashboardicons.com.

Або можна задати кастомні іконки – додаємо файли в каталог /assets і включаємо його в конфігу Glance:

...
server:
  host: 0.0.0.0
  port: 8080
  assets-path: /app/assets
...

Server Stats – CPU, RAM, Disk

Цікава штука server-stats, хоча вона ще в beta:

...
          - type: server-stats
            servers:
              - type: local
                name: setevoy-work
...

Потребує додаткового сервісу – glanceapp/agent, я його поки що просто додав до docker-compose.yaml:

services:
  glance:
    container_name: glance
    image: glanceapp/glance
    restart: unless-stopped
    volumes:
      - ./config:/app/config
      - ./assets:/app/assets
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
    ports:
      - 8080:8080
    env_file: .env

  glance-agent:
    container_name: glance-agent
    image: glanceapp/agent
    ports:
      - 27973:27973

Docker containers

І останній тут – запущені Docker-контейнери, бо іноді забуваю, що щось запущено:

...
          - type: docker-containers
            hide-by-default: false
            running-only: true
...

(про Uptime Kuma теж напишу)

Вся сторінка Home вийшла поки що такою:

Custom API для FreeBSD/NAS

Ну і з цікавих рішень: хочеться з FreeBSD виводити якусь цікаву інформацію.

Active SSH connections

Спершу приклад “активні SSH-юзери” – перевіряємо хто підключений і звідки, виводимо тільки унікальні адреси:

root@setevoy-nas:~ # who | awk '$6 ~ /^\(/ {print $1, $6}' | sort -u
setevoy (10.100.0.3)
setevoy (192.168.0.3)

10.100.0.3 – це я підключений з домашнього ноута через VPN, а 192.168.0.3 – ноутбук в локальній мережі в офісі.

Тепер робимо простий python-скрипт, який буде нашим API-ендпоінтом.

Пишемо файл /usr/local/bin/glance_api.py:

#!/usr/bin/env python3.11
# minimal API server for Glance NAS page
# exposes active SSH sessions as JSON

from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess
import json

HOST = "192.168.0.2"
PORT = 9001


class Handler(BaseHTTPRequestHandler):

    def do_GET(self):

        if self.path == "/ssh":

            # run who and extract unique SSH sessions
            output = subprocess.check_output(
                "who | awk '$6 ~ /^\\(/ {print $1, $6}' | sort -u",
                shell=True
            ).decode().strip().splitlines()

            sessions = []

            for line in output:
                if line:
                    parts = line.split()
                    sessions.append({
                        "user": parts[0],
                        "ip": parts[1].strip("()")
                    })

            response = {
                "count": len(sessions),
                "sessions": sessions
            }

            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps(response).encode())

        else:
            self.send_response(404)
            self.end_headers()


if __name__ == "__main__":
    server = HTTPServer((HOST, PORT), Handler)
    server.serve_forever()

Запускаємо його:

root@setevoy-nas:~ # chmod +x /usr/local/bin/glance_api.py

root@setevoy-nas:~ # /usr/local/bin/glance_api.py

Перевіряємо локально:

root@setevoy-nas:~ # curl 192.168.0.2:9001/ssh
{"count": 2, "sessions": [{"user": "setevoy", "ip": "10.100.0.3"}, {"user": "setevoy", "ip": "192.168.0.3"}]}

Тепер додаємо в Glance новий віджет з типом custom-api:

...
      # ---------- RIGHT ----------
      - size: small
        widgets:
          - type: custom-api
            title: Active SSH
            url: http://nas.setevoy:9001/ssh
            template: |
              {{ $count := .JSON.Int "count" }}

              {{ if eq $count 0 }}
                No active SSH sessions
              {{ else }}
                <ul class="list list-gap-10">
                {{ range .JSON.Array "sessions" }}
                  <li>
                    <span class="color-highlight">{{ .String "user" }}</span>
                    <span class="color-muted">({{ .String "ip" }})</span>
                  </li>
                {{ end }}
                </ul>
              {{ end }}
...

І результат:

Uptime, CPU, ZFS pool status

Додатково можна вивести ще інформацію – uptime, CPU load, etc.

Додаємо в скрипт ще один ендпоінт /status, тепер весь скрипт такий:

#!/usr/bin/env python3.11
# minimal API server for Glance NAS page
# exposes active SSH sessions as JSON

from http.server import BaseHTTPRequestHandler, HTTPServer
import subprocess
import json

HOST = "192.168.0.2"
PORT = 9001


class Handler(BaseHTTPRequestHandler):

    def do_GET(self):

        if self.path == "/ssh":

            # run who and extract unique SSH sessions
            output = subprocess.check_output(
                "who | awk '$6 ~ /^\\(/ {print $1, $6}' | sort -u",
                shell=True
            ).decode().strip().splitlines()

            sessions = []

            for line in output:
                if line:
                    parts = line.split()
                    sessions.append({
                        "user": parts[0],
                        "ip": parts[1].strip("()")
                    })

            response = {
                "count": len(sessions),
                "sessions": sessions
            }

            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps(response).encode())

        elif self.path == "/status":

            # get uptime and load averages
            uptime_raw = subprocess.check_output(
                "uptime",
                shell=True
            ).decode().strip()

            # extract load averages
            load_part = uptime_raw.split("load averages:")[1].strip()
            load_values = [x.strip() for x in load_part.split(",")]

            # extract uptime text
            uptime_text = uptime_raw.split(" up ", 1)[1].split(", load averages:", 1)[0].rsplit(",", 1)[0].strip()

            # get zpool info
            zpool_raw = subprocess.check_output(
                "zpool list -H -o name,health,size,alloc,free,capacity",
                shell=True
            ).decode().strip().split()

            zpool = {
                "name": zpool_raw[0],
                "health": zpool_raw[1],
                "size": zpool_raw[2],
                "alloc": zpool_raw[3],
                "free": zpool_raw[4],
                "capacity": zpool_raw[5],
            }

            # get child datasets only
            datasets_raw = subprocess.check_output(
                "zfs list -H -o name,used,avail -r nas | tail -n +2",
                shell=True
            ).decode().strip().splitlines()

            datasets = []

            for line in datasets_raw:
                parts = line.split()
                datasets.append({
                    "name": parts[0],
                    "used": parts[1],
                    "avail": parts[2],
                })

            response = {
                "load": {
                    "1m": load_values[0],
                    "5m": load_values[1],
                    "15m": load_values[2],
                },
                "uptime": uptime_text,
                "zpool": zpool,
                "datasets": datasets
            }

            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            self.wfile.write(json.dumps(response).encode())

        else:
            self.send_response(404)
            self.end_headers()


if __name__ == "__main__":
    server = HTTPServer((HOST, PORT), Handler)
    server.serve_forever()

Відразу скрипт для rc.d/usr/local/etc/rc.d/glance_api:

#!/bin/sh

# PROVIDE: glance_api
# REQUIRE: NETWORKING
# KEYWORD: shutdown

. /etc/rc.subr

name="glance_api"
rcvar="glance_api_enable"

command="/usr/local/bin/python3.11"
command_args="/usr/local/bin/glance_api.py"
pidfile="/var/run/${name}.pid"

start_cmd="${name}_start"
stop_cmd="${name}_stop"

glance_api_start()
{
    echo "Starting glance_api..."
    daemon -p ${pidfile} ${command} ${command_args}
}

glance_api_stop()
{
    echo "Stopping glance_api..."
    if [ -f ${pidfile} ]; then
        kill `cat ${pidfile}`
        rm -f ${pidfile}
    fi
}

load_rc_config $name
: ${glance_api_enable:=no}

run_rc_command "$1"

Запускаємо:

root@setevoy-nas:~ # chmod +x /usr/local/etc/rc.d/glance_api
root@setevoy-nas:~ # sysrc glance_api_enable=YES
glance_api_enable:  -> YES

root@setevoy-nas:~ # service glance_api start
Starting glance_api...

В Glance додаємо ще один блок:

...
          - type: custom-api
            title: NAS Status
            url: http://nas.setevoy:9001/status
            template: |
              <div>
                <div><b>Uptime:</b> {{ .JSON.String "uptime" }}</div>

                <div style="margin-top:10px;">
                  <b>Load:</b>
                  {{ .JSON.String "load.1m" }} /
                  {{ .JSON.String "load.5m" }} /
                  {{ .JSON.String "load.15m" }}
                </div>

                <div style="margin-top:10px;">
                  <b>ZFS:</b>
                  <span class="color-positive">
                    {{ .JSON.String "zpool.health" }}
                  </span>
                  ({{ .JSON.String "zpool.capacity" }} used)
                </div>

                <div style="margin-top:10px;">
                  <b>Datasets:</b>
                  <ul class="list list-gap-5">
                  {{ range .JSON.Array "datasets" }}
                    <li>
                      {{ .String "name" }} —
                      {{ .String "used" }} used
                    </li>
                  {{ end }}
                  </ul>
                </div>
              </div>
...

І тепер NAS Page виглядає так:

Можна було б в glance_api.py додати і виконання дій через POST – але я не став заморачуватись, та і виконувати команди з дашборди – це вже трохи занадто.

Останній штрих – Chrome/Firefox extension Custom New Tab URL:

Ну а потім, яе Glance переїде на окремий хост – то замінити URL.

Єдиний мінус в Glance – що він не вміє в auto refresh 🙁 Але можна зробити теж через екстешени браузеру.

Loading

FreeBSD: Home NAS, part 11 – extended моніторинг з додатковими експортерами
0 (0)

9 Лютого 2026

В попередньому пості FreeBSD: Home NAS, part 10 – моніторинг з VictoriaMetrics та Grafana налаштували VictoriaMetrics, node_exporter, Grafana, зробили базовий дашборд і базові алерти.

Тепер до цього хочеться додати  трохи більше моніторингу – бачити дані по CPU/RAM процесів, інформацію по SMART та ZFS.

Все, що тут написав – додав в репозиторій setevoy2/nas-monitoring: там і скрипти, і Grafana dashboard.

Всі частини серії по налаштуванню домашнього NAS на FreeBSD:

Установка кастомних експортерів

Не всі експортери мають порти або є в репозиторії – тому опишу, як зробив власний “pseudo FreeBSD port”.

Установка ZFS exporter

Репозиторій експортеру – zfs_exporter.

Взагалі в портах є інший експортер – py-prometheus-zfs, але я вже зробив з цим, і вийшло наче непогане – і цікаве – рішення, тому збережу в блог те, як це налаштував.

І те саме рішення використав для go-ecoflow-exporter.

Отже, у нас є GitHub репозиторій експортеру, в репозиторії є релізи, де можна скачати готовий білд – але не всі підтримують готові файли для FreeBSD.

Зато у всіх є код, і більшість сервісі на Go – тому їх легко зібрати самому.

Ідея доволі проста:

  • скрипт build.sh: завантажити або оновити код експортеру
  • Makefile: для запуску build.sh та копіювання файлу самого експортеру і його rc.d скрипта

Створюємо структуру каталогів:

# mkdir -p /opt/exporters/zfs_exporter/{rc.d,src}

Створення build.sh

Додаємо скрипт – він буде клонити репозиторій і виконувати go build.

Не став заморачуватись з файлом VERSION – просто беремо master бранч, і білдимо з нього.

Пишемо /opt/exporters/zfs_exporter/build.sh:

#!/bin/sh

# stop on first error
set -e

BASE_DIR="/opt/exporters/zfs_exporter"
SRC_DIR="${BASE_DIR}/src/zfs_exporter"
BIN_NAME="zfs_exporter"
REPO_URL="https://github.com/pdf/zfs_exporter.git"

# ensure src dir exists
mkdir -p "${BASE_DIR}/src"

# clone repo if it does not exist
if [ ! -d "${SRC_DIR}" ]; then
    git clone "${REPO_URL}" "${SRC_DIR}"
fi

cd "${SRC_DIR}"

# always update sources
git pull

# build binary into BASE_DIR
go build -o "${BASE_DIR}/${BIN_NAME}"

Задаємо права на запуск:

# chmod +x /opt/exporters/zfs_exporter/build.sh

Запускаємо:

# /opt/exporters/zfs_exporter/build.sh

Перевіряємо:

# ll /opt/exporters/zfs_exporter/src/
total 8
drwxr-xr-x  6 root setevoy  512B Feb  9 13:32 zfs_exporter

І бінарний файл:

# file /opt/exporters/zfs_exporter/zfs_exporter 
/opt/exporters/zfs_exporter/zfs_exporter: ELF 64-bit LSB executable, x86-64, version 1 (FreeBSD) ...

Можна запустити його для перевірки:

# /opt/exporters/zfs_exporter/zfs_exporter
time=2026-02-09T13:42:30.119+02:00 level=INFO source=zfs_exporter.go:40 msg="Starting zfs_exporter" version="(version=, branch=, revision=7af698c8844864eb1e724ed08c47e5a7b4bbcc53)"
time=2026-02-09T13:42:30.120+02:00 level=INFO source=zfs_exporter.go:41 msg="Build context" context="(go=go1.24.12, platform=freebsd/amd64, user=, date=, tags=unknown)"
...
time=2026-02-09T13:42:30.120+02:00 level=INFO source=tls_config.go:354 msg="Listening on" address=[::]:9134
...

Тепер додаємо Makefile, аби простіше буде робити установку і апдейти.

Створення Makefile

Вся логіка апдейту і білда буде в build.sh, а в Makefile просто викликаємо сам скрипт і виконуємо установку файлів в систему:

# simple makefile for zfs_exporter

PREFIX=/usr/local
BIN_NAME=zfs_exporter
BASE_DIR=/opt/exporters/zfs_exporter

.PHONY: build install clean

build:
  $(BASE_DIR)/build.sh

install:
  install -m 0755 $(BASE_DIR)/$(BIN_NAME) $(PREFIX)/bin/$(BIN_NAME)

clean:
  rm -f $(BASE_DIR)/$(BIN_NAME)

Можемо використовувати його як:

# cd /opt/exporters/zfs_exporter
# make build

// або
# make -C /opt/exporters/zfs_exporter build

// або
# make -f /opt/exporters/zfs_exporter/Makefile build

Тепер у нас така структура експортеру:

# tree /opt/exporters/zfs_exporter
/opt/exporters/zfs_exporter
├── Makefile
├── build.sh
├── rc.d
├── src
│   └── zfs_exporter
│       ├── CHANGELOG.md
│       ├── LICENSE
...
│       └── zfs_exporter.go
└── zfs_exporter

Запускаємо для перевірки make build:

# make build 
/opt/exporters/zfs_exporter/build.sh
Already up to date.

І make install:

# make install 
install -m 0755 /opt/exporters/zfs_exporter/zfs_exporter /usr/local/bin/zfs_exporter

Перевіряємо ще раз, вже з /usr/local/bin:

# /usr/local/bin/zfs_exporter
...
time=2026-02-09T13:44:59.651+02:00 level=INFO source=tls_config.go:354 msg="Listening on" address=[::]:9134
...

Створення rc.d скрипта

Пишемо файл /opt/exporters/zfs_exporter/rc.d/zfs_exporter:

#!/bin/sh

# PROVIDE: zfs_exporter
# REQUIRE: DAEMON
# KEYWORD: shutdown

. /etc/rc.subr

name="zfs_exporter"
rcvar="zfs_exporter_enable"

command="/usr/local/bin/zfs_exporter"
pidfile="/var/run/${name}.pid"

# defaults (override in rc.conf)
: ${zfs_exporter_enable:=no}
: ${zfs_exporter_listen_address:=":9134"}
: ${zfs_exporter_extra_flags:=""}
: ${zfs_exporter_log_file:="/var/log/zfs_exporter.log"}

start_cmd="${name}_start"
stop_cmd="${name}_stop"
status_cmd="${name}_status"

zfs_exporter_start()
{
    echo "Starting ${name}"
    /bin/sh -c "${command} --web.listen-address=${zfs_exporter_listen_address} ${zfs_exporter_extra_flags} > ${zfs_exporter_log_file} 2>&1 & echo \$! > ${pidfile}"
}

zfs_exporter_stop()
{
    if [ -f "${pidfile}" ]; then
        kill "$(cat ${pidfile})"
        rm -f "${pidfile}"
        echo "Stopped ${name}"
    else
        echo "${name} is not running"
    fi
}

zfs_exporter_status()
{
    if [ -f "${pidfile}" ] && kill -0 "$(cat ${pidfile})" 2>/dev/null; then
        echo "${name} is running (pid $(cat ${pidfile}))"
    else
        echo "${name} is not running"
        return 1
    fi
}

load_rc_config $name
run_rc_command "$1"

Задаємо права на запуск:

# chmod +x /opt/exporters/zfs_exporter/rc.d/zfs_exporter

Додаємо його копіювання до /usr/local/etc/rc.d в таргет install нашого Makefile:

# simple makefile for zfs_exporter

PREFIX=/usr/local
BIN_NAME=zfs_exporter
BASE_DIR=/opt/exporters/zfs_exporter
RC_DIR=$(PREFIX)/etc/rc.d

.PHONY: build install clean

build:
  $(BASE_DIR)/build.sh

install:
  install -m 0755 $(BASE_DIR)/$(BIN_NAME) $(PREFIX)/bin/$(BIN_NAME)
  install -m 0755 $(BASE_DIR)/rc.d/$(BIN_NAME) $(RC_DIR)/$(BIN_NAME)

clean:
  rm -f $(BASE_DIR)/$(BIN_NAME)

Запускаємо:

# make install 
install -m 0755 /opt/exporters/zfs_exporter/zfs_exporter /usr/local/bin/zfs_exporter
install -m 0755 /opt/exporters/zfs_exporter/rc.d/zfs_exporter /usr/local/etc/rc.d/zfs_exporter

Додаємо до /etc/rc.conf:

# sysrc zfs_exporter_enable="YES"
zfs_exporter_enable:  -> YES

Запускаємо сервіс:

# service zfs_exporter start
Starting zfs_exporter

Перевіряємо статус:

# service zfs_exporter status
zfs_exporter is running (pid 91712)

Перевіряємо лог:

# tail -f /var/log/zfs_exporter.log 
time=2026-02-09T13:52:07.033+02:00 level=INFO source=zfs_exporter.go:40 msg="Starting zfs_exporter" version="(version=, branch=, revision=7af698c8844864eb1e724ed08c47e5a7b4bbcc53)"
...
time=2026-02-09T13:52:07.033+02:00 level=INFO source=tls_config.go:354 msg="Listening on" address=[::]:9134
...

І метрики:

# curl -s localhost:9134/metrics | grep zfs_ | head -5
# HELP zfs_dataset_available_bytes The amount of space in bytes available to the dataset and all its children.
# TYPE zfs_dataset_available_bytes gauge
zfs_dataset_available_bytes{name="nas",pool="nas",type="filesystem"} 2.723599372288e+12
zfs_dataset_available_bytes{name="nas/backups",pool="nas",type="filesystem"} 2.723599372288e+12
zfs_dataset_available_bytes{name="nas/media",pool="nas",type="filesystem"} 2.723599372288e+12

VMAgent описував в попередньому пості в частині Установка VMAgent – додаємо до нього збір метрик з нового експортеру.

Редагуємо /usr/local/etc/prometheus/prometheus.yml, додаємо новий таргет:

...
  - job_name: "zfs_exporter"
    static_configs:
      - targets:
          - "127.0.0.1:9134"
...

Рестартимо vmagent і перевіряємо метрики в VictoriaMetrics:

Exporter upgrade з make

Тут у нас просто кілька кроків:

# service zfs_exporter stop

# make -C /opt/exporters/zfs_exporter build

# make -C /opt/exporters/zfs_exporter install

# service zfs_exporter start

Без VERSION чи тегів або релізів – все максимально просто.

Можна додати до Makefile:

...

upgrade:
        service zfs_exporter stop || true
        $(MAKE) build
        $(MAKE) install
        service zfs_exporter start

Установка go-ecoflow-exporter

Аналогічно зробив для go-ecoflow-exporter, але тут є пару відмінностей, бо треба передати пачку змінних оточення.

Для цього в скрипті /opt/exporters/ecoflow_exporter/rc.d/ecoflow_exporter додаємо export:

...
ecoflow_exporter_start()
{
    echo "Starting ${name}"

    export \
        EXPORTER_TYPE \
        ECOFLOW_EMAIL \
        ECOFLOW_PASSWORD \
        ECOFLOW_DEVICES \
        MQTT_DEVICE_OFFLINE_THRESHOLD_SECONDS \
        DEBUG_ENABLED \
        METRIC_PREFIX \
        SCRAPING_INTERVAL \
        PROMETHEUS_ENABLED \
        PROMETHEUS_PORT

    /bin/sh -c "${command} --listen ${ecoflow_exporter_listen_address} ${ecoflow_exporter_extra_flags} > ${ecoflow_exporter_log_file} 2>&1 & echo \$! > ${pidfile}"
}
...

А значення – через /etc/rc.conf.d і файл /etc/rc.conf.d/ecoflow_exporter:

# cat /etc/rc.conf.d/ecoflow_exporter
ecoflow_exporter_enable="YES"

EXPORTER_TYPE="mqtt"
...
PROMETHEUS_PORT="2112"

Ім’я файлу в rc.conf.d має збігатись з іменем в rc.d, тобто:

# cat /usr/local/etc/rc.d/ecoflow_exporter | grep name=
name="ecoflow_exporter"

Додаємо новий таргет до VMAgent:

...
  - job_name: "ecoflow_exporter"
    static_configs:
      - targets:
          - "127.0.0.1:2112"
...

Установка smartctl_exporter

Він є в портах – smartctl_exporter і в репозиторії FreeBSD, тому просто встановлюємо з pkg:

# pkg install -y smartctl_exporter

Додаємо запуск:

# sysrc smartctl_exporter_enable="YES"

Запускаємо сервіс:

# service smartctl_exporter start

Перевіряємо метрики:

# curl -s 127.0.0.1:9633/metrics | grep smart | grep -v \# | head -5
smartctl_device{ata_additional_product_id="unknown",ata_version="ACS-4 T13/BSR INCITS 529 revision 5",device="ada0" ...

Додаємо VMAgent таргет:

...
  - job_name: smartctl_exporter
    static_configs:
      - targets:
          - 127.0.0.1:9633
...

Але smartctl_exporter по дефолту збирає інформацію тільки для /dev/ada* – а в мене ще є NVMe.

В rc.d скрипті експортеру є glob:

...
smartctl_exporter_devices (string):           Shell glob (like /dev/ada[0-9]) for all devices
...

Задаємо диски в /etc/rc..conf:

...
smartctl_exporter_devices="/dev/ada* /dev/nvme0"
...

NVMe просто ім’я явно, без glob, бо буде тягнути розділи типу /dev/nvme0ns1.

Node Exporter та Textfile

Скільки років користуюсь node_exporter в Kubernetes – не знав, що у нього є така цікава можливість як Textfile, є навіть цілі колекції, див. node-exporter-textfile-collector-scripts.

Що хотілось бачити у себе – це інформацію по CPU/RAM процесів, і спочатку думав просто взяти process_exporter, як це робив в Kubernetes (див. Kubernetes: моніторинг процесів з process-exporter).

Але process_exporter не працює з FreeBSD, бо він всю інформацію збирає з каталогу /proc, який включити можна – але все одно буде не Linux-proc.

Тому зробив інакше – через node_exporter та textfiles, через які збираю температуру CPU та інформацію по процесам.

Перевіряємо, що директорію, з якої директорі node_exporter читає файли з метриками:

root@setevoy-nas:~ # ps aux | grep node_exporter
nobody            2511   0.0  0.2   1264028  13012  -  S    Fri17       1:17.17 /usr/local/bin/node_exporter --web.listen-address=:9100 --collector.textfile.directory=/var/tmp/node_exporter

--collector.textfile.directory=/var/tmp/node_exporter – Окей, включено, можна додавати дані.

Скрипт для CPU temperature, CPU та RAM процесами

Пишемо скрипт /usr/local/bin/process_resources_exporter.sh:

#!/bin/sh

OUT="/var/tmp/node_exporter/process_resources.prom"

# reset file once
{
  echo "# HELP local_process_memory_bytes resident memory size per process"
  echo "# TYPE local_process_memory_bytes gauge"

  echo "# HELP local_process_cpu_percent cpu usage percent per process"
  echo "# TYPE local_process_cpu_percent gauge"

  echo "# HELP node_cpu_temperature_celsius CPU/system temperature via ACPI"
  echo "# TYPE node_cpu_temperature_celsius gauge"
} > "$OUT"

# ----
# top processes by memory (aggregate by process name)
# ----
ps -axo comm,rss | grep -vE '^(idle|pagezero|kernel)' | awk '
{
  gsub(/ /,"_",$1)
  mem[$1] += $2
}
END {
  for (p in mem)
    printf "local_process_memory_bytes{process=\"%s\"} %d\n", p, mem[p] * 1024
}
' | sort -k2 -nr | head -10 >> "$OUT"

# ----
# top processes by cpu (aggregate by process name)
# ----
ps -axo comm,%cpu | grep -vE '^(idle|pagezero|kernel)' | awk '
{
  gsub(/ /,"_",$1)
  cpu[$1] += $2
}
END {
  for (p in cpu)
    printf "local_process_cpu_percent{process=\"%s\"} %.2f\n", p, cpu[p]
}
' | sort -k2 -nr | head -10 >> "$OUT"

# ----
# cpu temperature via ACPI
# ----
TEMP=$(sysctl -n hw.acpi.thermal.tz0.temperature 2>/dev/null | tr -d 'C')

if [ -n "$TEMP" ]; then
  echo "node_cpu_temperature_celsius $TEMP" >> "$OUT"
fi

Запускаємо для тесту:

# chmod +x /usr/local/bin/process_resources_exporter.sh

# /usr/local/bin/process_resources_exporter.sh

Перевіряємо файл /var/tmp/node_exporter/process_resources.prom  з метриками:

# cat /var/tmp/node_exporter/process_resources.prom 
# HELP local_process_memory_bytes resident memory size per process
# TYPE local_process_memory_bytes gauge
# HELP local_process_cpu_percent cpu usage percent per process
# TYPE local_process_cpu_percent gauge
# HELP node_cpu_temperature_celsius CPU/system temperature via ACPI
# TYPE node_cpu_temperature_celsius gauge
local_process_memory_bytes{process="jellyfin"} 456441856
...
local_process_cpu_percent{process="syslogd"} 0.00
node_cpu_temperature_celsius 27.9

І експортер з нього імпортує метрики:

Додаємо скрипт в cron, раз на хвилину буде достатньо:

* * * * * /usr/local/bin/process_resources_exporter.sh

Скрипт для freebsd_update та pkg updates

Аналогічно можна додати інформацію по доступним апдейтам.

Скрипт /usr/local/bin/updates_exporter.sh:

#!/bin/sh

OUT="/var/tmp/node_exporter/updates.prom"

# header
{
  echo "# HELP node_freebsd_update_available FreeBSD base system updates available (1=yes, 0=no)"
  echo "# TYPE node_freebsd_update_available gauge"

  echo "# HELP node_pkg_updates_available Number of pkg updates available"
  echo "# TYPE node_pkg_updates_available gauge"
} > "$OUT"

# --------
# freebsd-update
# --------
FREEBSD_UPDATES=0

# freebsd-update fetch returns:
# - exit 0 even if no updates
# - but prints "No updates needed to update system"
if freebsd-update fetch | grep -q "No updates needed"; then
  FREEBSD_UPDATES=0
else
  FREEBSD_UPDATES=1
fi

echo "node_freebsd_update_available $FREEBSD_UPDATES" >> "$OUT"

# --------
# pkg updates
# --------
# pkg version -l "<" lists outdated packages
PKG_UPDATES=$(pkg version -l "<" 2>/dev/null | wc -l | tr -d ' ')

# fallback safety
PKG_UPDATES=${PKG_UPDATES:-0}

echo "node_pkg_updates_available $PKG_UPDATES" >> "$OUT"

Робимо chmod, додаємо в cron – але тут раз на годину, і перевіряємо метрики в VictoriaMetrics:

Скрипт для Services health

Тут перевіряємо, що сервіси запущені, якщо все ОК – пишемо в метрику service_up значення 1, якщо проблеми – то 0.

Скрипт /usr/local/bin/service_status_exporter.sh:

#!/bin/sh

DIR="/var/tmp/node_exporter"
OUT="$DIR/service_status.prom"
TMP="$DIR/service_status.prom.tmp"

# ------------
# helpers
# ------------

check_proc() {
  pgrep -f "$1" >/dev/null 2>&1
}

check_port() {
  host="$1"
  port="$2"
  nc -z "$host" "$port" >/dev/null 2>&1
}

# ----------------
# write metrics (atomic)
# ----------------

cat <<EOF > "$TMP"
# HELP service_up Service availability status (1 = up, 0 = down)
# TYPE service_up gauge
EOF

# jellyfin
if check_port 127.0.0.1 8096; then
  echo 'service_up{name="jellyfin"} 1' >> "$TMP"
else
  echo 'service_up{name="jellyfin"} 0' >> "$TMP"
fi

# filebrowser
if check_port 127.0.0.1 8080; then
  echo 'service_up{name="filebrowser"} 1' >> "$TMP"
else
  echo 'service_up{name="filebrowser"} 0' >> "$TMP"
fi

# grafana
if check_port 127.0.0.1 3000; then
  echo 'service_up{name="grafana"} 1' >> "$TMP"
else
  echo 'service_up{name="grafana"} 0' >> "$TMP"
fi

# victoria-metrics
if check_port 127.0.0.1 8428; then
  echo 'service_up{name="victoria-metrics"} 1' >> "$TMP"
else
  echo 'service_up{name="victoria-metrics"} 0' >> "$TMP"
fi

# sshd (port only)
if check_port 127.0.0.1 22; then
  echo 'service_up{name="sshd"} 1' >> "$TMP"
else
  echo 'service_up{name="sshd"} 0' >> "$TMP"
fi

# nfsd (process only)
if check_proc nfsd; then
  echo 'service_up{name="nfsd"} 1' >> "$TMP"
else
  echo 'service_up{name="nfsd"} 0' >> "$TMP"
fi

# atomic replace
mv "$TMP" "$OUT"

І метрики:

Grafana дашборд – нові графіки

А тепер це все додаємо в Grafana.

Графік CPU процесами:

topk(5, local_process_cpu_percent)

Аналогічно для пам’яті:

Статус дисків зі SMART:

smartctl_device_smart_status

Стан сервісів:

І все разом тепер:

Залишилось додати алерти – і, в принципі, моніторинг готовий.

Loading