MAATRIX / Блог / Тестовая база — копия боевой: как обезличить и не сломать логику

Тестовая база — копия боевой: как обезличить и не сломать логику

MAATRIX

Самый простой способ получить реалистичную тестовую базу — снять дамп с прода и залить на стенд. Так и делают в девяти компаниях из десяти. Проблема в том, что вместе с реалистичными данными на тестовый сервер переезжают настоящие email, телефоны, паспортные данные и суммы транзакций реальных людей — а тестовый контур почти всегда защищён хуже боевого. Разберём, как обезличить копию так, чтобы разработчик и тестировщик получили рабочую базу, а не набор NULL'ов и разбитых внешних ключей.

Зачем вообще обезличивать тестовую копию

Тестовая среда и боевая формально решают разные задачи, и это различие в приоритетах превращается в различие в защите. На проде патчи ставят в первую очередь, доступы пересматривают регулярно, порт СУБД закрыт файрволом наружу. На стенде — «оно и так работает», пароль попроще для удобства разработки, а порт базы иногда открыт всему миру, потому что кому-то было лень поднимать VPN. Мы разбирали этот сценарий подробно в статье о том, как тестовый сервер с копией боевой базы становится точкой входа для атаки — сейчас важно одно следствие: если на этом сервере лежат реальные персональные данные, риск утечки на тестовом контуре объективно выше, чем на проде, при том же наборе данных.

Есть и формальная сторона. Если в базе есть персональные данные (а это шире, чем ФИО и паспорт — подробнее в статье что считается персональными данными), требования 152-ФЗ и аналогичного регулирования распространяются на любую среду, где эти данные обрабатываются, — включая тестовую. Обезличенные данные из-под этих требований выводятся, потому что их уже нельзя связать с конкретным человеком без дополнительной информации, которая уничтожена или недоступна.

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

Почему наивное затирание данных ломает базу

Первое, что приходит в голову, — пройтись UPDATE-ом и занулить чувствительные поля или залить их одним фиктивным значением. Это работает ровно до первого прогона тестов или до первого разработчика, который откроет приложение локально.

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

  • NOT NULL и UNIQUE ломаются буквально. Если в колонке email стоит ограничение уникальности, а вы заливаете туда NULL или одну и ту же строку test@test.com для всех строк, вставка упадёт на первой же дублирующейся записи, а NOT NULL не даст залить пустое значение вовсе.
  • Формат поля перестаёт соответствовать валидации приложения. Если код проверяет телефон регуляркой на 11 цифр, а вы залили туда xxxxxxxxxxx или пустую строку, форма профиля в тестовом окружении просто перестанет открываться — разработчик потратит час на поиск несуществующего бага вместо реальной задачи.
  • Индексы и партиционирование получают вырожденное распределение. Если у вас партиционирование по первым символам email или хэш-индекс по телефону, а все значения заменены на одну строку, все строки схлопываются в один партишен или бакет — и вы не увидите проблем с производительностью, которые всплывут на реальном распределении данных в проде.
  • Внешние интеграции падают на этапе валидации. Платёжный шлюз в sandbox-режиме, sms-провайдер, сервис проверки email — многие валидируют формат на своей стороне, прежде чем вернуть тестовый ответ. Мусорное значение эту проверку не пройдёт, и разработчик получит ошибку интеграции там, где её в принципе не должно быть.

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

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

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

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

Принцип: формат и тип сохраняются, значение — фейковое

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

На PostgreSQL это чаще всего делается прямо SQL-выражением поверх существующего дампа, без внешних зависимостей:

-- email: сохраняем формат адреса и гарантируем уникальность через id
UPDATE users SET
  email = 'user' || id || '@test.local';

-- телефон: сохраняем формат +7XXXXXXXXXX, значение детерминировано от id
UPDATE users SET
  phone = '+7900' || lpad(id::text, 7, '0');

-- ФИО: берём из небольшого пула правдоподобных имён по индексу от id,
-- а не рандомизируем каждый вызов — важно для повторяемости прогонов
UPDATE users SET
  full_name = (ARRAY[
    'Иван Смирнов','Мария Кузнецова','Алексей Попов','Ольга Волкова',
    'Дмитрий Соколов','Анна Лебедева','Сергей Новиков','Елена Морозова'
  ])[1 + (id % 8)];

Для полей, где нужна не просто уникальность, а именно псевдослучайность без предсказуемого паттерна (например, чтобы тестировщик не мог по возрастанию id угадать следующее значение), удобно использовать pgcrypto:

CREATE EXTENSION IF NOT EXISTS pgcrypto;

UPDATE users SET
  email = 'u_' || substr(encode(digest(id::text || 'salt-value', 'sha256'), 'hex'), 1, 12) || '@test.local';

Здесь важна деталь: значение зависит только от id и фиксированной соли, а не от random(). Это даёт детерминированность — если вы прогоните скрипт обезличивания дважды на одном и том же дампе, получите одинаковый результат. Это удобно для отладки: баг, найденный вчера на конкретном тестовом email, воспроизводится и сегодня.

Для MySQL логика та же, отличается только синтаксис:

UPDATE users SET
  email = CONCAT('user', id, '@test.local'),
  phone = CONCAT('+7900', LPAD(id, 7, '0'));

Если ручные UPDATE по всем таблицам утомительны, есть смысл посмотреть на готовые инструменты — например, расширение PostgreSQL Anonymizer, которое позволяет декларативно описать маскирующее правило прямо в комментарии к колонке (SECURITY LABEL FOR anon ON COLUMN users.email IS 'MASKED WITH FUNCTION anon.fake_email()') и применять его при экспорте. Плюс — меньше кода и единое место описания правил; минус — лишняя зависимость на сервере. Для небольшой и средней базы связка «дамп плюс SQL-скрипт» обычно проще в поддержке, чем внешнее расширение.

Согласованность связанных полей — главная ловушка

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

Классический пример — email как логин. Если вход выполняется по email, а вы обезличили таблицу users, но забыли, что тот же email закэширован в поле last_login_email другой таблицы, — тестировщик попробует зайти по старому реальному адресу, который для этой строки уже не соответствует новому токену. Правило простое: ищите PII по всей схеме, а не только в очевидных «профильных» таблицах — денормализация встречается чаще, чем кажется.

-- найти все столбцы, которые потенциально содержат email, во всей базе
SELECT table_name, column_name
FROM information_schema.columns
WHERE column_name ILIKE '%email%'
   OR column_name ILIKE '%mail%';

Второй момент — уникальность после замены должна сохраняться там, где она нужна для логики, а не только для ограничения в СУБД. Если приложение использует email как идентификатор пользователя во внешнем API-моке, дубликат после обезличивания сломает интеграцию тише, чем ошибка UNIQUE constraint violation, — приложение просто начнёт путать пользователей местами. Привязка сгенерированного значения к первичному ключу строки ('user' || id) снимает эту проблему автоматически: пока id уникален, уникален и email.

Третий момент — согласованность между таблицами, связанными по смыслу, но хранящими копию значения, а не ссылку (частый антипаттерн денормализации ради скорости чтения). Если orders.customer_email — это скопированное на момент заказа значение, а не подзапрос к users, обезличивать нужно оба места одним и тем же выражением от одного и того же id, иначе в заказе окажется email одного фейкового пользователя, а в профиле — другого:

UPDATE orders o SET
  customer_email = 'user' || o.customer_id || '@test.local'
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.customer_id);

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

Какие поля трогать нельзя — или трогать очень аккуратно

Соблазн обезличить «всё подряд, на всякий случай» приводит к обратной проблеме: ломается бизнес-логика, которую вы как раз и собирались тестировать на реалистичных данных. Полезно заранее классифицировать колонки на три группы.

Тип поляПримерыЧто делать
Персональные данные, не влияющие на логикуФИО, телефон, домашний адрес, паспорт, дата рожденияЗаменять на правдоподобный фейк того же формата
Персональные данные, влияющие на логикуemail как логин, номер карты (последние 4 цифры для отображения), гео для расчёта доставкиЗаменять с сохранением уникальности/формата/диапазона
Не персональные, но критичные для бизнес-логикистатус заказа, сумма, дата создания, флаги подписки, роли, внешние ключиНе трогать вообще, либо трогать точечно и осознанно

Последняя группа — источник большинства «тестовая база работает не так, как прод» после обезличивания. Если скрипт вслепую проходит по всем колонкам типа numeric или timestamp и превращает amount = 15420.00 в случайное число, вы теряете возможность воспроизвести баг, зависевший от конкретной суммы (ошибка округления, нарушение правила «скидка от 10000 рублей»). Аналогично со статусами заказов и ролями пользователей — если тестировщику нужен сценарий для администратора с активной подпиской, а скрипт перемешал роли «для единообразия», такого пользователя в базе просто не окажется.

Отдельно стоит сказать про поля, которые выглядят как персональные данные, но на деле — технические идентификаторы: внешний ID пользователя в стороннем биллинге или CRM. Если тестовое окружение интегрировано с sandbox-версией того же сервиса, замена ID на случайное значение оборвёт интеграцию — sandbox не найдёт такого пользователя у себя. Такие поля либо не трогают, либо заменяют на реальный тестовый ID из sandbox-аккаунта.

Практическое правило: обезличивание проектируется по колонкам, а не по таблицам целиком, и каждое решение — «трогаем / не трогаем / трогаем с сохранением формата» — осознанное, а не результат регулярного выражения, совпавшего с именем колонки.

Пошаговый процесс: от дампа до рабочего тестового стенда

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

1. Снять дамп прода. Для PostgreSQL — через pg_dump в custom-формате, чтобы потом было проще управлять восстановлением по частям:

pg_dump -Fc -h prod-db.internal -U readonly_user -d appdb -f /tmp/prod_dump.dump

Таблицы с логами или историческими событиями, не нужные для тестирования (аудит-логи, сырые вебхуки), стоит исключить уже на этом шаге флагом --exclude-table — меньше данных для обезличивания и меньше объём на диске тестового сервера.

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

pg_restore -h test-db.internal -U appuser -d appdb --clean --if-exists /tmp/prod_dump.dump

3. Сразу после restore, до открытия доступа команде, прогнать скрипт обезличивания. Это момент, когда база физически на тестовом сервере, но ещё логически недоступна разработчикам. Скрипт стоит оформить одной транзакцией, чтобы при сбое база не осталась в частично обезличенном состоянии:

BEGIN;
  -- все UPDATE-выражения обезличивания из карты колонок
COMMIT;

4. Проверить целостность. Минимальный набор: количество строк в каждой таблице совпадает с исходным дампом, уникальные ограничения не нарушены, EXPLAIN-план на ключевых запросах не деградировал драматически (сигнал, что распределение значений стало вырожденным). Полезно прогнать автотесты приложения против обезличенной базы — если они падают именно на обезличенных полях, это повод скорректировать скрипт, а не тесты.

5. Удалить сам дамп с реальными данными. Файл /tmp/prod_dump.dump содержит настоящие персональные данные и не должен пережить процесс дольше, чем нужно для restore — тот же принцип, что и при миграции базы данных между серверами: промежуточные копии не должны становиться постоянной частью инфраструктуры.

6. Зафиксировать процесс как регламент. Если тестовая база обновляется регулярно, шаги 1–5 стоит собрать в единый скрипт и договориться о периодичности обновления — как формализовать это, чтобы процесс не зависел от памяти конкретного инженера, разбирается в статье про регламент тестового окружения и стейджа.

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

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

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

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

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

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

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

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

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

Можно ли обезличить только часть таблиц, самые чувствительные?

Можно и часто нужно — полное обезличивание всей схемы не всегда оправдано по трудозатратам. Но решение о том, какие таблицы пропустить, должно быть осознанным решением на основе карты PII, а не экономией времени «пока сойдёт».

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

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

Что делать с бинарными полями — сканами документов, фото профиля?

Их логичнее не копировать вовсе, а подменять на заглушку (плейсхолдер-изображение, пустой PDF нужного MIME-типа) уже на этапе restore, а не пытаться «обезличить» содержимое файла — это решает проблему радикальнее, чем маскирование текстовых полей.

Нужно ли обезличивать данные для нагрузочного тестирования, если важна только реалистичность объёма, а не содержимого?

Да, если источник данных — реальный дамп прода. Объём и распределение значений сохраняются при обезличивании (правильный подход меняет содержимое, а не структуру и статистику), так что нагрузочный тест на обезличенной копии остаётся показательным.

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

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

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