Заметки о домашнем сервере

sparn про Linux, Docker и всякий self-hosting. Пишу, чтобы не забыть.

Когда я только собирал домашний сервер, главным аргументом «против» в моей голове был не шум и не место, а электричество. Казалось, что коробка, которая работает 24/7, намотает прилично, и счета вырастут заметно. Прошёл год — пора посчитать честно, а не на ощущениях.

Воткнул сервер через умную розетку с замером мощности и снимал показания пару недель в разных режимах. Картина вышла такая:

  • idle (что бывает большую часть суток): ~12–14 Вт;
  • обычная работа — пара контейнеров что-то делают: ~15–18 Вт;
  • редкие пики под нагрузкой (бэкап, обновления): кратковременно до ~30 Вт.

То есть большую часть времени это около 15 Вт в среднем. Я специально брал низкое по энергопотреблению железо, без прожорливой видеокарты, и это окупилось именно тут.

Дальше арифметика. Берём средние 15 Вт и считаем за год:

15 Вт × 24 ч × 365 дней = 131 400 Вт·ч ≈ 131 кВт·ч в год

В месяц это около 11 кВт·ч. Дальше умножаем на тариф. Возьму для примера 6 ₽ за кВт·ч (у каждого свой, подставьте):

131 кВт·ч × 6 ₽ ≈ 790 ₽ в год
≈ 66 ₽ в месяц

Я перепроверил — даже не поверил с первого раза. Получается, мой домашний сервер за год по электричеству стоит примерно как пара чашек кофе в кофейне. В месяц — меньше, чем я не глядя трачу на всякую ерунду.

Сравнение, которое всё ставит на места: ноутбук в зарядке, чайник, который кипит несколько раз в день, тёплый пол — всё это ест кратно больше. Один час работы электрочайника по мощности сопоставим с тем, что сервер потребляет за половину суток. На фоне холодильника или стиральной машины сервер в счёте просто теряется.

Вывод спустя год простой: страх перед «намотает» был необоснованным. Для типового маломощного домашнего сервера электричество — это статья расходов, которую можно округлить до нуля и забыть. Куда дороже обходится время, которое я на него радостно трачу, но это уже совсем другая бухгалтерия.

Дотянул-таки до обновления домашнего сервера с Ubuntu 22.04 LTS на 24.04. Откладывал, потому что система работала и трогать не хотелось, но поддержка 22.04 не вечная, да и хотелось свежее ядро. Записываю, как прошло и что насторожило.

Сначала подготовка, потому что обновление мажорной версии — это всегда чуть-чуть лотерея. Сделал полный бэкап важных папок (/app, /etc, домашний каталог) через restic. Отдельно — снапшот всей машины на уровне гипервизора, чтобы в случае чего откатиться за минуту целиком, а не восстанавливать по кусочкам. Затем привёл текущую систему в порядок:

sudo apt update && sudo apt full-upgrade
sudo apt autoremove
sudo reboot

Чистый старт без висящих обновлений — чтобы апгрейд не споткнулся о наполовину применённые пакеты.

Сам процесс запускается так:

sudo apt install update-manager-core
sudo do-release-upgrade

Дальше — то, что насторожило по ходу.

needrestart стал интерактивным. В 24.04 он по умолчанию во время апгрейда спрашивает, какие сервисы перезапустить. Посреди длинной установки это неожиданно: думаешь, что процесс идёт сам, а он встал и ждёт ввода. Если хочется тишины, можно заранее перевести его в автоматический режим, поправив /etc/needrestart/needrestart.conf ($nrconf{restart} = 'a';). Я оставил вручную, но имейте в виду — отойти и забыть не получится.

Вопросы про изменённые конфиги. Несколько раз спросило, оставить мою версию конфига или взять версию мейнтейнера (тот самый диалог про *.dpkg-dist). По умолчанию надо оставлять своё, но я каждый раз смотрел дифф через предложенную опцию D, чтобы не пропустить важных изменений в дефолтах.

Сторонние репозитории отключаются. do-release-upgrade закомментировал PPA и внешние списки в sources.list.d. Это нормально и правильно, но после апгрейда их надо осознанно вернуть, поправив на новый кодовое имя релиза (noble), а не вслепую раскомментировать.

После перезагрузки первым делом проверил версию и что вообще живо:

lsb_release -a
uname -r

И главное для меня — контейнеры. Docker пережил обновление нормально, но я всё равно прошёлся по стекам:

docker ps -a
docker compose -f /app/forgejo/compose.yml up -d

Пара контейнеров не поднялась автоматически с первого раза — помогло docker compose up -d по каждому стеку вручную, дальше restart: unless-stopped подхватил. Никаких потерь данных, тома на месте.

Из реально сломанного — практически ничего, что приятно удивило. Один мой самописный скрипт ругнулся на изменившееся поведение системного Python (в 24.04 он строже относится к установке пакетов мимо venv — привет externally-managed-environment), завернул его в нормальный venv и забыл. В остальном — гладко. Снапшот так и не пригодился, но без него я бы нервничал заметно сильнее.

У меня была классическая проблема: бэкап настроен, скрипт по таймеру отрабатывает, и я об этом... никак не узнаю. Пока всё хорошо — тишина. И пока однажды не понадобится восстановиться, ты искренне веришь, что бэкапы делаются. А потом выясняется, что три недели назад скрипт тихо падал на каждом запуске, и свежего архива нет. Классика.

Проблема тут фундаментальная: обычный мониторинг проверяет, что сервис жив. А мне нужно обратное — узнать, что задача НЕ выполнилась. Это называется dead-man's switch: задача должна регулярно подавать признаки жизни, и если она замолчала — это и есть сигнал тревоги. Молчание = проблема.

Для этого поднял у себя Healthchecks. Это сервис, который ждёт от твоей задачи ping по HTTP. Задача успешно отработала — дёрнула URL. Не дёрнула в отведённое окно — Healthchecks сам шлёт алерт. То есть мне не нужно ничего активно опрашивать; если бэкап молча перестал делаться, я об этом узнаю не через месяц, а на следующее же утро.

Стек в Docker, /app/healthchecks/:

services:
  healthchecks:
    image: healthchecks/healthchecks:latest
    container_name: healthchecks
    restart: unless-stopped
    environment:
      - SECRET_KEY=${SECRET_KEY}
      - SITE_ROOT=http://hc.local:8000
      - DEFAULT_FROM_EMAIL=hc@hc.local
      - EMAIL_HOST=${SMTP_HOST}
      - EMAIL_PORT=587
      - EMAIL_HOST_USER=${SMTP_USER}
      - EMAIL_HOST_PASSWORD=${SMTP_PASS}
    volumes:
      - ./data:/data
    ports:
      - "8000:8000"

В вебморде создаёшь check, задаёшь расписание (можно прямо cron-выражением) и grace period — сколько ждать опоздание, прежде чем паниковать. Получаешь уникальный ping-URL.

Дальше прикручиваю его к бэкап-скрипту. Главное — пинговать в самом конце, после set -e, чтобы упавший скрипт не успел отрапортовать об успехе:

#!/bin/bash
set -e

PING_URL="https://hc.local:8000/ping/xxxxxxxx-xxxx-xxxx"

# ... сам бэкап ...
restic backup /app /etc

# дошли сюда без ошибок — рапортуем
curl -fsS -m 10 --retry 3 "$PING_URL" > /dev/null

Можно ещё красивее: пинговать $PING_URL/start в начале и $PING_URL/fail в блоке обработки ошибки — тогда в истории видно длительность задачи и явные падения отдельно от опозданий.

Алерты вешаю на почту, но Healthchecks умеет и в кучу других каналов. Теперь у меня правило простое: если что-то важное должно происходить регулярно — оно дёргает Healthchecks. И отсутствие новостей перестало означать хорошие новости — теперь отсутствие пинга означает, что мне прилетит письмо. Спится спокойнее.

Долго держал все периодические задачи в crontab по привычке. На днях надоело и перевёл их на systemd timers. Записываю плюсы, чтобы потом не гуглить то же самое.

Что мне в итоге понравилось:

  • логи задачи лежат в journalctl -u mybackup.service, а не где-то в почте root или вообще нигде;
  • можно навесить зависимости — например, запускать только After=docker.service;
  • OnCalendar читается человеком, а не как */15 3 * * 1;
  • Persistent=true — если сервер спал в момент запуска, задача отработает после включения. Cron такое просто молча пропускает.

Минимальная пара файлов. Сервис в /etc/systemd/system/backup.service:

[Unit]
Description=Ночной бэкап
After=docker.service

[Service]
Type=oneshot
ExecStart=/app/scripts/backup.sh

Таймер рядом, backup.timer:

[Unit]
Description=Запуск бэкапа раз в сутки

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true

[Install]
WantedBy=timers.target

Включается так:

sudo systemctl daemon-reload
sudo systemctl enable --now backup.timer
systemctl list-timers

list-timers сразу показывает, когда следующий запуск и когда был прошлый — очень удобно, в cron такого обзора из коробки нет. Единственное, к чему привыкаешь: один таск — это два файла вместо одной строки. Для пары задач это перебор, но когда задач десяток и хочется логи и порядок — оно того стоит.

Я давно держал все конфиги сервера и dotfiles на GitHub, и вроде всё работало. Но накопилось несколько вещей, которые меня в этой схеме не устраивали. Во-первых, часть репо — это compose-файлы и .env-шаблоны для домашнего сервера, и мне просто некомфортно, что всё это лежит на чужих серверах, пусть даже в приватных репозиториях. Во-вторых, хотелось, чтобы бэкап моих же конфигов жил рядом с тем, что они описывают, а не зависел от доступности внешнего сервиса. В общем, решил поднять свой git.

Выбор был между Gitea и Forgejo. Forgejo — это форк Gitea, который пошёл своим путём после смены управления у Gitea, и сообщество мне там симпатичнее. По функционалу для моих задач они почти идентичны, так что взял Forgejo.

Подняв в Docker, как и всё остальное у меня. Стек живёт в /app/forgejo/:

services:
  forgejo:
    image: codeberg.org/forgejo/forgejo:9
    container_name: forgejo
    restart: unless-stopped
    environment:
      - USER_UID=1000
      - USER_GID=1000
      - FORGEJO__server__DOMAIN=git.local
      - FORGEJO__server__ROOT_URL=http://git.local:3000/
    volumes:
      - ./data:/data
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "3000:3000"
      - "222:22"

SSH-порт контейнера прокинул на 222, чтобы не конфликтовать с системным sshd. После первого старта Forgejo открывает страницу установки — там выбрал SQLite (для одного пользователя База на postgres избыточна), задал админа и выключил открытую регистрацию, чтобы случайно никто не завёл аккаунт.

Дальше добавил remote к своим dotfiles:

git remote add home ssh://git@192.168.1.10:222/sparn/dotfiles.git
git push home main

Я не стал полностью уходить с GitHub — там удобно показывать что-то людям и пользоваться Actions. Сделал так: home-репо у меня основной, а на GitHub push зеркалом через дополнительный remote, либо через встроенный в Forgejo механизм push-mirror. Получилось, что мои конфиги теперь в двух местах, и одно из них полностью под моим контролем.

Отдельно радует, что веб-морда лёгкая и шустрая даже на скромном железе. Памяти контейнер ест мизер, на idle почти не виден. Для домашнего использования — то что надо: свой git, свои данные, и при этом всё привычно как на больших площадках. Бэкап самого Forgejo — это просто архив папки ./data, кладу его в общий ночной бэкап сервера, так что отдельной возни нет.

Сервер у меня жил на старом HDD, и это чувствовалось во всём: контейнеры стартовали медленно, база отвечала с ленцой, а сам диск по ночам тарахтел так, что слышно из другой комнаты. Купил SSD. И раз уж всё равно переезжать с нуля, решил сразу включить шифрование диска через LUKS — чтобы, если железо однажды уведут или я отнесу его в ремонт, данные не утекли вместе с ним.

Сначала готовлю SSD. Создаю на нём раздел и накатываю LUKS:

sudo cryptsetup luksFormat /dev/sdb1
# YES, придумываем пароль
sudo cryptsetup open /dev/sdb1 cryptdata
sudo mkfs.ext4 /dev/mapper/cryptdata
sudo mount /dev/mapper/cryptdata /mnt/ssd

Теперь /dev/mapper/cryptdata — это расшифрованный том, с которым работаешь как с обычным разделом. Под капотом всё на диске лежит зашифрованным.

Перенос данных — через rsync, чтобы сохранить права, владельцев и симлинки. Сервисы перед этим гашу, иначе утащу базу в неконсистентном состоянии:

docker compose -f /app/paperless/docker-compose.yml down   # и остальные стеки
sudo rsync -aAXv --info=progress2 /app/ /mnt/ssd/app/

Флаги тут важные: -a тянет права и время, -A ACL, -X расширенные атрибуты. Для контейнерных томов это критично — иначе потом ловишь permission denied на ровном месте.

Дальше — чтобы том сам подключался при загрузке. Прописываю его в /etc/crypttab и /etc/fstab. Сначала узнаю UUID раздела:

sudo blkid /dev/sdb1
# UUID="xxxx-xxxx-..."
# /etc/crypttab
cryptdata  UUID=xxxx-xxxx-...  none  luks
# /etc/fstab
/dev/mapper/cryptdata  /app  ext4  defaults  0  2

И тут главный вопрос, на котором спотыкаются все: как разблокировать диск при загрузке. С none в crypttab система при старте просит пароль. На обычном десктопе — без проблем, ввёл с клавиатуры. Но у меня сервер стоит без монитора, и тянуться к нему при каждой перезагрузке — так себе удовольствие.

Я оставил ввод пароля, но через консоль по сети. На сервере поднят dropbear-initramfs — это крошечный SSH-сервер, который живёт ещё до загрузки основной системы, в initramfs. После ребута я захожу на него с ноута и ввожу пароль LUKS:

ssh -p 222 root@192.168.1.10
# попадаю в initramfs
cryptroot-unlock
# вводим пароль -> система продолжает загрузку

Да, это значит, что полностью автономно после отключения света сервер не поднимется — он будет ждать, пока я введу пароль. Для меня это осознанный компромисс: смысл шифрования как раз в том, что без пароля диск не открыть, даже забрав его. Хранить ключ на самой машине рядом с диском — это как запереть дверь и приклеить ключ к косяку.

Что в итоге. SSD оживил сервер до неузнаваемости: контейнеры стартуют мгновенно, база летает, тишина в комнате. А LUKS дал спокойствие — теперь украденный или потерянный диск это просто кусок зашифрованного мусора, а не мой архив документов и бэкапов нараспашку. Переезд занял вечер, и ни о чём не жалею.

[TIL] Надоело лазить по сервисам через 192.168.1.10:8000, :8080, :9000 и помнить, кто на каком порту. Хотелось открывать их по-человечески: paperless.home.lan, nas.home.lan и так далее. Решается это split-DNS — когда твой локальный DNS отдаёт на эти имена внутренний IP сервера.

У меня в роли локального DNS уже стоит AdGuard Home, так что добавил всё в его DNS-rewrites. В UI это Filters → DNS rewrites, а если в конфиге AdGuardHome.yaml, то так:

filtering:
  rewrites:
    - domain: '*.home.lan'
      answer: 192.168.1.10

Один wildcard — и любое имя в зоне home.lan резолвится в сервер. Удобно: завёл новый сервис, придумал ему имя, и оно сразу работает, без правки DNS.

Если AdGuard нет, ровно то же делается на dnsmasq одной строкой:

# /etc/dnsmasq.conf
address=/home.lan/192.168.1.10

Проверка, что отдаёт нужный IP:

nslookup paperless.home.lan 192.168.1.1
# Address: 192.168.1.10

Один нюанс: чтобы это работало на всех устройствах, в роутере DHCP должен раздавать именно этот DNS, иначе телефон пойдёт спрашивать у публичного резолвера и ничего не найдёт. Я взял зону .home.lan (не .local — её занимает mDNS, и не настоящий домен — чтобы не пересекаться с внешним миром).

Дальше повесил перед сервисами reverse-proxy, чтобы порты тоже спрятать, и теперь paperless.home.lan открывается просто в браузере. Маленькое удобство, а пользуюсь каждый день.

Свет у меня моргает редко, но метко. Пару раз в год — короткое отключение на минуту-две, и каждый раз сервер падал жёстко, как будто выдернули вилку. А он так и было, по сути. После одного такого падения ext4 ругнулся при загрузке, а у Postgres пришлось чистить WAL. Ничего не потерял, но понервничал. После второго — поехал за ИБП.

Купил обычный линейно-интерактивный UPS с USB. Задача простая: продержать сервер пару минут и, если свет не вернулся, корректно его погасить — чтобы файловая система и базы закрывались штатно, а не аварийно.

Софт для этого — NUT (Network UPS Tools). Он общается с ИБП по USB, понимает заряд и состояние сети, и дёргает shutdown по заданным правилам.

Ставится из репозитория:

sudo apt install nut

Дальше три конфига. Сначала описываем сам ИБП в /etc/nut/ups.conf:

[homeups]
    driver = usbhid-ups
    port = auto
    desc = "Home server UPS"

Проверяем, что NUT его видит:

sudo upsdrvctl start
upsc homeups
# battery.charge: 100
# ups.status: OL        <- OL = на сети, OB = на батарее

Самое интересное — логика выключения. Живёт в /etc/nut/upssched.conf и в скрипте-обработчике. Я не глушу сервер при первом же чихе сети: даю отлежаться, и реагирую либо по таймеру на батарее, либо по проценту заряда — что наступит раньше.

# /etc/nut/upssched.conf
CMDSCRIPT /etc/nut/upssched-cmd

# свет пропал — НЕ паникуем, ждём 60 секунд
AT ONBATT * START-TIMER onbatt_grace 60
# свет вернулся раньше — отменяем таймер
AT ONLINE * CANCEL-TIMER onbatt_grace
# UPS сам кричит "заряд низкий" — гасимся немедленно
AT LOWBATT * EXECUTE ups_shutdown

А вот сам обработчик, где добавляю ещё и порог по проценту — не хочу дожидаться, пока батарея сядет в ноль:

#!/bin/bash
# /etc/nut/upssched-cmd
case $1 in
    onbatt_grace)
        CHARGE=$(upsc homeups battery.charge)
        logger "NUT: на батарее, заряд ${CHARGE}%"
        # ниже 50% — не рискуем, гасимся
        if [ "$CHARGE" -le 50 ]; then
            /usr/sbin/upsmon -c fsd   # инициируем shutdown
        fi
        ;;
    ups_shutdown)
        logger "NUT: LOWBATT, аварийное выключение"
        /usr/sbin/upsmon -c fsd
        ;;
esac

Идея такая: пропал свет — ждём минуту (вдруг это короткое моргание и всё вернётся). Если через минуту мы всё ещё на батарее и заряд просел до 50% — гасим заранее, с запасом. А если ИБП сам сигналит LOWBATT — выключаемся не раздумывая.

Команда upsmon -c fsd (forced shutdown) запускает штатное выключение: systemd останавливает сервисы, контейнеры получают SIGTERM, базы закрываются как надо, ext4 размонтируется чисто.

Проверять это удобно, выдернув ИБП из розетки и глядя в логи (journalctl -fu nut-monitor). У меня сервер на батарее в простое живёт минут двадцать, так что 50%-порог срабатывает сильно раньше — времени на корректное гашение вагон.

С тех пор отключения света — не событие. Свет мигнул, сервер пережил, я даже не заметил. А если надолго — он сам аккуратно ляжет спать и утром поднимется без единого fsck.

У меня дома был ящик. Обычный ящик, куда годами падали договоры, чеки на технику, квитанции, справки и прочая бумага, которую «вдруг понадобится». Найти что-то в нём было невозможно. Решение — Paperless-ngx: сервис, который сканы бумажек превращает в нормальный искабельный архив.

Поднял в Docker, как всё остальное, в /app/paperless/. Минимальный стек — само приложение, Redis и Postgres:

services:
  paperless:
    image: paperlessngx/paperless-ngx:2.14.7
    depends_on: [db, redis]
    environment:
      PAPERLESS_REDIS: redis://redis:6379
      PAPERLESS_DBHOST: db
      PAPERLESS_OCR_LANGUAGE: rus+eng
      PAPERLESS_CONSUMER_POLLING: 30
    volumes:
      - ./data:/usr/src/paperless/data
      - ./media:/usr/src/paperless/media
      - ./consume:/usr/src/paperless/consume
    ports:
      - "8000:8000"

Главная магия — папка consume. Кидаешь туда PDF или картинку, и Paperless её сам подхватывает: прогоняет через OCR (у меня rus+eng, потому что половина бумаг — на русском), распознаёт текст и складывает документ в архив. После этого по бумажке можно искать как по тексту: вбил «договор аренды 2023» — и вот он.

Сканер у меня выдаёт в сетевую папку, которую я просто примонтировал в consume. То есть процесс целиком: положил лист в сканер, нажал кнопку — через минуту документ уже в архиве, распознан и проиндексирован. Руками не трогаю вообще.

Дальше — организация. Тут две вещи, без которых архив быстро превращается в свалку:

  • Корреспонденты — от кого/кому документ. Налоговая, банк, работодатель, магазин.
  • Тегичеки, гарантия, медицина, авто. Можно навесить несколько.

Самое приятное — Paperless умеет назначать их автоматически по правилам. Я один раз вручную разметил десяток чеков из одного магазина, и теперь он сам ставит тег чеки и нужного корреспондента на всё похожее. Чем больше документов — тем умнее угадывает.

Отдельно про дату. Paperless вытаскивает дату прямо из текста документа, так что чек двухлетней давности встаёт в архив под своей реальной датой, а не под датой сканирования. Мелочь, а порядок наводит.

Ну и про бэкап, потому что архив документов терять нельзя. Тут есть встроенный экспортёр, который выгружает всё в человекочитаемый вид (PDF + метаданные в JSON), а не только дамп базы:

docker compose -f /app/paperless/docker-compose.yml \
  exec paperless document_exporter ../export

Эту папку export я гоню в свой обычный ночной бэкап вместе с остальными томами. Если завтра сервер сгорит — восстановлю не дамп непонятного формата, а нормальные PDF с тегами.

Бумажный ящик, кстати, я не выбросил. Но открываю его теперь раз в год, чтобы отсканировать накопившееся и снова про него забыть.

Долгое время у меня в стеке крутился Watchtower — штука, которая сама следит за обновлениями образов и тихо подтягивает новые версии. Идея красивая: ты ничего не делаешь, а контейнеры всегда свежие. На практике эта красота однажды разбудила меня в три часа ночи уведомлением «сервис недоступен».

Что произошло. У одного из контейнеров вышел новый тег latest, и это была мажорная версия — со сменой формата конфига и схемы базы. Watchtower честно сделал свою работу: выкачал новый образ, снёс старый контейнер, поднял новый. Новый стартанул, увидел старый конфиг, не понял его и упал. И так по кругу — рестарт, падение, рестарт. К утру лог был размером с небольшую книгу.

Сам виноват, конечно. latest — это не версия, это лотерея. Ты подписываешься на «что разработчик зальёт следующим», а он может залить что угодно.

После этого Watchtower я выключил. Совсем:

docker compose -f /app/watchtower/docker-compose.yml down

Теперь обновляюсь руками и осознанно. Во-первых, везде прибил конкретные теги вместо latest:

services:
  paperless:
    # было: image: paperlessngx/paperless-ngx:latest
    image: paperlessngx/paperless-ngx:2.14.7

Во-вторых, завёл простой ритуал. Раз в пару недель смотрю, что вышло:

# что вообще доступно
docker compose -f /app/<сервис>/docker-compose.yml pull --dry-run

# а потом обязательно читаю changelog/release notes на гитхабе

И только если в release notes нет слов про «breaking changes», «migration» и «backup your data before upgrade» — обновляюсь. А если есть — сначала делаю бэкап томов, потом обновляю, потом проверяю, что всё живо.

# бэкап перед апгрейдом — стало рефлексом
docker run --rm -v paperless_data:/data -v $(pwd):/backup \
  alpine tar czf /backup/paperless_data_$(date +%F).tar.gz -C /data .

Да, это медленнее. Да, иногда я неделю сижу на «старой» версии. Зато я точно знаю, что именно и когда поменялось, и если что-то сломается — это будет в удобное мне время, а не в 3:00 под аккомпанемент пуш-уведомлений.

Автоматизация хороша ровно до того момента, пока она не автоматизирует катастрофу. Обновление сервисов — как раз тот случай, где я предпочитаю держать руку на рубильнике сам.