snapshot restore из kreknin:daily-2026-05-25 за 46 сек (epz=820604, products=105922, artmone=2621, counts == source). RCA: 5 индексов (включая system .tasks) удалены через ES API DELETE _all за 1 сек на 2026-05-26 10:21 UTC — 1ч 11мин после создания bookva-es в cutover-prep. Каноничный endpoint elasticsearch.kzntsv.site попал под команду которая предназначалась bookva-es:9200 (internal-only, без traefik route). Caller identity unrecoverable: ES audit = X-Pack платный, traefik accessLog был выключен, Portainer CE без audit. Preventive controls applied + verified: 1. ES env action.destructive_requires_name=true (stack 33) — DELETE _all и wildcard теперь 400 BadRequest; by-name DELETE работает (нужно для reindex). Pattern удаления что случился физически невозможен. 2. Traefik JSON accessLog в /letsencrypt/access.log — будущие DELETE оставят forensic след с IP/user/method/path. Wiki concept: .wiki/concepts/es-destructive-delete-incident-2026-05-26.md с recovery runbook + preventive controls + cross-refs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9.4 KiB
ES destructive delete incident — canonical endpoint vs internal-only bookva-es
Summary
В период bookva-tenant-cutover-prep (2026-05-26) дважды были удалены все user-индексы (epz, products, artmone, плюс system .tasks и placeholder read_me) на canonical ES elasticsearch.kzntsv.site (books VDS stack 33). Удаление выполнено через ES REST API одной командой типа DELETE _all / DELETE * (5 индексов за <1 секунды). Контейнер не рестартовал, bind-volume не задет — данные стёрты на уровне cluster state.
Caller identity не восстановим — ES audit log = X-Pack платная фича (отсутствует на free 7.10), traefik accessLog был выключен, Portainer audit log = enterprise.
Hypothesis (timeline-based, не доказан)
Оператор в момент cutover-prep хотел очистить новый bookva-es перед заполнением, но команда ушла на canonical elasticsearch.kzntsv.site вместо bookva-es:9200.
2026-05-25 09:51 UTC daily snapshot SUCCESS [read_me, products, epz, artmone, .tasks] ← данные ЕСТЬ
...
2026-05-26 03:02 UTC daily snapshot success [read_me] only ← Wipe #1 случился в окне
2026-05-26 05:49–06:44 UTC re-reindex artmone+epz+products восстанавливают данные
2026-05-26 09:10:18 UTC bookva-es container created (cutover-prep Step 2)
2026-05-26 10:21:43–44 UTC Wipe #2: 5 индексов удалены за 1 сек ← 1ч 11мин после bookva-es
bookva-es использует named volume bookva-es-data, БЕЗ traefik labels (internal-only через docker network proxy). Не задел canonical через volume mount или routing. DELETE с воркстейшна на bookva-es:9200 физически невозможен (порт не expose'нут наружу); на elasticsearch.kzntsv.site — возможен через traefik basicAuth.
Forensics — что есть и чего нет
| Источник | Доступность | Содержание |
|---|---|---|
ES container log docker logs elasticsearch |
✅ есть | o.e.c.m.MetadataDeleteIndexService пишет факт + index UUID + timestamp. Caller отсутствует |
| ES audit log | ❌ нет | X-Pack Security платная фича. Free 7.10 audit отключён |
| Traefik accessLog | ❌ был выключен | Файл traefik.yml не содержал секции accessLog:. Запросы (включая DELETE) не пишутся |
| Portainer audit | ❌ нет | Portainer CE без enterprise = audit log не включён |
Snapshot diff _snapshot/kreknin/_all |
✅ есть | Daily snapshots показывают какой день имеет какие индексы → окно wipe'а |
Shell history ~/.bash_history root |
⚠️ ограниченно | Только если delete был сделан с самого VDS. Если с воркстейшна / Kibana DevTools — пусто |
Recovery procedure
Если индексы исчезли — snapshot restore из kreknin repo. Daily snapshot pipeline (books-vds-backup-daily-kreknin, cron 06:00 MSK) автоматически снапшотит непустые индексы. До wipe'а snapshot контейнерит full data → restore = ~46 сек для ~800MB (I/O bound, файлы уже на диске).
ssh -i ~/.ssh/id_ed25519_books_ops root@89.253.255.133 'bash -s' <<'EOF'
PW=$(python -c "import json; print(json.load(open('/opt/books/api/config/default.json'))['elasticsearch']['auth']['password'])")
# 1. Найти последний snapshot С полными данными
curl -sk -u "books:$PW" 'https://elasticsearch.kzntsv.site/_snapshot/kreknin/_all' \
| python -c "import json,sys; [print(s['snapshot'], s['indices']) for s in json.load(sys.stdin)['snapshots']]"
# 2. Restore (synchronous, не overwrites open indices — current state должен быть пустой)
curl -sk -u "books:$PW" -H 'Content-Type: application/json' -X POST \
'https://elasticsearch.kzntsv.site/_snapshot/kreknin/daily-<YYYY-MM-DD>/_restore?wait_for_completion=true' \
-d '{"indices":"epz,products,artmone","include_global_state":false,"include_aliases":true,"index_settings":{"number_of_replicas":0}}'
# 3. Verify counts == source
curl -sk -u "books:$PW" 'https://elasticsearch.kzntsv.site/_cat/indices?v'
# 4. Restart consumers (reset connection pool)
docker restart books-api books-task-runner books-job-scheduler
EOF
Если все snapshot'ы посткоммитные (содержат только placeholder), fallback на reindex-from-remote из elasticold.kzntsv.site (см. ../../tasks/migrate-elasticsearch-to-books-vds § Playbook). Source ES не выключен per migration policy.
Preventive controls (applied 2026-05-28)
Control #1 — ES action.destructive_requires_name=true
Env var на stack 33 (Portainer-managed). После применения wildcard/_all delete возвращают 400 Bad Request:
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/_all'
{"error":{"type":"illegal_argument_exception","reason":"Wildcard expressions or all indices are not allowed"},"status":400}
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/ep*'
{"error":{"type":"illegal_argument_exception","reason":"Wildcard expressions or all indices are not allowed"},"status":400}
$ curl -sk -u books:<pw> -X DELETE 'https://elasticsearch.kzntsv.site/epz'
{"acknowledged":true} # legit by-name delete still works
Класс ошибки «DELETE wildcard на неправильный endpoint» теперь блокируется ES до того, как достанется до данных. Reindex по одному индексу остаётся легитимным (нужно для миграции). Полностью физически невозможно удалить >1 индекса одной командой.
Apply: добавить action.destructive_requires_name: "true" в environment: блок stack 33 compose, PUT через Portainer API endpoint /api/stacks/33?endpointId=1. ES container recreate ~10 сек, consumers переподключатся через retry.
Control #2 — Traefik accessLog (JSON)
Без этого никакая ретроспективная диагностика невозможна. Static config в /usr/docker/traefik/data/traefik.yml:
accessLog:
filePath: /letsencrypt/access.log
format: json
bufferingSize: 100
/letsencrypt/ уже bind-mounted (./letsencrypt:/letsencrypt в /usr/docker/traefik/docker-compose.yml), файл persistent. Restart container — изменения подхватились (accessLog = static config, не dynamic).
Sample entry:
{"ClientHost":"...", "ClientUsername":"books", "RequestMethod":"DELETE", "RequestHost":"elasticsearch.kzntsv.site",
"RequestPath":"/_all", "RouterName":"elasticsearch@docker", "DownstreamStatus":400, ...}
Будущие подозрительные запросы видны с IP, username basicAuth, methodом, путём, response status. Логирование = ~30–50 MB/day при типичной нагрузке (~1 req/sec).
Follow-ups
- Logrotate на
/usr/docker/traefik/letsencrypt/access.log— пока без него файл будет расти; не критично (disk free 63G, ressedimption ~1–2 года), но micro-task на logrotate cron имеет смысл. - bookva-es traefik route — если когда-нибудь bookva-es потребует внешнего доступа, обязательно отдельный hostname (
bookva-es.kzntsv.site) с отдельной basicAuth. Никогда не делитьсяelasticsearch.kzntsv.siteмежду slovo/bookva. - Snapshot retention review — сейчас 7 daily snapshots. Если бы инцидент проявился через 8 дней, daily-25.05 был бы вытеснен. Для критичных корпусов имеет смысл добавить weekly/monthly tier. Отдельная мини-задача.
- Логирование callers без accessLog ретроспективно невозможно — это main learning. Любой новый production endpoint должен иметь access log с момента создания.
Cross-refs
- ../entities/books-vds § Стек, ES — stack 33 inventory.
- ../sources/books-vds-backup-daily-kreknin-2026-05-25 — backup pipeline который сделал spasibo daily-25.05 snapshot.
- ../../tasks/restore-elasticsearch-indices-books-vds — incident response trace (этой сессии).
- ../../tasks/migrate-elasticsearch-to-books-vds — original migration playbook (применим для reindex-from-remote fallback).