База данных, миграции, резервные копии и репликация
На этой странице описано, как 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/.
Когда миграции запускаются
Миграции применяются автоматически при каждом старте сервера — yoyo
применяет все ожидающие миграции до обслуживания первого запроса.
Отдельного ручного шага не нужно: inv deploy --ref=<version>
перезапускает сервис, yoyo делает всё остальное.
При локальной разработке inv dev запускает тот же путь при старте.
Для применения миграций без перезапуска сервера используйте inv migrate.
Проверка целостности
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.
Резервные копии
dinary использует три слоя резервного копирования под разные сценарии отказа.
Снапшот перед деплоем (автоматически)
inv deploy всегда делает снапшот через sqlite3 .backup перед
любыми изменениями на сервере и сохраняет его в
data/backups/pre-deploy-<timestamp>/dinary.db на вашей рабочей
станции. Ручного шага не требуется.
Чтобы вручную скачать живой снапшот для локальной отладки:
inv restore-primary # скачать прод-БД в data/dinary.db
Горячая реплика: 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 — там
обоснование). Идемпотентна — повторный запуск после перезагрузки
или смены 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 setup-yadisk
restore-yadisk читает из yandex: на той машине, где
запущена. inv setup-yadisk настраивает этот remote локально — на
VM 1 при disaster recovery или на ноутбуке для отладочного
бутстрапа:
inv setup-yadisk
Использует ту же схему WebDAV + app-пароль, что и inv
setup-replica, — браузерный OAuth не нужен. Идемпотентна:
пропускает промпт, если yandex: уже настроен и работает.
restore-yadisk вызывает её автоматически при отсутствии
yandex:, так что ручной запуск заранее необязателен.
Восстановление на конкретную дату из Яндекс.Диска
inv restore-yadisk --list-only # список снапшотов
inv restore-yadisk # восстановить самый свежий
inv restore-yadisk --snapshot 2026-03-15 # конкретную дату
inv restore-yadisk --yes # без подтверждения
Два предполагаемых сценария
- Бутстрап отладочной БД на ноутбуке. Материализовать свежий
прод-снапшот в
./data/dinary.dbна рабочей станции, чтобы воспроизвести баг на реальных данных. Низкий риск: любой перезаписанный отладочный файл восстанавливается из автосохранённогоdata/dinary.db.before-restore-<ts>. - Disaster recovery прод-БД. Запускать задачу на самой VM 1
(через SSH), когда и локальная БД, и Litestream-реплика на VM 2
непригодны. Тройная защита «SSH +
cd ~/dinary+ интерактивное подтверждение» — это намеренное трение, чтобы одним словомinv restore-yadiskв случайном терминале нельзя было молча затереть прод.
restore-yadisk — 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,sqlite3,zstd. На VM 1 все три уже есть черезinv setup-server. На macOS:brew install rclone sqlite zstd. - Настроен удалённый
yandex:в rclone. Если его нет,restore-yadiskсам запросит авторизацию (та же схема WebDAV + app-пароль, что уinv setup-replica). Для предварительной настройки до первого restore:inv setup-yadisk.
Гарантии безопасности
- Снапшот сначала разжимается во временной директории и
прогоняется через
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-yadisk --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-yadisk --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 deploy --ref=v0.4.0 --no-start # задеплоить код, сервис не запускать
inv restore-yadisk # восстановить БД с Яндекс.Диска
inv restart-server # запустить; yoyo применит прямые миграции
Координированный сброс импорта
Для пересборки расходов и доходов из Google Sheets (например, после восстановления из бэкапа, опережающего актуальные изменения в таблицах). Каталог категорий заполняется автоматически при запуске сервиса:
inv import-budget-all --yes
inv import-income-all --yes
inv import-verify-bootstrap-all
inv import-verify-income-all
Обновление ключа хоста VM2
После переустановки VM2 обновите доверие с операторской машины:
inv setup-reset-trust
Команда удаляет старую запись VM2 из known_hosts на VM1, повторно сканирует хост и переустанавливает pubkey VM1 в authorized_keys на VM2. Не применяйте для устранения неожиданного несоответствия ключа хоста без физического подтверждения переустановки VM2 — неожиданное несоответствие является сигналом безопасности.
Аварийный доступ при Tailscale-only sshd
Если при запуске inv setup-server был передан --tailscale (привязывает sshd только к Tailscale) и доступ потерян:
- Oracle Cloud Console → VM Instance → Console connection → Launch Cloud Shell
sudo rm /etc/ssh/sshd_config.d/10-tailscale-only.confsudo systemctl reload ssh
Мониторинг
inv healthcheck # локальная data/dinary.db
inv healthcheck --remote # прод по SSH
Проверяет: активность systemd-сервисов, совпадение page_count реплики с первичной БД, наличие вчерашнего обменного курса в кеше, статус логгинга последнего расхода в Google Sheets. Возвращает ненулевой код при первом сбое.
inv backup-cloud-status # возраст последнего снапшота на Яндекс.Диске
inv backup-cloud-status --max-age-hours 3 # ужесточить порог
inv backup-cloud-status --json-output # машиночитаемый вывод
Отчёты и доступ к данным
inv report-expenses # расходы по (категория, событие, теги)
inv report-expenses --year 2025 # фильтр по году
inv report-expenses --month 2025-03 # фильтр по месяцу
inv report-expenses --csv # CSV в stdout
inv report-expenses --remote # запрос к прод-БД по SSH
inv report-income --remote # доходы по годам
inv sql -q "SELECT * FROM app_metadata ORDER BY key" # только чтение по умолчанию
inv sql -q "DELETE FROM expenses WHERE id = 999" --write # разрешить мутации
inv sql -f my_query.sql --csv > out.csv
inv sql -q "SELECT COUNT(*) FROM expenses" --remote # снапшот прода по SSH
Практические рекомендации
- Три слоя избыточности, каждый под свой сценарий отказа:
снапшот перед деплоем закрывает «ой, я сломал БД руками во время ручной
хирургии»; Litestream на VM 2 — «ой, я потерял VM 1»;
Яндекс.Диск (
inv setup-replica) — «ой, я потерял обе VM / потерял весь облачный провайдер». - Относитесь к
data/dinary.dbкак к источнику истины. Не редактируйте его, пока сервис запущен, и вообще никогда не правьте WAL/SHM-сайдкары руками.