Files
docs/infrastructure/k8s-cicd-platform-plan.md
T

1805 lines
74 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.
---
title: План платформы Kubernetes + Terraform + ArgoCD + Gitea CI/CD
---
# План развёртывания тестового и мульти-контурного окружения Sova
> **Контекст:** сейчас проект живёт в Docker Compose (`environments/`, `infrastructure/`, `local/`, `monitoring/`, заготовки `jenkins/`). Цель — **три изолированных сервера** (test / stage / prod), на каждом — свой k3s-кластер с **Terraform**, **ArgoCD**, **мониторингом**, **CI/CD через Gitea** (центральный Git/Registry на test-сервере или отдельно) и **системой тегов** для promotion между контурами.
>
> **Prod PostgreSQL:** master + sync replica, автоматический failover — только prod, не test (§7.6).
>
> **As-is prod (без K8s):** одна VM — **12 GB RAM, 6 vCPU, 216 GB disk** (nginx + PHP + PostgreSQL + Redis на одной машине). Это **нижняя граница**, не целевой размер; при миграции в k3s + HA PG ресурсы увеличиваются (§3).
>
> Дополняет корневой [`DEPLOYMENT_PLAN.md`](../../DEPLOYMENT_PLAN.md) (там акцент на Managed K8s в облаке). **Этот документ** — практический план под сценарий «self-hosted k3s + Gitea + **отдельный сервер на контур**».
---
## 0. TL;DR — что получится в итоге
```mermaid
flowchart TB
subgraph devs [Разработчики]
Dev[Push / Tag в Gitea]
end
subgraph srv_test [Сервер TEST]
subgraph k3s_test [k3s test]
GIT[Gitea + Registry + CI]
ARGOt[ArgoCD test]
BT[backend test]
AT[adminPanel test]
PGt[(PG single)]
MOCK[sova-mocks]
end
end
subgraph srv_stage [Сервер STAGE]
subgraph k3s_stage [k3s stage]
BS[backend stage]
AS[adminPanel stage]
PGs[(PG single)]
end
end
subgraph srv_prod [Серверы PROD]
subgraph k3s_prod [k3s prod apps]
BP[backend prod]
AP[adminPanel prod]
end
subgraph pg_ha [PostgreSQL HA 2 nodes]
PG1[(Primary)]
PG2[(Replica sync)]
PG1 <-->|WAL sync| PG2
RW[PgBouncer rw]
end
end
Dev --> GIT
GIT --> ARGOt
GIT -->|promote| k3s_stage
GIT -->|promote| k3s_prod
BT --> PGt
BT --> MOCK
BS --> PGs
BP --> RW
RW --> PG1
RW -.->|failover| PG2
```
**Браузерные точки входа** (плейсхолдеры до выдачи реального домена):
| Сервис | URL (плейсхолдер) | Сервер / контур |
|--------|-------------------|-----------------|
| Gitea | `https://git.sova.dev` | **test** (центральный Git) |
| ArgoCD test | `https://argocd.test.sova.dev` | test |
| ArgoCD stage | `https://argocd.stage.sova.dev` | stage |
| ArgoCD prod | `https://argocd.sova.dev` | prod |
| Grafana | per-env `grafana.{test,stage,sova}.dev` | каждый сервер |
| backend **test** | `https://api.test.sova.dev` | test |
| adminPanel **test** | `https://admin.test.sova.dev` | test |
| backend **stage** | `https://api.stage.sova.dev` | stage |
| adminPanel **stage** | `https://admin.stage.sova.dev` | stage |
| backend **prod** | `https://api.sova.dev` | prod |
| adminPanel **prod** | `https://admin.sova.dev` | prod |
> Когда выдадут домен — замените `sova.dev` одной командой по репозиторию `sova-deploy` и Terraform DNS-модулю.
---
## 1. Анализ текущего состояния (as-is)
### 1.1. Что уже есть в репозитории
| Каталог | Содержимое | Готовность к K8s |
|---------|------------|------------------|
| `infrastructure/` | Dockerfile nginx (VTS), PHP 8.4/8.2, nextjs, pgsql 14, redis | **База для образов**, но prod-Dockerfile приложений не готовы |
| `environments/` | Layered Docker Compose: apps, dbs, dev, monitoring, jenkins | **Эталон** портов, доменов, зависимостей |
| `local/` | Изолированный `sova-local`: PG16, MySQL Bitrix, nginx :8081/:8082, admin :3211 | **Эталон** env-переменных и smoke-тестов |
| `monitoring/` | Prometheus scrape config, Grafana provisioning, community dashboards | **Перенос** в kube-prometheus-stack values |
| `jenkins/` | 2 одинаковых Jenkinsfile (backend/cabinet), пустой frontend | **Не переносить as-is** — заменить Gitea Actions |
| `scripts/` | cron hourly/daily, certbot, local-smoke | cron → **CronJob**; certbot → **cert-manager** |
### 1.2. Текущая архитектура Docker Compose
```mermaid
flowchart LR
subgraph public [public-network]
Nginx[nginx :80/:443]
Next[nextjs :3001]
Grafana[grafana :3000]
end
subgraph internal [internal-network]
PHP84[php84 backend]
PHP82[php82 cabinet]
PG[(pgsql :5432)]
Redis[(redis)]
Prom[prometheus]
end
Internet --> Nginx
Nginx -->|api.sovamed.ru| PHP84
Nginx -->|cabinet.*| PHP82
Nginx -->|adm.* static| AdminDist
Nginx -->|dev.sovamed.ru| Next
PHP84 --> PG
PHP84 --> Redis
Prom --> Nginx
```
### 1.3. Приложения и приоритет миграции
| Приложение | Стек | Prod-домен сегодня | Фаза K8s |
|------------|------|--------------------|----------|
| **backend** | Symfony 7.3, PHP 8.4 | `api.sovamed.ru` | **Фаза 1 (test)** |
| **adminPanel** | React 19, Vite | `adm.sovamed.ru` | **Фаза 1 (test)** |
| cabinet | Symfony 5.4, PHP 8.2 | `cabinet.sovamed.ru` | Фаза 2 |
| sovamed | Next.js | `dev.sovamed.ru` | Фаза 2 |
| kiosk, widgetDoctors | Static SPA | `cdn.*`, `dev.wmtmed.ru` | Фаза 3 |
### 1.4. Критические пробелы (блокеры)
1. `apps/backend/Dockerfile`**пустой** (нужен multistage build).
2. `apps/adminPanel/Dockerfile`**отсутствует**.
3. Jenkins pipelines — **scaffold** с `registry.example.com`, shared library не в репо.
4. PostgreSQL prod использует **pg_cron, mysql_fdw, firebird_fdw** — проверить нужность в test.
5. Bitrix MySQL — cabinet/backend зависят; для test можно mock/отдельный pod MySQL.
6. `apps/` может быть **gitignored** в infra-репо — CI должен собирать из **отдельных Gitea-реп**.
### 1.5. Prod as-is (без Kubernetes)
Текущая prod-VM (Docker Compose / bare services на одной машине):
| Ресурс | Значение сегодня | Комментарий |
|--------|------------------|-------------|
| RAM | **12 GB** | nginx + PHP-FPM + PostgreSQL + Redis + мониторинг |
| CPU | **6 vCPU** | |
| Disk | **216 GB** | PG data + логи + образы Docker |
| K8s | **нет** | миграция = новый sizing + HA PG |
Это **baseline**, не целевая конфигурация. При переходе на k3s добавляется overhead платформы (~1.52 GB RAM), а для HA PostgreSQL нужен **второй сервер** (§7.6).
---
## 2. Целевая архитектура (to-be)
### 2.1. Принципы
1. **Infrastructure as Code:** Terraform поднимает сервер/OS/k3s; Helm + ArgoCD — всё внутри кластера.
2. **GitOps:** единственный источник истины деплоя — репозиторий `sova-deploy` (не `kubectl apply` руками).
3. **Immutable artifacts:** образы по тегам в Container Registry; rollback = смена тега в Git.
4. **Контуры изолированы:** test / stage / prod — **разные физические серверы**, разные БД, разные secrets.
5. **k3s single-node на каждом сервере** на старте; prod apps и prod PG HA могут быть на **23 серверах** (§3.4, §7.6).
6. **Prod PostgreSQL:** primary + sync replica + auto failover; test/stage — один инстанс PG достаточно.
### 2.2. Слои платформы
```mermaid
flowchart TB
subgraph L0 [L0 — Сервер]
OS[Ubuntu 22.04/24.04 LTS]
TF[Terraform: provisioning]
end
subgraph L1 [L1 — Kubernetes]
K3s[k3s single-node]
CNI[Flannel / built-in]
Storage[local-path-provisioner]
end
subgraph L2 [L2 — Platform]
ING[ingress-nginx]
CM[cert-manager]
ARGO[ArgoCD]
MON[kube-prometheus-stack]
LOKI[Loki + Promtail]
SS[sealed-secrets]
end
subgraph L3 [L3 — DevOps]
GIT[Gitea]
RUN[Gitea Actions Runner]
REG[Registry: gitea built-in или Harbor]
end
subgraph L4 [L4 — Apps per env]
APP[backend + adminPanel Helm]
DB[PostgreSQL + MySQL Bitrix + Redis per env]
MOCK[Mock/stub layer test-only]
CRON[CronJobs Symfony console]
end
L0 --> L1 --> L2 --> L3 --> L4
```
### 2.3. Namespace map
| Namespace | Назначение |
|-----------|------------|
| `kube-system` | k3s системные компоненты |
| `ingress` | ingress-nginx |
| `cert-manager` | cert-manager |
| `argocd` | ArgoCD |
| `monitoring` | Prometheus, Grafana, Alertmanager, Loki |
| `sealed-secrets` | controller |
| `gitea` | Gitea + runner (опционально вынести на VM) |
| `sova-test` | приложения test |
| `sova-stage` | приложения stage |
| `sova-prod` | приложения prod (когда будет готов) |
| `sova-data-test` | PostgreSQL (main + cabinet), MySQL Bitrix, Redis test |
| `sova-mocks` | WireMock MIS, Mailpit, captcha/calltouch/bitrix-http stubs (только test) |
> Полная матрица внешних зависимостей backend и стратегия test/stage/prod: **[Backend: внешние сервисы](./backend-external-services.md)**.
---
## 3. Серверы и ресурсы (test / stage / prod — разные машины)
### 3.1. Топология
| Сервер | Роль | k3s | PostgreSQL |
|--------|------|-----|------------|
| **test** | QA, mocks, центральный Gitea/Registry/CI | single-node | **1 инстанс** |
| **stage** | pre-prod, sandbox-интеграции | single-node | **1 инстанс** |
| **prod-app** | backend, adminPanel, Redis, MySQL Bitrix | single-node | клиент → HA |
| **prod-db-1** | PG **primary** (sync) | нет | primary |
| **prod-db-2** | PG **replica** (sync standby) | нет | replica |
| **prod-db-witness** | **etcd quorum only** | нет | — |
**Минимум prod HA:** prod-app + **2 db-VM** + **1 witness-VM** (1 vCPU / 1 GB). Gitea/Registry на **test**; stage/prod **pull** образов (§9.3).
> **Witness нельзя** ставить на prod-app — см. §7.6.3 и §19.
### 3.2. Sizing: test-сервер
| Ресурс | Минимум | Рекомендуется |
|--------|---------|---------------|
| CPU | 4 vCPU | **8 vCPU** |
| RAM | 16 GB | **32 GB** |
| Disk | 150 GB SSD | **200+ GB NVMe** |
| OS | Ubuntu 22.04/24.04 LTS | |
| Сеть | Публичный IP, 80/443/22 | |
| DNS | A-запись `*.test.sova.dev` → IP test-сервера | |
**Бюджет диска (200 GB):** registry ~50 GB (retention!), Loki ~15 GB, PG test ~30 GB, остальное — apps/k8s.
| Компонент | RAM |
|-----------|-----|
| k3s + Gitea + Registry + runner | ~2.5 GB |
| monitoring lite + Loki | ~2.5 GB |
| PG + MySQL + Redis test | ~2 GB |
| backend + adminPanel | ~1.5 GB |
| **Итого** | **~9 GB** + 30% → **16 GB min** |
### 3.3. Sizing: stage-сервер
| Ресурс | Минимум | Рекомендуется |
|--------|---------|---------------|
| CPU | 4 vCPU | **6 vCPU** |
| RAM | 12 GB | **16 GB** |
| Disk | 100 GB | **150 GB** |
Без Gitea/Registry. Один PostgreSQL.
### 3.4. Sizing: prod (baseline as-is: 12 GB / 6 CPU / 216 GB)
Текущая prod-VM — всё на одной машине **без k8s** (§1.5). Целевая prod с HA **не умещается** в 12 GB на одном диске с k8s.
#### Вариант A — рекомендуемый (prod-app + 2× prod-db)
| Сервер | CPU | RAM | Disk | Нагрузка |
|--------|-----|-----|------|----------|
| **prod-app** | **8 vCPU** | **16 GB** | **150 GB** | k3s, apps, Redis, MySQL Bitrix, ingress, ArgoCD |
| **prod-db-1** | **4 vCPU** | **12 GB** | **150 GB NVMe** | PG primary + Patroni |
| **prod-db-2** | **4 vCPU** | **12 GB** | **150 GB NVMe** | PG sync replica |
**Итого prod:** ~16 vCPU, ~40 GB RAM (vs 6 / 12 GB сегодня).
#### Вариант B — промежуточный (2 сервера, replica позже)
| Сервер | CPU | RAM | Disk |
|--------|-----|-----|------|
| **prod-app** | 8 vCPU | 16 GB | 150 GB |
| **prod-db-1** | 6 vCPU | 16 GB | 216 GB (как сейчас) |
⚠️ Без **prod-db-2** HA **нет** — только подготовка; replica обязательна до cutover prod в k8s.
#### Диск prod-db
PG data 80120 GB, WAL 20 GB, pg_dump 30 GB, запас 40+ GB. MySQL Bitrix / Redis — на **prod-app**.
### 3.5. Terraform per environment
Модули: `test-server`, `stage-server`, `prod-app`, `prod-db-1`, `prod-db-2`. Remote state вне серверов (§5.1). Firewall: prod-app → prod-db:5432; replication prod-db-1 ↔ prod-db-2.
---
## 4. Структура репозиториев в Gitea
Разделить **код приложений**, **инфраструктуру Terraform**, **манифесты деплоя**:
```text
gitea.sova.dev/
├── sova-backend # apps/backend (Symfony)
├── sova-adminpanel # apps/adminPanel (React)
├── sova-cabinet # позже
├── sova-infra # этот монорепо: infrastructure/, monitoring/, terraform/
├── sova-deploy # Helm charts + ArgoCD Applications + values per env
└── sova-platform # опционально: только Terraform + bootstrap scripts
```
### 4.1. `sova-deploy` — GitOps репозиторий (главный для ArgoCD)
```text
sova-deploy/
├── README.md
├── apps/
│ ├── backend/
│ │ ├── Chart.yaml
│ │ ├── values.yaml # defaults
│ │ ├── values-test.yaml
│ │ ├── values-stage.yaml
│ │ ├── values-prod.yaml
│ │ └── templates/
│ │ ├── deployment.yaml # pod: nginx + php-fpm sidecar
│ │ ├── service.yaml
│ │ ├── ingress.yaml
│ │ ├── hpa.yaml
│ │ ├── migrate-job.yaml # PreSync hook
│ │ ├── cronjobs.yaml # upload:doctors, bitrix-update-reviews
│ │ └── externalsecret.yaml
│ └── adminpanel/
│ ├── Chart.yaml
│ ├── values-test.yaml
│ └── templates/...
├── data/
│ ├── postgresql/ # test/stage only: Helm values (Bitnami/CNPG 1 instance)
│ └── redis/ # test/stage: Helm values
│ # prod PostgreSQL — вне K8s; конфиги в sova-platform (§4.2), не здесь
├── argocd/
│ ├── projects/sova-project.yaml
│ ├── app-of-apps.yaml
│ └── apps/
│ ├── platform-ingress.yaml
│ ├── platform-cert-manager.yaml
│ ├── platform-monitoring.yaml
│ ├── platform-argocd.yaml
│ ├── backend-test.yaml
│ ├── adminpanel-test.yaml
│ ├── backend-stage.yaml
│ └── ...
└── environments/
├── test/
│ └── kustomization.yaml # optional Kustomize overlay
├── stage/
└── prod/
```
### 4.2. `sova-infra` / `sova-platform` — Terraform и bare-metal
> **ArgoCD (`sova-deploy`)** применяет только Kubernetes-манифесты и Helm. **Patroni, HAProxy, Keepalived, etcd** на prod-db-VM — **не** в `sova-deploy`, а здесь.
```text
sova-platform/
├── terraform/
│ ├── modules/
│ │ ├── prod-db/ # Patroni + etcd + HAProxy + VIP (SSH, provider-agnostic)
│ │ ├── k3s/ # установка k3s на test/stage/prod-app
│ │ ├── server/ # опционально: VM через API провайдера
│ │ ├── dns/
│ │ └── backup/
│ ├── envs/
│ │ ├── test-server/
│ │ ├── stage-server/
│ │ ├── prod-app/
│ │ └── prod-db/ # 2× PG + witness (см. terraform/envs/prod-db в монорепо)
│ └── global/
│ └── versions.tf
├── ansible/
│ ├── inventory/prod-db/
│ └── playbooks/
│ ├── patroni-cluster.yml # альтернатива remote-exec в Terraform
│ ├── haproxy-keepalived.yml
│ └── templates/ # patroni.yml, haproxy.cfg, keepalived.conf
├── bootstrap/
│ ├── 01-hardening.sh
│ ├── 02-install-k3s.sh
│ └── 03-install-helm-charts.sh
```
В монорепо уже есть стартовый модуль: [`terraform/modules/prod-db`](../../terraform/modules/prod-db/README.md).
---
## 5. Terraform: пошаговая реализация
### 5.1. Providers и remote state (критично)
**Проблема «курицы и яйца»:** если Terraform state лежит на том же сервере, что и k3s/Gitea/MinIO, при падении сервера вы не сможете восстановить инфраструктуру — state недоступен вместе с кластером.
**Правило:** state для **базовой инфраструктуры** (VPS/k3s bootstrap) хранится **только снаружи** кластера и сервера:
| Вариант | Когда использовать |
|---------|-------------------|
| **Yandex Object Storage** (S3-compatible) | Основной вариант для RU |
| AWS S3 / Backblaze B2 | Если уже есть аккаунт |
| **Terraform Cloud / HCP Terraform** | Managed backend, locking из коробки |
| Приватный Git **без секретов** | Только как временный fallback; locking вручную |
**Не использовать** как единственный backend: Gitea на том же сервере, MinIO внутри k3s, локальный файл на VPS без репликации.
```hcl
# terraform/envs/test-server/backend.tf
terraform {
required_version = ">= 1.6"
required_providers {
null = { source = "hashicorp/null", version = "~> 3.2" }
helm = { source = "hashicorp/helm", version = "~> 2.12" }
kubectl = { source = "alekc/kubectl", version = "~> 2.0" }
}
backend "s3" {
bucket = "sova-terraform-state"
key = "envs/test-server/terraform.tfstate"
region = "ru-central1"
endpoints = { s3 = "https://storage.yandexcloud.net" }
skip_credentials_validation = true
skip_region_validation = true
skip_requesting_account_id = true
}
}
```
Credentials для backend (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` для Yandex S3) — в CI/CD secrets или локально в `~/.aws/credentials`, **не** в state file.
> State для **Helm releases внутри уже работающего k3s** можно держать в том же remote backend (отдельный `key`), но recovery-сценарий всегда начинается с внешнего state bootstrap-модуля.
### 5.2. Модуль `server` (существующий VPS)
```hcl
# terraform/modules/k3s-single-node/main.tf
variable "server_ip" {}
variable "ssh_user" { default = "root" }
variable "k3s_version" { default = "v1.30.4+k3s1" }
variable "cluster_domain" { default = "sova.dev" }
resource "null_resource" "k3s_install" {
connection {
type = "ssh"
host = var.server_ip
user = var.ssh_user
private_key = file(var.ssh_private_key_path)
}
provisioner "remote-exec" {
script = "${path.module}/scripts/install-k3s.sh"
environment = {
K3S_VERSION = var.k3s_version
CLUSTER_DOMAIN = var.cluster_domain
}
}
}
```
Скрипт `install-k3s.sh` должен:
1. Отключить swap, настроить `br_netfilter`, iptables.
2. Установить k3s: `curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=... sh -`.
3. Положить kubeconfig в `/root/.kube/config`.
4. Установить `kubectl`, `helm`.
5. Включить **Traefik disable** (`--disable traefik`) — используем ingress-nginx.
6. Включить **local-path-provisioner** (default в k3s).
### 5.3. Platform через Terraform Helm releases
**Ingress на bare metal (k3s):** не нужны NodePort + iptables + внешний nginx на хосте. k3s включает **ServiceLB (Klipper LoadBalancer)** — при `type: LoadBalancer` порты **80 и 443** автоматически биндятся на IP сервера.
```hcl
resource "helm_release" "ingress_nginx" {
name = "ingress-nginx"
repository = "https://kubernetes.github.io/ingress-nginx"
chart = "ingress-nginx"
namespace = "ingress"
create_namespace = true
set {
name = "controller.service.type"
value = "LoadBalancer" # k3s ServiceLB → 80/443 на IP ноды
}
set {
name = "controller.service.externalTrafficPolicy"
value = "Local" # сохранить client IP для rate-limit / logs
}
}
```
**Альтернатива** (если ServiceLB недоступен): `DaemonSet` + `hostNetwork: true` — ingress слушает 80/443 напрямую на каждой ноде.
Traefik в k3s **отключён** при установке (`--disable traefik`) — единственный edge ingress.
> MetalLB и NodePort 30080/30443 — **не нужны** на single-node k3s с ServiceLB.
### 5.4. Terraform outputs
```hcl
output "kubeconfig_path" { value = "/root/.kube/config" }
output "argocd_url" { value = "https://argocd.sova.dev" }
output "gitea_url" { value = "https://git.sova.dev" }
```
### 5.5. Порядок apply
```mermaid
flowchart TD
A[terraform apply: k3s install] --> B[helm: sealed-secrets]
B --> C[helm: cert-manager + ClusterIssuer]
C --> D[helm: ingress-nginx]
D --> E[helm: argocd]
E --> F[helm: kube-prometheus-stack]
F --> G[helm: gitea]
G --> H[push sova-deploy → argocd app-of-apps]
H --> I[ArgoCD sync platform + apps]
```
---
## 6. Platform layer (детально)
### 6.1. ingress-nginx
- Аналог текущего `infrastructure/nginx` edge.
- **k3s ServiceLB:** chart с `controller.service.type: LoadBalancer` — порты 80/443 на IP сервера без NodePort/iptables (см. §5.3).
- Один IngressClass `nginx` для всех env.
- Анnotations для rate-limit, body-size (upload файлов backend).
- Метрики: ServiceMonitor → Prometheus (замена scrape `nginx:88` из Docker).
**Маппинг доменов test:**
| Ingress host | Service | Порт |
|--------------|---------|------|
| `api.test.sova.dev` | `backend-test` | 80 |
| `admin.test.sova.dev` | `adminpanel-test` | 80 |
| `argocd.sova.dev` | `argocd-server` | 80 |
| `grafana.sova.dev` | `kube-prometheus-grafana` | 80 |
| `git.sova.dev` | `gitea-http` | 3000 |
### 6.2. cert-manager
```yaml
# ClusterIssuer Let's Encrypt staging (для отладки)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: letsencrypt-staging
spec:
acme:
server: https://acme-staging-v02.api.letsencrypt.org/directory
email: devops@sova.dev
privateKeySecretRef:
name: letsencrypt-staging
solvers:
- http01:
ingress:
class: nginx
```
После проверки — production issuer. **До выдачи домена** используйте:
- self-signed ClusterIssuer + импорт CA в браузер;
- или **nip.io** / **sslip.io**: `api.test.192-168-1-100.sslip.io`.
### 6.3. ArgoCD
Установка через Helm chart `argo/argo-cd`:
- Ingress: `argocd.sova.dev`
- SSO: позже через Gitea OAuth
- Репозитории: `sova-deploy` (SSH deploy key read-only)
- **App of Apps** паттерн (см. раздел 9)
- Image Updater (опционально): автобамп тегов — или только CI меняет values
```yaml
# argocd/app-of-apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: sova-root
namespace: argocd
spec:
project: default
source:
repoURL: git@gitea.sova.dev:sova/sova-deploy.git
targetRevision: main
path: argocd/apps
destination:
server: https://kubernetes.default.svc
namespace: argocd
syncPolicy:
automated:
prune: true
selfHeal: true
```
### 6.4. Sealed Secrets / External Secrets
Секреты **не коммитить** открытым текстом.
| Секрет | Ключи (из backend `.env`) |
|--------|---------------------------|
| `backend-env-test` | `DATABASE_URL`, `REDIS_URL`, `APP_SECRET`, `JWT_*`, `MIS_URL`, ... |
| `backend-env-stage` | те же, другие значения |
| `registry-credentials` | pull from Gitea Container Registry |
Workflow:
1. `kubeseal` → зашифрованный SealedSecret в Git.
2. ArgoCD применяет → controller расшифровывает в обычный Secret.
**Бэкап master key Sealed Secrets (критично для DR):**
При пересоздании кластера controller **генерирует новый** приватный ключ. Все SealedSecret из Git перестанут расшифровываться, если ключ не восстановить **до** sync ArgoCD.
```bash
# Сразу после установки controller (Фаза 1/2):
kubectl get secret -n kube-system \
-l sealedsecrets.bitnami.com/sealed-secrets-key \
-o yaml > sealed-secrets-master-key-BACKUP.yaml
# Сохранить в менеджер паролей / offline vault — НЕ в публичный Git
```
**При восстановлении кластера:**
1. Установить sealed-secrets controller.
2. Применить backup Secret **до** деплоя приложений с SealedSecrets.
3. Проверить: `kubectl get sealedsecret -A` → статус `Synced`.
4. Только потом включать ArgoCD sync apps.
Ротация ключа: [официальный re-encryption flow](https://github.com/bitnami-labs/sealed-secrets#sealing-key-renewal) — планировать раз в год, не при каждом rebuild.
---
## 7. Data layer (PostgreSQL, Redis, Bitrix MySQL)
### 7.1. PostgreSQL по контурам
| Контур | Топология | Решение |
|--------|-----------|---------|
| **test** | 1 инстанс | Bitnami chart или CNPG `instances: 1` |
| **stage** | 1 инстанс | То же, отдельный сервер |
| **prod** | **primary + sync replica + auto failover** | §7.6 |
| Вариант (test/stage) | Плюсы | Минусы |
|---------|-------|--------|
| **A. Helm Bitnami PostgreSQL** | Просто, как в Compose | RAM, бэкапы сами |
| **B. CloudNativePG** `instances: 1` | Единый оператор с prod | Сложнее на test |
| **C. Managed PG в облаке** | Надёжно | Доп. cost |
**Рекомендация test/stage:** Bitnami PostgreSQL chart (`pg-test`, `pg-stage`).
### 7.2. Схема БД test
```sql
-- Аналог local/postgres/init
CREATE DATABASE sova_backend_test;
CREATE DATABASE sova_cabinet_test; -- когда подключим cabinet
CREATE USER sova_test WITH PASSWORD '...';
GRANT ALL ON DATABASE sova_backend_test TO sova_test;
```
Миграции: Symfony `doctrine:migrations:migrate` через ArgoCD **PreSync Job** (см. §7.2.1).
### 7.2.1. Миграции БД и откат (подводные камни)
**PreSync Job** — правильный паттерн, но с ограничениями:
| Риск | Что делать |
|------|------------|
| Тяжёлая миграция (индекс на миллионах строк, >5 мин) | ArgoCD sync timeout — увеличить `timeout.reconciliation` / `syncOptions` для Job; тестировать миграции на копии prod |
| PreSync Job упал | Деплой **останавливается** — это ожидаемо и правильно |
| **Rollback ArgoCD** | Откатывает только **stateless** манифесты (Deployment, ConfigMap). **Схема БД не откатывается автоматически** |
| Откат после failed migration | `doctrine:migrations:execute --down VERSION` вручную или restore PG из snapshot |
```yaml
# argocd Application — фрагмент syncPolicy
syncPolicy:
syncOptions:
- CreateNamespace=true
hooks:
- type: PreSync
# Job: php bin/console doctrine:migrations:migrate --no-interaction
# activeDeadlineSeconds: 600 # для тяжёлых миграций
```
**Правило:** rollback в ArgoCD = откат **кода**, не **данных**. Для test допустим `pg_dump` перед каждым major release; для prod — обязателен backup + documented down-migration.
### 7.3. Redis
Bitnami Redis chart, auth enabled (`REDIS_PASSWORD` → Secret).
### 7.4. Bitrix MySQL (обязательно для полного backend test)
Backend читает Bitrix **напрямую через MySQL** (`BitrixService`, sync отзывов) и через **SQL views** в PostgreSQL (`view_news`, `view_promo`, … → таблицы `b_iblock_*`).
**Test-контур — новый инстанс**, не prod Bitrix:
- Helm `bitnami/mysql` в `sova-data-test`;
- init SQL из `local/mysql-bitrix/init/` (seed);
- `DATABASE_BITRIX_URL` в SealedSecret;
- опционально **mysql_fdw** в test-PG (как на prod) — см. [§1.3 в backend-external-services.md](./backend-external-services.md).
### 7.5. Внешние HTTP-сервисы и заглушки (test)
Backend вызывает MIS (Инфоклиника), SmartCaptcha, почту, Calltouch, Bitrix HTTP (картинки). **SMS и почта в test — заглушки**; **БД — свои инстансы**; остальное — по матрице ниже.
| Сервис | Test | Stage | Prod |
|--------|------|-------|------|
| MIS / widget API | Mock в `sova-mocks` | Sandbox или mock | Prod MIS |
| SMS (sms.ru, sms4b) | Noop (код пока не вызывается) | Noop | Live |
| Почта | Mailpit / `null://null` | Sandbox SMTP | Live SMTP |
| SmartCaptcha | Mock (always OK) | Mock | Yandex API |
| Calltouch | Noop (в коде вызов закомментирован) | Noop | Live |
| Bitrix HTTP (images) | Static mock nginx | Stage site | Prod |
**Реализация mock-слоя:**
1. Namespace `sova-mocks` — Helm releases: `mis-mock` (WireMock + JSON fixtures), `mailpit`, `captcha-mock`, `bitrix-http-mock`.
2. Env backend test: `MIS_URL`, `BITRIX_URL`, `SMARTCAPTCHA_URL`, `MAILER_DSN` → Cluster DNS (`*.sova-mocks.svc.cluster.local`).
3. Репозиторий `sova-mocks` (рядом с `sova-deploy`) — fixtures для расписания и anonymous-reserve.
4. **Техдолг до выката:** hardcoded `https://widget.sovamed.ru` в `UploadFilialsCommand` / `UploadPrice*` — mock должен отвечать на те же пути или команды рефакторятся на env.
Подробности, env-фрагмент SealedSecret и чек-лист: **[backend-external-services.md](./backend-external-services.md)**.
### 7.6. PostgreSQL HA — prod only (master + replica, sync, failover)
> **Только prod.** Test и stage — один PG-инстанс (§7.1). MySQL Bitrix, Redis — **не** входят в эту схему.
#### 7.6.1. Что значит «сквозная запись в две БД»
**Не делать:** двойной `INSERT` из Symfony в два `DATABASE_URL` — race conditions, split-brain, невозможность транзакций.
**Правильно:** **синхронная streaming-репликация PostgreSQL**:
1. Backend пишет **только в primary** (один connection string).
2. Primary перед `COMMIT` **ждёт подтверждения от sync replica**, что WAL записан (`synchronous_commit = on`).
3. Данные **гарантированно** на обоих узлах после успешного commit.
4. При падении primary **Patroni / CNPG / Managed PG** promote replica → новый primary за **30120 сек**.
5. Backend переподключается через **стабильный endpoint** (PgBouncer, VIP, CNPG `-rw` Service) — не напрямую на IP одной VM.
```mermaid
sequenceDiagram
participant BE as backend pod
participant RW as PgBouncer rw
participant P as PG Primary
participant R as PG Replica sync
BE->>RW: INSERT / COMMIT
RW->>P: SQL
P->>R: WAL stream sync
R-->>P: flush ack
P-->>RW: COMMIT ok
RW-->>BE: ok
Note over P: primary down
R->>R: Patroni promote
BE->>RW: reconnect
RW->>R: new primary
```
#### 7.6.2. Варианты реализации (prod)
| Вариант | Где крутится | Плюсы | Минусы |
|---------|--------------|-------|--------|
| **A. Patroni + etcd** на **2 db-VM** | prod-db-1, prod-db-2 (вне k3s) | Изолированные IOPS, привычный ops, extensions (pg_cron, mysql_fdw) | etcd 3-й узел или witness |
| **B. CloudNativePG** | operator на prod-app k3s, PG pods anti-affinity на 2 db-VM | GitOps-native, auto failover | PG в k8s + storage class; extensions проверить |
| **C. Yandex Managed PG HA** | managed cloud | Меньше ops | cost, сеть до prod-app, extensions |
**Рекомендация для Sova:** **Вариант A (Patroni на 2 db-VM)** — prod уже на VM с PG; extensions (`pg_cron`, `mysql_fdw`, `firebird_fdw`) критичны; apps на k3s **prod-app** подключаются к Patroni VIP.
**Terraform-модуль (provider-agnostic, SSH):** [`terraform/modules/prod-db`](../../terraform/modules/prod-db/README.md) + окружение [`terraform/envs/prod-db`](../../terraform/envs/prod-db/). Серверы заказываются у любого хостера (Selectel и т.д.), модуль **не** вызывает API провайдера.
#### 7.6.3. Patroni topology (рекомендуемый)
```text
prod-db-1 (192.168.x.10) prod-db-2 (192.168.x.11)
┌─────────────────────┐ ┌─────────────────────┐
│ PostgreSQL 16 │ sync │ PostgreSQL 16 │
│ Patroni │◄────────►│ Patroni │
│ role: primary │ WAL │ role: replica │
└──────────┬──────────┘ └──────────┬──────────┘
│ │
└──────────┬──────────────────────┘
┌───────▼────────┐
│ HAProxy / VIP │ :5432 rw → primary only
│ (или PgBouncer)│ :5433 ro → replica (опц.)
└───────▲────────┘
prod-app k3s (backend pods)
```
**etcd / DCS (критично):** quorum **3 узла**`prod-db-1`, `prod-db-2` + **отдельная witness-VM** (1 vCPU / 1 GB RAM, без k3s, без PostgreSQL).
| Размещение witness | Вердикт |
|--------------------|---------|
| **Отдельная крошечная VM** | ✅ Рекомендуется |
| **prod-app** (k3s) | ❌ **Запрещено** — ребут/обновление k3s → потеря etcd quorum → Patroni **demote primary** → БД read-only |
| Только 2 db-узла без 3-го | ❌ Нет quorum при падении одного db |
**Если witness на prod-app неизбежен** (не рекомендуется): перед любым обслуживанием prod-app — `patronictl pause` на кластере; после — `patronictl resume`. Зафиксировано в §19.
Альтернатива bare VM: Patroni с **Kubernetes DCS** только если PG внутри k8s (CloudNativePG, §7.6.4).
**PostgreSQL config (фрагмент):**
```ini
# postgresql.conf — primary
synchronous_commit = on
synchronous_standby_names = 'FIRST 1 (prod-db-2)'
wal_level = replica
max_wal_senders = 5
```
```ini
# postgresql.conf — replica
hot_standby = on
```
#### 7.6.4. CloudNativePG (альтернатива, если PG в GitOps)
```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
name: sova-pg-prod
namespace: sova-data-prod
spec:
instances: 2
primaryUpdateStrategy: unsupervised
postgresql:
parameters:
synchronous_commit: "on"
minSyncReplicas: 1
maxSyncReplicas: 1
storage:
size: 120Gi
affinity:
enablePodAntiAffinity: true
topologyKey: kubernetes.io/hostname
```
Backend `DATABASE_URL`:
```text
postgresql://sova:***@sova-pg-prod-rw.sova-data-prod.svc:5432/sova_backend
```
`-rw` Service переключается на новый primary после failover. **CNPG pods должны быть на разных физических нодах** — для этого 2 db-VM как worker nodes или dedicated node pool.
#### 7.6.5. Backend connection и PgBouncer (prod)
**Два endpoint — два режима pooler** (типичная боль Symfony + PgBouncer):
| Порт | Режим PgBouncer | Кто подключается | Зачем |
|------|-----------------|------------------|-------|
| **6432** | `pool_mode = transaction` | `DATABASE_URL` backend (php-fpm, API) | Короткие HTTP-запросы, высокая плотность соединений |
| **5432** | `session` **или** напрямую PostgreSQL/VIP | PreSync migrate Job, Symfony Messenger worker, `doctrine:migrations:*` | Advisory locks, prepared statements, long transactions |
```yaml
# SealedSecret backend-env-prod (фрагмент)
DATABASE_URL: postgresql://sova_prod:***@pg-prod.sova.internal:6432/sova_backend?sslmode=prefer
# API / php-fpm → PgBouncer transaction mode
DATABASE_MIGRATE_URL: postgresql://sova_prod:***@pg-prod.sova.internal:5432/sova_backend?sslmode=prefer
# migrate-job.yaml и messenger worker → session mode или VIP минуя transaction pooler
```
```yaml
# apps/backend/templates/migrate-job.yaml — фрагмент
env:
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: backend-env
key: DATABASE_MIGRATE_URL # не 6432 transaction pool
```
`MESSENGER_TRANSPORT_DSN=doctrine://default` при Doctrine transport — worker Deployment тоже на **5432 / session**, не на 6432.
**VIP:** `pg-prod.sova.internal` → Keepalived VIP на db-узлах (HAProxy). PgBouncer можно co-locate на db-VM или prod-app — оба порта слушают на одном DNS.
Symfony + Doctrine: **не один DSN на всё**. Rollback / failover — см. §7.6.6; `pdo_pgsql` reconnect после switchover.
**PHP-FPM:** короткий `statement_timeout`; при `connection lost` — 503 на 1–2 запроса во время switchover; readiness probe проверяет PG rw.
#### 7.6.6. Failover и RPO/RTO
| Метрика | Sync replication | Async (не для prod Sova) |
|---------|------------------|--------------------------|
| **RPO** (потеря данных) | **0** при 1 sync standby | секунды–минуты WAL |
| **RTO** (время восстановления) | **13 мин** (Patroni + k8s rollout) | то же |
| Commit latency | +15 ms (RTT до replica) | ниже |
При падении **обоих** db-узлов — restore из **WAL-G / pg_dump** в Object Storage (§7.6.7).
#### 7.6.7. Бэкапы prod PG (дополнение к репликации)
Реплика **не заменяет** backup (удаление таблицы реплицируется на standby).
| Метод | Частота | Хранение |
|-------|---------|----------|
| `pg_dump -Fc` CronJob / systemd timer | daily | S3 / Yandex Object Storage 30d |
| WAL archiving (pgBackRest / WAL-G) | continuous | S3, PITR |
| Тест restore | monthly | на stage PG |
#### 7.6.8. Миграция с текущей prod-VM (12 GB / single PG)
```mermaid
flowchart LR
A[Prod VM today single PG] --> B[Поднять prod-db-1 + prod-db-2 Patroni]
B --> C[pg_basebackup / logical dump restore]
C --> D[Sync replica catch-up]
D --> E[Cutover DATABASE_URL на VIP]
E --> F[prod-app k3s backend]
A --> F
```
1. Поднять **prod-db-1**, **prod-db-2** (§3.4).
2. `pg_dump` с текущей VM → restore на Patroni primary.
3. Подключить sync replica, дождаться `pg_stat_replication.sync_state = sync`.
4. Короткое окно maintenance: stop writes на старой VM → final sync → switch `DATABASE_URL` на VIP.
5. Поднять **prod-app** k3s, направить traffic на новый backend.
6. Старую VM держать read-only **7 дней** как fallback.
#### 7.6.9. Мониторинг и алерты (prod PG)
| Alert | Expr / check |
|-------|----------------|
| Replication lag | `pg_replication_lag_seconds > 10` |
| Sync standby lost | `sync_standby_names` пуст, только async |
| Patroni no leader | `patroni_master == 0` |
| Disk > 80% | node filesystem on db-VM |
Grafana dashboard: postgres_exporter на **обоих** db-узлах.
#### 7.6.10. Чек-лист prod PG HA
- [ ] 2 db-VM в разных зонах / стойках (если возможно)
- [ ] `synchronous_commit = on`, `minSyncReplicas: 1` или Patroni sync
- [ ] Backend **не** использует IP одной ноды — только VIP / `-rw`
- [ ] etcd quorum: **3 узла**, witness на **отдельной** VM (не prod-app)
- [ ] Failover drill на stage **не подходит** (1 PG) — отдельный drill на prod-db или ephemeral cluster
- [ ] pg_dump + WAL в S3, restore проверен
- [ ] Extensions `pg_cron`, `mysql_fdw` работают на primary после migrate
---
## 8. Приложения: образы и Helm
### 8.1. backend — Dockerfile (создать в `sova-backend`)
Опираться на `infrastructure/php-8.4/Dockerfile` + план из `DEPLOYMENT_PLAN.md` §4.1.
**Pod layout (sidecar):**
```mermaid
flowchart LR
subgraph pod [Pod backend-test-xxx]
NG[nginx container :8080]
PHP[php-fpm container :9000]
NG -->|fastcgi 127.0.0.1:9000| PHP
end
ING[Ingress] --> NG
PHP --> PG[(PostgreSQL)]
PHP --> MY[(MySQL Bitrix)]
PHP --> RD[(Redis)]
PHP --> MIS[Mock MIS test-only]
```
> **Prod:** PostgreSQL **вне** pod — подключение к Patroni VIP (§7.6). На test/stage — PG в кластере или на том же сервере.
Конфиг nginx для sidecar — упрощённый из `infrastructure/nginx/prod/conf.d` для `api.sovamed.ru` (только `/public` + fastcgi).
### 8.2. adminPanel — Dockerfile
Multistage: `node:24-alpine` build → `nginx:alpine` runtime.
**Runtime env без пересборки:**
```javascript
// public/env.js — подменяется через ConfigMap + initContainer
window.__ENV__ = {
API_BASE_URL: "https://api.test.sova.dev"
};
```
### 8.3. CronJobs (миграция из `scripts/`)
| Docker/cron | K8s CronJob | Расписание |
|-------------|-------------|------------|
| `scripts/cron.hourly.sh` → upload:doctors, upload:deps | `backend-sync-doctors` | `0 1 * * *` |
| `scripts/cron.oncyday.sh` → bitrix-update-reviews | `backend-sync-reviews` | `0 3 * * *` |
| `ClearScheduleCacheCommand` | `backend-clear-schedule-cache` | `0 */6 * * *` |
**CronJobs и внешние зависимости:** sync-команды (`upload:doctors`, `bitrix-update-reviews`, `upload:news`, …) требуют **test MySQL Bitrix** и **mock MIS**. На первом выкате test включить только `ClearScheduleCacheCommand`; остальные — после готовности mock-слоя (см. [backend-external-services.md §6](./backend-external-services.md)).
```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
name: backend-upload-doctors
namespace: sova-test
spec:
schedule: "0 1 * * *"
timeZone: "Europe/Moscow"
concurrencyPolicy: Forbid # не запускать вторую копию, пока первая не завершилась
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
activeDeadlineSeconds: 3600 # kill зависшую job через 1 ч
template:
spec:
restartPolicy: OnFailure
containers:
- name: console
image: registry.sova.dev/sova/backend:{{ .Values.image.tag }}
command: ["php", "bin/console", "upload:doctors"]
envFrom:
- secretRef: { name: backend-env }
```
> `concurrencyPolicy: Forbid` обязателен для sync-кронов (`bitrix-update-reviews`, `upload:*`), иначе при зависшей БД параллельные job'ы начнут мешать друг другу.
---
## 9. CI/CD через Gitea Actions
### 9.1. Почему Gitea Actions, а не Jenkins
| Jenkins (as-is) | Gitea Actions |
|-----------------|---------------|
| Scaffold, shared libs отсутствуют | YAML в репозитории приложения |
| Отдельный контейнер + docker.sock | Runner in K8s с **Kaniko/Buildah** (k3s = containerd, не docker.sock) |
| Не в `make dev` | Native интеграция с Gitea |
### 9.2. Gitea setup
1. Helm chart `gitea/gitea` в namespace `gitea`.
2. Ingress `git.sova.dev`, TLS via cert-manager.
3. Включить **Actions** (`ENABLED_ACTIONS=true`).
4. Зарегистрировать **act_runner** (Kubernetes executor).
**act_runner: ресурсы и сборка образов**
k3s использует **containerd**, монтировать `docker.sock` в runner **нельзя**. Для build/push образов:
| Инструмент | Когда |
|------------|-------|
| **Kaniko** | Сборка в pod без privileged; push в Gitea CR |
| **Buildah** | Альнатива Kaniko, rootless |
| Runner на **отдельной VM** | Если CI нагрузка критична для prod pods |
```yaml
# act_runner в K8s — обязательные limits (иначе npm/composer install «положит» кластер)
resources:
requests:
cpu: "500m"
memory: "1Gi"
limits:
cpu: "2"
memory: "4Gi"
```
Рекомендация: **отдельный node pool / taint** `ci=true:NoSchedule` для runner pods, когда появится второй сервер. На single-node — limits + `priorityClassName` ниже, чем у backend.
```yaml
# gitea-values.yaml (фрагмент)
gitea:
config:
actions:
ENABLED: true
repository:
DEFAULT_BRANCH: main
service:
http:
port: 3000
ingress:
enabled: true
hosts:
- host: git.sova.dev
```
### 9.3. Container Registry
**Вариант A:** встроенный Gitea Container Registry (`git.sova.dev/sova/backend`).
**Вариант B:** Harbor (тяжелее, но удобнее для retention policies).
**Retention policy (обязательно с первого дня):**
CI пушит образ на каждый тег (`backend-v*-test`). PHP + Node образы быстро съедают **200 GB** диска.
| Мера | Настройка |
|------|-----------|
| Gitea CR cleanup | Admin → Packages → retention: хранить последние N тегов per repo |
| CronJob в кластере | Удалять untagged manifests старше 7 дней |
| CI | Не пушить `:latest` на каждый commit — только semver-теги |
```yaml
# Пример CronJob registry-gc (Harbor / или скрипт к Gitea API)
# DELETE untagged + keep last 10 tags per image
```
**Pull образов на stage/prod (центральный Registry на test-сервере):**
Gitea Container Registry живёт на **test** (`git.sova.dev`). k3s на stage/prod **по умолчанию не может** pull приватных образов.
1. Gitea → Settings → Applications → **Generate token** (read:package) для robot `sova-pull`.
2. На **stage** и **prod** создать Secret (или SealedSecret в `sova-deploy`):
```yaml
apiVersion: v1
kind: Secret
metadata:
name: gitea-registry-secret
namespace: sova-stage # или sova-prod
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: <base64 docker config for git.sova.dev>
```
3. В `values-stage.yaml` / `values-prod.yaml`:
```yaml
imagePullSecrets:
- name: gitea-registry-secret
image:
repository: git.sova.dev/sova/backend
tag: backend-v1.0.0-stage
```
4. Firewall: stage/prod-app → test-server **443** (Registry API).
См. чек-лист §20 (stage/prod).
### 9.4. Pipeline backend (`.gitea/workflows/build.yml`)
```yaml
name: backend-ci-cd
on:
push:
tags:
- 'backend-v*'
pull_request:
branches: [main]
env:
REGISTRY: git.sova.dev
IMAGE: git.sova.dev/sova/backend
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.4'
extensions: pdo_pgsql, redis, intl, zip, gd
- run: composer install --prefer-dist
- run: composer phpunit
- run: composer audit
build-and-push:
needs: test
if: startsWith(github.ref, 'refs/tags/backend-v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Parse tag
id: meta
run: |
TAG="${GITHUB_REF#refs/tags/}"
echo "full_tag=$TAG" >> $GITHUB_OUTPUT
# backend-v1.2.3-test → env=test, version=1.2.3
echo "env=$(echo $TAG | sed -E 's/backend-v([0-9.]+)-([a-z]+)/\2/')" >> $GITHUB_OUTPUT
echo "version=$(echo $TAG | sed -E 's/backend-v([0-9.]+).*/\1/')" >> $GITHUB_OUTPUT
- name: Docker login
run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login $REGISTRY -u sova-ci --password-stdin
- name: Build
run: |
docker build -f Dockerfile \
-t $IMAGE:${{ steps.meta.outputs.full_tag }} \
-t $IMAGE:${{ steps.meta.outputs.version }} \
.
- name: Push
run: |
docker push $IMAGE:${{ steps.meta.outputs.full_tag }}
docker push $IMAGE:${{ steps.meta.outputs.version }}
deploy-gitops:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- name: Bump image tag in sova-deploy (with retry)
env:
DEPLOY_KEY: ${{ secrets.SOVA_DEPLOY_KEY }}
run: |
git clone git@gitea.sova.dev:sova/sova-deploy.git
cd sova-deploy
ENV="${{ steps.meta.outputs.env }}"
TAG="${{ steps.meta.outputs.full_tag }}"
git config user.email "ci-bot@sova.dev"
git config user.name "sova-ci"
MAX_RETRIES=5
for attempt in $(seq 1 $MAX_RETRIES); do
git pull --rebase origin main
yq -i ".image.tag = \"${TAG}\"" "apps/backend/values-${ENV}.yaml"
git add "apps/backend/values-${ENV}.yaml"
git diff --cached --quiet && { echo "No changes"; exit 0; }
git commit -m "chore(backend): bump ${ENV} to ${TAG}"
if git push origin main; then
echo "Push OK on attempt ${attempt}"
exit 0
fi
echo "Push failed (race?), retry ${attempt}/${MAX_RETRIES}..."
git reset --hard HEAD~1
sleep $((attempt * 2))
done
echo "Failed to push after ${MAX_RETRIES} attempts"
exit 1
```
> **Гонка состояний:** если два пайплайна пушат в `sova-deploy` одновременно, второй получит `non-fast-forward`. Цикл `pull --rebase` + retry обязателен (см. выше). Альтернатива — [gitops-promoter](https://github.com/argoproj-labs/gitops-promoter) или ArgoCD Image Updater без git commit из CI.
> ArgoCD подхватит изменение в `sova-deploy` за ~3 мин (poll) или мгновенно через webhook.
### 9.5. Pipeline adminPanel
Аналогично, tag pattern: `adminpanel-v1.0.0-test`.
Build-arg `VITE_API_BASE_URL` **не использовать** если перешли на runtime `env.js`.
---
## 10. Система тегирования и promotion
### 10.1. Формат тегов (SemVer + контур)
```text
{app}-v{MAJOR.MINOR.PATCH}-{env}
Примеры:
backend-v1.4.2-test → деплой в sova-test
backend-v1.4.2-stage → деплой в sova-stage
backend-v1.4.2-prod → деплой в sova-prod
adminpanel-v2.0.0-test
```
**Правила:**
| Действие | Кто | Тег |
|----------|-----|-----|
| Feature готова к QA | разработчик | `backend-v1.5.0-test` |
| QA passed → stage | тимлид / CI manual | `backend-v1.5.0-stage` |
| Stage passed → prod | release manager | `backend-v1.5.0-prod` |
| Hotfix prod | release manager | `backend-v1.4.3-prod` |
### 10.2. Promotion flow (без пересборки образа)
```mermaid
sequenceDiagram
participant Dev as Developer
participant Gitea as Gitea CI
participant CR as Container Registry
participant Deploy as sova-deploy repo
participant Argo as ArgoCD
Dev->>Gitea: tag backend-v1.5.0-test
Gitea->>Gitea: test + build
Gitea->>CR: push backend:backend-v1.5.0-test
Gitea->>Deploy: values-test.yaml tag=bump
Argo->>Deploy: poll / webhook
Argo->>Argo: sync sova-test
Note over Dev,Argo: После QA — promotion без rebuild
Dev->>Gitea: tag backend-v1.5.0-stage
Gitea->>CR: retag same digest OR promote manifest
Gitea->>Deploy: values-stage.yaml tag=bump
Argo->>Argo: sync sova-stage
```
**Promotion policy:**
1. **Test:** автоматически по тегу `*-test`.
2. **Stage:** автоматически по тегу `*-stage` **или** manual approval job в CI.
3. **Prod:** только manual approval + тег `*-prod` + ArgoCD project restriction.
### 10.3. ArgoCD Projects — RBAC по контурам
```yaml
# argocd/projects/sova-project.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
name: sova
namespace: argocd
spec:
destinations:
- namespace: sova-test
server: https://kubernetes.default.svc
- namespace: sova-stage
server: https://kubernetes.default.svc
- namespace: sova-prod
server: https://kubernetes.default.svc
sourceRepos:
- git@gitea.sova.dev:sova/sova-deploy.git
roles:
- name: test-deployer
policies:
- p, proj:sova:test-deployer, applications, sync, sova/backend-test, allow
- name: prod-deployer
policies:
- p, proj:sova:prod-deployer, applications, sync, sova/backend-prod, allow
```
### 10.4. Rollback
1. **ArgoCD UI:** History → Rollback (откат Git commit в sova-deploy).
2. **Или:** новый тег с предыдущей версией `backend-v1.4.1-test`.
3. **Или:** `kubectl rollout undo` — аварийно, но GitOps desync.
**Важно:** rollback откатывает **Deployment/ConfigMap**, но **не миграции БД**. Если новая версия применила `doctrine:migrations:migrate`, откат кода оставит схему «впереди» кода — возможны runtime-ошибки. Варианты:
- `php bin/console doctrine:migrations:execute --down 'VersionXXX'` вручную;
- restore PostgreSQL из snapshot (test);
- forward-fix: новый релиз с совместимой миграцией.
См. §7.2.1.
---
## 11. Мониторинг по контурам
### 11.1. Замена текущего `monitoring/`
| Docker Compose | Kubernetes |
|----------------|------------|
| `prom/prometheus` | Prometheus Operator (kube-prometheus-stack) |
| `grafana/grafana-enterprise` | Grafana subchart |
| `node-exporter` | DaemonSet (встроен) |
| `php-fpm-exporter` | ServiceMonitor на backend pods |
| nginx `:88/metrics` | ServiceMonitor на ingress-nginx |
| rules `*.rules` (отсутствуют) | **PrometheusRule** CR — создать |
### 11.2. Labels для multi-env
Все ServiceMonitor / Pod labels:
```yaml
labels:
app: backend
env: test # test | stage | prod
team: sova
```
Grafana dashboards — переменная `$env` для фильтрации.
### 11.3. Минимальный набор алертов
```yaml
# PrometheusRule — backend-down
groups:
- name: sova-apps
rules:
- alert: BackendDown
expr: kube_deployment_status_replicas_available{deployment="backend", namespace=~"sova-.*"} == 0
for: 2m
labels:
severity: critical
annotations:
summary: "Backend недоступен в {{ $labels.namespace }}"
- alert: HighErrorRate
expr: rate(nginx_ingress_controller_requests{status=~"5..", ingress=~"backend.*"}[5m]) > 0.1
for: 5m
labels:
severity: warning
```
### 11.4. Логи (Loki)
- Promtail DaemonSet собирает логи всех pods в `sova-*` namespaces.
- Grafana datasource Loki + Explore.
- Замена `docker logs` для отладки.
**Retention и диск (обязательно):** без лимитов Loki съест оставшееся место на NVMe.
```yaml
# kube-prometheus-stack / loki values (фрагмент)
loki:
limits_config:
retention_period: 168h # 7 дней
compactor:
retention_enabled: true
storage:
# local-path PVC с явным sizeLimit
size: 10Gi
```
На single-node 200 GB: Loki **≤1020 GB**, Prometheus **≤15 GB**, registry **≤50 GB** — остальное под PG и образы.
### 11.5. External monitoring (Bitrix server)
Сохранить scrape `192.168.2.11` из `monitoring/prometheus/prometheus.yml` как **additionalScrapeConfigs** в Helm values — если Bitrix-сервер доступен из k8s network.
---
## 12. Пошаговый план внедрения (фазы)
### Фаза 0 — Подготовка (1–2 дня)
- [ ] Заказ/получение сервера, SSH доступ, публичный IP
- [ ] **Remote Terraform state:** bucket Yandex Object Storage / S3 / Terraform Cloud (§5.1)
- [ ] Создать организацию/репы в Gitea (или поднять Gitea временно через docker на сервере)
- [ ] Зафиксировать плейсхолдер домена `sova.dev` в `/etc/hosts` для локальной проверки
- [ ] Написать production Dockerfile для backend и adminPanel
- [ ] Вынести `apps/` в отдельные Gitea-репозитории (если ещё не сделано)
### Фаза 1 — Bootstrap сервера + k3s (1 день)
- [ ] Terraform: `terraform apply` модуль k3s (state во **внешнем** backend)
- [ ] Проверка: `kubectl get nodes` → Ready
- [ ] Sealed-secrets controller
- [ ] **Бэкап Sealed Secrets master key** → offline vault (§6.4)
### Фаза 2 — Platform (23 дня)
- [ ] cert-manager + ClusterIssuer (staging)
- [ ] ingress-nginx с `type: LoadBalancer` (k3s ServiceLB, §5.3)
- [ ] ArgoCD + Ingress `argocd.sova.dev`
- [ ] kube-prometheus-stack + Grafana `grafana.sova.dev`
- [ ] Loki stack с **retention 7d** и лимитом PVC (§11.4)
### Фаза 3 — Gitea + Registry + CI (23 дня)
- [ ] Gitea Helm + `git.sova.dev`
- [ ] act_runner registered (Kaniko/Buildah, **requests/limits**, §9.2)
- [ ] **Registry retention policy** — cleanup старых тегов (§9.3)
- [ ] Создать repos: sova-backend, sova-adminpanel, sova-deploy, sova-platform
- [ ] CI secrets: REGISTRY_PASSWORD, SOVA_DEPLOY_KEY
- [ ] Первый успешный pipeline: build hello-world image
- [ ] deploy-gitops с **retry** на git push (§9.4)
### Фаза 4 — Test contour (35 дней)
- [ ] PostgreSQL (main + cabinet) + MySQL Bitrix + Redis в `sova-data-test`
- [ ] Mock-слой в `sova-mocks`: MIS (WireMock), Mailpit, captcha-mock
- [ ] SealedSecrets для `backend-env-test` (все URL → test DB + mocks, см. [backend-external-services.md §5.2](./backend-external-services.md))
- [ ] Helm chart backend + adminpanel
- [ ] ArgoCD Applications: backend-test, adminpanel-test, mocks-test
- [ ] Tag `backend-v0.1.0-test` → end-to-end deploy
- [ ] Smoke: login JWT, `/news/list`, anonymous-reserve через mock MIS + mock captcha
- [ ] CronJobs: сначала `ClearScheduleCacheCommand`; sync — после mock/Bitrix seed
### Фаза 5 — Stage contour (23 дня, **отдельный сервер**)
- [ ] Terraform: `stage-server` — k3s single-node (§3.3)
- [ ] PostgreSQL **single instance** на stage-сервере
- [ ] Pull образов с Gitea Registry (test-сервера)
- [ ] values-stage.yaml, ArgoCD apps на stage
- [ ] Tag `backend-v0.1.0-stage`
### Фаза 6 — Prod contour (**prod-app + 2× prod-db**, §3.4, §7.6)
- [ ] Terraform: `prod-db-1`, `prod-db-2` — Patroni + sync replication + HAProxy VIP
- [ ] Terraform: `prod-app` — k3s, apps **без** PG pod
- [ ] Миграция данных с текущей prod-VM (§7.6.8)
- [ ] `DATABASE_URL` → VIP / PgBouncer, **не** IP одной db-VM
- [ ] Failover drill: stop primary → backend восстанавливает запись < 3 min
- [ ] pg_dump + WAL → Object Storage; restore drill
- [ ] Production ClusterIssuer, HPA, PDB на prod-app
- [ ] On-call alerts (PG replication, Patroni leader, disk)
### Фаза 7 — Расширение приложений
- [ ] cabinet (php82 chart)
- [ ] sovamed (nextjs deployment)
- [ ] kiosk static site
---
## 13. Маппинг Docker Compose → Kubernetes
| Compose service | K8s resource | Примечание |
|-----------------|--------------|------------|
| nginx + php84 | Deployment backend (sidecar) | один pod |
| adminPanel dist | Deployment adminpanel | nginx static |
| pgsql | StatefulSet / Helm postgresql | per env (main + cabinet DB) |
| mysql-bitrix | Helm mysql | per env, seed `local/mysql-bitrix/init/` |
| redis | Helm redis | per env |
| mock HTTP (local dead URLs) | Deployments в `sova-mocks` | test only: MIS, captcha, mailpit |
| nextjs | Deployment sovamed | port 3001 |
| prometheus + grafana | kube-prometheus-stack | |
| jenkins | **удалить** | Gitea Actions |
| cron scripts | CronJob | |
| certbot | cert-manager | |
| internal-network | NetworkPolicy | deny cross-env |
---
## 14. NetworkPolicy (изоляция контуров)
Политики **разные per contour**. Пример ниже — **test** (БД внутри k8s). **Prod** — PostgreSQL **вне** кластера на prod-db-VM (§7.6).
### 14.1. Test (`sova-test`)
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-ingress-only
namespace: sova-test
spec:
podSelector:
matchLabels:
app: backend
policyTypes: [Ingress, Egress]
ingress:
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: ingress
egress:
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sova-data-test
ports:
- port: 5432
- port: 3306
- port: 6379
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sova-mocks
ports:
- port: 8080
- port: 1025
- port: 8025
- to: # DNS
- namespaceSelector: {}
ports:
- port: 53
protocol: UDP
# Stage: egress :443 к whitelist (MIS sandbox, SMTP). Prod: см. §14.2.
```
### 14.2. Prod (`sova-prod`) — PostgreSQL вне k8s
На prod **нельзя** копировать test-политику с `namespaceSelector: sova-data-test` — backend не достучится до Patroni VIP.
```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: backend-egress-prod
namespace: sova-prod
spec:
podSelector:
matchLabels:
app: backend
policyTypes: [Egress]
egress:
# PostgreSQL HA — VIP и/или прямые IP db-VM (private VLAN)
- to:
- ipBlock:
cidr: 10.0.1.20/32 # Keepalived VIP (pg-prod.sova.internal)
- ipBlock:
cidr: 10.0.1.10/32 # prod-db-1
- ipBlock:
cidr: 10.0.1.11/32 # prod-db-2
ports:
- port: 5432 # session / migrate / messenger
- port: 6432 # PgBouncer transaction (если на db-VM)
# MySQL Bitrix, Redis — на prod-app (in-cluster Service) или ipBlock
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: sova-data-prod
ports:
- port: 3306
- port: 6379
# Registry на test-сервере
- to:
- ipBlock:
cidr: 203.0.113.5/32 # IP test-сервера (git.sova.dev)
ports:
- port: 443
# MIS, SMTP, captcha — prod whitelist
- to:
- ipBlock:
cidr: 0.0.0.0/0
except:
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
ports:
- port: 443
- to: {}
ports:
- port: 53
protocol: UDP
```
> IP подставить из Terraform tfvars / private VLAN Selectel. DNS egress обязателен.
### 14.3. Stage
Как test (БД in-cluster) **плюс** egress `:443` к test Registry и sandbox-интеграциям.
---
## 15. Безопасность
1. **SSH:** только ключи, fail2ban, нестандартный порт опционально.
2. **k3s API:** не expose 6443 публично; kubectl через SSH tunnel.
3. **Secrets:** SealedSecrets, rotation раз в квартал.
4. **Registry:** private, robot account для CI.
5. **ArgoCD:** admin password → SSO Gitea; project RBAC.
6. **Pod security:** `runAsNonRoot`, drop capabilities, readOnlyRootFilesystem где возможно.
7. **Backup:** pg_dump nightly → S3/MinIO; etcd snapshot k3s.
---
## 16. Runbook: первый деплой backend test
```bash
# 1. На dev машине — создать тег
git tag backend-v0.1.0-test
git push origin backend-v0.1.0-test
# 2. CI (Gitea Actions) автоматически:
# - тесты
# - docker build + push git.sova.dev/sova/backend:backend-v0.1.0-test
# - commit в sova-deploy apps/backend/values-test.yaml
# 3. ArgoCD (или дождаться auto-sync)
argocd app sync backend-test
# 4. Проверка
curl -s https://api.test.sova.dev/ | jq .
curl -s https://api.test.sova.dev/news/list?page=1&perPage=2
# 5. Миграции (если PreSync hook не настроен)
kubectl -n sova-test exec -it deploy/backend -- php bin/console doctrine:migrations:status
```
---
## 17. Runbook: promotion test → stage
```bash
# Вариант A — новый тег (рекомендуется)
git tag backend-v0.1.0-stage
git push origin backend-v0.1.0-stage
# Вариант B — manual bump без rebuild (same image digest)
cd sova-deploy
yq -i '.image.tag = "backend-v0.1.0-test"' apps/backend/values-stage.yaml
git commit -am "promote backend 0.1.0 test→stage"
git push
argocd app sync backend-stage
```
---
## 18. Связь с локальной разработкой
| Локально (`make local-up`) | Test K8s |
|----------------------------|----------|
| http://localhost:8081 | https://api.test.sova.dev |
| http://localhost:3211 | https://admin.test.sova.dev |
| `local/.env.local` | SealedSecret `backend-env-test` |
| `mock-*.local` (dead URLs) | Services в `sova-mocks` с реальными ответами |
| `make local-smoke` | CI job `smoke-test-test` после deploy |
| postgres :15432 | pg-test.sova-data-test:5432 |
| mysql-bitrix :13306 | mysql-bitrix-test.sova-data-test:3306 |
Разработчик **не деплоит руками** — только теги и MR в Gitea.
---
## 19. Риски и mitigations
| Риск | Mitigation |
|------|------------|
| Terraform state на упавшем сервере | Remote backend **вне** k3s (§5.1) |
| Sealed Secrets key потерян при DR | Бэкап master key в offline vault (§6.4) |
| Registry заполнил диск | Retention policy + CronJob GC (§9.3) |
| CI runner «положил» кластер | requests/limits, Kaniko, taint node для CI (§9.2) |
| Loki заполнил диск | retention 7d + PVC limit (§11.4) |
| Гонка CI в sova-deploy | pull --rebase + retry loop (§9.4) |
| Rollback не откатывает миграции | §7.2.1, manual down-migration или PG restore |
| PG single point of failure на prod | **2 db-VM**, sync replication + Patroni (§7.6) |
| Commit latency из-за sync replica | RTT db-1↔db-2 < 2 ms (один DC); мониторить lag |
| Failover 503 на API | PgBouncer + pod restart policy; health checks |
| **Ребут prod-app demote PG** | Witness **не** на prod-app; иначе `patronictl pause` перед обслуживанием (§7.6.3) |
| etcd witness на prod-app | Split-brain / read-only БД при restart k3s → **отдельная witness-VM** |
| stage/prod не pull образов | `imagePullSecrets` + firewall к test Registry (§9.3) |
| PgBouncer ломает migrations | `DATABASE_MIGRATE_URL` на :5432 session, API на :6432 transaction (§7.6.5) |
| Prod 12 GB RAM недостаточно для k3s+HA | §3.4: prod-app 16 GB, db nodes отдельно |
| Один сервер — SPOF | Бэкапы; test/stage/prod **разные** серверы |
| RAM не хватит на prod-app | §3.4: min 16 GB для k3s + apps; PG на отдельных db-VM |
| Bitrix MySQL недоступен из k8s | **Test:** свой MySQL в `sova-data-test`; prod — VPN/peering |
| Вызов prod MIS/SMS из test | NetworkPolicy + env только на `sova-mocks`; без egress :443 в test |
| Hardcoded widget.sovamed.ru в cron | Рефакторинг на env или fixtures на mock |
| Пустой backend Dockerfile | **Блокер фазы 0** |
| Gitea + мониторинг + apps на одном CPU | Мониторинг «lite» values, 30s scrape |
| Domain не готов | sslip.io / /etc/hosts / mkcert |
---
## 20. Чек-лист «готовность test-контура»
- [ ] `kubectl get nodes` — Ready
- [ ] `argocd.sova.dev` открывается, apps Synced
- [ ] `git.sova.dev` — репы созданы, CI runner online
- [ ] `grafana.sova.dev` — dashboards показывают backend pods
- [ ] `api.test.sova.dev/news/list` — 200 JSON
- [ ] `admin.test.sova.dev/login` — SPA грузится, API calls работают
- [ ] Tag `backend-v*-test` → auto deploy < 10 min
- [ ] Rollback через ArgoCD проверен
- [ ] Mock MIS: `/api/reservation/intervals` и `anonymous-reserve` — 200
- [ ] Mailpit: письмо из `/service/sendmail` видно в UI (Basic Auth или port-forward)
- [ ] Backend **не** резолвит prod `widget.sovamed.ru` / sms.ru (проверка egress)
- [ ] CronJob `ClearScheduleCacheCommand` отработал; sync CronJobs — по готовности mock/Bitrix
- [ ] Sealed Secrets master key **забэкаплен** offline (не только в кластере)
- [ ] Terraform state в **внешнем** S3/TFC
- [ ] Registry retention настроен, диск < 70% занят
- [ ] Loki retention 7d, PVC в пределах лимита
- [ ] Secrets не лежат в Git plaintext (только SealedSecrets)
### Чек-лист stage/prod (дополнительно)
- [ ] `imagePullSecrets` (`gitea-registry-secret`) в `sova-stage` / `sova-prod` — pull с test Registry (§9.3)
- [ ] `values-*.yaml`: `imagePullSecrets` + `image.repository: git.sova.dev/sova/...`
- [ ] NetworkPolicy prod: egress `ipBlock` к Patroni VIP / prod-db IP (§14.2), не только namespace
### Чек-лист prod (дополнительно)
- [ ] Patroni: 2 узла, `sync_state = sync`
- [ ] Failover drill: primary stop → writes OK через VIP
- [ ] `DATABASE_URL` (6432 transaction) и `DATABASE_MIGRATE_URL` (5432 session) в SealedSecret
- [ ] Witness etcd на **отдельной** VM, не на prod-app
- [ ] pg_dump restore проверен
---
## 21. Дальнейшее развитие
1. **Read replica** для тяжёлых отчётов (ro endpoint `:5433`) — не путать с sync standby.
2. **Managed K8s** (YC/Selectel) для prod-app — PG HA может остаться на Patroni db-VM.
3. **Cabinet + sovamed** — новые Helm charts по образцу backend.
4. **Service Mesh** — только при необходимости mTLS между сервисами.
5. **Preview environments** — ArgoCD Application per MR (на test-сервере).
---
## 22. Связанные документы
- [**Terraform: prod-db HA**](../../terraform/modules/prod-db/README.md) — Patroni + etcd + VIP, SSH-only
- [`DEPLOYMENT_PLAN.md`](../../DEPLOYMENT_PLAN.md) — детали Dockerfile, Yandex Cloud variant
- [`docs/infrastructure/docker.md`](./docker.md) — текущий Docker Compose (если есть)
- [`docs/apps/backend-scenarios/`](../apps/backend-scenarios/index.md) — бизнес-потоки backend
- [`docs/flows.md`](../flows.md) — потоки данных
- [`monitoring/prometheus/prometheus.yml`](../../monitoring/prometheus/prometheus.yml) — текущие scrape targets
---
*Документ версии 1.1. Контуры test/stage/prod — **разные серверы**. Prod PG: sync HA (§7.6). Плейсхолдер домена: `sova.dev`.*