Система автоматического резервного копирования с мониторингом для сети клиник

Исходная ситуация: клиники без защиты данных

Сеть медицинских клиник МедПлюс — 12 филиалов, медицинская информационная система (МИС) на PostgreSQL, электронные медицинские карты 180 000 пациентов, ежедневно 2 500 приёмов. Общий объём данных: 1.2 ТБ (базы данных, DICOM-снимки, документы).

Проблемы, с которыми обратились:

  • Нет единой стратегии — каждый филиал бэкапил «как получится»: кто-то cron + pg_dump, кто-то ручное копирование на флешку, 3 филиала — вообще без бэкапов
  • Нет проверки — никто ни разу не пробовал восстановиться из бэкапа. Классический «кот Шрёдингера»
  • Нет оповещений — о том, что бэкап не выполнился, узнавали только когда он уже нужен
  • Требования регулятора — 152-ФЗ «О персональных данных» обязывает обеспечить сохранность и восстановимость данных, хранить журнал операций

Нам нужно было построить систему бэкапов с нуля: стратегия, скрипты, мониторинг, проверка, учения — полный цикл.

Стратегия резервного копирования: матрица бэкапов

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

ЧтоТип бэкапаЧастотаХранениеКудаRPORTO
PostgreSQL (МИС)pg_basebackup + WALFull: ежедневно, WAL: непрерывно30 днейЛокальный NAS + S30 мин30 мин
DICOM-снимкиrsync incrementalКаждые 6 часов1 годЦентральный NAS + S3 Glacier6 часов4 часа
Документыrsync incrementalКаждые 2 часа90 днейЛокальный NAS + S32 часа1 час
Конфигурации серверовtar + etckeeperЕжедневно + при изменении90 днейGit + S324 часа15 мин
Виртуальные машиныProxmox vzdumpЕженедельно4 версииЦентральный NAS7 дней2 часа

RPO (Recovery Point Objective) — максимально допустимая потеря данных. Для медицинских записей RPO = 0 (ни одна запись не должна быть потеряна), поэтому используем непрерывное архивирование WAL.

RTO (Recovery Time Objective) — максимально допустимое время восстановления. Для МИС — 30 минут, для снимков — 4 часа.

Стратегия хранения — правило 3-2-1:

  • 3 копии данных (оригинал + 2 бэкапа)
  • 2 разных типа носителей (NAS + облако S3)
  • 1 копия в другом географическом расположении (S3 в другом дата-центре)

Скрипты бэкапов с обработкой ошибок

Каждый скрипт бэкапа построен по единому шаблону: подготовка → выполнение → проверка → отправка в удалённое хранилище → уведомление. При любой ошибке — немедленный алерт.

#!/bin/bash
# /opt/backup/scripts/backup_postgresql.sh
# Бэкап PostgreSQL с непрерывным WAL-архивированием

set -euo pipefail

# ── Конфигурация ──
BACKUP_BASE="/backup/postgresql"
S3_BUCKET="s3://medplus-backups/postgresql"
RETENTION_DAYS=30
LOG_FILE="/var/log/backup/postgresql.log"
LOCK_FILE="/var/run/backup_postgresql.lock"
STATUS_FILE="/opt/backup/status/postgresql.json"

# Telegram
BOT_TOKEN="7012345678:AAH..."
CHAT_ID="-100123456789"

# ── Функции ──
log() { echo "$(date '+%Y-%m-%d %H:%M:%S') [$1] $2" | tee -a "$LOG_FILE"; }

alert() {
    local level="$1" msg="$2"
    curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
        -d chat_id="$CHAT_ID" \
        -d text="${level} Backup PostgreSQL $(hostname): ${msg}" \
        -d parse_mode="HTML" > /dev/null
}

cleanup() {
    rm -f "$LOCK_FILE"
    if [ $? -ne 0 ]; then
        log "ERROR" "Backup failed"
        alert "🔴" "FAILED — check logs at $LOG_FILE"
        # Записываем статус для health check
        echo '{"status":"failed","timestamp":'$(date +%s)',"error":"see log"}' > "$STATUS_FILE"
    fi
}
trap cleanup EXIT

# ── Защита от параллельного запуска ──
if [ -f "$LOCK_FILE" ]; then
    PID=$(cat "$LOCK_FILE")
    if kill -0 "$PID" 2>/dev/null; then
        log "WARN" "Another backup is running (PID $PID)"
        exit 1
    fi
fi
echo $$ > "$LOCK_FILE"

# ── Выполнение бэкапа ──
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="$BACKUP_BASE/$DATE"
mkdir -p "$BACKUP_DIR"

log "INFO" "Starting PostgreSQL backup to $BACKUP_DIR"
START_TIME=$(date +%s)

# pg_basebackup с контрольной суммой
pg_basebackup \
    -h /var/run/postgresql \
    -U backup_user \
    -D "$BACKUP_DIR" \
    -Ft -z \
    --checkpoint=fast \
    --wal-method=stream \
    --label="medplus_${DATE}" \
    -P 2>> "$LOG_FILE"

END_TIME=$(date +%s)
DURATION=$((END_TIME - START_TIME))
SIZE=$(du -sh "$BACKUP_DIR" | awk '{print $1}')

log "INFO" "Backup completed: $SIZE in ${DURATION}s"

# ── Проверка целостности ──
log "INFO" "Verifying backup integrity..."
if pg_verifybackup "$BACKUP_DIR" 2>> "$LOG_FILE"; then
    log "INFO" "Integrity check PASSED"
else
    log "ERROR" "Integrity check FAILED"
    alert "🔴" "Integrity verification FAILED for $DATE"
    exit 1
fi

# ── Отправка в S3 ──
log "INFO" "Uploading to S3..."
aws s3 sync "$BACKUP_DIR" "$S3_BUCKET/$DATE/" \
    --storage-class STANDARD_IA \
    --only-show-errors 2>> "$LOG_FILE"

# ── Ротация старых бэкапов ──
find "$BACKUP_BASE" -maxdepth 1 -type d -mtime +${RETENTION_DAYS} -exec rm -rf {} \;
aws s3 ls "$S3_BUCKET/" | awk '{print $2}' | while read dir; do
    DIR_DATE=$(echo "$dir" | tr -d '/')
    if [[ $(date -d "$DIR_DATE" +%s 2>/dev/null) -lt $(date -d "-${RETENTION_DAYS} days" +%s) ]]; then
        aws s3 rm "$S3_BUCKET/$dir" --recursive --quiet
        log "INFO" "Deleted old S3 backup: $dir"
    fi
done

# ── Записываем статус ──
echo "{\"status\":\"ok\",\"timestamp\":$(date +%s),\"size\":\"$SIZE\",\"duration\":$DURATION}" > "$STATUS_FILE"

alert "🟢" "OK — $SIZE in ${DURATION}s, verified, uploaded to S3"
log "INFO" "Backup pipeline completed successfully"

Для DICOM-снимков использовали rsync с инкрементальным копированием через hard links:

#!/bin/bash
# /opt/backup/scripts/backup_dicom.sh — инкрементальный бэкап снимков

SOURCE="/data/dicom/"
BACKUP_BASE="/backup/dicom"
LATEST_LINK="$BACKUP_BASE/latest"
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="$BACKUP_BASE/$DATE"

# rsync с hard links к предыдущему бэкапу
# Неизменённые файлы не копируются, а создаётся hard link
rsync -a --delete \
    --link-dest="$LATEST_LINK" \
    "$SOURCE" "$BACKUP_DIR/"

# Обновляем симлинк на последний бэкап
ln -snf "$BACKUP_DIR" "$LATEST_LINK"

# Результат: первый бэкап 800 GB, инкрементальные 5-15 GB
# (только новые снимки за 6 часов)

Systemd-таймеры вместо cron

Мы использовали systemd-таймеры вместо cron по нескольким причинам: встроенное логирование через journald, зависимости между сервисами, автоматический retry при сбое, статус через systemctl.

# /etc/systemd/system/backup-postgresql.service
[Unit]
Description=PostgreSQL Backup
After=postgresql.service
Requires=postgresql.service

[Service]
Type=oneshot
User=root
ExecStart=/opt/backup/scripts/backup_postgresql.sh
TimeoutStartSec=3600
StandardOutput=journal
StandardError=journal

# Ограничение ресурсов — бэкап не должен убить сервер
CPUQuota=50%
IOWeight=100
Nice=10
# /etc/systemd/system/backup-postgresql.timer
[Unit]
Description=PostgreSQL Backup Timer

[Timer]
# Ежедневно в 2:00, 8:00, 14:00, 20:00
OnCalendar=*-*-* 02,08,14,20:00:00
# Если пропустили (сервер был выключен) — выполнить при старте
Persistent=true
# Разброс ±15 минут (чтобы не все филиалы стартовали одновременно)
RandomizedDelaySec=900

[Install]
WantedBy=timers.target
# Активация таймеров
systemctl daemon-reload
systemctl enable --now backup-postgresql.timer
systemctl enable --now backup-dicom.timer
systemctl enable --now backup-documents.timer

# Проверка расписания
systemctl list-timers --all | grep backup
# NEXT                        LEFT          UNIT
# Sat 2026-04-05 14:00:00 MSK 2h 15min left backup-postgresql.timer
# Sat 2026-04-05 12:00:00 MSK 15min left    backup-dicom.timer

# Просмотр последнего выполнения
systemctl status backup-postgresql.service
journalctl -u backup-postgresql.service --since today

Для каждого филиала создали единый Ansible-плейбук, который деплоит все скрипты и таймеры:

# ansible/roles/backup/tasks/main.yml
- name: Copy backup scripts
  copy:
    src: "scripts/{{ item }}"
    dest: "/opt/backup/scripts/{{ item }}"
    mode: '0750'
  loop:
    - backup_postgresql.sh
    - backup_dicom.sh
    - backup_documents.sh
    - backup_healthcheck.sh

- name: Deploy systemd units
  template:
    src: "systemd/{{ item }}.j2"
    dest: "/etc/systemd/system/{{ item }}"
  loop:
    - backup-postgresql.service
    - backup-postgresql.timer
    - backup-dicom.service
    - backup-dicom.timer
  notify: reload systemd

- name: Enable backup timers
  systemd:
    name: "{{ item }}"
    enabled: yes
    state: started
  loop:
    - backup-postgresql.timer
    - backup-dicom.timer

Health check: мониторинг возраста, размера и целостности бэкапов

Бэкап, который выполняется по расписанию, может молча сломаться: диск полон, пароль протух, сеть до S3 не доступна. Поэтому мы построили отдельную систему health check — скрипт, который проверяет состояние бэкапов каждые 30 минут.

#!/bin/bash
# /opt/backup/scripts/backup_healthcheck.sh
# Проверка здоровья всех бэкапов

set -uo pipefail

BOT_TOKEN="7012345678:AAH..."
CHAT_ID="-100123456789"
STATUS_DIR="/opt/backup/status"
ALERTS=()

check_backup() {
    local name="$1" max_age_hours="$2" min_size_mb="$3"
    local status_file="$STATUS_DIR/${name}.json"
    
    # Проверяем наличие файла статуса
    if [ ! -f "$status_file" ]; then
        ALERTS+=("$name: status file missing (never ran?)")
        return
    fi
    
    # Проверяем статус последнего бэкапа
    local status=$(jq -r '.status' "$status_file")
    if [ "$status" != "ok" ]; then
        ALERTS+=("$name: last backup FAILED")
        return
    fi
    
    # Проверяем возраст бэкапа
    local timestamp=$(jq -r '.timestamp' "$status_file")
    local age_hours=$(( ($(date +%s) - timestamp) / 3600 ))
    if [ "$age_hours" -gt "$max_age_hours" ]; then
        ALERTS+=("$name: backup is ${age_hours}h old (max: ${max_age_hours}h)")
    fi
    
    # Проверяем размер (защита от пустых дампов)
    local size=$(jq -r '.size' "$status_file")
    local size_mb=$(echo "$size" | numfmt --from=iec --to-unit=1M 2>/dev/null || echo 0)
    if [ "$size_mb" -lt "$min_size_mb" ]; then
        ALERTS+=("$name: suspiciously small ($size, expected >${min_size_mb}MB)")
    fi
}

# Проверяем каждый тип бэкапа
check_backup "postgresql"  8   500    # Должен быть не старше 8 часов, минимум 500 MB
check_backup "dicom"       7   100    # Не старше 7 часов, минимум 100 MB
check_backup "documents"   3   10     # Не старше 3 часов, минимум 10 MB

# Проверяем доступность S3
if ! aws s3 ls s3://medplus-backups/ > /dev/null 2>&1; then
    ALERTS+=("S3 bucket is not accessible!")
fi

# Проверяем свободное место на диске бэкапов
DISK_USAGE=$(df /backup --output=pcent | tail -1 | tr -d ' %')
if [ "$DISK_USAGE" -gt 85 ]; then
    ALERTS+=("Backup disk usage: ${DISK_USAGE}% (threshold: 85%)")
fi

# Отправляем алерты
if [ ${#ALERTS[@]} -gt 0 ]; then
    MSG="Backup Health Check FAILED on $(hostname):\n"
    for alert in "${ALERTS[@]}"; do
        MSG+="- $alert\n"
    done
    curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
        -d chat_id="$CHAT_ID" \
        -d text="$MSG" \
        -d parse_mode="HTML" > /dev/null
fi

# Дополнительно: email для compliance audit trail
if [ ${#ALERTS[@]} -gt 0 ]; then
    echo -e "${ALERTS[*]}" | mail -s "Backup Alert: $(hostname)" admin@medplus.ru
fi

Кроме Telegram, все операции с бэкапами логируются в файл для compliance audit trail:

# /var/log/backup/audit.log — формат для аудитора
# timestamp | hostname | operation | object | status | size | duration | operator
2026-04-05 02:00:15 | clinic-01 | backup | postgresql | OK | 2.1G | 342s | systemd
2026-04-05 02:05:57 | clinic-01 | verify | postgresql | OK | - | 28s | systemd
2026-04-05 02:06:25 | clinic-01 | upload | postgresql | OK | 2.1G | 156s | systemd
2026-04-05 08:00:12 | clinic-01 | backup | dicom | OK | 14G | 1847s | systemd
2026-04-05 10:30:00 | clinic-01 | healthcheck | all | OK | - | 3s | systemd

Учения по восстановлению: Disaster Recovery Drill

Бэкап, из которого нельзя восстановиться — не бэкап. Мы внедрили ежемесячные учения по восстановлению (DR Drill) и автоматическую еженедельную проверку.

Автоматический тест восстановления (еженедельно):

#!/bin/bash
# /opt/backup/scripts/restore_test.sh
# Автоматическое тестовое восстановление PostgreSQL бэкапа

set -euo pipefail

TEST_DIR="/tmp/restore_test_$(date +%s)"
TEST_PORT=5433  # Отдельный порт, чтобы не конфликтовать с production
LOG="/var/log/backup/restore_test.log"

log() { echo "$(date '+%Y-%m-%d %H:%M:%S') $1" >> "$LOG"; }

cleanup() {
    pg_ctl -D "$TEST_DIR" stop 2>/dev/null || true
    rm -rf "$TEST_DIR"
}
trap cleanup EXIT

# Находим последний бэкап
LATEST=$(ls -td /backup/postgresql/*/ | head -1)
log "Testing restore from: $LATEST"

# Распаковываем бэкап
mkdir -p "$TEST_DIR"
tar xzf "$LATEST/base.tar.gz" -C "$TEST_DIR"
tar xzf "$LATEST/pg_wal.tar.gz" -C "$TEST_DIR/pg_wal/"

# Запускаем тестовый PostgreSQL на отдельном порту
cat >> "$TEST_DIR/postgresql.conf" << EOF
port = $TEST_PORT
unix_socket_directories = '/tmp'
log_destination = 'stderr'
logging_collector = off
EOF

# Создаём recovery signal
touch "$TEST_DIR/recovery.signal"
cat >> "$TEST_DIR/postgresql.conf" << EOF
restore_command = 'cp /backup/postgresql/wal_archive/%f %p || true'
recovery_target = 'immediate'
recovery_target_action = 'promote'
EOF

# Стартуем и ждём восстановления
pg_ctl -D "$TEST_DIR" -l "$TEST_DIR/startup.log" start
sleep 10

# Проверяем, что база поднялась и данные читаемы
TEST_RESULT=$(psql -h /tmp -p $TEST_PORT -U postgres -d medplus \
    -c "SELECT COUNT(*) FROM patients" -t 2>&1)

if [[ "$TEST_RESULT" =~ ^[0-9]+$ ]] && [ "$TEST_RESULT" -gt 0 ]; then
    log "RESTORE TEST PASSED: $TEST_RESULT patients found"
    # Дополнительные проверки
    TABLES=$(psql -h /tmp -p $TEST_PORT -U postgres -d medplus \
        -c "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public'" -t)
    log "Tables count: $TABLES"
    
    # Сравниваем количество записей с production
    PROD_COUNT=$(psql -h /var/run/postgresql -U postgres -d medplus \
        -c "SELECT COUNT(*) FROM patients" -t)
    DIFF=$((PROD_COUNT - TEST_RESULT))
    log "Records difference from production: $DIFF"
    
    echo '{"status":"ok","timestamp":'$(date +%s)',"patients":'$TEST_RESULT',"diff":'$DIFF'}' \
        > /opt/backup/status/restore_test.json
else
    log "RESTORE TEST FAILED: $TEST_RESULT"
    echo '{"status":"failed","timestamp":'$(date +%s)'}' \
        > /opt/backup/status/restore_test.json
    # Алерт в Telegram
    curl -s -X POST "https://api.telegram.org/bot${BOT_TOKEN}/sendMessage" \
        -d chat_id="$CHAT_ID" \
        -d text="RESTORE TEST FAILED on $(hostname)! Check $LOG"
fi

Ежемесячные ручные учения: раз в месяц мы проводим полноценный DR Drill — восстановление на чистой виртуальной машине с нуля. Процедура задокументирована в runbook, и каждый раз её выполняет другой инженер клиента (чтобы навык не зависел от одного человека).

Результаты учений фиксируются в журнале:

# /opt/backup/drills/drill_log.csv
# date,engineer,type,rto_target,rto_actual,data_verified,notes
2026-02-01,Иванов,full_restore,30min,22min,yes,"Штатное восстановление"
2026-03-01,Петрова,full_restore,30min,45min,yes,"S3 был медленный, увеличили bandwidth"
2026-04-01,Сидоров,full_restore,30min,18min,yes,"Оптимизировали скрипт, параллельный rsync"

Результаты и compliance

За 3 месяца эксплуатации система показала себя:

МетрикаДоПосле
Покрытие бэкапами9 из 12 филиалов12 из 12 (100%)
RPO (потеря данных)До 7 дней0 минут (WAL streaming)
RTO (время восстановления)Неизвестно (не тестировали)18-22 минуты (проверено)
Обнаружение сбоя бэкапаКогда понадобится30 минут (health check)
Успешность восстановленияНеизвестно100% (6 учений из 6)
Объём храненияХаотичный3.8 ТБ (оптимизирован rsync hard links)

Для прохождения аудита по 152-ФЗ мы подготовили:

  • Политика резервного копирования — утверждённый документ с матрицей бэкапов
  • Журнал операций — автоматический audit trail (файл + syslog)
  • Протоколы учений — подписанные акты DR Drill с результатами
  • Мониторинг SLA — дашборд в Grafana с метриками RPO/RTO

Рекомендации от инженеров itfresh.ru для построения системы бэкапов:

  • Начните со стратегии (матрица бэкапов), а не со скриптов — это сэкономит месяцы переделок
  • Используйте правило 3-2-1: три копии, два типа носителей, одна копия offsite
  • Health check важнее самого бэкапа — бэкап без мониторинга = «кот Шрёдингера»
  • Автоматический тест восстановления еженедельно — это не паранойя, а гигиена
  • Ручные учения ежемесячно — каждый раз другой инженер, чтобы навык не ушёл с человеком

Часто задаваемые вопросы

Systemd-таймеры дают: встроенное логирование (journalctl -u backup.service), зависимости (бэкап только если PostgreSQL запущен), статус через systemctl, автоматический retry, лимиты ресурсов (CPUQuota, IOWeight). Cron — это текстовый планировщик без состояния. Если cron-задача упала, вы не узнаете — нет встроенного алертинга и нет статуса «последний запуск».
pg_dump создаёт логический дамп (SQL-команды) — медленное восстановление на больших базах (часы для 1 ТБ). pg_basebackup копирует файлы данных — восстановление за минуты. Главное преимущество: pg_basebackup + WAL streaming даёт RPO=0 (ноль потерянных транзакций). pg_dump теряет все данные с момента последнего дампа.
Формула: (размер данных x количество полных копий) + (дневной прирост x дни хранения инкрементов). Для базы 100 GB с 30-дневным хранением и приростом 2 GB/день: 100 GB x 4 копии + 2 GB x 30 дней = 460 GB. Добавьте 30% запас. Rsync с hard links экономит 60-80% при инкрементальном копировании файлов.
Для баз более 500 GB используйте pg_basebackup с WAL streaming — он работает на уровне файловой системы и не создаёт нагрузку на CPU. Альтернативы: pgBackRest (параллельное сжатие, инкрементальные бэкапы, верификация), Barman (от 2ndQuadrant, поддержка нескольких серверов). Для Proxmox/KVM — снапшоты виртуальных машин через vzdump.
Минимальный набор: дата и время операции, тип операции (backup/verify/restore/drill), объект (какая БД/система), результат (success/failure), размер и длительность, имя ответственного. Записи должны быть immutable — отправляйте копию в syslog на отдельный сервер. Для 152-ФЗ дополнительно нужны подписанные акты ежемесячных учений по восстановлению.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#система резервного копирования#бэкап мониторинг#systemd timer бэкап#bash скрипт бэкап#проверка целостности бэкапа#telegram алерты бэкап#стратегия 3-2-1#backup restore drill
Комментарии 0

Оставить комментарий

загрузка...