MAATRIX / Блог / Пользователя удалили, а он остался в бэкапах, логах и кеше

Пользователя удалили, а он остался в бэкапах, логах и кеше

MAATRIX

Саппорт закрывает тикет «удалите мой аккаунт» одной строкой: DELETE FROM users WHERE id = 42. Запрос отработал, строка исчезла, все довольны — а через три недели тот же email всплывает в выгрузке для маркетинга, в логе ошибки 500-й, которую разбирал дежурный инженер, и в дашборде аналитики, где кто-то считал retention по когортам. Формально пользователя удалили. По факту — только из одной системы из пяти-семи, где он успел наследить. Разберём, где технически оседают такие копии и как построить инвентаризацию, после которой можно честно сказать «мы проверили все места», а не просто понадеяться на это.

«Удалено из базы» — это про одну систему из нескольких

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

  • резервные копии — снимки состояния базы и файловых томов за прошлые периоды;
  • логи приложения — запросы, ответы, трассировки ошибок, где могли целиком сериализоваться объекты пользователя;
  • кеширующий слой — Redis, Memcached, CDN, локальный кеш в памяти процесса;
  • аналитика и отчётность — event-стримы, ETL-выгрузки в отдельное хранилище, BI-дашборды;
  • очереди сообщений — retained-сообщения и dead-letter очереди, где лежит payload с данными пользователя;
  • поисковый индекс — Elasticsearch/Meilisearch с отдельным документом на пользователя или на его контент;
  • сторонние интеграции — email-рассылки, CRM, платёжные системы, куда данные синхронизировались отдельным потоком.

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

Бэкапы: копия, о которой все помнят, но не знают, что в неё попало

Про бэкапы обычно вспоминают первыми — и тут же успокаиваются: «они всё равно сотрутся по ротации». Это правда для основного дампа БД, но инвентаризация бэкапов почти никогда не ограничивается одним дампом. На практике за годы жизни проекта набирается набор бэкап-джобов, про которые никто не держит единого списка:

  • полный дамп продовой БД (pg_dump, mysqldump, xtrabackup) по расписанию;
  • снапшоты Docker-томов с загруженными файлами пользователей;
  • бэкапы отдельных сервисов — например, Elasticsearch snapshot API гоняется отдельным cron-джобом, про который забыли, потому что настраивал его другой человек;
  • ручные дампы «на всякий случай» перед миграцией, лежащие на диске у кого-то из инженеров;
  • бэкапы бэкапов — офсайт-копии у стороннего провайдера, синхронизируемые отдельным rclone-джобом.

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

# что вообще запланировано по крону
crontab -l
ls /etc/cron.d/ /etc/cron.daily/ /etc/cron.weekly/

# systemd-таймеры — часто бэкапы настроены через них, а не через cron
systemctl list-timers --all | grep -i backup

# что реально лежит в репозитории restic/borgbackup
restic snapshots
borg list /path/to/repo

Для каждого найденного бэкап-джоба зафиксируйте: что именно он снимает (вся БД, отдельная таблица, файловый том), куда пишет (локально, у стороннего провайдера бэкапов, в объектное хранилище) и по какому графику ротации живёт. Вопрос «через сколько дней эта конкретная копия исчезнет сама» — это уже не про инвентаризацию, а про регламент удаления с привязкой к ротации, и он разобран отдельно в материале про регламент удаления данных и копии в бэкапах. Здесь важнее не забыть сам факт существования бэкап-джоба — практика показывает, что забытые «временные» снапшоты живут годами именно потому, что их не внесли в общий список при инвентаризации.

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

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

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

Логи приложения — тихий склад персональных данных

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

  • middleware, логирующий тело запроса и ответа целиком (удобно для отладки API, опасно, если тело содержит объект пользователя);
  • сериализация исключения в стектрейсе — объект User попадает в лог целиком через %s/toString(), потому что кто-то передал его в сообщение об ошибке для удобства отладки;
  • access-логи веб-сервера, где email или ID пользователя оказался в query string (GET /api/reset?email=user@example.com).

Если логи собираются в агрегатор — Graylog, Grafana Loki, ELK — искать конкретного пользователя вполне реально, потому что индекс уже построен. Пример для Loki через logcli:

# ищем упоминания идентификатора пользователя за последние 90 дней
logcli query '{app="api"} |= "user_id=42"' --since=90d --limit=5000

# то же самое по email, если он мог попасть в лог как есть
logcli query '{app="api"} |= "user42@example.com"' --since=90d

Для Graylog то же самое делается через веб-интерфейс поиска или REST API с запросом по универсальному диапазону дат. Важный нюанс: если логи не структурированы (обычный текстовый вывод без единого поля user_id), поиск превращается в грубый full-text grep и может как давать ложные срабатывания (совпадение цифр ID с чем-то другим), так и пропускать случаи, где идентификатор записан в другом формате. Это ещё один довод в пользу структурированного логирования — с явным полем user_id, но без самих персональных данных в теле сообщения. Подробнее о том, почему токены и персональные данные вообще не должны попадать в лог как есть, — в материале про антипаттерн с токенами и персональными данными в логах.

Отдельно стоит проверить архивные логи, которые ротировались на диск и не попали в агрегатор — /var/log/app/*.log.gz за прошлые месяцы часто выпадают из индекса поиска, но физически лежат на сервере или в архивном хранилище логов.

Кеш и сессии — данные, о которых забывают, потому что «это же временное»

Кеш кажется безопасным местом по умолчанию: он временный, у него есть TTL, он сам всё почистит. На практике это верно только тогда, когда TTL действительно выставлен и данные действительно устаревают предсказуемо. Три сценария, где это не так:

  • ключ кеша хранит не идентификатор, а весь объект пользователя целиком — типичная ситуация с Redis, когда user:42 кешируется как сериализованный JSON со всеми полями, а не как ссылка на запись в БД;
  • CDN закешировал персонализированную страницу целиком, потому что cache key не учитывал сессию или cookie пользователя — это отдельная и довольно частая проблема, разобранная в материале про кеширование персональных страниц целиком;
  • локальный in-memory кеш в самом процессе приложения (например, LRU-словарь для «горячих» пользователей) не имеет TTL вовсе и живёт до перезапуска процесса, который может случиться и через месяц.

Проверка Redis на присутствие данных конкретного пользователя делается сканированием по паттерну ключа — важно использовать SCAN, а не KEYS, чтобы не заблокировать инстанс на проде:

# ищем все ключи, где явно фигурирует ID пользователя
redis-cli --scan --pattern "user:42:*"

# смотрим TTL конкретного ключа — если -1, ключ бессрочный и его забыли поставить на TTL
redis-cli ttl "user:42:profile"

# грубый, но рабочий способ найти email в значениях сессионных ключей
redis-cli --scan --pattern "session:*" | while read key; do
  redis-cli get "$key" | grep -q "user42@example.com" && echo "найдено в $key"
done

Для CDN проверка иная: нужно посмотреть заголовки ответа (X-Cache: HIT, Age) для URL, которые могли быть закешированы с персональным содержимым, и явно послать purge по конкретному URL или по тегу кеша, если такая возможность настроена у провайдера CDN. Если тегированной инвалидации нет — это тоже находка инвентаризации, а не повод считать вопрос закрытым.

Аналитика и отчётность — отдельный поток, который живёт своей жизнью

Аналитические системы — самое неочевидное место, потому что путь данных туда обычно не проходит через основной код приложения. События (клики, просмотры, действия) улетают в отдельный поток — напрямую в ClickHouse, в самостоятельно хостимый продуктовый аналитический сервис или в промежуточную очередь — часто до того, как в архитектуре появился хоть какой-то слой анонимизации.

Особенность таких хранилищ в том, что они почти всегда проектируются под запись и чтение большими объёмами, а не под точечное удаление одной строки. В ClickHouse, например, точечное удаление — это асинхронная мутация, а не мгновенная операция:

-- сколько событий вообще есть по пользователю
SELECT count() FROM events WHERE user_id = 42;

-- запуск удаления — это мутация, а не мгновенный DELETE
ALTER TABLE events DELETE WHERE user_id = 42;

-- проверяем, что мутация реально завершилась, а не висит в очереди
SELECT * FROM system.mutations WHERE table = 'events' AND is_done = 0;

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

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

Практический подход к полной инвентаризации

Инвентаризация — это не разовый список «на будущее», а процесс, который стоит формализовать до того, как придёт первый реальный запрос на удаление. Рабочая последовательность:

  1. Постройте карту потоков данных. Пройдитесь по коду приложения и найдите каждое место, где записывается user_id, email, телефон или другой идентификатор — grep по моделям ORM и по названиям полей быстро даёт первое приближение:
grep -rn "user_id\|email\|phone" --include="*.py" src/ | grep -iE "insert|save|publish|log\.|cache\."
  1. Для каждой записи в коде определите получателей. Куда уходит это событие или запись дальше: в лог, в очередь сообщений, в кеш, в аналитический стрим. Часто выясняется, что один и тот же вызов логирования пишет данные сразу в два места — локальный файл и агрегатор.
  1. Составьте таблицу систем с методом проверки для каждой. Без конкретной команды проверки система в списке бесполезна — инвентаризация должна быть исполняемой, а не декларативной:
СистемаКак проверить наличие данныхТипичный срок жизни копии
Продовая БДSELECT ... WHERE id = 42до удаления — мгновенно
Бэкапы БДсписок снапшотов + выборочная распаковкадо истечения ротации
Логи (Loki/Graylog)поиск по идентификатору за периодпо retention индекса логов
Redis-кешSCAN по паттерну ключаTTL ключа (если он выставлен)
CDN-кешзаголовки ответа + проверка cache keyTTL edge-кеша
Аналитическое хранилищеSQL/агрегатный запрос по user_idобычно не ротируется само
Очереди сообщенийпросмотр retained/dead-letter топиковзависит от retention брокера
  1. Автоматизируйте регулярную проверку, а не разовую. Инвентаризация устаревает сама по себе — появляется новая интеграция, новый лог-стрим, новый кеш-слой, и карта данных перестаёт быть точной уже через несколько месяцев. Простой скрипт, который по списку систем и идентификатору пользователя проходит все проверки из таблицы выше и печатает статус по каждой, — не идеальное решение, но оно превращает вопрос «мы точно всё проверили?» из догадки в проверяемый факт с логом выполнения.
  1. Разделяйте находку и решение. Инвентаризация отвечает на вопрос «где физически лежат данные», а не на вопрос «что с ними нужно сделать и в какой срок» — это уже вопрос политики и, зачастую, требований к обработке персональных данных, который решает не инженер в одиночку. Что такое персональные данные применительно к вашей конкретной системе — отдельный и не всегда очевидный вопрос, разобранный в материале о том, что считается персональными данными.

Практический побочный эффект такой инвентаризации — она почти всегда находит нескольких «сирот»: забытый cron-бэкап, дебажный лог с полным дампом объекта пользователя, аналитический стрим, про который забыли, что он вообще существует. Найти их до того, как их найдёт кто-то другой при разборе инцидента, — и есть основная практическая ценность этой работы.

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

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

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

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

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

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

С чего начать, если инвентаризацию никогда не делали?

С карты потоков данных из кода — grep по полям, которые идентифицируют пользователя, и трассировка, куда уходит каждая запись. Это медленнее, чем кажется на старте, но даёт честный список систем, а не список «что вспомнили на созвоне».

Как понять, что в аналитике вообще есть персональные данные, если события выглядят анонимными?

Проверьте, не связан ли анонимный event ID с пользователем через промежуточную таблицу сопоставления (mapping) или через IP/user-agent, которые в совокупности с другими полями могут идентифицировать человека — это частая слепая зона, потому что в самой event-таблице действительно может не быть email или user_id напрямую.

Можно ли автоматизировать поиск сразу по всем системам одной командой?

Частично — для систем с API поиска (Loki, Graylog, Redis, SQL-хранилища) можно собрать единый скрипт-обходчик по списку из инвентаризации. Для систем без API (ручные выгрузки, файлы на чужих дисках, сторонние SaaS-интеграции) автоматизация не работает — это нужно проверять и фиксировать вручную.

Что делать с копией у стороннего провайдера бэкапов, если к ней нет прямого доступа?

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

Нужно ли останавливать все бэкапы и кеши, пока идёт инвентаризация?

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

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

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

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