MAATRIX / Блог / Автоматика удалила боевые данные: разбор опасного скрипта

Автоматика удалила боевые данные: разбор опасного скрипта

MAATRIX

Утро начинается с сообщения в чате: «а почему у нас пропали заказы за последний месяц». Дальше — паника, попытки понять, что произошло, и неприятное открытие: ночью отработал крон-скрипт, который должен был почистить старые тестовые записи, а вместо этого стёр часть боевой базы. Это один из самых частых сценариев потери данных — не взлом и не отказ диска, а собственная автоматика, которую никто толком не проверил перед тем, как дать ей права на удаление. Разберём, как такие скрипты обычно ломаются, что делать в первые минуты после обнаружения и как писать деструктивную автоматику так, чтобы она не могла случайно снести прод.

Как обычно рождается такая ошибка

Сценарий почти всегда один и тот же, меняются только детали. Разработчик пишет скрипт для рутинной задачи — удалить тестовые заказы старше 30 дней, почистить временные файлы, снести неактивные аккаунты. Пишет и тестирует его на staging или локальной копии базы, где данные не жалко и ошибка ничего не стоит. Скрипт отрабатывает правильно, его добавляют в крон и переносят в продакшен — часто без дополнительного ревью, потому что «это же просто уборка мусора, не бизнес-логика».

Проблема почти никогда не в самой идее скрипта, а в том, насколько узко или широко очерчено условие «что удалять». Разберём типичные варианты:

  • Условие по дате без привязки к конкретной таблице или окружению. Скрипт ищет записи WHERE created_at < NOW() - INTERVAL '30 days' в таблице orders, но на проде в этой же таблице лежат не только тестовые, а вообще все заказы — тестового окружения там просто нет, разделения по флагу is_test никогда не было, потому что на staging оно было не нужно.
  • Скопированный путь или имя базы, которое в проде указывает не туда, куда думали. Скрипт писали и отлаживали в директории /var/www/staging/uploads, при переносе поправили только хост в конфиге подключения, а путь к файлам остался захардкожен или собирается из переменной, которая на проде резолвится в общий каталог, а не в изолированный.
  • Отсутствие фильтра по окружению вообще. Скрипт подключается к базе, которая указана в переменной окружения DATABASE_URL, и на тесте это тестовая база, а после деплоя — боевая. Скрипт не проверяет, к чему именно подключился, он просто выполняет свою логику.
  • Ошибка в условии WHERE, которая не проявляется на маленьком наборе данных. На staging в таблице десять строк, LEFT JOIN с пустой связанной таблицей выглядит безобидно. На проде та же логика с миллионом строк и реальными связями превращается в удаление куда более широкого множества записей, чем задумывалось.

Ко всему этому обычно добавляется вторая часть проблемы: скрипт запускается от имени пользователя с полными правами — root в системе или учётка БД с правами на DELETE/DROP без ограничений, — потому что так было проще один раз настроить и больше к этому не возвращаться. Дополнительных проверок перед выполнением деструктивной операции нет: ни подтверждения, ни ограничения по числу строк, ни сверки с ожидаемым окружением. В итоге единственное, что стоит между «почистить мусор» и «удалить прод», — это правильность одного SQL-условия или одной переменной окружения, написанных человеком в спешке.

Почему это не ловят до продакшена

Здесь есть два системных пробела, которые почти всегда присутствуют одновременно, и именно их отсутствие превращает локальную ошибку в условии в боевой инцидент.

Нет безопасного dry-run режима. Dry-run — это возможность запустить скрипт так, чтобы он показал, что БУДЕТ удалено, но ничего реально не тронул: вывел список ID, количество строк, диапазон дат — и остановился. Если такого режима нет, единственный способ проверить логику скрипта — запустить его по-настоящему, и первый «настоящий» запуск на проде становится одновременно первой проверкой боевых данных.

Нет ограничения по числу затронутых записей. Это защита от массовой ошибки, а не от конкретного бага: даже если условие отбора неверно, разумный лимит — скажем, скрипт откажется удалять больше 500 строк за один запуск без явного подтверждения — превращает потенциальную катастрофу в аномалию, которую заметят по логу и остановят до того, как будет стёрта вся таблица. Без лимита скрипт удалит ровно столько, сколько подходит под условие, — хоть десять строк, хоть десять миллионов, и никакой встроенной сигнализации о том, что число получилось подозрительно большим, не сработает.

К этому часто добавляются более мелкие, но тоже значимые пробелы: скрипт не логирует, что именно он удалил (только количество строк, без ID или дампа удаляемых записей — а без этого потом сложнее понять масштаб и восстановить точечно); в CI/CD нет отдельного шага ревью для миграций и очистительных скриптов — они проходят по тому же пайплайну, что обычный код; крон настроен один раз и не пересматривается, когда меняется схема данных или добавляется новое окружение.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Первые минуты после обнаружения

Когда пропажа данных обнаружена, порядок действий важнее скорости — паническое «давайте что-нибудь сделаем» обычно только увеличивает ущерб.

  1. Остановить скрипт немедленно, если он ещё выполняется. Проверьте crontab -l и список процессов (ps aux | grep <имя_скрипта>), убейте процесс, если он всё ещё запущен, и отключите крон-задачу, чтобы она не сработала повторно на следующем цикле. Пока источник удаления активен, любая другая работа бессмысленна.
  2. Не трогать базу и не пытаться «на глаз» что-то восстановить руками. Ручные INSERT по памяти или попытки откатить что-то через UI приложения обычно только запутывают картину и мешают чистому восстановлению из бэкапа.
  3. Оценить масштаб: что именно и сколько удалено. Поднимите логи скрипта (если он логировал хотя бы количество и условие отбора), посмотрите pg_stat_user_tables / information_schema на предмет резко изменившегося количества строк, сверьтесь с логами приложения или очередями событий — часто там остаются косвенные следы (ID заказов, на которые ссылались другие системы, но которых больше нет в основной таблице).
  4. Зафиксировать текущее состояние перед восстановлением. Сделайте снапшот или дамп текущей (уже повреждённой) базы отдельно — если восстановление из бэкапа окажется частичным, у вас останется точка для сравнения и для ручной сверки различий.
  5. Восстановиться из бэкапа. Это ключевой шаг, и он снова возвращает к главному правилу резервного копирования: бэкап должен быть не просто создан, а регулярно проверен восстановлением на тестовом стенде. Если у вас в реальности практикуется ситуация из статьи бэкапы шли год и оказались нерабочими — обнаружите вы это именно в такой момент, а не заранее. Сам процесс восстановления конкретной базы из дампа подробно разобран в статье восстановление базы данных из бэкапа: практика.
  6. Оценить разрыв между последним бэкапом и моментом удаления. Если бэкапы снимаются раз в сутки, а скрипт отработал ночью, разрыв может составлять почти сутки данных — это тот RPO (recovery point objective), с которым придётся смириться или который нужно закрыть логами приложения, если они пишутся отдельно (например, события в очереди сообщений, которые можно частично реплеить после восстановления базы).

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

Ограничение прав: скрипту нужно ровно то, что нужно

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

Практический подход:

  • Создайте отдельную роль в БД под конкретный скрипт, а не используйте общую учётку приложения с полными правами:
CREATE ROLE cleanup_orders_bot LOGIN PASSWORD '...';
GRANT SELECT, DELETE ON orders TO cleanup_orders_bot;
-- никаких прав на другие таблицы, никакого DROP/TRUNCATE
  • В системе скрипт должен работать от отдельного непривилегированного пользователя, а не от root — даже если запуск идёт через cron, крон-задачу можно и нужно вешать на пользователя с минимально необходимыми правами на файловую систему.
  • Если скрипт трогает файлы, а не только базу, ограничьте его правами на конкретную директорию (через права unix-пользователя или через chroot/контейнер), чтобы скопированный или неверно посчитанный путь физически не мог дотянуться до боевых данных за пределами предназначенной для него области.

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

Как писать деструктивные скрипты правильно

Из разбора причин следует конкретный набор практик, который стоит сделать обязательным для любого скрипта, способного удалять или необратимо изменять данные.

1. Dry-run по умолчанию. Скрипт без явного флага должен только показывать, что будет удалено, а не удалять. Реальное удаление — это отдельный, явно указанный режим:

#!/usr/bin/env bash
set -euo pipefail

DRY_RUN=true
LIMIT=500
ENV_EXPECTED="staging"

for arg in "$@"; do
  case "$arg" in
    --execute) DRY_RUN=false ;;
    --limit=*) LIMIT="${arg#*=}" ;;
  esac
done

# явная проверка окружения перед любой деструктивной операцией
if [[ "${APP_ENV:-}" != "$ENV_EXPECTED" && "$DRY_RUN" == false ]]; then
  echo "Скрипт настроен на окружение '$ENV_EXPECTED', но APP_ENV='${APP_ENV:-unset}'."
  echo "Для запуска на другом окружении явно передайте --force-env=<окружение>."
  exit 1
fi

COUNT=$(psql "$DATABASE_URL" -tAc \
  "SELECT count(*) FROM orders WHERE created_at < now() - interval '30 days' AND is_test = true;")

echo "Найдено записей для удаления: $COUNT"

if (( COUNT > LIMIT )); then
  echo "Превышен лимит в $LIMIT записей — требуется ручное подтверждение (--force-limit)."
  exit 1
fi

if [[ "$DRY_RUN" == true ]]; then
  echo "Dry-run: реальное удаление не выполнено. Запустите с флагом --execute для удаления."
  exit 0
fi

read -rp "Подтвердите удаление $COUNT записей в окружении $APP_ENV (yes/no): " CONFIRM
[[ "$CONFIRM" == "yes" ]] || { echo "Отменено."; exit 1; }

psql "$DATABASE_URL" -c \
  "DELETE FROM orders WHERE created_at < now() - interval '30 days' AND is_test = true;"

echo "Удалено записей: $COUNT"

2. Явное подтверждение для боевого окружения. Даже с включённым --execute скрипт должен требовать интерактивного подтверждения или отдельного явного флага (--force-env=production), если определяет, что запущен не в том окружении, для которого писался.

3. Лимит на количество удаляемых записей с ручным подтверждением превышения. Это последняя линия защиты от ошибки в условии отбора: если реальное число подходящих строк оказалось на порядок больше ожидаемого, скрипт должен остановиться и потребовать осознанного решения человека, а не выполнить операцию молча.

4. Минимально необходимые права, как описано выше — отдельная роль в БД, отдельный системный пользователь, без доступа к тому, что скрипту не нужно.

5. Логирование того, что удалено, а не только количества. Перед DELETE полезно сохранить сами удаляемые записи (в отдельную таблицу-архив, в CSV-дамп, в файл лога) — это не защита от ошибки, но радикально упрощает восстановление точечных данных, если полный откат из бэкапа избыточен:

CREATE TABLE orders_deleted_archive AS
SELECT *, now() AS deleted_at FROM orders
WHERE created_at < now() - interval '30 days' AND is_test = true;

6. Ревью деструктивных скриптов отдельным шагом. Любой скрипт, содержащий DELETE, DROP, TRUNCATE или рекурсивное удаление файлов (rm -rf, find ... -delete), стоит помечать в код-ревью явно и требовать подтверждения второго человека перед первым запуском на проде — так же, как это принято для миграций схемы БД.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

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

Достаточно ли просто добавить --dry-run флаг, если по умолчанию скрипт всё равно удаляет?

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

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

Восстанавливайте из максимально свежего доступного бэкапа и параллельно ищите вторичные источники данных — логи приложения, очереди сообщений (Kafka, RabbitMQ с включённой персистентностью), реплики только для чтения, кэш в Redis с TTL, письма-уведомления с деталями заказов. Полного восстановления это не гарантирует, но может закрыть часть разрыва.

Нужен ли лимит по количеству записей, если условие отбора и так строгое?

Да — лимит защищает не от ошибки в конкретном условии, а от любой будущей ошибки, включая те, которых сейчас никто не предвидел: миграцию схемы, изменение логики в связанной таблице, человеческий фактор при правке скрипта через полгода.

Как часто нужно проверять, что бэкапы реально восстанавливаются?

Регулярно и на отдельном стенде — раз в месяц для некритичных систем, раз в неделю для тех, где цена простоя высока. Бэкап, который ни разу не разворачивали обратно, с практической точки зрения не отличается от отсутствия бэкапа.

Можно ли обойтись без dry-run, если скрипт запускается редко и вручную?

Частота запуска не снижает риск: единичный ручной запуск с неверным условием отбора наносит тот же ущерб, что и автоматический по крону. Dry-run стоит делать обязательным для любого скрипта, который может удалить данные, вне зависимости от того, кто и как часто его запускает.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →