From adc40aca22e159c0e7295fe54d6980ab882331e2 Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Tue, 5 May 2026 17:26:30 +0300 Subject: [PATCH] =?UTF-8?q?docs(ops):=20operator=20runbook=20=E2=80=94=20k?= =?UTF-8?q?afka,=20outbox=20DLQ,=20db=20migrations=20+=20index?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Карточки процедур для on-call. Self-contained: можно открыть в момент инцидента без необходимости понимать всю архитектуру. - README.md — index by symptom + быстрые гайды по 401, 500, admin-ui 502. - kafka-restart.md — pause/unpause, replicas=0 fix, force-delete, restart consumer'ов после Kafka up. - outbox-dlq.md — диагностика DLQ через UI/API/SQL, типичные причины (orphan topic, ACL, broken payload), replay через UPDATE dlq_at=NULL. - db-migrations.md — Liquibase changelog, добавление, откат, recovery failed migration через DELETE databasechangelog. Vault unseal был отдельным коммитом ранее. --- docs/ops/README.md | 98 ++++++++++++++++++++++++++++++ docs/ops/db-migrations.md | 125 ++++++++++++++++++++++++++++++++++++++ docs/ops/kafka-restart.md | 122 +++++++++++++++++++++++++++++++++++++ docs/ops/outbox-dlq.md | 110 +++++++++++++++++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 docs/ops/README.md create mode 100644 docs/ops/db-migrations.md create mode 100644 docs/ops/kafka-restart.md create mode 100644 docs/ops/outbox-dlq.md diff --git a/docs/ops/README.md b/docs/ops/README.md new file mode 100644 index 0000000..1e00ef1 --- /dev/null +++ b/docs/ops/README.md @@ -0,0 +1,98 @@ +# Ordinis НСИ ЦУОД — Operator Runbook + +Карточки процедур для on-call инженера. Каждая карточка self-contained: можно +открыть в момент инцидента и пройти по шагам без необходимости понимать всю +архитектуру. + +## Indexed by symptom + +| Симптом | Карточка | +|---------------------------------------------------------------|----------| +| Spring Boot pod'ы залипли в `Init:0/1` после reboot | [vault-unseal.md](vault-unseal.md) | +| Kafka pod не Running, либо `vault-agent` не достукивается | [kafka-restart.md](kafka-restart.md) | +| Метрика `ordinis_outbox_dlq_size > 0` | [outbox-dlq.md](outbox-dlq.md) | +| Pod в `CrashLoopBackOff` с liquibase ошибкой в логах | [db-migrations.md](db-migrations.md) | +| API возвращает 401 на любые запросы | См. §1 ниже | +| API возвращает 500 на конкретный endpoint | См. §2 ниже | +| Admin UI не загружается (502/503) | См. §3 ниже | + +## 1. 401 Unauthorized + +Сейчас на staging `ORDINIS_AUTH_REQUIRED=true`. Anonymous = 401. Проверить: + +```bash +# 1. Header передан? +curl -v -H "Authorization: Bearer $TOKEN" ... | grep -i authorization + +# 2. JWT валиден? +echo $TOKEN | cut -d. -f2 | base64 -d 2>/dev/null | jq +# проверить exp, iss, realm_access.roles + +# 3. Issuer правильный? +# должен быть https://auth.nstart.space/realms/nstart +``` + +Если JWT прошёл проверку, но 401 — backend логи: +```bash +kubectl logs -n ordinis-staging deploy/ordinis-app -c app --tail=50 \ + | grep -iE "jwt|invalid_token|expired" +``` + +Типичные причины: +- `Invalid signature` — Keycloak ротировал ключи, но бэкенд кэширует старый. Restart pod. +- `Token expired` — refresh token не сработал, юзер должен перелогиниться. +- `Issuer mismatch` — JWT issued другим Keycloak realm'ом. + +## 2. 500 Internal Server Error на endpoint + +```bash +# Найти trace_id из ответа +curl -i ... | grep -i traceid +# либо UI показывает traceId в alert + +# Найти полный stack trace +kubectl logs -n ordinis-staging deploy/ordinis-app -c app --tail=200 \ + | grep "" +``` + +Типичные причины (в наших коммитах): +- `could not determine data type of parameter $N` — Hibernate JPQL с `IS NULL` + на nullable timestamp. Решение — sentinel value (см. fix audit search). +- `DataIntegrityViolationException dictionary_records_no_overlap` — bitemporal + exclusion constraint, период пересекается. Friendly 409 уже маппится — если + 500 значит код-путь неуспел поймать. +- `JsonMappingException` — payload поломан (фронт передал stale schema). + +## 3. Admin UI не загружается / 502 / 503 + +```bash +kubectl get pods -n ordinis-staging | grep admin-ui +``` + +| Status | Причина | +|-----------------------|---------| +| `ImagePullBackOff` | Тег image не существует в registry. Проверь `kubectl describe`. Восстановить:
`kubectl set image deployment/ordinis-admin-ui -n ordinis-staging ui=repo.nstart.cloud/terravault/ordinis-admin-ui:main` | +| `CrashLoopBackOff` | nginx config invalid либо resolver недоступен. См. logs. | +| `Running 1/1`, но 502 | Upstream (writer/read-api) сам в дауне. Проверь те pod'ы. | + +## 4. Что-то ещё — где смотреть + +- **Pod state:** `kubectl get pods -n ordinis-staging` +- **Recent events:** `kubectl get events -n ordinis-staging --sort-by='.lastTimestamp' | tail -20` +- **Resource usage:** `kubectl top pod -n ordinis-staging` +- **Logs:** `kubectl logs -n ordinis-staging deploy/ -c app --tail=100` +- **Liveness/readiness probes:** `kubectl describe pod -n ordinis-staging ` +- **Ingress:** `kubectl get ingress -n ordinis-staging` +- **Helm release:** `helm history ordinis -n ordinis-staging` + +## 5. Эскалация + +>30 мин downtime, не понятно куда копать → tech lead (zimin.an@nstart.space). + +## 6. Связанные ресурсы + +- API integration guide: [../tech/api-integration.md](../tech/api-integration.md) +- Альтум integration: [../integration/altum-imaging-order.md](../integration/altum-imaging-order.md) +- Project state: [../status/2026-05-05-state.md](../status/2026-05-05-state.md) +- Helm chart: `ordinis-infra/charts/ordinis/` +- Vault setup: `ordinis-infra/k8s/vault/setup.sh` diff --git a/docs/ops/db-migrations.md b/docs/ops/db-migrations.md new file mode 100644 index 0000000..ac64bf1 --- /dev/null +++ b/docs/ops/db-migrations.md @@ -0,0 +1,125 @@ +# Runbook: Liquibase миграции БД + +**Источник правды:** `ordinis-migrations/src/main/resources/db/changelog/` +- `master.xml` — список включённых changelog'ов в порядке применения. +- `changes/00NN-*.xml` — конкретные изменения (CREATE TABLE, ADD COLUMN и т.д.). + +Миграции применяются **автоматически** при старте `ordinis-app` через +`spring.liquibase` (Spring Boot starter). Если миграция падает — pod +не стартует, остальные pods тоже не поднимутся (read-api/projection-writer +имеют startup-зависимость от схемы БД). + +## 1. Просмотр applied миграций + +```bash +export KUBECONFIG=/tmp/kubeconfig-161 +PG_PASS=$(kubectl get secret -n ordinis-staging ordinis-postgres-app \ + -o jsonpath='{.data.password}' | base64 -d) +kubectl exec -n ordinis-staging ordinis-postgres-1 -c postgres -- \ + env PGPASSWORD="$PG_PASS" psql -h localhost -U ordinis_app -d ordinis -c \ + "SELECT id, author, dateexecuted, exectype FROM databasechangelog ORDER BY orderexecuted;" +``` + +Колонка `exectype`: +- `EXECUTED` — миграция применена успешно +- `FAILED` — упала (pod не должен стартовать в этом случае) +- `MARK_RAN` — пропущена через preCondition +- `RERAN` — была применена повторно (manual) + +## 2. Добавление новой миграции + +1. Создать `ordinis-migrations/src/main/resources/db/changelog/changes/00NN-.xml`. + - Номер строго следующий (последний +1). + - Author = `ordinis`. + - Idempotent: использовать `` для добавлений. +2. Зарегистрировать в `master.xml` через ``. +3. Тестировать локально на dev-Postgres: + ```bash + docker run -d --rm --name ord-pg -e POSTGRES_PASSWORD=dev \ + -p 5432:5432 postgres:18 + mvn -pl ordinis-migrations -am liquibase:update \ + -Dliquibase.url=jdbc:postgresql://localhost:5432/postgres \ + -Dliquibase.username=postgres -Dliquibase.password=dev + ``` +4. Commit + push. CI пересобирает app image, deploy применяет новые миграции + при следующем старте pod'а. + +## 3. Откат миграции + +⚠️ **Liquibase rollback требует `` блок в changeset**. Без него +автоматический откат невозможен — только manual SQL. + +### Если changeset имеет `` + +```bash +# Через kubectl exec на pod где есть mvn — обычно нет, проще локально: +mvn -pl ordinis-migrations -am liquibase:rollback \ + -Dliquibase.url=jdbc:postgresql://192.168.100.161:5432/ordinis \ + -Dliquibase.username=ordinis_app \ + -Dliquibase.password=$PG_PASS \ + -Dliquibase.rollbackCount=1 +``` + +### Manual SQL (если rollback не задан) + +1. Прочитать changeset XML — посмотреть какие изменения сделаны. +2. Написать обратные SQL (DROP TABLE, ALTER TABLE DROP COLUMN и т.д.). +3. Удалить запись из `databasechangelog`: + ```sql + DELETE FROM databasechangelog WHERE id = '00NN-1-some-id' AND author = 'ordinis'; + ``` +4. После этого Liquibase при следующем старте либо переприменит миграцию + (если она всё ещё в master.xml), либо считает её не-применённой. + +## 4. Failed migration recovery + +Сценарий: миграция упала, pod в CrashLoopBackOff. + +### A) Миграция частично применилась + +Liquibase помечает changeset как FAILED в `databasechangelog`. Чтобы +повторить: + +```sql +DELETE FROM databasechangelog WHERE id = '00NN-1-broken' AND exectype = 'FAILED'; +``` + +После этого pod рестарт → Liquibase снова попытается. Если миграция всё ещё +broken, исправить XML (или применённый частично state в БД), пересобрать +image, redeploy. + +### B) Миграция полностью применилась но с ошибкой в данных + +Например column добавлена с NOT NULL без default. Manual SQL: + +```sql +ALTER TABLE my_table ALTER COLUMN broken_col DROP NOT NULL; +-- либо UPDATE my_table SET broken_col = 'default' WHERE broken_col IS NULL; +``` + +И исправить XML на `defaultValueComputed=...` для следующих deployments. + +## 5. Текущий список миграций (на 2026-05-05) + +| ID | Назначение | +|---|---| +| 0001-extensions.xml | PostGIS, btree_gist (для tstzrange exclusion) | +| 0002-dictionary-definitions.xml | Таблица `dictionary_definitions` | +| 0003-dictionary-records.xml | Таблица `dictionary_records` + tstzrange exclusion | +| 0004-audit-log.xml | Таблица `audit_log` | +| 0005-outbox-events.xml | Таблица `outbox_events` + partial index | +| 0006-idempotency-keys.xml | Таблица `idempotency_keys` | +| 0007-add-version-columns.xml | JPA optimistic lock columns | +| 0008-fix-scope-check-uppercase.xml | Чек на scope в верхнем регистре | +| 0009-fix-records-scope-check.xml | Чек consistency dictionary↔record scope | +| 0010-envers-tables.xml | Hibernate Envers (revinfo, *_aud) | +| 0011-outbox-dlq.xml | DLQ column + partial indexes | + +## 6. Эскалация + +| Проблема | Действие | +|---|---| +| Migration на staging упала, pod не стартует | DELETE FAILED row + fix XML + redeploy. См. §4A. | +| Migration on prod упала | **СТОП**. Не делать manual fix без apply tech lead. Возможно нужен rollback всего deployment. | +| `databasechangelog` lock застрял (другая миграция параллельно) | `DELETE FROM databasechangelog_lock WHERE id=1; INSERT INTO databasechangelog_lock VALUES (1, false, NULL, NULL);` | +| Postgres недоступен | См. CNPG cluster status: `kubectl get cluster -n ordinis-staging` | diff --git a/docs/ops/kafka-restart.md b/docs/ops/kafka-restart.md new file mode 100644 index 0000000..aa05236 --- /dev/null +++ b/docs/ops/kafka-restart.md @@ -0,0 +1,122 @@ +# Runbook: Kafka StrimziPodSet рестарт / поднятие + +**Когда использовать:** +- Kafka pod (`ordinis-kafka-dual-0`) не Running после reboot ноды. +- Strimzi cluster operator не пересоздаёт pod — например после manual delete с paused reconciliation. +- StrimziPodSet есть, но `READY PODS = 0`. +- Кафка-операции отвечают timeout / connection refused. + +## 1. Диагностика + +```bash +export KUBECONFIG=/tmp/kubeconfig-161 +kubectl get kafka,kafkanodepool,strimzipodset -n ordinis-staging +kubectl get pods -n ordinis-staging | grep kafka +``` + +**Что искать:** + +| Симптом | Причина | +|---|---| +| `kafka.kafka.strimzi.io/ordinis-kafka` имеет `WARNINGS` колонку с текстом | reconciliation paused или config invalid — см. `kubectl describe kafka` | +| `strimzipodset/ordinis-kafka-dual` отсутствует | удалён — нужно unpause reconciliation чтобы Strimzi его создал заново | +| `kafkanodepool/dual` имеет `DESIRED REPLICAS=0` | scale=0 был задан — Strimzi не позволяет 0 реплик в Kafka cluster | +| Pod `ordinis-kafka-dual-0` в `Pending` | scheduler не нашёл ноду — `kubectl describe pod` | +| Pod `Running` но `0/1` ready | startup probe failed — `kubectl logs` | + +## 2. Восстановление + +### Вариант A: pause/unpause reconciliation + +Если Kafka CR имеет `strimzi.io/pause-reconciliation=true` annotation: + +```bash +kubectl annotate kafka.kafka.strimzi.io/ordinis-kafka -n ordinis-staging \ + strimzi.io/pause-reconciliation- --overwrite +kubectl annotate kafkanodepool.kafka.strimzi.io/dual -n ordinis-staging \ + strimzi.io/pause-reconciliation- --overwrite +``` + +Strimzi в течение ~30 sec пересоздаст StrimziPodSet и поднимет pod. + +### Вариант B: scale до 0 → нужно вернуть к 1+ + +```bash +kubectl patch kafkanodepool/dual -n ordinis-staging --type=merge \ + -p '{"spec":{"replicas":1}}' +``` + +⚠️ **Strimzi требует минимум 1 replica** — при `replicas:0` валидация падает с +`InvalidResourceException: KafkaNodePools have 0 replicas`. + +### Вариант C: StrimziPodSet удалён (например `kubectl delete strimzipodset`) + +Если pause снят и replicas≥1, Strimzi пересоздаст автоматически. Дать +60 sec на reconciliation. Если не происходит: + +```bash +# Принудительно перепинговать operator +kubectl annotate kafka.kafka.strimzi.io/ordinis-kafka -n ordinis-staging \ + strimzi.io/manual-rolling-update- +kubectl annotate kafka.kafka.strimzi.io/ordinis-kafka -n ordinis-staging \ + strimzi.io/manual-rolling-update=true --overwrite +``` + +### Вариант D: Pod залип в Pending / CrashLoopBackOff + +Force-delete + дать operator пересоздать: + +```bash +kubectl delete pod -n ordinis-staging ordinis-kafka-dual-0 \ + --grace-period=0 --force +``` + +PVC `data-0-ordinis-kafka-dual-0` сохраняется, данные kraft-метаданных целы. + +## 3. Подтверждение + +```bash +kubectl get pods -n ordinis-staging | grep kafka +# → ordinis-kafka-dual-0 1/1 Running + +kubectl exec -n ordinis-staging ordinis-kafka-dual-0 -- \ + bin/kafka-broker-api-versions.sh \ + --bootstrap-server ordinis-kafka-kafka-bootstrap:9092 2>&1 | head -3 +``` + +## 4. После kafka up — рестарт consumer'ов + +Spring Boot pods (`ordinis-app`, `ordinis-projection-writer`) могли застрять +в reconnect-loop. Force-delete для быстрого resume: + +```bash +kubectl delete pod -n ordinis-staging \ + -l 'app.kubernetes.io/name in (ordinis-app,ordinis-projection-writer)' \ + --grace-period=0 --force +``` + +OutboxPoller начнёт публиковать накопленные события сразу. + +## 5. Проверка outbox lag + +После рестарта Kafka outbox мог накопить unpublished events. Проверь: + +```bash +curl -sk -H "Authorization: Bearer $TOKEN" \ + --resolve ordinis.k8s.265.nstart.cloud:443:192.168.100.161 \ + "https://ordinis.k8s.265.nstart.cloud/api/v1/admin/outbox/stats" +# → {"pending":0,"dlq":0} ← желаемое +``` + +Если pending растёт — см. [outbox-dlq.md](outbox-dlq.md). + +## 6. Эскалация + +| Проблема | Действие | +|---|---| +| Strimzi operator pod (kafka-test ns) не Running | `kubectl get pods -n kafka-test` → перезапустить | +| Consul недоступен (Strimzi его не использует, но Vault — да) | см. [vault-unseal.md](vault-unseal.md) | +| Kraft metadata corruption (редко) | DR: восстановить PVC из snapshot, переинициализировать topic | +| ACL нарушение / SASL auth fail | Проверить secret `ordinis-app-kafka` в Vault, перезапустить consumer | + +>20 минут downtime → tech lead. diff --git a/docs/ops/outbox-dlq.md b/docs/ops/outbox-dlq.md new file mode 100644 index 0000000..ffb2e31 --- /dev/null +++ b/docs/ops/outbox-dlq.md @@ -0,0 +1,110 @@ +# Runbook: Outbox DLQ — диагностика и replay + +**Когда использовать:** +- Метрика `ordinis_outbox_dlq_size > 0` (Prometheus alert). +- В UI `/outbox` → ненулевое количество в "В DLQ". +- В логе writer: `Outbox event id=N → DLQ after 1000 attempts`. + +## 1. Что это + +Outbox publisher делает at-most ~1000 попыток (`ordinis.outbox.attempt-max`) +опубликовать каждое событие в Kafka. После — событие маркируется `dlq_at` +и исключается из polling. Без cap poller бесконечно ретраит мёртвые сообщения +(orphan topic, broken ACL, malformed payload), забивая лог и lag-метрику. + +## 2. Просмотр DLQ + +### Через admin UI +- `https://ordinis.k8s.265.nstart.cloud/outbox` → таблица со всеми DLQ + записями: ID, eventType, topic, attemptCount, lastError, dlqAt. + +### Через API +```bash +curl -sk -H "Authorization: Bearer $TOKEN" \ + --resolve ordinis.k8s.265.nstart.cloud:443:192.168.100.161 \ + "https://ordinis.k8s.265.nstart.cloud/api/v1/admin/outbox/dlq?page=0&size=50" \ + | jq '.content[] | {id, eventType, kafkaTopic, attemptCount, lastError}' +``` + +### Напрямую в БД +```sql +-- через kubectl exec на postgres pod +SELECT id, event_type, kafka_topic, dictionary_name, attempt_count, + LEFT(last_error, 200) AS error, + dlq_at, created_at +FROM outbox_events +WHERE dlq_at IS NOT NULL +ORDER BY dlq_at DESC +LIMIT 50; +``` + +## 3. Типичные причины + +| `last_error` содержит | Причина | Решение | +|----------------------------------------------------|------------------------------------------------------|--------------------------------------| +| `Topic not present` / `UnknownTopicOrPartition` | Топик удалён / переименован / не создан Strimzi'ем | Создать topic либо удалить запись (`DELETE FROM outbox_events WHERE id=N`) | +| `TopicAuthorizationException` / `AclAuthorization` | SASL user не имеет PRODUCE rights | Strimzi `KafkaUser` ACL — добавить permission | +| `TimeoutException` | Kafka broker недоступен | См. [kafka-restart.md](kafka-restart.md). После up — replay (см. §4) | +| `RecordTooLargeException` | Payload > `message.max.bytes` | Увеличить limit в Kafka либо сократить payload | +| `JsonMappingException` / `JsonProcessingException` | Payload поломан — нечитаемая JSON | DELETE запись (нечего публиковать) + investigate writer code | +| `SerializationException` | Kafka schema registry не разрешает (если включён) | См. schema registry config | + +## 4. Replay — пометить как unpublished снова + +После исправления причины (топик создан, ACL дан) — снять `dlq_at`, обнулить +attempt_count, poller подхватит. + +```sql +-- одну запись +UPDATE outbox_events +SET dlq_at = NULL, attempt_count = 0, last_error = NULL +WHERE id = 12345; + +-- весь DLQ (после fix корневой причины) +UPDATE outbox_events +SET dlq_at = NULL, attempt_count = 0, last_error = NULL +WHERE dlq_at IS NOT NULL; +``` + +Через ~1 sec OutboxPoller подхватит и попытается опубликовать заново. + +## 5. Hard delete (когда replay не нужен) + +Если событие безнадёжно (стейл данных, удалённая запись, тестовый мусор): + +```sql +DELETE FROM outbox_events WHERE id = 12345; +DELETE FROM outbox_events WHERE dlq_at IS NOT NULL AND dictionary_name = 'old_test_dict'; +``` + +⚠️ **Audit:** если событие было `RecordCreated` / `RecordUpdated` — consumer'ы +могут не получить change. Альтум-кэш будет stale до следующего read с `?at`. + +## 6. Подключение к staging Postgres + +```bash +export KUBECONFIG=/tmp/kubeconfig-161 +PG_PASS=$(kubectl get secret -n ordinis-staging ordinis-postgres-app \ + -o jsonpath='{.data.password}' | base64 -d) +kubectl exec -it -n ordinis-staging ordinis-postgres-1 -c postgres -- \ + env PGPASSWORD="$PG_PASS" psql -h localhost -U ordinis_app -d ordinis +``` + +## 7. Превентивные меры + +- **Alert на `ordinis_outbox_dlq_size > 0`** — задействовать когда настроим + Prometheus alerts (v1 closure backlog). +- **Kafka topic provisioning** — все топики `ordinis.cuod.events.{public,internal,restricted}` + должны быть pre-created в Strimzi `KafkaTopic` CR при разворачивании + кластера. Если автоcreate enabled — broker создаст с дефолтами что может + не совпадать (replication factor 1 vs 3 и т.д.). +- **Schema validation** — outbox `payload` валидируется только Jackson'ом. + Schema registry интеграция запланирована на v2. + +## 8. Эскалация + +| Проблема | Действие | +|---|---| +| DLQ растёт > 100/час | tech lead. Probably broken topic config or producer code | +| DLQ есть после рестарта Kafka — ничего не replays автоматически | Manual `UPDATE outbox_events SET dlq_at=NULL` после убеждения что Kafka OK | +| Дубликаты после replay (consumer получил оба раза) | Consumer должен быть idempotent (наш consumer-side имеет idempotency_keys) |