427 lines
24 KiB
Markdown
427 lines
24 KiB
Markdown
# 🏭 Инфраструктура
|
||
|
||
## 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)
|