docs: split integrator guide (Diplodoc) vs internal docs + add build-docs CI

Чёткое разделение audiences:
- docs/ — consumer-facing only, Diplodoc-built. Public contract для
  интеграторов (Альтум, BI, third-party).
- docs-internal/ — operator runbooks, status snapshots, legacy tech pages.
  НЕ в Diplodoc build, НЕ публикуются.

Изменения:
- Move docs/{ops,status,tech} → docs-internal/{ops,status,tech}
- docs/toc.yaml: убраны секции Operations, Архитектура (legacy), Status
- docs/index.md: убран tab "оператор / on-call", focus на integrators
- docs/README.md: rewritten — scope только integrator guide, editorial
  guidelines (no leak internal architecture, stable API contract)
- .yfm: убран явный ignore status/* (теперь не нужно — папка переехала)
- docs-internal/README.md: index для внутренней документации с rationale
  разделения

Scrubbing implementation details из Diplodoc страниц:
- events.md: убраны Strimzi/cluster.142 references, OutboxPoller детали,
  internal alert names (OrdinisOutboxLagHigh, OrdinisOutboxDLQNonEmpty),
  metric names (nsi_outbox_*). Заменены на consumer-observable contract.
- webhooks.md: убраны metric names (nsi_webhook_ssrf_rejected_total),
  alert table (OrdinisWebhookHighFailureRate и т.д.). Retry policy
  переписана на user-side perspective.
- errors.md: убран alert name OrdinisReadApiErrorBudgetBurnFast, Tempo
  endpoint URL, internal connection details.
- x-references.md: OrphanReferenceScanner → general "Ordinis периодически
  детектирует". Убраны metric/alert names. Cascade roadmap без link на
  internal design doc.
- auth.md: убран file path ScopeContext.java.
- bundle-cuod.md: убран Maven module path.
- caching.md: cascade design link заменён на text reference.

CI:
- .gitlab-ci.yml: новый job build-docs (stage: build) — Node 20 alpine,
  pnpm install --frozen-lockfile + pnpm build, artifact docs/_dist на
  неделю. Triggers: auto на docs/**, либо manual web. Готов к follow-up
  pages-deploy job когда host решён.
- Новый pattern .docs-changes для rules.

Build verified clean: 13 HTML pages, 6.9M, no broken links/refs.
This commit is contained in:
Zimin A.N.
2026-05-07 23:27:03 +03:00
parent d5268b9395
commit bcfb07f547
23 changed files with 232 additions and 215 deletions
+98
View File
@@ -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 "<traceId>"
```
Типичные причины (в наших коммитах):
- `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 ui=<registry>/ordinis-admin-ui:main -n ordinis-staging` |
| `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/<name> -c app --tail=100`
- **Liveness/readiness probes:** `kubectl describe pod -n ordinis-staging <pod>`
- **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: [../integration/api/index.md](../integration/api/index.md) (Diplodoc, latest)
- Альтум integration: [../integration/altum-imaging-order.md](../integration/altum-imaging-order.md)
- Project state snapshots: см. `docs/status/*-state.md` в repo (исключены из Diplodoc build)
- Helm chart: `ordinis-infra/charts/ordinis/`
- Vault setup: `ordinis-infra/k8s/vault/setup.sh`
+125
View File
@@ -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-<topic>.xml`.
- Номер строго следующий (последний +1).
- Author = `ordinis`.
- Idempotent: использовать `<preConditions onFail="MARK_RAN">` для добавлений.
2. Зарегистрировать в `master.xml` через `<include file="changes/00NN-*.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 требует `<rollback>` блок в changeset**. Без него
автоматический откат невозможен — только manual SQL.
### Если changeset имеет `<rollback>`
```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` |
+122
View File
@@ -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.
+110
View File
@@ -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 <name> 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) |
+96
View File
@@ -0,0 +1,96 @@
# SLO / SLI — Ordinis НСИ ЦУОД
Документ описывает Service Level Objectives для Ordinis на стадии v1.
Метрики и пороги — стартовая точка, корректируются по реальным данным
после 2-3 месяцев наблюдения.
**Source of truth для алертов:**
`ordinis-infra/charts/ordinis/templates/prometheus-rules.yaml` (отдельный repo).
## 1. SLI (Service Level Indicators)
### Read-api availability
- **Метрика:** `up{job="ordinis-read-api"}`
- **Что считаем:** доля времени когда хотя бы один read-api pod отвечает.
- **Цель v1:** 99.5% за месяц (allowance ~3.6 ч downtime).
- **Цель v2:** 99.9% (с HA postgres + 2+ replicas read-api).
### Read-api latency
- **Метрика:** `histogram_quantile(0.99, http_server_requests_seconds_bucket{job="ordinis-read-api"})`
- **Цель:** p99 < 500ms для GET-запросов.
- **Текущий alert:** p99 > 1s for 10m → warning.
- **Justification:** Альтум-BFF имеет TTL 5 мин, latency на холодном кэше
не должна перекрывать UX (заказ съёмки UI ждёт справочник).
### Write-api error rate (writer)
- **Метрика:** `5xx_rate / total_rate` over 5m
- **Цель:** < 1% за 30 дней.
- **Alert:** > 5% за 5 мин → warning.
- **Источники 5xx, которые надо исключить:** валидация (это 4xx), exclusion
constraint (409 friendly), IP type cast (баг 2026-05-05, fixed). Если
возрастает — ищем новый bug.
### Outbox publish lag
- **Метрика:** `ordinis_outbox_pending_events`
- **Цель:** ≤ 50 в normal load, < 10 в average.
- **Alert:** > 100 for 5m → warning.
- **Семантика:** это batch lag между записью изменения в БД и публикацией
в Kafka. Большое значение = consumer'ы видят stale данные.
### Outbox DLQ
- **Метрика:** `ordinis_outbox_dlq_size`
- **Цель:** = 0 всегда (любая запись в DLQ = bug).
- **Alert:** > 0 for 5m → critical.
- **Действие:** docs/ops/outbox-dlq.md.
### Outbox publish error rate
- **Метрика:** `rate(ordinis_outbox_publish_errors_total[5m])`
- **Цель:** ~ 0.
- **Alert:** > 0.1/sec for 10m → warning.
### Postgres availability
- **Метрика:** `cnpg_pg_postmaster_start_time_seconds{cluster="ordinis-postgres"}`
- **Цель:** 99.9% (CNPG single-instance на v1, на v2 — 99.99% с replica).
- **Alert:** absent or 0 for 2m → critical.
## 2. Notification routing (TODO)
Сейчас alerts пишутся в Prometheus AlertManager как обычные alerts.
Routing в Slack/Telegram/Email — отдельная задача:
- [ ] AlertManager config с receiver per severity:
- **critical** → on-call channel (немедленный)
- **warning** → digest channel (раз в час summary)
- [ ] Эскалация: critical → engineer → tech lead через 15 мин если не acked.
См. `docs/status/2026-05-05-state.md` в repo (status snapshots исключены из Diplodoc build).
## 3. Error budget (v1 target)
При 99.5% availability за месяц:
- Total минут: 30 * 24 * 60 = 43200
- Allowed downtime: 0.5% × 43200 = ~216 мин (~3.6 ч)
**Burn rate alert:** если за последний час потрачено >2% бюджета —
warning. >5% — critical (т.е. совокупно >2.16 ч за час).
Сейчас не настроен — TODO.
## 4. Что НЕ покрывает v1
- Нет dashboards (Grafana). Видим только raw метрики через Prometheus UI.
- Нет distributed tracing (Tempo) на staging. Trace_id есть в логах, но
собрать timeline через сервисы — пока вручную.
- Нет logs aggregation (Loki). Поиск по логам — kubectl logs (привычно
но не масштабируется).
- Нет capacity planning (rate of change для скрытых ресурсов: PVC fill rate,
Kafka log retention).
Roadmap до v2 — отдельный документ "observability stack".
## 5. Связанные runbooks
- [vault-unseal.md](vault-unseal.md)
- [kafka-restart.md](kafka-restart.md)
- [outbox-dlq.md](outbox-dlq.md)
- [db-migrations.md](db-migrations.md)
- [README.md](README.md) — index by symptom
+138
View File
@@ -0,0 +1,138 @@
# Runbook: Vault unseal (staging / prod)
**Когда использовать:** Vault на кластере залип в `Sealed=true` после reboot
ноды или manual seal. Признаки:
- Spring Boot pod'ы (`ordinis-app`, `ordinis-read-api`, `ordinis-projection-writer`)
стоят в `Init:0/1`.
- В логах `vault-agent-init` контейнера: `Vault is sealed`, error 503 на
`auth/kubernetes/login`.
- `kubectl exec -n config vault-0 -- vault status``Sealed: true`.
**Auto-unseal сейчас НЕ настроен** (v1 closure backlog: Transit secret engine
с master Vault либо Yandex Cloud KMS). До миграции — ручная процедура ниже.
---
## 1. Где взять unseal-ключи
Ключи Shamir, threshold **3 из 5**. Хранение:
- **Corporate 1Password** — vault `Ordinis НСИ ЦУОД`:
- `vault-staging-unseal-keys` — для cluster.265 (192.168.100.161)
- `vault-prod-unseal-keys` — для cluster.142 (192.168.100.142)
- **Резерв** (cold storage) — у DevOps lead в KeePass.
Доступ к 1Password vault — у списка инженеров с on-call. При расширении —
добавлять через 1Password admin. Никогда не пересылать ключи в Slack /
Telegram / email.
> ⚠️ **При утере 5 из 5 ключей:** Vault инициализируется заново → ВСЕ
> secrets потеряны (postgres passwords, Kafka SASL, Keycloak signing keys
> в K8s secrets — отдельно). Plan: получить root token + replay `setup.sh` +
> заново положить secrets из 1Password (Postgres backup + Vault: писать
> только в зеркало). Делать только с письменным разрешением tech lead.
## 2. Быстрый unseal (script)
```bash
cd ~/code/ordinis-infra/k8s/vault
# staging
./unseal.sh staging "<KEY_1>" "<KEY_2>" "<KEY_3>"
# prod
./unseal.sh prod "<KEY_1>" "<KEY_2>" "<KEY_3>"
```
Скрипт делает:
1. Pull kubeconfig с ноды (если ещё не скачан).
2. Проверяет `vault status` — если уже unsealed, выходит.
3. Применяет 3 ключа последовательно через `vault operator unseal`.
4. Проверяет `Sealed: false`.
5. Force-deletes Spring Boot pod'ы — vault-agent-init иначе ждёт backoff
до 3+ минут на следующую попытку auth.
После — `kubectl get pods -n ordinis-{env}` показывает `2/2 Running`
через ~2 минуты.
## 3. Ручной unseal (если script недоступен)
```bash
# 1. Получить kubeconfig
ssh root@192.168.100.161 "cat /etc/kubernetes/admin.conf" > /tmp/kc-161
sed -i '' 's|server: https://[^:]*:|server: https://192.168.100.161:|' /tmp/kc-161
export KUBECONFIG=/tmp/kc-161
# 2. Проверить статус
kubectl exec -n config vault-0 -- vault status
# 3. Применить 3 ключа (по одному)
kubectl exec -n config vault-0 -- vault operator unseal '<KEY_1>'
kubectl exec -n config vault-0 -- vault operator unseal '<KEY_2>'
kubectl exec -n config vault-0 -- vault operator unseal '<KEY_3>'
# 4. Убедиться Sealed: false
kubectl exec -n config vault-0 -- vault status
# 5. Перезапустить Spring Boot pod'ы
kubectl delete pod -n ordinis-staging \
-l 'app.kubernetes.io/name in (ordinis-app,ordinis-read-api,ordinis-projection-writer)' \
--grace-period=0 --force
# 6. Проверить
kubectl get pods -n ordinis-staging
```
## 4. Подтверждение что всё работает
```bash
# read-api
curl -sk --resolve ordinis.k8s.265.nstart.cloud:443:192.168.100.161 \
https://ordinis.k8s.265.nstart.cloud/api/v1/spacecraft/records?lang=ru-RU \
| jq 'length'
# → должно вернуть число > 0
# admin
curl -sk -o /dev/null -w "%{http_code}\n" \
--resolve ordinis.k8s.265.nstart.cloud:443:192.168.100.161 \
"https://ordinis.k8s.265.nstart.cloud/api/v1/admin/audit?page=0&size=5"
# → 200
```
## 5. Эскалация / проблемы
| Симптом | Что делать |
|------------------------------------------------------|--|
| `vault status``connection refused` | Pod `vault-0` не Running. `kubectl get pods -n config` + `kubectl describe`. |
| `unseal` принимает ключ но Sealed остаётся true | Проверить count `Unseal Progress` — может нужен 4-й ключ если threshold увеличили. |
| Все 3 ключа отвергает (`invalid key`) | Whitespace / обрезанные символы. Скопировать заново из 1Password (полное поле). |
| Pod'ы Spring Boot не поднимаются после unseal | `kubectl logs <pod> -c vault-agent-init` — могут быть policy/role миграции после изменений. |
| Consul backend (Vault использует Consul) недоступен | `kubectl get pods -n config consul-server-0` + `kubectl logs`. |
При 30+ минут downtime → эскалация tech lead → решение о переносе
prod-нагрузки на запасной кластер либо публичная коммуникация в SLA-канал.
## 6. Связанные документы
- Установка Vault с нуля: `ordinis-infra/k8s/vault/setup.sh`
- Архитектура auth: `ordinis-auth/.../{ScopeContext,SecurityConfig}.java`
- Список секретов в Vault: `secret/ordinis/cuod/` (postgres, kafka, redis)
## 7. Переход к auto-unseal (TODO для v1 closure)
Когда будет master Vault либо Yandex Cloud KMS:
1. Добавить `seal "transit"` либо `seal "yandexcloudkms"` stanza в vault config
ConfigMap (`config/vault-config`).
2. Запустить migration:
```
kubectl exec -n config vault-0 -- vault operator unseal -migrate KEY_1
kubectl exec -n config vault-0 -- vault operator unseal -migrate KEY_2
kubectl exec -n config vault-0 -- vault operator unseal -migrate KEY_3
```
3. После миграции существующие Shamir-keys больше не работают — выдаются
Recovery Keys (5 шт, тот же threshold 3) для emergency rekey/operator-init
операций.
4. Обновить этот runbook: основной случай — auto-unseal, ручной unseal
только при downtime master Vault / KMS.