Nuxt

Автоматическое резервное копирование MongoDB в Docker: от скрипта до админки

BorisBoris
|
17 января 2026 г.
|
14 мин чтения
|
3 просмотров
Автоматическое резервное копирование MongoDB в Docker: от скрипта до админки

База данных — сердце любого веб-приложения. Потерять её данные означает потерять всё: статьи, пользователей, комментарии, историю. При этом удивительно, как часто разработчики откладывают настройку бэкапов "на потом". Я решил не ждать, пока случится непоправимое, и реализовал простую, но надёжную систему резервного копирования для своего Nuxt-блога с MongoDB в Docker.

В этой статье расскажу, как я это сделал: от bash-скрипта с автоматической ротацией до API для скачивания бэкапов через админку — с уведомлениями в Telegram о каждом успешном бэкапе.

Что мы реализуем

  1. Bash-скрипт для создания бэкапов через mongodump
  2. Автоматическую ротацию — храним только последние N копий
  3. Telegram-уведомления о каждом бэкапе
  4. API-эндпоинты для просмотра и скачивания бэкапов из админки
  5. Страницу в админке со статистикой и возможностью скачивания
  6. Cron-задачу для ежедневного автоматического запуска

Стек: Nuxt 4, Nuxt UI, MongoDB 7, Docker Compose, Bash, Nitro API


Часть 1: Архитектура решения

Прежде чем писать код, определим, как всё будет работать:

┌─────────────────────────────────────────────────────────────────┐
│                         Сервер (Host)                           │
├─────────────────────────────────────────────────────────────────┤
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────┐  │
│  │   Cron      │───▶│   Скрипт    │───▶│  ./backups/         │  │
│  │  (3:00 AM)  │    │  backup.sh  │    │  blog_20250108.gz   │  │
│  └─────────────┘    └──────┬──────┘    └──────────┬──────────┘  │
│                            │                      │             │
│                            ▼                      │             │
│                     ┌─────────────┐               │             │
│                     │  Telegram   │               │             │
│                     │  (алерт)    │               │             │
│                     └─────────────┘               │             │
├─────────────────────────────────────────────────────────────────┤
│                     Docker Compose                              │
│  ┌─────────────┐    ┌─────────────┐    ┌─────────────────────┐  │
│  │  blog-app   │◀───│  mongodump  │◀───│    blog-mongo       │  │
│  │  (Nuxt)     │    │             │    │    (MongoDB 7)      │  │
│  │             │    └─────────────┘    └─────────────────────┘  │
│  │  /api/admin │                                                │
│  │  /backups   │◀──── volume: ./backups:/app/backups:ro         │
│  └─────────────┘                                                │
└─────────────────────────────────────────────────────────────────┘

Ключевые решения:

  • mongodump выполняется внутри контейнера MongoDB — не нужно устанавливать mongo-tools на хост
  • Бэкапы хранятся на хосте в папке ./backups — переживут пересоздание контейнеров
  • Nuxt-приложение видит бэкапы через read-only volume — может отдавать их через API
  • Скрипт запускается на хосте через cron — не зависит от состояния контейнеров

Часть 2: Скрипт резервного копирования

Создадим bash-скрипт, который будет:

  1. Создавать сжатый архив базы данных
  2. Удалять старые бэкапы (старше N дней)
  3. Отправлять уведомление в Telegram

Структура скрипта

Создайте файл scripts/backup-mongo.sh:

Полный код backup-mongo.sh
#!/bin/bash
# MongoDB backup script for blog
# Run via cron: 0 3 * * * /path/to/scripts/backup-mongo.sh >> /var/log/mongo-backup.log 2>&1

set -e

# Configuration
BACKUP_DIR="${BACKUP_DIR:-./backups}"
KEEP_DAYS="${KEEP_DAYS:-7}"
CONTAINER_NAME="${CONTAINER_NAME:-blog-mongo}"
DB_NAME="${MONGO_DB:-blog}"

# Generate filename with timestamp
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
FILENAME="blog_${TIMESTAMP}.gz"

echo "[$(date)] Starting backup..."

# Create backup directory if not exists
mkdir -p "$BACKUP_DIR"

# Create backup using mongodump
docker exec "$CONTAINER_NAME" mongodump \
  --db="$DB_NAME" \
  --archive=/tmp/backup.gz \
  --gzip \
  --quiet

# Copy backup from container
docker cp "$CONTAINER_NAME:/tmp/backup.gz" "$BACKUP_DIR/$FILENAME"

# Clean up temp file in container
docker exec "$CONTAINER_NAME" rm -f /tmp/backup.gz

# Get file size
SIZE=$(du -h "$BACKUP_DIR/$FILENAME" | cut -f1)
echo "[$(date)] Backup created: $FILENAME ($SIZE)"

# Remove old backups
DELETED=$(find "$BACKUP_DIR" -name "blog_*.gz" -mtime +"$KEEP_DAYS" -delete -print | wc -l)
if [ "$DELETED" -gt 0 ]; then
  echo "[$(date)] Deleted $DELETED old backup(s)"
fi

# Send Telegram notification if configured
if [ -n "$TELEGRAM_BOT_TOKEN" ] && [ -n "$TELEGRAM_CHAT_ID" ]; then
  COUNT=$(find "$BACKUP_DIR" -name "blog_*.gz" | wc -l | tr -d ' ')
  MESSAGE="✅ <b>Бэкап создан</b>%0A%0A📦 <b>Файл:</b> $FILENAME%0A💾 <b>Размер:</b> $SIZE%0A📁 <b>Всего копий:</b> $COUNT"

  curl -s "https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
    -d "chat_id=${TELEGRAM_CHAT_ID}" \
    -d "text=${MESSAGE}" \
    -d "parse_mode=HTML" > /dev/null

  echo "[$(date)] Telegram notification sent"
fi

echo "[$(date)] Backup completed successfully"

Разбор ключевых моментов

Почему set -e?

Команда set -e останавливает скрипт при первой ошибке. Если mongodump упадёт — мы не будем пытаться копировать несуществующий файл.

Почему архив создаётся в контейнере?

docker exec "$CONTAINER_NAME" mongodump \
  --db="$DB_NAME" \
  --archive=/tmp/backup.gz \
  --gzip

Мы запускаем mongodump внутри контейнера blog-mongo, потому что:

  • Не нужно устанавливать mongo-tools на хост
  • Версия mongodump гарантированно совместима с версией MongoDB
  • Дамп создаётся локально, без сетевого оверхеда

Ротация через find

find "$BACKUP_DIR" -name "blog_*.gz" -mtime +$KEEP_DAYS -delete -print
  • -mtime +7 — файлы старше 7 дней
  • -delete — удаляет найденные файлы
  • -print — выводит имена удалённых файлов (для подсчёта)

Формат имени файла

blog_20250108_153042.gz — дата и время в имени позволяют:

  • Сортировать файлы хронологически
  • Сразу видеть, когда создан бэкап
  • Избежать коллизий при нескольких бэкапах в день

Сделайте скрипт исполняемым

chmod +x scripts/backup-mongo.sh

Часть 3: Настройка Docker Compose

Чтобы Nuxt-приложение могло отдавать бэкапы через API, нужно примонтировать папку с бэкапами.

Изменения в docker-compose.prod.yml

Обновлённая конфигурация app сервиса
services:
  app:
    image: ${CI_REGISTRY_IMAGE}:${CI_COMMIT_SHA:-latest}
    container_name: blog-app
    restart: unless-stopped
    environment:
      - NUXT_MONGODB_URI=mongodb://mongo:27017
      - NUXT_MONGO_DB_NAME=${MONGO_DB}
      - NUXT_JWT_SECRET=${JWT_SECRET}
      - NUXT_JWT_REFRESH_SECRET=${JWT_REFRESH_SECRET}
      - NUXT_PUBLIC_SITE_URL=${SITE_URL}
      - NUXT_TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
      - NUXT_TELEGRAM_CHAT_ID=${TELEGRAM_CHAT_ID}
      - HOST=0.0.0.0
      - PORT=3000
      - BACKUP_DIR=/app/backups
    volumes:
      - uploads_data:/app/.output/public/uploads
      - ./backups:/app/backups:ro  # <-- Добавлено: read-only доступ к бэкапам
    # ... остальная конфигурация

Важные моменты:

  • :ro — read-only mount, приложение может только читать бэкапы
  • BACKUP_DIR=/app/backups — переменная окружения для API
  • Папка ./backups создаётся на хосте, не внутри контейнера

Часть 4: API для работы с бэкапами

Создадим два эндпоинта:

  1. GET /api/admin/backups — список всех бэкапов
  2. GET /api/admin/backups/[filename] — скачивание конкретного файла

Эндпоинт списка бэкапов

Создайте файл server/api/admin/backups/index.get.ts:

Полный код index.get.ts
import { readdir, stat } from 'fs/promises'
import { join } from 'path'
import { requireAdmin } from '../../../utils/auth'

const BACKUP_DIR = process.env.BACKUP_DIR || '/app/backups'

export default defineEventHandler(async (event) => {
  await requireAdmin(event)

  try {
    const files = await readdir(BACKUP_DIR)
    const backups = await Promise.all(
      files
        .filter(name => name.startsWith('blog_') && name.endsWith('.gz'))
        .map(async (name) => {
          const filePath = join(BACKUP_DIR, name)
          const stats = await stat(filePath)
          return {
            name,
            size: formatSize(stats.size),
            sizeBytes: stats.size,
            date: stats.mtime.toISOString()
          }
        })
    )

    // Sort by date descending (newest first)
    backups.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())

    return { backups }
  } catch (error: any) {
    if (error.code === 'ENOENT') {
      return { backups: [] }
    }
    throw createError({
      statusCode: 500,
      message: 'Failed to read backups directory'
    })
  }
})

function formatSize(bytes: number): string {
  if (bytes < 1024) return bytes + ' B'
  if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
  if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
  return (bytes / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
}

Что возвращает эндпоинт:

{
  "backups": [
    {
      "name": "blog_20250108_153042.gz",
      "size": "2.4 MB",
      "sizeBytes": 2516582,
      "date": "2025-01-08T15:30:42.000Z"
    },
    {
      "name": "blog_20250107_030000.gz",
      "size": "2.3 MB",
      "sizeBytes": 2411724,
      "date": "2025-01-07T03:00:00.000Z"
    }
  ]
}

Эндпоинт скачивания бэкапа

Создайте файл server/api/admin/backups/[filename].get.ts:

Полный код [filename].get.ts
import { createReadStream } from 'fs'
import { stat } from 'fs/promises'
import { join, basename } from 'path'
import { requireAdmin } from '../../../utils/auth'

const BACKUP_DIR = process.env.BACKUP_DIR || '/app/backups'

export default defineEventHandler(async (event) => {
  await requireAdmin(event)

  const filename = getRouterParam(event, 'filename')

  if (!filename) {
    throw createError({
      statusCode: 400,
      message: 'Filename is required'
    })
  }

  // Validate filename format to prevent directory traversal
  const sanitizedName = basename(filename)
  if (!sanitizedName.startsWith('blog_') || !sanitizedName.endsWith('.gz')) {
    throw createError({
      statusCode: 400,
      message: 'Invalid backup filename'
    })
  }

  const filePath = join(BACKUP_DIR, sanitizedName)

  try {
    const stats = await stat(filePath)

    setHeader(event, 'Content-Type', 'application/gzip')
    setHeader(event, 'Content-Disposition', `attachment; filename="${sanitizedName}"`)
    setHeader(event, 'Content-Length', stats.size)

    return sendStream(event, createReadStream(filePath))
  } catch (error: any) {
    if (error.code === 'ENOENT') {
      throw createError({
        statusCode: 404,
        message: 'Backup file not found'
      })
    }
    throw createError({
      statusCode: 500,
      message: 'Failed to read backup file'
    })
  }
})

Важно: защита от Directory Traversal

const sanitizedName = basename(filename)
if (!sanitizedName.startsWith('blog_') || !sanitizedName.endsWith('.gz')) {
  throw createError({ statusCode: 400, message: 'Invalid backup filename' })
}

Без этой проверки злоумышленник мог бы запросить ../../etc/passwd и получить системные файлы. Функция basename() убирает путь, а проверка формата гарантирует, что мы отдаём только файлы бэкапов.


Часть 5: Страница бэкапов в админке

API — это хорошо, но гораздо удобнее иметь визуальный интерфейс. Создадим страницу в админке, которая покажет:

  • Количество бэкапов и занимаемое место
  • Время последнего бэкапа
  • Список всех бэкапов с кнопками скачивания
  • Инструкцию по восстановлению

Добавление пункта в навигацию

Сначала добавим пункт "Бэкапы" в боковое меню админки.

В файле layouts/admin.vue найдите массив sidebarItems и добавьте:

const sidebarItems = [
  { label: 'Dashboard', icon: 'i-heroicons-home', to: '/admin' },
  { label: 'Статьи', icon: 'i-heroicons-document-text', to: '/admin/articles' },
  // ... другие пункты
  { label: 'Бэкапы', icon: 'i-heroicons-circle-stack', to: '/admin/backups' }
]

Страница бэкапов

Создайте файл pages/admin/backups.vue:

Полный код backups.vue
<script setup lang="ts">
definePageMeta({
  layout: 'admin',
  middleware: 'admin',
  title: 'Бэкапы'
})

interface Backup {
  name: string
  size: string
  sizeBytes: number
  date: string
}

const { getAuthHeaders } = useAuth()
const toast = useToast()

const { data, pending, refresh } = await useFetch<{ backups: Backup[] }>('/api/admin/backups', {
  headers: getAuthHeaders()
})

const downloadBackup = async (backup: Backup) => {
  try {
    const response = await $fetch(`/api/admin/backups/${backup.name}`, {
      headers: getAuthHeaders(),
      responseType: 'blob'
    })

    const url = window.URL.createObjectURL(response as Blob)
    const link = document.createElement('a')
    link.href = url
    link.download = backup.name
    document.body.appendChild(link)
    link.click()
    document.body.removeChild(link)
    window.URL.revokeObjectURL(url)

    toast.add({
      title: 'Загрузка началась',
      description: backup.name,
      color: 'success'
    })
  } catch (err: any) {
    toast.add({
      title: 'Ошибка загрузки',
      description: err.message,
      color: 'error'
    })
  }
}

const formatDate = (dateStr: string) => {
  const date = new Date(dateStr)
  return date.toLocaleString('ru-RU', {
    day: '2-digit',
    month: '2-digit',
    year: 'numeric',
    hour: '2-digit',
    minute: '2-digit'
  })
}

const getRelativeTime = (dateStr: string) => {
  const date = new Date(dateStr)
  const now = new Date()
  const diffMs = now.getTime() - date.getTime()
  const diffHours = Math.floor(diffMs / (1000 * 60 * 60))
  const diffDays = Math.floor(diffHours / 24)

  if (diffHours < 1) return 'только что'
  if (diffHours < 24) return `${diffHours} ч. назад`
  if (diffDays === 1) return 'вчера'
  return `${diffDays} дн. назад`
}

const totalSize = computed(() => {
  if (!data.value?.backups?.length) return '0 B'
  const total = data.value.backups.reduce((acc, b) => acc + b.sizeBytes, 0)
  if (total < 1024) return total + ' B'
  if (total < 1024 * 1024) return (total / 1024).toFixed(1) + ' KB'
  if (total < 1024 * 1024 * 1024) return (total / (1024 * 1024)).toFixed(1) + ' MB'
  return (total / (1024 * 1024 * 1024)).toFixed(1) + ' GB'
})

useSeoMeta({
  title: 'Бэкапы - Админ-панель'
})
</script>

<template>
  <div>
    <div class="flex justify-between items-center mb-6">
      <h1 class="text-2xl font-bold text-gray-900 dark:text-white">
        Резервные копии базы данных
      </h1>
      <UButton
        icon="i-heroicons-arrow-path"
        variant="ghost"
        :loading="pending"
        @click="refresh()"
      >
        Обновить
      </UButton>
    </div>

    <!-- Stats -->
    <div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
      <UCard>
        <div class="flex items-center gap-3">
          <div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
            <UIcon name="i-heroicons-circle-stack" class="w-6 h-6 text-primary-500" />
          </div>
          <div>
            <p class="text-sm text-gray-500 dark:text-gray-400">Всего копий</p>
            <p class="text-2xl font-bold text-gray-900 dark:text-white">
              {{ data?.backups?.length || 0 }}
            </p>
          </div>
        </div>
      </UCard>

      <UCard>
        <div class="flex items-center gap-3">
          <div class="p-2 bg-success-100 dark:bg-success-900/30 rounded-lg">
            <UIcon name="i-heroicons-server" class="w-6 h-6 text-success-500" />
          </div>
          <div>
            <p class="text-sm text-gray-500 dark:text-gray-400">Занято места</p>
            <p class="text-2xl font-bold text-gray-900 dark:text-white">
              {{ totalSize }}
            </p>
          </div>
        </div>
      </UCard>

      <UCard>
        <div class="flex items-center gap-3">
          <div class="p-2 bg-warning-100 dark:bg-warning-900/30 rounded-lg">
            <UIcon name="i-heroicons-clock" class="w-6 h-6 text-warning-500" />
          </div>
          <div>
            <p class="text-sm text-gray-500 dark:text-gray-400">Последний бэкап</p>
            <p class="text-2xl font-bold text-gray-900 dark:text-white">
              {{ data?.backups?.[0] ? getRelativeTime(data.backups[0].date) : '' }}
            </p>
          </div>
        </div>
      </UCard>
    </div>

    <!-- Backups list -->
    <UCard>
      <template #header>
        <div class="flex items-center justify-between">
          <h2 class="font-semibold text-gray-900 dark:text-white">Список бэкапов</h2>
          <UBadge color="neutral" variant="subtle">
            Хранятся 7 дней
          </UBadge>
        </div>
      </template>

      <div v-if="pending" class="flex justify-center py-8">
        <UIcon name="i-heroicons-arrow-path" class="w-8 h-8 animate-spin text-gray-400" />
      </div>

      <div v-else-if="!data?.backups?.length" class="text-center py-12">
        <UIcon name="i-heroicons-circle-stack" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
        <p class="text-gray-500 dark:text-gray-400 mb-2">Бэкапов пока нет</p>
        <p class="text-sm text-gray-400 dark:text-gray-500">
          Они появятся после первого запуска скрипта резервного копирования
        </p>
      </div>

      <div v-else class="divide-y divide-gray-200 dark:divide-gray-700">
        <div
          v-for="backup in data.backups"
          :key="backup.name"
          class="flex items-center justify-between py-4 first:pt-0 last:pb-0"
        >
          <div class="flex items-center gap-4">
            <div class="p-2 bg-gray-100 dark:bg-gray-700 rounded-lg">
              <UIcon name="i-heroicons-document-arrow-down" class="w-5 h-5 text-gray-500" />
            </div>
            <div>
              <p class="font-medium text-gray-900 dark:text-white">
                {{ backup.name }}
              </p>
              <p class="text-sm text-gray-500 dark:text-gray-400">
                {{ formatDate(backup.date) }} · {{ backup.size }}
              </p>
            </div>
          </div>

          <UButton
            icon="i-heroicons-arrow-down-tray"
            color="primary"
            variant="soft"
            @click="downloadBackup(backup)"
          >
            Скачать
          </UButton>
        </div>
      </div>
    </UCard>

    <!-- Info -->
    <UCard class="mt-6">
      <template #header>
        <h2 class="font-semibold text-gray-900 dark:text-white">Восстановление из бэкапа</h2>
      </template>

      <div class="prose prose-sm dark:prose-invert max-w-none">
        <p>Для восстановления базы данных из бэкапа:</p>
        <ol>
          <li>Скачайте нужный файл бэкапа</li>
          <li>Скопируйте файл на сервер</li>
          <li>Выполните команды:</li>
        </ol>
        <pre><code>docker cp backup.gz blog-mongo:/tmp/
docker exec blog-mongo mongorestore \
  --db=blog \
  --archive=/tmp/backup.gz \
  --gzip \
  --drop</code></pre>
      </div>
    </UCard>
  </div>
</template>

Что показывает страница

Статистика в карточках:

  • Общее количество бэкапов
  • Занимаемое место на диске (суммарный размер всех файлов)
  • Время последнего бэкапа в относительном формате ("2 ч. назад", "вчера")

Список бэкапов:

  • Имя файла с датой и временем
  • Размер файла
  • Кнопка скачивания для каждого бэкапа

Инструкция по восстановлению:

  • Краткие шаги прямо на странице — не нужно искать документацию в критический момент

Скачивание через Blob

const downloadBackup = async (backup: Backup) => {
  const response = await $fetch(`/api/admin/backups/${backup.name}`, {
    headers: getAuthHeaders(),
    responseType: 'blob'
  })

  const url = window.URL.createObjectURL(response as Blob)
  const link = document.createElement('a')
  link.href = url
  link.download = backup.name
  link.click()
  window.URL.revokeObjectURL(url)
}

Почему так сложно? Потому что эндпоинт защищён авторизацией. Простая ссылка <a href="/api/admin/backups/file.gz"> не сработает — браузер не отправит JWT-токен. Приходится:

  1. Делать fetch с заголовком Authorization
  2. Получать файл как Blob
  3. Создавать временную ссылку через createObjectURL
  4. Программно кликать по ней

Часть 6: Настройка Cron

Для автоматического ежедневного бэкапа настроим cron на сервере.

Редактирование crontab

crontab -e

Добавьте строку:

0 3 * * * cd /path/to/project && MONGO_DB=blog TELEGRAM_BOT_TOKEN=xxx TELEGRAM_CHAT_ID=xxx ./scripts/backup-mongo.sh >> /var/log/mongo-backup.log 2>&1

Разбор:

  • 0 3 * * * — каждый день в 03:00
  • cd /path/to/project — переход в папку проекта (важно для относительных путей)
  • MONGO_DB=blog — имя базы данных
  • TELEGRAM_* — переменные для уведомлений
  • >> /var/log/mongo-backup.log 2>&1 — логирование stdout и stderr

Проверка работы cron

# Просмотр активных задач
crontab -l

# Просмотр логов cron (Ubuntu/Debian)
grep CRON /var/log/syslog

# Просмотр наших логов бэкапа
tail -f /var/log/mongo-backup.log

Часть 7: Восстановление из бэкапа

Бэкап бесполезен, если вы не умеете из него восстанавливаться. Вот пошаговая инструкция:

Восстановление на том же сервере

# 1. Скопировать бэкап в контейнер
docker cp ./backups/blog_20250108_030000.gz blog-mongo:/tmp/

# 2. Восстановить базу (с удалением существующих данных)
docker exec blog-mongo mongorestore \
  --db=blog \
  --archive=/tmp/blog_20250108_030000.gz \
  --gzip \
  --drop

# 3. Удалить временный файл
docker exec blog-mongo rm -f /tmp/blog_20250108_030000.gz

Флаг --drop удаляет существующие коллекции перед восстановлением. Без него данные будут объединены, что может привести к дубликатам.

Восстановление на другом сервере

# 1. Скачать бэкап через API или SCP
curl -H "Authorization: Bearer $TOKEN" \
  https://yoursite.com/api/admin/backups/blog_20250108_030000.gz \
  -o backup.gz

# Или через SCP
scp user@server:/path/to/backups/blog_20250108_030000.gz ./

# 2. Скопировать в контейнер и восстановить
docker cp backup.gz blog-mongo:/tmp/
docker exec blog-mongo mongorestore \
  --db=blog \
  --archive=/tmp/backup.gz \
  --gzip \
  --drop

Частичное восстановление (одна коллекция)

# Восстановить только коллекцию articles
docker exec blog-mongo mongorestore \
  --db=blog \
  --collection=articles \
  --archive=/tmp/backup.gz \
  --gzip \
  --drop

Часть 8: Тестирование

Перед тем как доверить бэкапам свои данные, протестируйте систему.

Ручной запуск скрипта

cd /path/to/project
MONGO_DB=blog ./scripts/backup-mongo.sh

Ожидаемый вывод:

[Wed Jan 8 15:30:42 MSK 2025] Starting backup...
[Wed Jan 8 15:30:45 MSK 2025] Backup created: blog_20250108_153042.gz (2.4M)
[Wed Jan 8 15:30:45 MSK 2025] Telegram notification sent
[Wed Jan 8 15:30:45 MSK 2025] Backup completed successfully

Проверка API

# Список бэкапов
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://localhost:3000/api/admin/backups

# Скачивание
curl -H "Authorization: Bearer $ADMIN_TOKEN" \
  http://localhost:3000/api/admin/backups/blog_20250108_153042.gz \
  -o test-backup.gz

# Проверка архива
gunzip -t test-backup.gz && echo "Archive is valid"

Тест восстановления

Никогда не тестируйте восстановление на production-базе!

# Создать тестовый контейнер MongoDB
docker run -d --name mongo-test -p 27018:27017 mongo:7

# Скопировать и восстановить
docker cp backup.gz mongo-test:/tmp/
docker exec mongo-test mongorestore \
  --archive=/tmp/backup.gz \
  --gzip

# Проверить данные
docker exec mongo-test mongosh --eval "db.getSiblingDB('blog').articles.countDocuments()"

# Удалить тестовый контейнер
docker rm -f mongo-test

Возможные улучшения

Текущая реализация покрывает базовые потребности, но её можно расширить:

  1. Облачное хранение — автоматическая загрузка бэкапов в S3, Backblaze B2 или Google Cloud Storage
  2. Уведомления об ошибках — отдельное сообщение в Telegram, если бэкап упал
  3. Мониторинг свежести — алерт, если последний бэкап старше 25 часов
  4. Шифрование — GPG-шифрование архивов перед сохранением
  5. Ручной бэкап из админки — кнопка "Создать бэкап сейчас"

Итоги

Мы реализовали полноценную систему резервного копирования:

КомпонентЧто делает
scripts/backup-mongo.shСоздаёт бэкап, ротирует старые, шлёт в Telegram
docker-compose.prod.ymlМонтирует папку бэкапов в контейнер
GET /api/admin/backupsВозвращает список бэкапов с размерами и датами
GET /api/admin/backups/[file]Отдаёт файл для скачивания
pages/admin/backups.vueUI для просмотра и скачивания бэкапов
CronЗапускает скрипт ежедневно в 3:00

Время на реализацию: пару часов. Спокойствие: бесценно.

Теперь, если что-то пойдёт не так, у меня всегда есть свежая копия базы данных. Я получаю уведомление в Telegram после каждого бэкапа и могу в любой момент скачать нужную копию прямо из админки.

Комментарии

Войдите, чтобы оставить комментарий

Пока нет комментариев. Будьте первым!