Статьи Обо мне

Как я потерял базу данных с 10 000 записей пациентов и восстановил её за 10 минут: уроки по бэкапам

## 3:00 утра и таинственный DELETE FROM patients

Три часа ночи. Я сижу на кухне, жму глаза и пытаюсь понять, почему последний запрос в Grafana выглядит как идеальный круглый нуль. Все метрики — нули. Активные пациенты, новые обращения, лабораторные тесты — всё. Я думал, что это глюк панели. Перезагрузил. Ноль.

Потом проверил сам сервис. Он отвечает 200 OK, но в ответе пустой массив. SELECT COUNT(*) FROM patients; вернул 0. Моё сердце, которое обычно бьется ровно 60 ударов в минуту, попыталось вырваться через грудную клетку.

10 256 записей. Каждая — не просто имя и номер. Это история болезни, прикреплённые исследования, назначения врачей, результаты анализов. Год работы. И всё это превратилось в цифровую пустоту.

Первая мысль: «Это хакер». Мы небольшая лаборатория, нас не должны трогать. Но логи показали не взлом, а мою собственную руку. В логе приложения, которое я писал для автоматической чистки тестовых данных, была строчка, выполненная 6 часов назад:

DELETE FROM patients WHERE created_at < NOW() - INTERVAL '30 days';

Цель скрипта была благородна: удалять тестовые записи, которые создавались при разработке. Но в спешке, в 2 часа дня между двумя срочными анализами, я написал условие не test = true, а created_at. И забыл добавить AND test = true. В базе реальных пациентов все записи были созданы меньше 30 дней назад? Нет. Но скрипт запускался по cron каждую ночь, и вчера он нашёл первую «старую» запись — самого первого пациента, внесённого год назад. Условие created_at < NOW() - INTERVAL '30 days' оказалось истинным для ВСЕХ записей, созданных более месяца назад. А через месяц мы уже набрали почти все 10 тысяч. Скрипт молча и методично удалил их всех.

Вот тот проклятый скрипт, версия 1.0, который я написал на коленке:

#!/bin/bash
# cleanup_test_patients.sh
# Удаляем тестовые записи старше 30 дней

PG_HOST="localhost"
PG_DB="lab_clinic"
PG_USER="app_user"

psql -h $PG_HOST -d $PG_DB -U $PG_USER -c "DELETE FROM patients WHERE created_at < NOW() - INTERVAL '30 days';"

Казалось, всё просто и безопасно. Я даже добавил в cron аккуратную запись:

# В /etc/crontab
0 2 * * * /opt/app/scripts/cleanup_test_patients.sh >> /var/log/cleanup.log 2>&1

Лог показывал лишь: «DELETE 10256». Ни ошибок, ни предупреждений.

В тот момент, на кухне, я понял несколько вещей: 1. DELETE без WHERE — это детская шалость. DELETE с WHERE, который непреднамеренно истинен для всей таблицы — это профессиональное самоубийство. 2. Скрипты, которые меняют данные, нельзя писать «между анализами». Это требует того же состояния мозга, как хирургическая операция. 3. Cron — это тихий убийца. Он выполнит всё, что ты ему скажешь, без вопросов, в 2 часа ночи, когда ты сладко спишь.

Паника была холодной и рациональной. Я не кричал. Я просто ощутил вакуум в желудке. 10 000 пациентов. Завтра в 8:00 врачи придут и не смогут найти историю болезней. Лаборанты не смогут выгрузить результаты. Регистратура будет смотреть на пустой интерфейс. Это не просто потеря данных — это остановка работы клиники.

И самое горькое: я знал, что бэкапы были. Где-то. Я их настроил. Полгода назад. И с тех раз не проверял.

Первым движением было запустить pg_dump. Но это было глупо: база уже пуста. pg_dump выгрузит пустую схему. Мне нужна была последняя резервная копия. Я помнил, что она лежит в /var/backups/postgres/. Помнил, что она делается каждый день в 01:00.

Я побежал к серверу. Команда ls -la /var/backups/postgres/ показала три файла. Самый свежий — с сегодняшней датой, 1 час ночи. То есть, резервная копия была создана ПОСЛЕ того, как cron выполнил удаление в 2 часа ночи. Скрипт удаления сработал между резервной копией и моим обнаружением катастрофы. Последний бэкап был уже пустым.

Второй файл — от предыдущего дня. Его размер был 1.2 ГБ. Это был наш шанс.

Вот что я увидел в той директории:

/var/backups/postgres/
lab_clinic_2023-10-28_01:00.sql.gz   # Размер: 1.2GB (здоровый)
lab_clinic_2023-10-29_01:00.sql.gz   # Размер: 12KB (пустой, после DELETE)
lab_clinic_2023-10-30_01:00.sql.gz   # Размер: 12KB (пустой)

Мозг начал работать на автопилоте. Восстановить из файла за 28 октября. Это означало потерять данные за один день (29 октября) — все новые пациенты и обращения за этот день. Это было приемлемой ценой. Но как быстро восстановить 1.2 ГБ сжатого SQL в работающую базу, не останавливая приложение полностью?

Я знал, что просто psql -f на такой файл займет часы и заблокирует базу. А у нас через 5 часов начинается рабочий день.

Мой следующий шаг был не героическим, а просто правильным. Я остановил приложение (это был простой Docker Compose проект), переключил его на временную, пустую базу, чтобы интерфейс хотя бы запускался, а основную базу начал восстанавливать параллельно. Но это — уже история следующей секции, где паника встретилась с реальностью восстановления и где надежды, построенные на pg_dump, начали разбиваться о простые физические ограничения скорости I/O и времени.

## Паника, pg_dump и разбитые надежды

Паника — это когда твои пальцы начинают барабанить по столу, а в голове проносится мысль: «Сейчас объяснюсь с главврачом». Я полез в терминал. Подключился к базе, к той самой PostgreSQL 13.8, которая стояла на сервере в докере.

psql -h localhost -U postgres -d clinic

И выполнил самый простой запрос:

SELECT COUNT(*) FROM patients;
-- 0

Ноль. Абсолютный ноль. Десять тысяч записей, включая историю болезни, контакты, прикреплённые анализы — всё испарилось. Мой следующий шаг был логичным для любого, кто хоть раз читал мануалы по администрированию: pg_dump. У меня же есть бекапы! Ну, как есть... Я помнил, что полгода назад настраивал cron-задачу.

# Вот та самая команда, которую я наивно считал спасительной
pg_dump -U postgres clinic > /backups/clinic_backup.sql

Я побежал в директорию /backups. Там лежал один-единственный файл: clinic_backup.sql. Дата изменения — ровно шесть месяцев назад. Размер — 1.4 ГБ. Сердце ёкнуло с надеждой. Я попытался его восстановить.

# Попытка №1: наивная и обречённая
psql -U postgres -d clinic < /backups/clinic_backup.sql

Через две секунды — ошибка:

ERROR:  relation "patients" already exists

Конечно. Я же не дропнул схему. База-то пустая, но метаданные, возможно, остались. Ладно, дропаем базу и создаём заново.

# Жесткий и глупый вариант
psql -U postgres -c "DROP DATABASE clinic;"
psql -U postgres -c "CREATE DATABASE clinic;"
psql -U postgres -d clinic < /backups/clinic_backup.sql

Процесс пошёл. Строки поплыли. Я вышел на кухню, налил кофе. Вернулся через пять минут — тишина. Скрипт завис. Я прервал его (Ctrl+C) и заглянул в конец файла:

tail -n 50 /backups/clinic_backup.sql

И увидел это:

--
-- Data for Name: patients; Type: TABLE DATA; Schema: public; Owner: postgres
--

COPY patients (id, last_name, first_name, birth_date, ...) FROM stdin;
1 Иванов Иван 1980-01-01 ...
2 Петров Петр 1975-03-15 ...
...

Данные были! Но дальше, в самом конце файла, я нашёл роковую строку:

--
-- PostgreSQL database dump complete
--
-- Time: 2024-01-15 03:00:00 UTC
--

Дамп был сделан в 3:00 ночи. Ровно тогда же, когда случился DELETE. И тут меня осенило. Мой cron-скрипт, который я написал полгода назад, делал дамп без остановки приложения. Он работал в фоне, пока сервис принимал записи. А в 3:00 утра у нас как раз запускался агрегатор отчётов, который по дикой случайности в одной из своих транзакций содержал DELETE FROM patients WHERE ... без условия WHERE. И этот DELETE попал в снимок базы, который делал pg_dump.

По сути, я сохранил уже удалённые данные. Бекап был битым. Это был не просто старый бекап — это был труп.

Я проверил другие резервные копии. Их не было. Ни инкрементальных, ни сжатых, ни залитых на удалённый сервер. Только этот одинокий 1.4 ГБ файл, который хранился на том же самом диске, что и основная база. Если бы диск умер — умерло бы всё.

Вот цифры, которые я тогда осознал: * 10 000 записей пациентов. * 6 месяцев с последнего бекапа. * 0 проверок восстановления из этого бекапа. * 1 носитель для хранения (локальный диск). * 100% вероятность профессионального позора.

Я сел, закрыл лицо руками. Паника сменилась ледяным спокойствием отчаяния. Нужно было думать. И тогда я вспомнил про механизм, который в PostgreSQL называется Write-Ahead Logging (WAL). Логи транзакций. Они пишутся постоянно. И если у тебя есть полная базовая резервная копия (basebackup) и цепочка WAL-логов, ты можешь откатиться на любой момент времени — Point-in-Time Recovery (PITR).

Я никогда этим не пользовался. Это было в планах «на будущее». Будущее наступило.

Я залез в конфиг PostgreSQL (postgresql.conf), который лежал в том же docker-образе.

# Проверил критичные параметры
grep -E "(wal_level|archive_mode|archive_command)" /var/lib/postgresql/data/postgresql.conf

И увидел:

wal_level = replica
archive_mode = off
archive_command = ''

По умолчанию. archive_mode = off. Логи не архивировались, они просто перезаписывались. Последняя надежда рухнула. WAL-логи за последние шесть месяцев не сохранились.

В этот момент я понял всю глубину провала. У меня не было: 1. Регулярных бекапов. 2. Проверенных бекапов (тестов восстановления). 3. Изолированных бекапов (на отдельном носителе). 4. Точных бекапов (PITR).

Был только ритуал pg_dump в cron, который я установил и забыл. И который в итоге сохранил катастрофу.

Я потратил ещё минут пять, безнадёжно роясь в логах Docker и системных журналах, пытаясь найти хоть какие-то следы. Но нет. Данные были мертвы.

Именно в этот момент, в состоянии полной капитуляции, я начал искать нестандартные пути. И нашёл их. Не в волшебных командах PostgreSQL, а в том, что я как врач-лаборант делаю каждый день: в протоколах и журналах. Все запросы к API нашего сервиса логировались. В JSON-файлах. Грубо, неструктурированно, но — они были.

## Docker, cron и скрипт из 15 строк: реальный код спасения

Паника сменилась холодной яростью. Яростью на самого себя. Потому что в тот момент, когда я понял, что pg_dump последней версии не работает с моей старой контейнеризованной Postgres 13.8 из-за какого-то левого расширения, я вдруг вспомнил, что у меня уже был скрипт для бэкапов.

Не красивый, не умный, не на Python с кучей зависимостей. А тот самый — грязный, написанный в 5 утра полгода назад, на Bash, из 15 строк. Он лежал в /root/backup.sh и был забыт, как старый аптечный справочник. Я запустил его вручную, и он спас всё.

Вот он, этот уродец, который оказался красивее любого идеального решения:

#!/bin/bash
# backup.sh - спасательный круг для клиники
# Версия: 1.0 (от 2023-11-05, написано на кофеине и отчаянии)

set -euo pipefail

BACKUP_DIR="/var/backups/postgres"
CONTAINER_NAME="clinic-postgres-1"
DB_NAME="clinic"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
BACKUP_FILE="$BACKUP_DIR/${DB_NAME}_${TIMESTAMP}.sql.gz"
RETENTION_DAYS=7

mkdir -p "$BACKUP_DIR"

echo "[$(date)] Запуск бэкапа для $DB_NAME"

# Вот эта магия. Не pg_dump, а docker exec.
docker exec $CONTAINER_NAME pg_dump -U postgres $DB_NAME | gzip -9 > "$BACKUP_FILE"

if [ ${PIPESTATUS[0]} -ne 0 ]; then
    echo "[$(date)] ОШИБКА: не удалось создать дамп" >&2
    exit 1
fi

SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
echo "[$(date)] Успех. Файл: $BACKUP_FILE, размер: $SIZE"

# Чистка старых бэкапов. Просто и жёстко.
find "$BACKUP_DIR" -name "${DB_NAME}_*.sql.gz" -mtime +$RETENTION_DAYS -delete

echo "[$(date)] Очистка старее $RETENTION_DAYS дней завершена."

Почему это сработало, когда pg_dump снаружи — нет?

  1. Контекст внутри контейнера. docker exec запускает pg_dump внутри контейнера, где уже настроены все переменные окружения, пути, библиотеки и, самое главное, есть прямой доступ к сокету БД. Снаружи мне бы пришлось танцевать с бубном, пробрасывая порт 5432, настраивая .pgpass, и всё равно могли всплыть проблемы с версиями клиентских библиотек.
  2. Минимум зависимостей. На хосте не нужен postgresql-client. Нужен только docker и gzip. Всё.
  3. Потоковая компрессия. | gzip -9 — дамп сразу сжимается на лету. Экономит место (с 2.1 ГБ сырого SQL до 280 МБ) и время записи на диск.

Но скрипт без расписания — это просто текст в файле. Его надо было встроить в систему. И здесь на сцену вышел старый добрый cron, которого все так любят ругать.

Мой /etc/crontab выглядел так:

# Ежедневное резервное копирование БД клиники в — 2:00 ночи.
# Потому что в 3:00 я уже могу всё сломать, а в 2:00 система ещё спит.
0 2 * * * root /bin/bash /root/backup.sh >> /var/log/postgres_backup.log 2>&1

Ключевые моменты настройки cron: * root — скрипт лежал в /root/, и ему нужны были права на запись в /var/backups/. Я не стал мудрить с отдельным пользователем, это была цель — выжить, а не получить сертификат безопасности. * >> /var/log/... 2>&1 — всё, что скрипт выводит (и ошибки тоже), летит в лог-файл. Утром можно проверить, не сломалось ли что-то. Без этого ты слеп.

Восстановление из этого бэкапа заняло не 10 минут, а 7. Вот команды, которые я вбил, дрожащими руками, но уже с надеждой:

# 1. Остановить приложение, чтобы никто не писал в мёртвую БД
cd /opt/clinic && docker-compose stop app

# 2. Дропнуть текущую (убитую) базу. Жестоко, но необходимо.
docker exec -it clinic-postgres-1 psql -U postgres -c "DROP DATABASE clinic;"

# 3. Создать чистую базу с тем же именем
docker exec -it clinic-postgres-1 psql -U postgres -c "CREATE DATABASE clinic;"

# 4. Развернуть последний бэкап. Вот этот момент истины.
gunzip -c /var/backups/postgres/clinic_20240415_020001.sql.gz | docker exec -i clinic-postgres-1 psql -U postgres clinic

# 5. Запустить приложение обратно и молиться
cd /opt/clinic && docker-compose start app

Через 30 секунд после пункта 4 в терминале поплыли знакомые INSERT-ы на 10 тысяч записей. Через 7 минут Grafana ожила, и графики поползли вверх. Адреналин отступил, сменившись дикой усталостью и одним, но ёмким выводом.

Урок этой секции: Твой бэкап должен быть тупым, как пробник, и надёжным, как скальпель. Не гонись за красивыми оркестраторами в 3 часа ночи. Bash, docker exec, cron и gzip — это твой набор для экстренной трахеотомии базы данных. Напиши этот скрипт сегодня. Прямо сейчас. Положи его рядом и заставь cron тыкать в него каждый день, пока ты спишь. Потому что однажды ты проснёшься в 3:00 и скажешь этому скрипту спасибо. Как сказал я.

## Когда 10 ГБ — это уже не скрипт: S3, мониторинг и алерты

А знаете, что самое страшное? После того, как я восстановил данные тем самодельным скриптом, я сел и посчитал. За год работы моё приложение накопило чуть больше 10 гигабайт данных в PostgreSQL. И мой уютный скрипт, который pg_dumpил всё в один файлик на том же диске, начал сбоить. Не сразу, а по-тихому. Просто однажды cron мне на почту прислал пустое письмо с темой (Cron <root@server>) /root/backup.sh. А в логах — tar: Exiting with failure status due to previous errors.

10 ГБ — это магический порог, когда простые решения начинают рассыпаться. pg_dump на таком объёме зависал на минуты, забивая память. Архив tar.gz падал, не хватая места в /tmp. А самое главное — этот гигантский файл лежал на том же самом физическом диске, что и живая база. Если бы сгорел SSD на сервере — прощай, и база, и бэкап. Идиотизм, осознанный и выстраданный.

Пришлось строить систему. Цель: надёжно, дёшево, чтобы я мог спать спокойно. Мой стек: AWS S3 (он же Object Storage в Яндексе или Selectel — принцип тот же), awscli, cron и капля мониторинга.

Первым делом — выкидываем монолитный дамп. Вместо него — раздельные, по схемам, и с ротацией.

#!/bin/bash
# /usr/local/bin/backup_v2.sh
# Версия от 12.03.2024, боевая

set -euo pipefail
export PGUSER="postgres"
export PGPASSWORD="мой_супер_сложный_пароль"
export PGHOST="localhost"
export PGPORT="5432"

BACKUP_DIR="/var/backups/postgres"
S3_BUCKET="s3://my-clinic-backups/production"
DATE=$(date +%Y%m%d_%H%M%S)
RETENTION_DAYS=7

# 1. Создаём директорию под сегодня
mkdir -p "$BACKUP_DIR/$DATE"

# 2. Дамп только нужных схем, по отдельности. Пациентов — отдельно, логи лаборатории — отдельно.
SCHEMAS="clinic_core lab_results auth"
for SCHEMA in $SCHEMAS; do
    echo "[$DATE] Дамп схемы: $SCHEMA"
    pg_dump --format=directory \
            --jobs=4 \
            --verbose \
            --schema="$SCHEMA" \
            --file="$BACKUP_DIR/$DATE/$SCHEMA" \
            clinic_db 2>&1 | logger -t postgres_backup
done

# 3. Сжимаем каждую схему в свой архив (параллельно!)
cd "$BACKUP_DIR/$DATE"
for SCHEMA in $SCHEMAS; do
    tar -czf "$SCHEMA-$DATE.tar.gz" "$SCHEMA" &
done
wait

# 4. Отправляем в S3. awscli должен быть настроен (aws configure)
for ARCHIVE in *.tar.gz; do
    echo "[$DATE] Загрузка $ARCHIVE в S3"
    /usr/local/bin/aws s3 cp "$ARCHIVE" "$S3_BUCKET/$DATE/" --storage-class STANDARD_IA 2>&1 | logger -t postgres_backup_s3
done

# 5. Чистим локальные файлы старше RETENTION_DAYS
find "$BACKUP_DIR" -type d -mtime +$RETENTION_DAYS -exec rm -rf {} \; 2>/dev/null || true

# 6. Важно! Проверяем, что в S3 есть свежий бэкап. Если нет — кричим.
LATEST_IN_S3=$(/usr/local/bin/aws s3 ls "$S3_BUCKET/" | sort | tail -n 1 | awk '{print $2}' | sed 's/\///')
if [[ "$LATEST_IN_S3" != "$DATE" ]]; then
    echo "ALERT: Latest backup in S3 ($LATEST_IN_S3) does not match current date ($DATE)" | mail -s "BACKUP FAILURE" admin@clinic.local
fi

echo "[$DATE] Backup completed and uploaded to S3" | logger -t postgres_backup

Ключевые моменты, которые я выстрадал:

  • --format=directory --jobs=4: Это спасло время. Дамп в директорию с параллельностью. На 10 ГБ разница между старым и новым подходом — 25 минут против 8.
  • Разделение по схемам: Если прилетит DROP SCHEMA lab_results CASCADE;, мне не нужно разворачивать всю 10-гигабайтную базу. Достаточно выкачать один архив на 2 ГБ.
  • S3 Storage Class STANDARD_IA (Infrequent Access): В 2-3 раза дешевле обычного S3 для данных, к которым редко обращаешься. Идеально для бэкапов.
  • Логирование через logger: Все сообщения скрипта улетают в системный журнал (journalctl -t postgres_backup). Это золотая жила для отладки.

Но скрипт — это только половина дела. Вторая половина — мониторинг. Бэкап, который никто не проверяет, — это не бэкап, это иллюзия.

Я поставил простой, но убийственно эффективный стек:

  1. Проверка размера бэкапа в S3 (скрипт в cron раз в день): bash #!/bin/bash SIZE_BYTES=$(aws s3 ls --summarize --human-readable --recursive s3://my-clinic-backups/production/$(date +%Y%m%d) 2>/dev/null | tail -n 1 | awk '{print $3}') if [[ "$SIZE_BYTES" == "0" ]] || [[ -z "$SIZE_BYTES" ]]; then curl -X POST -H 'Content-type: application/json' \ --data '{"text":"Сегодняшний бэкап в S3 пустой или отсутствует. Немедленно проверь!"}' \ https://hooks.slack.com/services/ТВОЙ/WEBHOOK/URL fi

  2. Health Check самого процесса бэкапа. Я использовал Healthchecks.io (бесплатно для одного чека). В конце скрипта backup_v2.sh добавил: bash # Сообщаем healthchecks.io, что бэкап успешен curl -fsS --retry 3 https://hc-ping.com/ВАШ_УНИКАЛЬНЫЙ_UUID > /dev/null Если скрипт не "пинганул" в течение 26 часов — мне на почту и в телегу летит алерт. Молчание == проблема.

  3. Дашборд в Grafana. Поднял Prometheus node_exporter на сервере и добавил панель "Размер директории с бэкапами" и "Дата последнего файла в S3". Вижу историю, вижу аномалии.

Итог этого этапа: автоматическая система, которая не просто копирует данные, а кричит, если что-то идёт не так. Стоимость? S3 STANDARD_IA под 10 ГБ с ротацией на 30 дней — около 1.5$ в месяц. Healthchecks.io — 0$. Мой сон — бесценен.

Но это была лишь техническая часть. Следующий этап — самая тяжёлая. Когда я оглянулся на три года до катастрофы и понял, что все красные флажки были на виду. Я их просто игнорировал.

## Три ночи слепоты: что я игнорировал до катастрофы

Три ночи. Неделя. Месяц. Сейчас, глядя на экран с алертами, которые кричат о каждом пропущенном cron-джобе, я понимаю: катастрофа зрела долго. Я её поливал, удобрял и тщательно игнорировал. Это не был внезапный удар молнии. Это был медленный, уверенный спуск в ад по лестнице из собственной лени и самоуверенности. Давайте по полочкам разложу, что именно я игнорировал, пока не стало поздно.

1. «Да ну, на Docker же! Контейнер не удалится сам»

Самая первая и жирная ложь, которую я себе рассказывал. Моя база жила в Docker-контейнере. Я использовал docker-compose, и мой docker-compose.yml был образцом халатности:

version: '3.8'
services:
  postgres:
    image: postgres:13.8
    container_name: clinic_db
    environment:
      POSTGRES_DB: clinic
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    restart: unless-stopped

volumes:
  postgres_data:

Смотрите, красота: restart: unless-stopped. Надёжно, да? А volume привязан. Где он физически? А хрен его знает. Команда docker volume inspect clinic_postgres_data показывала путь где-то в недрах /var/lib/docker/volumes/. Я ни разу не проверял, сколько там места. Я ни разу не делал бэкап самого volume. Моя логика была гениальна: «Данные же в volume, они вне контейнера. Удалится контейнер — данные останутся». Я забыл, что docker system prune -a --volumes, выполненная в панике или по ошибке, сметёт и volumes. Я забыл про миграцию сервера, про сбои файловой системы. Volume — не бэкап. Это просто папка. Её тоже нужно копировать. Этого я не делал.

2. «pg_dump из-под рута — это и есть план»

У меня не было плана. Был рефлекс. Когда в голове пронеслось «надо бы сохранить данные», я вручную, по SSH, запускал:

docker exec clinic_db pg_dump -U postgres clinic > /tmp/backup_$(date +%Y%m%d).sql

Потом иногда копировал этот файл себе на ноутбук через scp. Иногда. Чаще забывал. У этого «плана» было дыры размером с черную дыру: * Ручное выполнение. Значит, зависит от моей памяти и настроения. * Хранение на том же сервере. Физический диск сервера — единая точка отказа. Полетел диск — прощай и база, и её «бэкап» в /tmp. * Нет верификации. Я ни разу не проверял, что дамп не битый. Не пытался его восстановить в тестовом контейнере. Доверял зелёному свету в терминале. * Нет ротации. Папка /tmp или /home/backups (куда я потом перенёс) превратилась в свалку. Старые дампы не удалялись, съедая место.

3. «Cron? Да я потом, когда будет время»

Время появилось ровно в ту ночь, когда cron уже не мог помочь. Я откладывал автоматизацию на потом, потому что это казалось «простой задачей на 15 минут». Эти 15 минут копились месяцами. Я не знал о systemd timers, которые надёжнее cron. Я не настроил logrotate для логов дампов. Я даже не поставил пакет postgresql-client на хост-машину, чтобы не зависеть от состояния контейнера при дампе (что и стало одной из причин провала с pg_dump в ночь Х).

Вот как выглядел мой первый «автоматизированный» скрипт, который я писал уже в пожарном режиме, но который иллюстрирует уровень моей наивности ДО катастрофы:

#!/bin/bash
# backup.sh - ТО, ЧТО Я ДЕЛАЛ ДО ПОЖАРА (НЕ РАБОТАЛО НАДЁЖНО)
BACKUP_DIR="/home/backups"
DB_NAME="clinic"
DATE=$(date +%Y%m%d_%H%M%S)

# 1. ДАМП БЕЗ ПРОВЕРОК
docker exec clinic_db pg_dump -U postgres $DB_NAME > $BACKUP_DIR/backup_$DATE.sql

# 2. НАИВНАЯ РОТАЦИЯ (УДАЛЯЛА ВСЁ СТАРШЕ 7 ДНЕЙ, НО ЕСЛИ СКРИПТ НЕ ЗАПУСКАЛСЯ...)
find $BACKUP_DIR -name "*.sql" -mtime +7 -delete

# 3. НИКАКИХ ЛОГОВ, НИКАКИХ УВЕДОМЛЕНИЙ
echo "Backup done?" # Вот и вся отладка

Этот скрипт падал молча. Если контейнер clinic_db был перезапущен и менял ID, docker exec не работал. Если заканчивалось место в BACKUP_DIR, скрипт просто не создавал файл. Я об этом не узнавал.

4. «Мониторинг? Это для больших компаний»

У меня стоял Grafana и Prometheus, но они следили за метриками приложения: загрузка CPU, RAM, HTTP-запросы. За целостностью и существованием данных они не следили. Ни один дашборд не отвечал на вопросы: * Увеличивается ли размер базы? (Признак работы) * Успешно ли выполнилась последняя задача бэкапа? * Соответствует ли количество записей в таблице patients вчерашнему дню +/- естественному приросту?

Я не отслеживал самый важный показатель: последний успешный бэкап. В Prometheus можно было выстрелить себе в ногу, но нельзя было узнать, когда в последний раз дамп был создан и весил больше нуля.

5. «Восстановление? Ну pg_restore же...»

Я ни разу не репетицировал восстановление. Это как иметь пожарный щит с надписью «Разбить стекло в случае пожара», но никогда не проверять, открывается ли ящик и работает ли огнетушитель. Я не знал: * Сколько времени займёт восстановление 10 ГБ дампа? (Оказалось, около 25 минут на моём железе). * Нужно ли останавливать приложение? (Да). * Что делать с транзакциями, которые шли в момент создания дампа? (Использовать --no-sync и надеяться на лучшее, а надо — на pg_dump с --serializable-deferrable или дамп реплики). * Как быть с миграциями БД, если структура изменилась? (Полная жопа).

Я жил в иллюзии контроля. У меня был ключ от машины (дамп), но я ни разу не садился за руль и не знал, заводится ли она. Всё это — классические грехи инди-разработчика, который слишком долго работал в режиме «прототипа». Прототип срастается с продакшеном, а привычки остаются игрушечными. Пока однажды игрушка не взрывается в три часа ночи, унося с собой 10 000 записей о пациентах. Следующая секция — о том, какие правила я выжег в своей подкорке после этого.

## Выводы, которые я залил в кровь: правила выживания в проде

Итак, кровь успокоилась, база вернулась, а я сижу с бутылкой воды и понимаю: все эти ночные хаки, все эти «вот сейчас допишу и всё будет работать» — это дорога к новому, более изощренному провалу. Мой провал был не в том, что я не знал, как делать бэкапы. Он был в том, что я не соблюдал правила. Правила выживания в проде, которые я теперь залил в кровь и которые даю вам — таким же сумасшедшим, которые деплоят с телефона в три часа ночи.

Урок 1: Ваш бэкап — это не скрипт. Это система с метриками и алертами.

Скрипт, который молча падает — это хуже, чем отсутствие скрипта. Он создает иллюзию безопасности. Моя ошибка была в том, что я проверял наличие файлов в папке /backups раз в месяц и думал: «О, файлы есть, всё ок».

Что делать: Каждый элемент вашей системы бэкапа должен быть наблюдаемым. Cron-джоб должен логировать свой старт и успешное завершение. Размер файла бэкапа должен попадать в метрику (Prometheus/Grafana). Сам факт создания файла в S3 (или другом хранилище) должен вызывать событие, которое можно отследить.

Я теперь делаю так: 1. Скрипт бэкапа пишет лог в определенный файл и отправляет метрику backup_last_run_timestamp в Prometheus через простой POST запрос. 2. Второй, независимый скрипт-«санитар» (он же runs каждые 10 минут) проверяет: - что timestamp последнего бэкапа не старше, например, 26 часов (даю фору на случай сбоя в один день); - что размер последнего бэкапа не равен нулю; - что файл физически присутствует в S3 (через aws s3 ls). 3. Если любая проверка не проходит — алерт в Telegram через простой бот.

Код санитара — это 30 строк Python, которые спасут вас от месяцев слепоты.

#!/usr/bin/env python3
# backup_sanity_check.py
import os
import time
import subprocess
from datetime import datetime, timedelta

# Конфигурация
BACKUP_LOG = "/var/log/postgres_backup.log"
S3_BUCKET = "my-clinic-backups"
S3_PATH = "daily/"
MAX_BACKUP_AGE_HOURS = 26
TELEGRAM_BOT_TOKEN = "xxx"
TELEGRAM_CHAT_ID = "yyy"

def send_telegram_alert(message):
    cmd = [
        "curl", "-s", "-X", "POST",
        f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage",
        "-d", f"chat_id={TELEGRAM_CHAT_ID}", "-d", f"text={message}"
    ]
    subprocess.run(cmd)

def main():
    # 1. Проверка лога последнего запуска
    if not os.path.exists(BACKUP_LOG):
        send_telegram_alert("⚠️ BACKUP CRITICAL: Log file missing!")
        return

    with open(BACKUP_LOG, 'r') as f:
        lines = f.readlines()
        if not lines:
            send_telegram_alert("⚠️ BACKUP CRITICAL: Log file empty!")
            return

        last_line = lines[-1]
        # Предполагаем формат: 2024-05-27 03:00:01 Backup completed successfully. Size: 10485760 bytes
        if "Backup completed successfully" not in last_line:
            send_telegram_alert("⚠️ BACKUP CRITICAL: Last run failed!")
            return

        try:
            log_time_str = last_line.split()[0] + " " + last_line.split()[1]
            log_time = datetime.strptime(log_time_str, "%Y-%m-%d %H:%M:%S")
            if datetime.now() - log_time > timedelta(hours=MAX_BACKUP_AGE_HOURS):
                send_telegram_alert(f"⚠️ BACKUP CRITICAL: Last backup too old ({log_time_str})!")
                return
        except Exception as e:
            send_telegram_alert(f"⚠️ BACKUP CRITICAL: Log parsing error: {e}")
            return

    # 2. Проверка наличия и размера файла в S3 (пример для последнего файла)
    # Здесь можно усложнить, но для примера проверяем последний по дате
    list_cmd = ["aws", "s3", "ls", f"s3://{S3_BUCKET}/{S3_PATH}", "--recursive"]
    result = subprocess.run(list_cmd, capture_output=True, text=True)
    if result.returncode != 0 or not result.stdout.strip():
        send_telegram_alert("⚠️ BACKUP CRITICAL: No files in S3 bucket!")
        return

    last_file_line = result.stdout.strip().split('\n')[-1]
    file_size = int(last_file_line.split()[2])
    if file_size < 1000:  # Минимальный ожидаемый размер, у вас будет свой
        send_telegram_alert(f"⚠️ BACKUP CRITICAL: Last backup size suspiciously small ({file_size} bytes)!")
        return

    print("✅ Backup sanity check passed.")

if __name__ == "__main__":
    main()

Этот скрипт запускается cron каждые 10 минут. Он не восстанавливает базу, он лишь кричит, когда система начинает тихо умирать.

Урок 2: Храните бэкап НЕ там, где живут данные. И проверяйте, что вы можете его ВОСПОЛЬЗОВАТЬ.

Мой первый провал — бэкап на том же диске. Второй провал — pg_dump не совпадал по версии с контейнером. Третий потенциальный провал — я никогда не проверял, что бэкап действительно можно восстановить.

Что делать: 1. Разделение носителей: Бэкап должен уходить в физически отдельное место. S3 (или другой cloud storage) — минимально. Идеально — еще и на отдельный физический сервер/NAS. Если ваш сервер умрет, бэкап на его диске умрет вместе с ним. 2. Регулярное тестирование восстановления: Раз в месяц (я делаю теперь каждые две недели) нужно проводить drill. Выбираете случайный бэкап за прошлый месяц, разворачиваете его на тестовом инстансе PostgreSQL (можно в отдельном docker-контейнере) и проверяете, что: - База поднимается. - Можно выполнить несколько ключевых запросов (SELECT COUNT(*) FROM patients;). - Не возникает ошибок из-за расширений.

Я автоматизировал это следующим образом: у меня есть отдельный docker-compose файл для тестового восстановления и скрипт, который выбирает бэкап двухнедельной давности, скачивает его из S3, запускает чистый контейнер PostgreSQL 13.8 и пытается в него восстановиться.

#!/bin/bash
# restore_test_drill.sh
set -e

BACKUP_DATE=$(date -d "14 days ago" +%Y-%m-%d)
S3_BUCKET="my-clinic-backups"
S3_PATH="daily/"
BACKUP_FILE="clinic_backup_${BACKUP_DATE}.sql.gz"

echo "🔍 Starting restore drill for backup from ${BACKUP_DATE}..."

# 1. Скачиваем бэкап
aws s3 cp "s3://${S3_BUCKET}/${S3_PATH}${BACKUP_FILE}" ./test_restore.sql.gz

# 2. Останавливаем и удаляем предыдущий тестовый контейнер (если есть)
docker-compose -f test_restore_compose.yml down -v

# 3. Запускаем чистый контейнер Postgres
docker-compose -f test_restore_compose.yml up -d

# 4. Ждем готовности Postgres (грубо, но работает)
sleep 10

# 5. Восстанавливаем данные
gunzip -c ./test_restore.sql.gz | docker exec -i test_restore_db psql -U postgres -d postgres

# 6. Проверяем
RECORD_COUNT=$(docker exec test_restore_db psql -U postgres -d postgres -t -c "SELECT COUNT(*) FROM patients;")
echo "📊 Record count in 'patients' table after restore: ${RECORD_COUNT}"

if [[ $RECORD_COUNT -gt 0 ]]; then
    echo "✅ Restore drill PASSED."
else
    echo "❌ Restore drill FAILED. Backup might be corrupt."
    # Здесь можно отправлять алерт
fi

# 7. Очищаем
docker-compose -f test_restore_compose.yml down -v
rm ./test_restore.sql.gz

Это занимает 15 минут раз в две недели и дает абсолютную уверенность, что ваш механизм работает.

Урок 3: Версии всего. Зафиксируйте их и храните рядом с бэкапом.

Моя проблема с pg_dump 15.5 и Postgres 13.8 — классическая. Со временем ваша инфраструктура будет меняться: обновятся клиентские библиотеки, инструменты, версии ПО.

Что делать: В файл бэкапа (или в отдельный манифест, который кладется рядом) добавляйте метаданные о версиях критически важного ПО. Это позволит вам (или тому, кто будет восстанавливать систему через год) понять, какими инструментами этот бэкап был создан и как его правильно восстановить.

Я добавил в свой скрипт бэкапа следующие строки:

#!/bin/bash
# В начало скрипта backup.sh
echo "=== Backup Metadata ==="
echo "Date: $(date)"
echo "PostgreSQL Container Version: $(docker exec clinic_db psql --version)"
echo "pg_dump Version: $(pg_dump --version)"
echo "AWS CLI Version: $(aws --version)"
echo "=== Backup Start ==="

А в сам файл лога (и в комментарий внутри SQL дампа, если возможно) пишется эта информация. Теперь, если через год мне нужно восстановить старый бэкап, я сразу вижу: «Ага, этот бэкап сделан с помощью pg_dump 15.5, а база была 13.8. Значит, для восстановления нужно либо найти pg_dump 15.5, либо использовать совместимые методы».

Урок 4: Не один бэкап, а слои: ежедневный, еженедельный, ежемесячный.

Когда данные растут, полный ежедневный бэкап может стать дорогим и долгим. Но если вы делаете только полный ежедневный, то в случае сбоя вы потеряете только один день. Это уже хорошо. Однако для защиты от более сложных сценариев (например, повреждение данных, которое не было обнаружено сразу) нужны более долгие интервалы.

Что делать: Построить пирамиду. - Ежедневный полный бэкап: В S3, с lifecycle policy — удалять через 7 дней. - Еженедельный полный бэкап (в воскресенье): В S3, хранить 30 дней. - Ежемесячный полный бэкап (первого числа месяца): В S3, хранить 1 год. Или выгружать на холодное хранилище (например, другой bucket с переходом в glacier).

Это легко реализуется путем добавления разных префиксов в S3 и модификации cron задач.

# В cron
# Daily
0 3 * * * /opt/backup/backup.sh daily
# Weekly (Sunday)
0 4 * * 0 /opt/backup/backup.sh weekly
# Monthly (1st day of month)
0 5 1 * * /opt/backup/backup.sh monthly

А в скрипте backup.sh:

TYPE=$1
case $TYPE in
  daily)
    S3_SUBPATH="daily/"
    LIFE_CYCLE="keep_7days"
    ;;
  weekly)
    S3_SUBPATH="weekly/"
    LIFE_CYCLE="keep_30days"
    ;;
  monthly)
    S3_SUBPATH="monthly/"
    LIFE_CYCLE="keep_1year"
    ;;
esac

# ... процесс создания бэкапа ...
aws s3 cp "${BACKUP_FILE}" "s3://${S3_BUCKET}/${S3_SUBPATH}"
# Можно даже применить policy через tags, если нужно

Это дает вам возможность откатиться не только на день назад, но и на неделю или месяц, если проблема была скрытой.

Урок 5: Автоматизация — это не цель, это средство. Человек должен оставаться в цепи.

Полная автоматизация — это миф. Если система полностью автоматизирована и никто её не проверяет, она неизбежно деградирует и сломается в самый неподходящий момент. Моя ошибка была в том, что я полностью доверился cron и забыл о ней.

Что делать: Включить человека в цикл. Да, алерты в Telegram — это уже включение. Но нужно еще: 1. Регулярный manual review: Раз в месяц просматривать логи бэкапов, проверять, что размеры файлов растут пропорционально росту данных (если рост остановился — это алерт). 2. Документирование процедуры восстановления: Написать четкую, простую инструкцию «Что делать, если база умерла». Эта инструкция должна быть доступна не только вам, но и хотя бы еще одному человеку (вашему коллеге, заместителю). Инструкция должна включать: - Где лежат бэкапы. - Какой скрипт запустить для восстановления. - Контакты для экстренной помощи. - И быть проверена на практике (см. Урок 2).

Я создал такую инструкцию в виде Markdown файла в корне проекта и в виде сообщения в закрепленном Telegram–чате нашей команды (да, команда — это я и еще один разработчик). Она начинается так:

# 🔴 EMERGENCY PROCEDURE: Database Restoration

## Last Updated: 2024-05-27

## 1. Immediate Actions
- STOP the application service: `docker-compose stop app`
- DO NOT restart PostgreSQL container. Leave it as is.

## 2. Locate Latest Valid Backup
- Check S3 bucket: `my-clinic-backups/daily/`
- Use the sanity check script to verify: `/opt/backup/backup_sanity_check.py`
- The most recent valid backup will be named `clinic_backup_YYYY-MM-DD.sql.gz`

## 3. Restoration Script
- Run: `/opt/backup/emergency_restore.sh YYYY-MM-DD`
- This script will:
   1. Download the backup from S3.
   2. Stop the current DB container.
   3. Restore the data.
   4. Start the DB container.
   5. Run a basic integrity check.

## 4. After Restoration
- Start the application: `docker-compose start app`
- Monitor logs for 10 minutes.
- If errors persist, contact [Your Name] @telegram or [Second Person] @telegram.

## 5. Testing This Procedure
- This procedure is tested every two weeks via `/opt/backup/restore_test_drill.sh`.

Эта инструкция — мой последний рубеж. Если я сам недоступен (заболел, отключен), другой человек имеет шанс спасти систему.

Итог: Катастрофа с 10 000 записей пациентов не научила меня делать бэкапы. Она научила меня строить систему, которая не позволяет мне быть слепым. Систему, которая кричит, когда я начинаю лениться. Систему, которую я регулярно тестирую под нагрузкой. Систему, которая документирована и доступна другим.

Теперь, когда я пишу код в три часа ночи, я знаю, что даже если мои глаза закрываются, моя система бэкапов — открыта и зорко следит за каждым движением данных. Она не позволит мне потерять их снова. И это — единственный способ выжить в проде, когда ты одновременно и разработчик, и врач, и последняя линия защиты.

📋 Копировать для: