--- 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: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, файлы уже на диске). ```bash 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-/_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: -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: -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: -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`: ```yaml 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: ```json {"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).