24 KiB
🏭 Инфраструктура
Docker Compose (локальная разработка)
Сервисы
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:
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 в корне проекта:
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:
- Этап сборки:
maven:3.9.9-eclipse-temurin-17@sha256:f58d59b...→mvn package - OpenTelemetry Java Agent
2.28.1загружается как фиксированный Maven-артефакт и проверяется по закреплённому SHA-256 - Этап запуска:
eclipse-temurin:17-jre-alpine@sha256:02320dd4...→java -jar app.jar
Dockerfile (Frontend)
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=15mJWT_REFRESH_TOKEN_TTL=7dJWT_REFRESH_COOKIE_SECURE=trueJWT_REFRESH_CLEANUP_ENABLED=trueJWT_REFRESH_CLEANUP_RETENTION=30dJWT_REFRESH_CLEANUP_BATCH_SIZE=500JWT_REFRESH_CLEANUP_MAX_BATCHES=20JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000JWT_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=5LOGIN_RATE_ATTEMPT_WINDOW=15mLOGIN_RATE_BASE_BLOCK_DURATION=1mLOGIN_RATE_MAX_BLOCK_DURATION=15mLOGIN_AUDIT_RETENTION=90dTRUSTED_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:
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 также требуется:
env:
- name: TENANTS_CONFIG_REQUIRED
value: "true"
При обязательной конфигурации отсутствие, пустой список или ошибка разбора tenants.json
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
При добавлении тенанта через API:
TenantConfigWatcherдо мутации разбирает и при необходимости применяет текущую mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операциюTenantLifecycleServiceсоздаёт отдельный candidate pool, проверяет соединение и FlywayKubernetesTenantSecretUpdaterвыполняетGETактуального Secret и применяет к списку только upsert запрошенного домена- полный объект Secret отправляется условным
PUTс текущимmetadata.resourceVersion; при409 Conflictвыполняются повторное чтение и повторное применение своей мутации TenantRoutingDataSourceатомарно публикует candidate только после успешной либо подтверждённой повторным чтением персистенции- старый pool перестаёт принимать новые запросы и передаётся на закрытие после drain/grace timeout
- lifecycle возвращает
TenantLifecycleMutationResultсTenantSecretUpdateReceipt; semantic fence регистрируется только для фактически записанного изменения (persisted=true,changed=true) 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:
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. Эти действия требуют полномочий оператора и
не выполняются автоматически.
Liveness и readiness probes backend
Production Deployment использует раздельные HTTP probes:
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, допускает обновление, и выполнить проверки без
применения:
kubectl kustomize ../k8s >/dev/null
kubectl apply --dry-run=server -k ../k8s
Отдельно проверьте полномочия фактического ServiceAccount:
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
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
Основные шаги:
- Backend unit/component tests, frontend static/unit tests, Compose validation, shell-тест immutable rollout/rollback и регрессионная проверка закрепления артефактов.
- Только после успешных gates — login, параллельная сборка и push backend/frontend с
SHA-tag и release tag без
latest. Сторонние Actions закреплены полными commit SHA. - BuildKit публикует для обоих образов максимальную provenance- и SBOM-attestation. Build jobs также возвращают registry digest каждого образа.
- Отдельный обязательный job сканирует опубликованные digests закреплённым Trivy
0.63.0и блокирует deploy при исправимыхHIGH/CRITICALуязвимостях. - Deploy job устанавливает фиксированный
kubectl v1.33.12только после SHA-256 проверки. Java Agent также имеет точную версию и checksum. scripts/deploy-images.shпринимает толькоimage@sha256:..., сохраняет предыдущие ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба предыдущих образа и повторно проверяет их готовность.- 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)
Архитектура мониторинга
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)