Files
magistr/docs/INFRASTRUCTURE.md
2026-07-19 20:16:12 +03:00

427 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🏭 Инфраструктура
## Docker Compose (локальная разработка)
### Сервисы
```yaml
services:
backend: # Spring Boot (Java 17), внутренний порт 8080
frontend: # Apache httpd, внутренний порт 80
db: # PostgreSQL 16.3, внутренний порт 5432
```
### Сеть
Compose сам создаёт изолированную bridge-сеть `magistr` для приложения и подключает backend
с frontend к существующей внешней Docker-сети `proxy`. Caddy из `../сaddy-proxy/` также
подключён к `proxy`: на `localhost` он отправляет `/api/*` непосредственно в `backend:8080`,
а остальные пути — в `frontend:80`. Контейнеры Magistr не публикуют порты на хосте;
PostgreSQL доступен только во внутренней сети `magistr`.
Перед первым запуском создайте общую сеть и поднимите Caddy:
```bash
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
docker compose -f ../сaddy-proxy/compose.yaml up -d
docker compose up -d --build
```
Для `https://localhost` Caddy выпускает сертификат своим локальным CA. На CachyOS/Arch
корневой сертификат устанавливается в системное хранилище через `trust anchor`; точная
команда приведена в `STARTUP_GUIDE.md`.
Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный
`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс.
### Переменные окружения
Файл `.env` в корне проекта:
```env
POSTGRES_USER=myuser
POSTGRES_PASSWORD=replace-with-local-password
POSTGRES_DB=app_db
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
JWT_REFRESH_CLEANUP_RETENTION=30d
LOGIN_RATE_MAX_FAILURES=5
LOGIN_RATE_ATTEMPT_WINDOW=15m
LOGIN_RATE_BASE_BLOCK_DURATION=1m
LOGIN_RATE_MAX_BLOCK_DURATION=15m
LOGIN_AUDIT_RETENTION=90d
TRUSTED_PROXY_CIDRS=172.16.0.0/12
BUSINESS_TIME_ZONE=Europe/Moscow
TZ=Europe/Moscow
OTEL_SDK_DISABLED=true
```
Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env`
и заполните пустые секреты. `POSTGRES_PASSWORD` и `JWT_SECRET` обязательны для
`docker compose up` и не хранятся в `compose.yaml`; JWT должен быть случайным секретом длиной
минимум 32 байта (`openssl rand -base64 48`). `POSTGRES_DB` одновременно задаёт создаваемую
БД и входит в `SPRING_DATASOURCE_URL`, поэтому произвольное локальное имя остаётся
согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются
backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического
reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует.
`BUSINESS_TIME_ZONE` задаёт правила календарных бизнес-дат, а `TZ` и
`JAVA_TOOL_OPTIONS=-Duser.timezone=...` фиксируют timezone JVM и контейнера. Абсолютные
timestamps при этом всегда передаются между Java и PostgreSQL в UTC.
Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён;
при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint.
В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Kubernetes `app-config` также явно задаёт `BUSINESS_TIME_ZONE=Europe/Moscow`,
`TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow` для backend pod.
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
### Dockerfile (Backend)
Backend собирается через multi-stage сборку Maven:
1. Этап сборки: `maven:3.9.9-eclipse-temurin-17@sha256:f58d59b...``mvn package`
2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и
проверяется по закреплённому SHA-256
3. Этап запуска: `eclipse-temurin:17-jre-alpine@sha256:02320dd4...``java -jar app.jar`
### Dockerfile (Frontend)
```dockerfile
FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets
RUN npm ci
RUN npm run build:vendor
FROM httpd:alpine3.23@sha256:4a15e9c...
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
```
Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
tag и manifest digest. Первый этап frontend собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
маршрутизировался в tenant `default`. Сервер
возвращает CSP с `script-src 'self'`, `script-src-attr 'none'`, `style-src 'self'` и точными
SHA-256 для оставшихся статических style-атрибутов. `unsafe-inline` и `unsafe-eval` не
используются. Дополнительно выставляются `nosniff`, `DENY`, строгий referrer policy и
ограниченная Permissions Policy.
---
## Kubernetes (продакшн)
### Расположение конфигурации
Production-манифесты находятся в `../k8s/`. Каталог расположен вне Git-корня `magistr`,
поэтому его изменения проверяются и перечисляются отдельно от `git diff` репозитория.
### Ключевые ресурсы
| Ресурс | Тип | Описание |
|--------|-----|----------|
| `backend` | Deployment | Spring Boot приложение |
| `frontend` | Deployment | Apache httpd |
| `tenants-secret` | Внешний Secret | JSON-список tenant-подключений; значения отсутствуют в манифестах |
### JWT настройки
`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie:
- `JWT_ACCESS_TOKEN_TTL=15m`
- `JWT_REFRESH_TOKEN_TTL=7d`
- `JWT_REFRESH_COOKIE_SECURE=true`
- `JWT_REFRESH_CLEANUP_ENABLED=true`
- `JWT_REFRESH_CLEANUP_RETENTION=30d`
- `JWT_REFRESH_CLEANUP_BATCH_SIZE=500`
- `JWT_REFRESH_CLEANUP_MAX_BATCHES=20`
- `JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000`
- `JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000`
Cleanup проходит отдельно по каждой активной tenant-БД. `RETENTION` задаёт срок хранения
свежих истёкших и отозванных audit-записей, `BATCH_SIZE` и `MAX_BATCHES` ограничивают объём
одного прохода, а interval/initial delay управляют безопасным расписанием.
### Защита входа и доверенные proxy
`app-config` задаёт общий PostgreSQL rate limit для всех backend-pod:
- `LOGIN_RATE_MAX_FAILURES=5`
- `LOGIN_RATE_ATTEMPT_WINDOW=15m`
- `LOGIN_RATE_BASE_BLOCK_DURATION=1m`
- `LOGIN_RATE_MAX_BLOCK_DURATION=15m`
- `LOGIN_AUDIT_RETENTION=90d`
- `TRUSTED_PROXY_CIDRS=10.42.0.0/16`
Последнее значение соответствует стандартной pod-сети K3s, из которой Traefik обращается
к backend. Если кластер запущен с другим `cluster-cidr`, перед rollout нужно указать
фактическую сеть ingress proxy. Не следует добавлять публичные сети клиентов: backend
доверяет `X-Forwarded-For` только от непосредственного источника из этого списка.
Счётчики и audit-события находятся в каждой tenant-БД, поэтому два pod используют одно
атомарное состояние. Старая история очищается раз в сутки ограниченными `SKIP LOCKED`
пачками; параметры `LOGIN_AUDIT_CLEANUP_*` из `.env.example` позволяют изменить расписание
и максимальный объём одного прохода.
`app-secret` задаёт `JWT_SECRET`. Этот Secret не создаётся файлами `../k8s/`: его заранее
предоставляет внешний secret manager или оператор. Deployment явно включает профиль
`production`, поэтому небезопасная cookie-конфигурация останавливает startup.
### Secrets для приложения и тенантов
Обязательные внешние объекты:
| Secret | Назначение |
|---|---|
| `app-secret` | JWT и fallback credentials backend |
| `tenants-secret` | Полный `tenants.json` с credentials tenant-БД |
| `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector |
| `gitea-registry` | Доступ Kubernetes к registry |
Backend ожидает ключ `tenants.json` из Secret `tenants-secret` по пути
`/config/tenants.json`. Чтобы kubelet доставлял последующие обновления Secret, необходимо
монтировать каталог `/config`, а не отдельный файл через `subPath`:
```yaml
volumeMounts:
- name: tenants-config
mountPath: /config
readOnly: true
volumes:
- name: tenants-config
secret:
secretName: tenants-secret
optional: false
items:
- key: tenants.json
path: tenants.json
```
В production для backend также требуется:
```yaml
env:
- name: TENANTS_CONFIG_REQUIRED
value: "true"
```
При обязательной конфигурации отсутствие, пустой список или ошибка разбора `tenants.json`
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
При добавлении тенанта через API:
1. `TenantConfigWatcher` до мутации разбирает и при необходимости применяет текущую
mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операцию
2. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
3. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
только upsert запрошенного домена
4. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
5. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
подтверждённой повторным чтением персистенции
6. старый pool перестаёт принимать новые запросы и передаётся на закрытие после
drain/grace timeout
7. lifecycle возвращает `TenantLifecycleMutationResult` с `TenantSecretUpdateReceipt`;
semantic fence регистрируется только для фактически записанного изменения
(`persisted=true`, `changed=true`)
8. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
синхронизирует полный нормализованный `TenantConfig`, включая изменения `name`, `domain`,
`url`, `username` и `password`; новый или изменённый pool проходит проверку соединения, Flyway
и атомарный swap без повторной записи Secret
Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
возрастает, а идемпотентное состояние не записывается повторно.
Если после подтверждённой записи Secret локальная активация или удаление завершаются ошибкой,
backend восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой
записи. Более новое межподовое изменение останавливает компенсацию и не перезаписывается.
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
состояние принимается без квитанции, допускающей небезопасный автоматический откат.
Watcher подтверждает SHA-256 файловой ревизии только после полного успешного применения
снимка. При ошибке прежний применённый хеш сохраняется, readiness снимается, а синхронизация
повторяется с экспоненциальной задержкой от 30 до 300 секунд.
Защита от задержки kubelet основана не на raw hash, а на полном нормализованном снимке.
Квитанция фактической persisted-мутации задаёт `previousTenants` и `committedTenants`:
previous и промежуточные committed-снимки временно deferred, последний committed ожидается.
Поэтому последовательность H0 → H1 → H2 не откатывается при доставке H0/H1, а H2 применяется.
Неизвестный merged snapshot, не совпадающий с deferred-состояниями, применяется сразу.
Persisted no-op и локальная операция fence не создают. Если sync оборвался после swap на
публикации readiness, retry проверяет уже активное соединение и не выполняет второй swap.
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только
Kubernetes verbs `get` и `update` для одного существующего Secret:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: backend-tenant-secret
namespace: magistr
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["tenants-secret"]
verbs: ["get", "update"]
```
`patch`, `list`, `watch`, `create` и `delete` текущему Kubernetes-клиенту не нужны. RoleBinding
должен связывать эту Role с фактическим `spec.template.spec.serviceAccountName` backend в
namespace `magistr`. Secret должен существовать заранее и не иметь `immutable: true`.
Создание, ротация, отзыв скомпрометированных значений и очистка истории описаны в
[`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md). Эти действия требуют полномочий оператора и
не выполняются автоматически.
### Liveness и readiness probes backend
Production Deployment использует раздельные HTTP probes:
```yaml
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
```
Liveness зависит только от состояния процесса. Readiness возвращает `200 UP` после успешных
миграций и свежей успешной проверки всех обязательных tenant-БД, иначе — `503 DOWN` и pod
исключается из приёма трафика без перезапуска. Actuator не публикует components, домены,
JDBC URL или credentials.
`../k8s/backend.yaml` монтирует каталог `/config` без `subPath`, а `app-config` задаёт
`TENANTS_CONFIG_PATH=/config/tenants.json` и `TENANTS_CONFIG_REQUIRED=true`. Отсутствующий
или невалидный обязательный Secret поэтому снимает readiness, не включая H2 fallback.
Перед production rollout оператор должен убедиться, что внешний `tenants-secret` существует,
содержит непустой ключ `tenants.json`, допускает обновление, и выполнить проверки без
применения:
```bash
kubectl kustomize ../k8s >/dev/null
kubectl apply --dry-run=server -k ../k8s
```
Отдельно проверьте полномочия фактического ServiceAccount:
```bash
BACKEND_SERVICE_ACCOUNT='укажите-фактический-service-account'
kubectl auth can-i get secret/tenants-secret \
--as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr
kubectl auth can-i update secret/tenants-secret \
--as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr
```
Production rollout и проверка реальных pod являются внешними операциями и без отдельного
разрешения из этой рабочей копии не выполнялись.
### Ручное обновление backend и frontend
```bash
export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:<64 hex>'
export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<64 hex>'
bash ../k8s/deploy.sh apply
```
Манифесты содержат нулевой digest-sentinel. `deploy.sh` требует два реальных digest,
локально подставляет их до `kubectl apply` и строго ждёт оба rollout; прямое применение
Deployment-файлов запрещено.
---
## Caddy (реверс-прокси)
**Расположение:** `../caddy-proxy/` для локальной разработки, в продакшене - отдельный сервис
В продакшене Caddy обрабатывает входящий трафик для `*.zuev.company`:
- Автоматическое получение TLS-сертификатов (Let's Encrypt)
- Маршрутизация `/api/*` → backend:8080
- Маршрутизация статики → frontend:80
---
## CI/CD (Gitea Actions)
### Пайплайн сборки Docker-образов
Расположение: `.gitea/workflows/docker-build.yaml`
Основные шаги:
1. Backend unit/component tests, frontend static/unit tests, Compose validation, shell-тест
immutable rollout/rollback и регрессионная проверка закрепления артефактов.
2. Только после успешных gates — login, параллельная сборка и push backend/frontend с
SHA-tag и release tag без `latest`. Сторонние Actions закреплены полными commit SHA.
3. BuildKit публикует для обоих образов максимальную provenance- и SBOM-attestation. Build
jobs также возвращают registry digest каждого образа.
4. Отдельный обязательный job сканирует опубликованные digests закреплённым Trivy `0.63.0` и
блокирует deploy при исправимых `HIGH`/`CRITICAL` уязвимостях.
5. Deploy job устанавливает фиксированный `kubectl v1.33.12` только после SHA-256 проверки.
Java Agent также имеет точную версию и checksum.
6. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие
ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба
предыдущих образа и повторно проверяет их готовность.
7. Workflow-wide concurrency lock не допускает одновременные production deployment.
`scripts/check-artifact-pinning.sh` проверяет Dockerfile, Compose, Actions, checksum и CI
gates. При передаче `K8S_DIR=../k8s` он дополнительно требует digest у каждого production
образа Kubernetes. Внешний `otel-collector.yaml` использует официальный Collector Contrib
`0.153.0@sha256:93aad750175cbf1a973ae1c5886c3371f4d800f61be25cdd26870b8441ffe9fa`;
версия и manifest digest обновляются только вместе и проверяются до rollout.
---
## Мониторинг (SigNoz + OpenTelemetry)
### Архитектура мониторинга
```mermaid
graph LR
Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"]
Frontend["JS (локальный OTel bundle)"] -->|OTLP HTTP| Collector
Collector --> SigNoz["SigNoz"]
Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"]
```
### Интеграция Backend
Backend отправляет через OpenTelemetry:
- **Логи** — через Logback + OTLP exporter
- **Трейсы** — автоинструментация Spring Boot
- **Метрики** — JVM метрики, HTTP метрики
Tenant ID добавляется в:
- MDC (логи): `MDC.put("tenant.id", tenant)`
- Span атрибуты: `Span.current().setAttribute("tenant.id", tenant)`
### Интеграция Frontend
Собранный `/vendor/otel.js` — клиентская телеметрия:
- Метрики производительности страниц
- Трейсы пользовательских действий
Исходные npm-пакеты имеют exact-версии и lockfile, собираются внутри frontend-образа и не
загружаются браузером со стороннего CDN.
PostgreSQL receivers collector получают endpoint и credentials только из
`otel-postgres-secret`; ConfigMap collector содержит лишь `${env:...}` ссылки.
### Дашборды SigNoz
- JVM Dashboard (Heap, GC, Threads)
- PostgreSQL Dashboard (Connections, Queries)
- HTTP Dashboard (Requests, Latency, Errors)