Files
admin/.wiki/concepts/es-destructive-delete-incident-2026-05-26.md
vitya 48a5cf397e tasks(restore-es-indices-books-vds): closed — snapshot restore + 2 preventive controls
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>
2026-05-28 20:11:07 +03:00

9.4 KiB
Raw Blame History

title: ES destructive delete incident — canonical endpoint vs internal-only bookva-es type: concept tags: [elasticsearch, incident, books-vds, tenant-cutover, operator-error] related: ../entities/books-vds, ../sources/books-vds-backup-daily-kreknin-2026-05-25 updated: 2026-05-28

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:4906: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:4344 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. Логирование = ~3050 MB/day при типичной нагрузке (~1 req/sec).

Follow-ups

  • Logrotate на /usr/docker/traefik/letsencrypt/access.log — пока без него файл будет расти; не критично (disk free 63G, ressedimption ~12 года), но 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