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

24 KiB
Raw Permalink Blame History

🏭 Инфраструктура

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:

  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)

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:

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:

  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:

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

Основные шаги:

  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)

Архитектура мониторинга

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)