323 lines
16 KiB
Markdown
323 lines
16 KiB
Markdown
# 🏭 Инфраструктура
|
||
|
||
## Docker Compose (локальная разработка)
|
||
|
||
### Сервисы
|
||
|
||
```yaml
|
||
services:
|
||
backend: # Spring Boot (Java 17), порт 8080
|
||
frontend: # Apache httpd:alpine, порт 80
|
||
db: # PostgreSQL alpine3.23, порт 5432
|
||
```
|
||
|
||
### Сеть
|
||
|
||
Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском:
|
||
|
||
```bash
|
||
docker network create proxy
|
||
```
|
||
|
||
### Переменные окружения
|
||
|
||
Файл `.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
|
||
```
|
||
|
||
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
|
||
|
||
Встроенного 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-eclipse-temurin-17` → `mvn package`
|
||
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`
|
||
|
||
### Dockerfile (Frontend)
|
||
|
||
```dockerfile
|
||
FROM httpd:alpine
|
||
COPY . /usr/local/apache2/htdocs/
|
||
RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|
||
```
|
||
|
||
---
|
||
|
||
## Kubernetes (продакшн)
|
||
|
||
### Расположение конфигурации
|
||
|
||
Ожидаемое расположение production-манифестов: `../k8s/`. В текущей рабочей копии этот
|
||
внешний каталог недоступен, поэтому приведённые ниже изменения являются обязательным
|
||
runbook для оператора, а не утверждением о применённом или проверенном состоянии кластера.
|
||
|
||
### Ключевые ресурсы
|
||
|
||
| Ресурс | Тип | Описание |
|
||
|--------|-----|----------|
|
||
| `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`
|
||
|
||
`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
|
||
|
||
TCP probe подтверждает только открытый порт и не отражает готовность обязательных tenant-БД.
|
||
В 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.
|
||
|
||
Параметры `initialDelaySeconds`, `periodSeconds`, `timeoutSeconds` и `failureThreshold` нужно
|
||
согласовать с текущим Deployment; замена механизма probe не подтверждает автоматически
|
||
корректность этих порогов.
|
||
|
||
### Внешние действия для production Kubernetes
|
||
|
||
Каталог `../k8s/` не изменялся и не проверялся в рамках текущей рабочей копии. Оператору после
|
||
получения доступа к нему необходимо:
|
||
|
||
1. обновить backend Deployment: directory mount без `subPath`,
|
||
`TENANTS_CONFIG_REQUIRED=true` и две HTTP probes;
|
||
2. добавить либо сузить Role до `get`/`update` одного `tenants-secret` и проверить RoleBinding
|
||
фактического ServiceAccount;
|
||
3. убедиться, что внешний `tenants-secret` существует, содержит непустой ключ `tenants.json`
|
||
и допускает обновление;
|
||
4. отрендерить манифесты и выполнить серверную проверку без применения:
|
||
|
||
```bash
|
||
kubectl kustomize ../k8s >/dev/null
|
||
kubectl apply --dry-run=server -k ../k8s
|
||
```
|
||
|
||
5. отдельно проверить полномочия фактического 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
|
||
```
|
||
|
||
Применение манифестов, rollout и проверка реальных pod являются отдельными внешними
|
||
операциями и без разрешения автоматически не выполняются.
|
||
|
||
### Обновление backend
|
||
|
||
```bash
|
||
kubectl rollout restart deployment backend -n magistr
|
||
```
|
||
|
||
---
|
||
|
||
## 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. Checkout кода
|
||
2. Login в Docker Registry
|
||
3. Build + Push образов (`backend`, `frontend`)
|
||
4. Генерация меток через `docker/metadata-action`
|
||
|
||
---
|
||
|
||
## Мониторинг (SigNoz + OpenTelemetry)
|
||
|
||
### Архитектура мониторинга
|
||
|
||
```mermaid
|
||
graph LR
|
||
Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"]
|
||
Frontend["JS (otel.js)"] -->|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
|
||
|
||
Файл `admin/js/otel.js` — клиентская телеметрия:
|
||
- Метрики производительности страниц
|
||
- Трейсы пользовательских действий
|
||
|
||
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)
|