База данных, миграции, резервные копии и репликация
На этой странице описано, как dinary хранит данные, как
запускаются миграции схемы, как устроено полное резервное
копирование (локальные холодные копии, горячая репликация на VM 2
через Litestream, ежедневные офсайт-бэкапы на Яндекс.Диск) и как
восстанавливаться из каждого из этих слоёв.
Файл базы данных
Сервер хранит всё в одной SQLite-базе:
data/dinary.db— живая база данных (категории, группы, теги, магазины, mapping-таблицы, расходы, доходы и метаданные sync-задач)data/dinary.db-walиdata/dinary.db-shm— вспомогательные файлы SQLite в режиме WAL, в которых лежат ещё не заchekpointенные транзакции, прежде чем они будут слиты обратно вdinary.db
Все три файла принадлежат друг другу. Не копируйте только
dinary.db: в WAL-сайдкаре могут быть закоммиченные, но ещё не
перенесённые в основной файл транзакции. Любой способ резервного
копирования, не использующий API SQLite .backup, должен сначала
остановить сервис dinary.
Миграции
Изменения схемы управляются миграциями yoyo в каталоге
src/dinary/migrations/.
Когда миграции запускаются автоматически
- При старте приложения SQLite-файл открывается и
yoyoприменяет все ожидающие миграции до того, как будет обслужен первый запрос. - Во время
inv deploydeploy-скрипт вызываетinv migrateдо перезапуска сервиса — так сломанная миграция роняет deploy, а не уже работающий сервер.
Для свежей установки отдельного ручного шага миграции не требуется.
Ручной запуск
Миграции применяются автоматически при каждом старте сервера — yoyo отслеживает уже применённые миграции и накатывает только новые. Отдельный ручной шаг не нужен: достаточно задеплоить и перезапустить.
Проверка целостности
SQLite предоставляет две прагмы для пост-миграционной и пост-восстановительной проверки:
PRAGMA integrity_checkобходит все btree-страницы и сообщает о структурных повреждениях (порванные страницы, рассогласование индекса и таблицы, осиротевшие записи freelist).PRAGMA foreign_key_checkперечисляет каждую строку, нарушающую объявленный внешний ключ.
Обе — только для чтения и дешёвые. dinary оборачивает их в:
inv verify-db # локальная data/dinary.db
inv verify-db --remote # снапшот prod-базы, проверяемый по SSH
--remote сначала делает на сервере снапшот через
sqlite3 .backup в /tmp, а затем проверяет его, чтобы случайно
не прочитать живой файл в середине checkpoint-а WAL.
Резервные копии
SQLite — однофайловая база, поэтому любой бэкап в итоге сводится к
«получить транзакционно-согласованную копию data/dinary.db».
dinary предлагает два механизма в зависимости от требований к
свежести и допустимой задержке.
Холодная копия: inv backup
inv backup # копия в ./backups/<timestamp>/
inv backup --dest=./my-backups
Команда подключается по SSH к серверу, запускает sqlite3 .backup
на живой БД для получения согласованного снапшота в /tmp,
стримит байты локально и записывает
./backups/<timestamp>/dinary.db. Снапшот атомарен даже когда
сервис пишет — онлайн-бэкап API SQLite копирует страницы под
коротким замком и повторяет срыв чтения.
Запускайте inv backup перед:
inv deploy(обёртка deploy-а сама делает такой же pre-deploy снапшот автоматически)- ручной миграцией схемы
- любой ad-hoc хирургией над БД
Горячая реплика: Litestream
inv backup — pull-based и срабатывает по требованию. Для
непрерывной потоковой репликации — «VM 1 только что исчезла,
сколько данных я потерял» — dinary поддерживает Litestream
(v0.5.x) как sidecar, который постоянно шлёт LTX-сегменты на
SFTP-получатель.
Предусловия
- Вторая VM, которой вы управляете (VM 2 — хост Litestream-реплики).
Референсная цель — Oracle Cloud Free Tier
VM.Standard.E2.1.Microc Ubuntu 22.04 Minimal, тот же шейп, что и VM 1. - На операторской машине в
.deploy/.envзаданDINARY_REPLICA_HOST(например,ubuntu@dinary-replicaчерез Tailscale MagicDNS). ~/.ssh/id_ed25519.pubс VM 1 добавлен в~/.ssh/authorized_keysна VM 2 — это и есть то доверие, которое позволяетlitestream.serviceна VM 1 пушить WAL- сегменты по SFTP. Разовыйssh-copy-idпридётся сделать руками: кросс-хостовое доверие автоматизировать с операторской машины нельзя.
Бутстрап VM 2
Разово, с операторской машины против VM 2:
inv setup-replica
Задача ставит unattended-upgrades, выделяет 1 GB swap, создаёт
/var/lib/litestream/ с правильным владельцем, чтобы SFTP-приёмник
смог писать туда WAL-сегменты, и закрывает публичный SSH (см.
Cloud security notes — там
обоснование). Идемпотентна — повторный запуск после перезагрузки
или смены IP в Tailscale сходится чисто.
setup-replica НЕ ставит на VM 2 сервис приложения dinary,
публичный тоннель или любой Python-рантайм — VM 2 сознательно
остаётся минимальным SFTP-sink-ом. Всё, что нужно ежедневному
офсайт-бэкапу (rclone, sqlite3, zstd, бинарник Litestream для
локального restore), добавляет inv setup-replica — см.
раздел «Офсайт-бэкап: Яндекс.Диск» ниже.
Разовая настройка Litestream на VM 1
- Скопируйте пример конфига локально и заполните SFTP-цель:
cp .deploy.example/litestream.yml .deploy/litestream.yml
# отредактируйте .deploy/litestream.yml — проставьте host, user, path, key-path
- Установите sidecar Litestream на VM 1 (теперь часть
inv setup-replica):
inv setup-replica
Задача ставит бинарник Litestream, заливает конфиг в
/etc/litestream.yml, создаёт systemd-unit
litestream.service и запускает его. Операция идемпотентна —
повторный inv setup-replica обновит бинарник и
перезагрузит конфиг.
- Проверьте здоровье репликации:
inv status --remote
На здоровом sidecar-е systemd-unit активен, а
litestream databases печатает путь к управляемой БД. Пустой
вывод означает, что sidecar либо не смог добраться до
SFTP-хоста, либо ещё делает первый снапшот (первый снапшот
появляется в течение секунд после первой записи в БД после
старта sidecar-а).
inv setup-server НЕ запускает Litestream автоматически даже при
наличии .deploy/litestream.yml: sidecar требует уже
доступного SFTP-хоста, чьи authorized_keys доверяют
ed25519-ключу VM 1, — это межхостовое доверие deploy-скрипт
поднять за вас не может. Запустите inv setup-replica
вручную, когда это предусловие выполнено.
Что делает sidecar
Litestream v0.5 — пассивный репликатор: он открывает БД только на чтение, забирает LTX-сегменты из WAL SQLite, уплотняет их в уровневые файлы и отправляет на SFTP-цель. Приложение никогда с ним не общается. Если sidecar падает, приложение продолжает писать в WAL как обычно — вы просто перестаёте накапливать состояние реплики до следующего запуска sidecar-а. Обратного давления нет; цикл checkpoint-а SQLite не затрагивается.
По умолчанию в примере конфига полный снапшот делается раз в
час, хранится одна неделя LTX-истории (глобальный блок
snapshot: { interval: 1h, retention: 168h }). Это ограничивает
«насколько далеко назад я могу откатить БД» одной неделей и
длину проигрыша LTX при восстановлении — одним часом. Эти поля в
v0.5 — глобальные; помещать их внутрь per-replica блока нельзя,
Litestream не запустится.
Восстановление из реплики
На любом хосте с установленным Litestream и SSH-доступом к цели-реплике:
litestream restore -config /path/to/litestream.yml /path/to/output/dinary.db
Litestream читает самый свежий снапшот в реплике, проигрывает
LTX до последней закоммиченной транзакции и пишет свежий
dinary.db. Восстановленная БД транзакционно согласована —
PRAGMA integrity_check не обязательна, но и не помешает.
Офсайт-бэкап: Яндекс.Диск (ежедневно, GFS-ретеншен)
Litestream-реплика на VM 2 — горячая (RPO — секунды), но живёт в
том же регионе того же облачного провайдера, что и VM 1. Для
сценария «обе VM исчезли» есть ежедневный офсайт-бэкап, который
VM 2 сама пушит на Яндекс.Диск. Оркестрируется через
inv setup-replica.
Что именно происходит
Ежедневно в 03:17 UTC (+ 30 мин jitter) systemd-oneshot на VM 2:
- Материализует локальную Litestream-реплику из
/var/lib/litestream/dinaryв плоский SQLite-файл черезlitestream restore. - Валидирует восстановленный файл через
PRAGMA integrity_check. Если проверка не прошла — ран прерывается, ничего не загружается: перезаписывать историю на Яндексе заведомо битым снапшотом мы отказываемся. - Сжимает через
zstd -19(соотношение у повторяющегося page-layout SQLite близко к оптимальному; CPU на входе < 1 МБ пренебрежимо мал). - Заливает в
yandex:Backup/dinary/dinary-<UTC-ISO>.db.zstчерезrclone. - Подрезает Яндекс.Диск по GFS-политике (см. ниже).
На Яндексе лежат обычные сжатые SQLite-файлы, а не непрозрачный
repo-формат — любая машина с zstd и sqlite3 откроет снапшот
напрямую, без тулинга dinary.
GFS-ретеншен
- 7 свежайших daily снапшотов.
- 4 свежайших weekly снапшота (последний в каждой ISO-неделе).
- 12 свежайших monthly (последний в каждом календарном месяце).
- Все yearly снапшоты — бессрочно (закрытые годы неизменяемы, расхождение между двумя годовыми снапшотами одного закрытого года — сигнал коррапшена, и его стоит хранить вечно).
Корзины пересекаются — снапшот удаляется только если он не попал ни в одну keeper-корзину. За 10 лет это ~29 файлов суммарно (~9 МБ на диске).
Разовый бутстрап
Разово с операторской машины против VM 2 (включает и настройку офсайт-бэкапа):
inv setup-replica
Задача:
- Ставит на VM 2 apt-пакеты
rclone,sqlite3,zstdи пин-версию бинарника Litestream. - На первом запуске интерактивно настраивает удалённый
yandex:в rclone. Если удалённого ещё нет, задача печатает ссылку на страницу Яндекс-app-паролей (https://id.yandex.ru/security/app-passwords), спрашивает Яндекс-логин и читает app-пароль черезgetpass(без эха).
Яндекс WebDAV НЕ принимает обычный пароль от аккаунта Яндекса. Нужен отдельный app-password, создаваемый в категории «Файлы» (Files / WebDAV) на странице выше. Пароли из категорий «Почта», «Календарь» и общего назначения WebDAV- endpoint отвергает. Этот app-password отзывается с той же страницы в любой момент и не влияет на основной пароль аккаунта.
Открытый пароль проходит только по SSH-каналу до VM 2,
отправляется на stdin rclone obscure -, и в
~ubuntu/.config/rclone/rclone.conf записывается уже только
obscured-форма. Открытый текст не попадает ни в argv (в листинг
ps), ни в историю шелла, ни на диск.
После записи конфига задача делает rclone lsd yandex: как
smoke-тест. Любой провал (неверный пароль, неверный scope, нет
сети) прерывает задачу и откатывает только что записанный
[yandex]-раздел — следующий запуск увидит, что remote'а нет, и
спросит креды заново. На зелёном smoke-тесте prompt на
последующих запусках пропускается.
- Пишет /usr/local/bin/dinary-backup и парный ему
retention-скрипт; ставит и включает dinary-backup.timer;
запускает один немедленный прогон, чтобы первый снапшот
появился на Яндекс.Диске в течение минуты после бутстрапа.
Почему app-пароль, а не полный OAuth: VM 2 без монитора, а
интерактивный rclone config ожидает связку «авторизоваться на
ноутбуке → скопировать токен на сервер». App-пароль эквивалентен
для нашего шаблона доступа (PUT/DELETE загружаемых файлов),
отзывается из UI аккаунта Яндекса в любой момент, и бутстрап
остаётся end-to-end неинтерактивным, если не считать ввода
пароля.
Идемпотентна: apt-install-ы — no-op на повторе, rclone-remote не
перенастраивается повторно, пока yandex: существует, скрипты и
systemd-unit-ы перезатираются, enable --now можно звать
повторно безопасно.
Наблюдение за ежедневным прогоном
ssh ubuntu@dinary-replica sudo journalctl -u dinary-backup.service -n 50 --no-pager
ssh ubuntu@dinary-replica sudo systemctl list-timers dinary-backup.timer
Проверка свежести: inv backup-cloud-status
Самый неприятный сценарий — таймер тихо перестал отрабатывать,
а всё остальное выглядит здоровым. inv backup-cloud-status — внешний
зонд, который живёт не на VM 2:
inv backup-cloud-status # одна строка, exit 0/1
inv backup-cloud-status --json-output # машиночитаемый вердикт
inv backup-cloud-status --max-age-hours 3 # понижаем порог во время инцидента
Задача заходит по SSH на VM 2, вызывает там rclone lsjson для
ремоута yandex:, извлекает UTC-метку из имени самого свежего
файла dinary-YYYY-MM-DDTHHMMZ.db.zst и сравнивает с
--max-age-hours (по умолчанию 26 ч — 24 ч + 1 ч на джиттер
таймера в 30 мин + 30 мин запаса). Примеры вывода:
OK: newest dinary-2026-04-22T0317Z.db.zst, age 7.1h, size 198.5 KB (threshold: 26h)
STALE: newest dinary-2026-04-20T0317Z.db.zst, age 49.3h, size 198.1 KB (threshold: 26h)
STALE: no snapshots on yandex:Backup/dinary/ (threshold: 26h)
Поскольку зонд живёт отдельно от VM 2, и смерть самой VM 2 (SSH падает), и тихо остановившийся таймер (снапшот устарел) дают один и тот же ненулевой код возврата — одна проверка ловит оба режима отказа.
Для крона на ноутбуке подключается через
linux-conf/osx/dinary_backup_check.sh (копируется в
~/scripts/ скриптом linux-conf/osx/copy_scripts.sh). Обёртка
делает cd в ~/projects/dinary, вызывает uv run inv
backup-cloud-status, и при ненулевом коде возврата передаёт захваченный
вывод в send_fail_email (macOS-овский msmtp → Yandex SMTP).
Рекомендуемая crontab-строка:
17 */6 * * * /Users/<you>/scripts/dinary_backup_check.sh
Четыре проверки в сутки дёшевы и ловят пропущенный прогон 03:17 UTC уже через несколько часов, а не следующим утром.
Восстановление на конкретную дату из Яндекс.Диска
inv restore-cloud-backup --list-only # список снапшотов
inv restore-cloud-backup # восстановить самый свежий
inv restore-cloud-backup --snapshot 2026-03-15 # конкретную дату
inv restore-cloud-backup --yes # без подтверждения
Два предполагаемых сценария
- Бутстрап отладочной БД на ноутбуке. Материализовать свежий
прод-снапшот в
./data/dinary.dbна рабочей станции, чтобы воспроизвести баг на реальных данных. Низкий риск: любой перезаписанный отладочный файл восстанавливается из автосохранённогоdata/dinary.db.before-restore-<ts>. - Disaster recovery прод-БД. Запускать задачу на самой VM 1
(через SSH), когда и локальная БД, и Litestream-реплика на VM 2
непригодны. Тройная защита «SSH +
cd ~/dinary+ интерактивное подтверждение» — это намеренное трение, чтобы одним словомinv restore-cloud-backupв случайном терминале нельзя было молча затереть прод.
restore-cloud-backup — local-only: пишет в ./data/dinary.db
относительно cwd, режима --remote нет. Запустить задачу на
удалённом хосте с операторской машины невозможно.
Флаги
| Флаг | По умолчанию | Значение |
|---|---|---|
--snapshot DATE |
latest |
Префикс даты в имени файла, например 2026-04-22 матчит полный dinary-2026-04-22T0317Z.db.zst |
--list-only |
off | Read-only: показать инвентарь снапшотов и выйти |
--yes |
off | Пропустить гейт «напечатайте yes» (preserve-before-restore всё равно отработает) |
Предусловия на операторской машине (разово)
- Установлен
rclone(apt install rcloneна Ubuntu,brew install rcloneна macOS). На VM 1 уже преднастроен черезinv setup-server, так что в стрессе disaster recovery ставить руками ничего не нужно. - На операторской машине настроен удалённый
yandex:в rclone, указывающий на тот же аккаунт Яндекс.Диска, что и уinv setup-replica. Еслиinv setup-replicaни разу не запускался с этой машины, настроить его разово:rclone config create yandex webdav url=https://webdav.yandex.ru vendor=other user=<login>(запросит app-пароль черезrclone obscure). - Установлены
sqlite3иzstd(на VM 1 — черезinv setup-server, на macOS — черезbrew install sqlite zstd).
Гарантии безопасности
- Снапшот сначала разжимается во временной директории и
прогоняется через
PRAGMA integrity_checkдо того, как вообще тронут существующийdata/dinary.db. Битый снапшот прерывает ран с нетронутой живой БД. - Если
data/dinary.dbсуществует и непустой, он переименовывается вdata/dinary.db.before-restore-<UTC-ISO>до того, как на его место ляжет снапшот. Предыдущее состояние всегда восстановимо из той же директории, даже при--yes.
Раннбук disaster recovery на проде
Когда живая БД на VM 1 потеряна, И Litestream-реплика на VM 2 тоже непригодна:
ssh ubuntu@dinary # или публичный IP / Tailscale IP
sudo systemctl stop dinary litestream # чтобы не получить наполовину переписанную БД
cd ~/dinary
inv restore-cloud-backup --snapshot 2026-03-15
# промпт подтверждения: печатает row count / size / mtime текущей
# БД плюс сжатый размер входящего снапшота и требует напечатать
# буквально 'yes'.
sudo systemctl start litestream dinary # вернуть запись + репликацию
inv verify-db # integrity + FK check
Если живая БД просто устарела, но цела, и Яндекс-снапшот нужен
только для сравнения, запустите задачу в отдельной scratch-
директории (чтобы ./data/dinary.db стал снапшотом, а не прод-БД):
cd /tmp/restore-preview
mkdir -p data
inv restore-cloud-backup --snapshot 2026-03-15 --yes
sqlite3 data/dinary.db 'SELECT COUNT(*) FROM expense'
Восстановление из холодной копии
- Остановите работающий сервис:
ssh $HOST 'sudo systemctl stop dinary'. - Замените
data/dinary.dbфайлом из бэкапа. - Удалите устаревшие WAL-сайдкары:
rm -f data/dinary.db-wal data/dinary.db-shm. - Запустите сервис:
inv restart-server. - По желанию запустите
inv verify-dbдля проверки целостности.
Практические рекомендации
- Три слоя избыточности, каждый под свой сценарий отказа:
inv backupзакрывает «ой, я сломал БД руками во время ручной хирургии»; Litestream на VM 2 — «ой, я потерял VM 1»; Яндекс.Диск (inv setup-replica) — «ой, я потерял обе VM / потерял весь облачный провайдер». - Относитесь к
data/dinary.dbкак к источнику истины. Не редактируйте его, пока сервис запущен, и вообще никогда не правьте WAL/SHM-сайдкары руками. - Аналитический workflow на лэптопе через DuckDB-over-SQLite
(фаза 5 плана
.plans/storage-migration.md) читает ту же реплику Litestream — отдельного пайплайна бэкапов под аналитику не нужно.