Бэкап базы Telegram-бота на SQLite: aiogram, PTB, Telegraf

У большинства небольших Telegram-ботов вся ценность — в одном файле bot.db: пользователи, подписки, балансы, состояния диалогов. Если VPS пропал или неудачная миграция испортила таблицу, без бэкапа бот начинает жизнь с нуля, а пользователи — с вопросов в поддержку. Ниже — как делать копию базы прямо из бота или снаружи по расписанию, куда её отправлять и как восстановиться. Про выбор базы для бота есть отдельная статья: база данных для Telegram-бота.

Почему нельзя просто скопировать bot.db

Бот пишет в базу постоянно: каждое /start, каждое нажатие кнопки, каждое обновление FSM. Если скопировать файл командой cp в момент записи, можно получить копию с незавершённой транзакцией. В режиме WAL ещё хуже: последние изменения лежат в bot.db-wal, и копия одного bot.db их просто не содержит.

Правильные способы — онлайн-бэкап SQLite (.backup в CLI или Connection.backup() в Python) и VACUUM INTO. Оба создают консистентную копию, пока бот работает. Подробно о механике — в статье как сделать бэкап SQLite.

Бэкап изнутри бота на Python

Модуль sqlite3 из стандартной библиотеки умеет делать онлайн-бэкап. Операция синхронная и на большой базе может занять секунды, поэтому в асинхронном боте её выносят в отдельный поток через asyncio.to_thread, чтобы не останавливать обработку апдейтов.

# backup.py
import gzip
import shutil
import sqlite3
from datetime import datetime
from pathlib import Path

DB_PATH = Path("data/bot.db")
BACKUP_DIR = Path("data/backups")


def make_backup() -> Path:
    BACKUP_DIR.mkdir(parents=True, exist_ok=True)
    ts = datetime.now().strftime("%Y%m%d-%H%M")
    out = BACKUP_DIR / f"bot-{ts}.db"

    src = sqlite3.connect(DB_PATH, timeout=30)
    dst = sqlite3.connect(out)
    try:
        src.backup(dst)  # консистентная копия живой базы
    finally:
        dst.close()
        src.close()

    check = sqlite3.connect(out)
    try:
        if check.execute("PRAGMA quick_check").fetchone()[0] != "ok":
            raise RuntimeError(f"quick_check failed for {out}")
    finally:
        check.close()

    gz = out.parent / (out.name + ".gz")
    with open(out, "rb") as f_in, gzip.open(gz, "wb") as f_out:
        shutil.copyfileobj(f_in, f_out)
    out.unlink()

    # локальная ротация: 14 дней
    cutoff = datetime.now().timestamp() - 14 * 86400
    for old in BACKUP_DIR.glob("bot-*.db.gz"):
        if old.stat().st_mtime < cutoff:
            old.unlink()
    return gz

Если бот работает через aiosqlite, ничего менять не нужно: бэкап открывает своё отдельное соединение sqlite3 в потоке. Чтобы чтение для бэкапа не мешало записи бота, включите в базе режим WAL (PRAGMA journal_mode=WAL;) и задайте busy_timeout в соединении бота.

Расписание в aiogram 3 через APScheduler

Пример для APScheduler 3.x (в 4.x API другой):

import asyncio
import os

from aiogram import Bot, Dispatcher
from aiogram.types import FSInputFile
from apscheduler.schedulers.asyncio import AsyncIOScheduler

from backup import make_backup

ADMIN_CHAT_ID = int(os.environ["ADMIN_CHAT_ID"])
TG_LIMIT_MB = 45  # с запасом до лимита Bot API


async def backup_job(bot: Bot) -> None:
    try:
        path = await asyncio.to_thread(make_backup)
    except Exception as e:
        await bot.send_message(ADMIN_CHAT_ID, f"Бэкап не удался: {e}")
        return
    size_mb = path.stat().st_size / 1024 / 1024
    if size_mb < TG_LIMIT_MB:
        await bot.send_document(
            ADMIN_CHAT_ID, FSInputFile(path), caption=f"{path.name}, {size_mb:.1f} МБ"
        )
    else:
        await bot.send_message(ADMIN_CHAT_ID, f"{path.name}: {size_mb:.0f} МБ, только в S3")


async def main() -> None:
    bot = Bot(token=os.environ["BOT_TOKEN"])
    dp = Dispatcher()
    scheduler = AsyncIOScheduler(timezone="Europe/Moscow")
    scheduler.add_job(backup_job, "cron", hour=3, minute=0, args=[bot])
    scheduler.start()
    await dp.start_polling(bot)


asyncio.run(main())

python-telegram-bot

В PTB есть встроенная JobQueue (ставится как pip install "python-telegram-bot[job-queue]"):

import asyncio
import datetime as dt
import os
from zoneinfo import ZoneInfo

from telegram.ext import Application, ContextTypes

from backup import make_backup

ADMIN_CHAT_ID = int(os.environ["ADMIN_CHAT_ID"])


async def backup_job(context: ContextTypes.DEFAULT_TYPE) -> None:
    path = await asyncio.to_thread(make_backup)
    await context.bot.send_document(chat_id=ADMIN_CHAT_ID, document=path)

app = Application.builder().token(os.environ["BOT_TOKEN"]).build()
app.job_queue.run_daily(backup_job, time=dt.time(3, 0, tzinfo=ZoneInfo("Europe/Moscow")))
app.run_polling()

Проверку размера перед отправкой добавьте так же, как в примере для aiogram.

Telegraf и better-sqlite3

Если бот на Node.js использует better-sqlite3, у соединения есть метод db.backup(path), который возвращает промис и делает онлайн-бэкап:

const file = `backups/bot-${Date.now()}.db`;
await db.backup(file);
await bot.telegram.sendDocument(process.env.ADMIN_CHAT_ID, { source: file });

Расписание можно повесить на node-cron внутри процесса или на системный таймер, как описано ниже.

Ограничения отправки бэкапа в Telegram

Отправить файл админу удобно, но у этого способа есть пределы.

  • Лимит размера. Через стандартный Bot API бот может отправить файл до 50 МБ. Для больших баз нужен собственный сервер Bot API или другое хранилище.
  • Персональные данные. В базе бота обычно лежат Telegram ID, имена, телефоны, история заказов. Файл в чате доступен всем участникам чата и всем устройствам, где открыт аккаунт админа. Отправляйте в личный чат или закрытую группу с минимумом участников, а лучше — зашифрованный архив.
  • Telegram — не хранилище бэкапов. Нет ротации, нет проверки целостности, сообщение можно случайно удалить. Используйте отправку в чат как удобное уведомление и быструю копию, а основной экземпляр храните в S3.

Шифрование архива через age перед отправкой:

age -r "$AGE_RECIPIENT" -o bot-20260926.db.gz.age bot-20260926.db.gz
# расшифровка на своей машине:
age -d -i ~/.config/age/key.txt -o bot-20260926.db.gz bot-20260926.db.gz.age

Бэкап снаружи: cron или systemd timer на VPS

Если не хочется трогать код бота, делайте копию отдельным скриптом. Это надёжнее: бэкап не зависит от того, жив ли процесс бота.

#!/usr/bin/env bash
# /usr/local/bin/backup-bot.sh
set -euo pipefail

DB=/opt/mybot/data/bot.db
DIR=/var/backups/mybot
TS=$(date +%Y%m%d-%H%M)
OUT="$DIR/bot-$TS.db"
mkdir -p "$DIR"

sqlite3 "$DB" ".backup '$OUT'"
sqlite3 "$OUT" "PRAGMA quick_check;" | grep -qx ok
gzip "$OUT"

aws s3 cp "$OUT.gz" "s3://bot-backups/mybot/bot-$TS.db.gz" \
  --endpoint-url https://storage.yandexcloud.net

find "$DIR" -name 'bot-*.db.gz' -mtime +14 -delete

Для Selectel используйте --endpoint-url https://s3.ru-1.storage.selcloud.ru. Удаление старых объектов в бакете настройте правилом жизненного цикла в панели провайдера.

# /etc/systemd/system/backup-bot.service
[Unit]
Description=Telegram bot SQLite backup

[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-bot.sh
# /etc/systemd/system/backup-bot.timer
[Unit]
Description=Daily bot backup

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

[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now backup-bot.timer

Если бот крутится на Windows-машине, то же самое делает планировщик задач:

$action = New-ScheduledTaskAction -Execute "C:\bot\.venv\Scripts\python.exe" -Argument '-c "import backup; backup.make_backup()"' -WorkingDirectory "C:\bot"
$trigger = New-ScheduledTaskTrigger -Daily -At 3am
Register-ScheduledTask -TaskName "BotBackup" -Action $action -Trigger $trigger

Бот в Docker

Главное правило: база должна лежать в volume или bind mount, а не в слое контейнера, иначе она исчезнет при пересоздании контейнера.

services:
  bot:
    build: .
    restart: unless-stopped
    volumes:
      - ./data:/app/data

С bind mount скрипт на хосте работает с ./data/bot.db напрямую. Если в образе нет CLI sqlite3, сделайте копию изнутри контейнера средствами Python:

docker compose exec bot python -c "import backup; print(backup.make_backup())"

Подробнее про именованные volume и Coolify — в статье бэкап базы в Docker и Coolify.

Восстановление

sudo systemctl stop mybot          # или: docker compose stop bot

mv /opt/mybot/data/bot.db /opt/mybot/data/bot.db.broken
rm -f /opt/mybot/data/bot.db-wal /opt/mybot/data/bot.db-shm

gunzip -c /var/backups/mybot/bot-20260926-0300.db.gz > /opt/mybot/data/bot.db
sqlite3 /opt/mybot/data/bot.db "PRAGMA integrity_check;"

sudo systemctl start mybot         # или: docker compose start bot

Старые -wal и -shm удаляйте обязательно: они относятся к прежней базе. После запуска проверьте /start, пару ключевых сценариев и число пользователей. Не ждите аварии, чтобы проверить этот порядок: раз в месяц восстанавливайте последний бэкап на тестовой машине — как это организовать, описано в статье как проверить, что бэкап рабочий. Общая инструкция по разным базам — как восстановить базу из бэкапа.

Как это сделать в dbsend

Вместо скрипта с S3 и ротацией можно отправлять базу в dbsend. CLI сам делает консистентную копию через VACUUM INTO, так что .backup перед ним не нужен:

npm install --global @dbsend/sdk
dbsend login --key sqv_pk_…

dbsend backup /opt/mybot/data/bot.db -d "$DBSEND_DATABASE_ID" -e sqlite -l "daily"
dbsend log

# восстановление: остановите бота, затем
dbsend restore <snapshotId> /opt/mybot/data/bot.db --yes

Эту команду можно поставить в тот же systemd timer вместо backup-bot.sh. Что добавляет dbsend: история версий с метками и закреплением, схема и число строк в каждой версии, сравнение любых двух версий (например, «сколько пользователей пропало после релиза»), алерт в Telegram, на email или в webhook, если бэкап не пришёл по расписанию (сравнение и алерты — на платных тарифах). Данные хранятся в S3 в России в зашифрованном виде. dbsend restore проверяет sha256 и PRAGMA integrity_check и с --yes удаляет устаревшие -wal/-shm. Код бота dbsend не меняет и сам базу не забирает — загрузку запускаете вы. Подробнее: бэкапы SQLite, документация CLI.

Если нужна копия с потерей не больше нескольких секунд, посмотрите сравнение Litestream, cron и dbsend. Если бот хранит данные в PocketBase, пригодится инструкция бэкап PocketBase.

Частые вопросы

Как часто бэкапить базу бота?

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

Не заблокирует ли бэкап работу бота?

backup() из примера выше (без параметра pages) копирует базу за один шаг, в одной читающей транзакции. В режиме WAL чтение не блокирует запись, и бот продолжит отвечать. В режиме rollback journal запись может подождать окончания копирования, поэтому задайте боту busy_timeout.

Можно ли хранить бэкапы только в Telegram?

Не стоит. Есть лимит 50 МБ на файл для ботов, нет ротации и проверки целостности, а файл с персональными данными оказывается на всех устройствах участников чата. Используйте Telegram как уведомление, а копии храните в S3 или сервисе бэкапов.

Где хранить ключи S3 и токен бота для скрипта?

В переменных окружения сервиса или в файле с правами 600, например через EnvironmentFile= в systemd-юните. Не храните их в репозитории и в самом скрипте.

Что делать, если база бота уже повреждена?

Остановите бота, сохраните повреждённый файл вместе с -wal и -shm и восстановитесь из последнего бэкапа, прошедшего integrity_check. Повреждённую копию можно попробовать спасти командой .recover в CLI sqlite3, но восстановление из бэкапа надёжнее.