feat(ansible): add GitOps foundation (k3s, gitea, vault, and FluxCD) with multi-wave deployment support

This commit is contained in:
emelinda 2026-08-03 14:49:22 +03:00
parent e39404cb4c
commit 8a8cd66f59
10 changed files with 655 additions and 25 deletions

View File

@ -1,4 +1,4 @@
K3S_TOKEN=gVadzB0OtWGDAM9rmlZvHiwfITsP8bxo K3S_TOKEN=changeme
# Внешний адрес (IP или домен), по которому к kube-apiserver обращаются извне. # Внешний адрес (IP или домен), по которому к kube-apiserver обращаются извне.
# Попадёт в TLS-сертификат (--tls-san). Без него внешний kubectl упадёт на x509. # Попадёт в TLS-сертификат (--tls-san). Без него внешний kubectl упадёт на x509.
@ -48,9 +48,25 @@ PLATFORM_DOMAIN=sarex.local # основной домен платфо
ADMIN_DOMAIN=admin.sarex.local # RabbitMQ management UI ADMIN_DOMAIN=admin.sarex.local # RabbitMQ management UI
MINIO_DOMAIN=minio.sarex.local # MinIO: S3 API (/) + консоль (/console/) MINIO_DOMAIN=minio.sarex.local # MinIO: S3 API (/) + консоль (/console/)
# --- GitOps-подложка: gitea (источник правды) + Vault + FluxCD в k3s ---
# Админ gitea заводится контейнером gitea-init; этими же кредами flux-bootstrap
# авторизуется при push'е манифестов. Пароль генерирует роль sarex_stack.
GITEA_ADMIN_USER=sarex
GITEA_ADMIN_PASSWORD=changeme
GITEA_ADMIN_EMAIL=sarex@sarex.local
# Организация и репозиторий, которые gitea-init создаёт, а Flux реконсилирует.
GITEA_ORG=infra
GITEA_REPO=iac
# Ветка и путь внутри репозитория, за которыми следит Flux.
AERO_GIT_BRANCH=master
AERO_FLUX_PATH=clusters/aero
# --- Docker-образы инфраструктуры (тег/реестр можно переопределить) --- # --- Docker-образы инфраструктуры (тег/реестр можно переопределить) ---
K3S_IMAGE=rancher/k3s:v1.36.2-k3s1 K3S_IMAGE=rancher/k3s:v1.36.2-k3s1
GITEA_IMAGE=gitea/gitea:1.22 GITEA_IMAGE=gitea/gitea:1.22
VAULT_IMAGE=hashicorp/vault:1.18
# Версия CLI должна совпадать с версией контроллеров Flux в остальных кластерах.
FLUX_CLI_IMAGE=ghcr.io/fluxcd/flux-cli:v2.8.5
POSTGRES_IMAGE=postgres:12-alpine POSTGRES_IMAGE=postgres:12-alpine
NGINX_IMAGE=nginx:1.27-alpine NGINX_IMAGE=nginx:1.27-alpine
SAREX_REDIS_IMAGE=cr.yandex/crp3ccidau046kdj8g9q/redis:7.0.10 SAREX_REDIS_IMAGE=cr.yandex/crp3ccidau046kdj8g9q/redis:7.0.10

15
.gitignore vendored
View File

@ -2,7 +2,22 @@
.claude .claude
CLAUDE.md CLAUDE.md
.env .env
.env.*
!.env.example
tmp tmp
.tmp/ .tmp/
snapshots/* snapshots/*
# --- Рантайм-артефакты контура aero (создаются docker-compose на хосте) ---
gitea-data/ # данные gitea: репозитории, sqlite
k3s-storage/ # PVC-данные local-path-provisioner
k3s/kubeconfig.yaml # kubeconfig, который пишет k3s-server
nginx/certs/ # самоподписанные TLS-сертификаты
# --- Секреты и ключи: не коммитить никогда ---
*.pem
*.key
*.p12
kubeconfig*
*service-account*.json

View File

@ -63,10 +63,13 @@ uv run poe install
| `uv run poe ping` | проверка SSH + sudo | | `uv run poe ping` | проверка SSH + sudo |
| `uv run poe provision` | базовые пакеты + docker/containerd + запуск службы | | `uv run poe provision` | базовые пакеты + docker/containerd + запуск службы |
| `uv run poe deploy` | деплой конфигурации: файлы, `.env`, сертификаты, `docker login` | | `uv run poe deploy` | деплой конфигурации: файлы, `.env`, сертификаты, `docker login` |
| `uv run poe platform` | деплой + поднять GitOps-подложку (k3s + gitea + vault + Flux) |
| `uv run poe stack` | деплой + поднять стек (`docker compose up -d`) | | `uv run poe stack` | деплой + поднять стек (`docker compose up -d`) |
| `uv run poe gen-env` | только сгенерировать пароли и `.env` | | `uv run poe gen-env` | только сгенерировать пароли и `.env` |
| `uv run poe login` | только `docker login` в реестр по ключу | | `uv run poe login` | только `docker login` в реестр по ключу |
| `uv run poe superuser` | сгенерировать логин+пароль и создать Django-суперпользователя | | `uv run poe superuser` | сгенерировать логин+пароль и создать Django-суперпользователя |
| `uv run poe flux-status` | состояние FluxCD в k3s |
| `uv run poe vault-status` | состояние Vault (инициализирован / распечатан) |
| `uv run poe check` / `deploy-check` | dry-run (`--check --diff`) | | `uv run poe check` / `deploy-check` | dry-run (`--check --diff`) |
Поднимаемые сервисы задаются в `sarex_services` (роль `sarex_stack`) — Поднимаемые сервисы задаются в `sarex_services` (роль `sarex_stack`) —
@ -77,6 +80,100 @@ uv run poe install
> (через `/mnt/c`) права `0777`, и ansible игнорирует `ansible.cfg` в > (через `/mnt/c`) права `0777`, и ansible игнорирует `ansible.cfg` в
> world-writable каталоге, поэтому путь к конфигу передаётся явно. > world-writable каталоге, поэтому путь к конфигу передаётся явно.
### GitOps-подложка контура (k3s + gitea + vault + FluxCD)
Целевая схема контура — не «всё в compose», а **compose как подложка, k3s как
среда выполнения**. Источником правды становится gitea внутри контура, а
раскаткой занимается FluxCD.
```
docker compose k3s (4 ноды в контейнерах)
├── k3s-server ──► нода ┐
├── k3s-worker-1..3 ──► ноды ├─► flux-system (controllers)
├── gitea (172.28.0.12) ◄────── ┘ и всё, что опишем в clusters/aero
│ └── infra/iac.git
├── vault (+init)
├── flux-k8s-init ──► namespace flux-system + Service-мост до gitea
└── flux-bootstrap ──► ставит Flux в k3s и привязывает к gitea
```
**Мост до gitea.** Контроллеры Flux работают внутри k3s, где резолвит CoreDNS,
а имён compose-сети там нет. Поэтому `flux-k8s-init` заводит в namespace
`flux-system` Service без селектора + Endpoints на статический IP gitea — тем же
приёмом, что `bridge-postgres`/`bridge-minio` для processing. Источник в
`GitRepository` указан именем `gitea.flux-system.svc.cluster.local`, а адрес
задан ровно в одном месте — переменной `GITEA_BRIDGE_IP` (она же `ipv4_address`
сервиса `gitea`). Контейнер `flux-bootstrap` резолвит то же имя через
`extra_hosts` — иначе flux CLI не смог бы запушить манифесты.
**Имена нод закреплены** через `hostname:` в compose. Это не косметика: k3s
берёт имя ноды из hostname, иначе им становится ID контейнера. Local-path
привязывает PV к ноде через `nodeAffinity` по имени, и после пересоздания
контейнера тома «повисли» бы.
### Хранилище PVC
`StorageClass local-path` (встроенный в k3s, он же default). Дефолтный путь
провижинера `/var/lib/rancher/k3s/storage` перекрыт bind-mount'ом на хост —
поэтому настраивать ConfigMap `local-path-config` не нужно:
```
{{ deploy_dir }}/k3s-storage/
├── server/ ← PVC, севшие на k3s-server
├── worker-1/
├── worker-2/
└── worker-3/
```
Файлы лежат на хосте обычными каталогами: переживают пересоздание контейнера
k3s и `docker compose down -v`, бэкапятся штатными средствами.
> **Следствие для stateful-сервисов.** Хранилище **node-local**: PVC привязан к
> той ноде, где под запустился первым. При переезде postgres/redis/rabbitmq/minio
> в k3s их нужно явно прибивать к конкретной ноде (`nodeSelector`), иначе данные
> размажутся по четырём каталогам, а потеря ноды сделает том недоступным.
> Бэкап должен покрывать все каталоги `k3s-storage/*`.
```bash
uv run poe platform # поднять подложку
uv run poe flux-status # убедиться, что Flux реконсилирует
```
Что происходит по шагам:
1. **Волна 1** — стартуют `k3s-server`, три воркера и `gitea`. Роль ждёт, пока
k3s запишет `./k3s/kubeconfig.yaml` (и что файл непустой) — иначе
`flux-bootstrap` смонтировал бы каталог вместо файла.
2. **`gitea-init`** — одноразовый: заводит администратора (`gitea admin user
create`), организацию и пустой репозиторий через API. Идемпотентен.
3. **`flux-k8s-init`** — одноразовый: namespace `flux-system` и Service-мост до
gitea (см. выше). Обязан отработать до bootstrap.
4. **`vault` + `vault-init`** — Vault на file-хранилище. `vault-init` живёт
постоянно: инициализирует Vault одним ключом, распечатывает его и включает
`kv-v2` на пути `secrets/` (тот же путь, что в k8s-контурах). После ребута
хоста Vault поднимается запечатанным — цикл распечатывает его сам.
5. **`flux-bootstrap`** — одноразовый: `flux bootstrap git` ставит контроллеры в
k3s и пушит их манифесты в gitea в `clusters/aero/flux-system`.
> **FluxCD не работает в docker-compose** — это контроллеры Kubernetes. В compose
> живёт только установщик; после его успеха Flux работает внутри k3s.
Секреты подложки (`GITEA_ADMIN_PASSWORD`, `K3S_TOKEN`) генерируются так же, как
остальные — в `aero/.secrets/<host>/`. Unseal-ключ и root-токен Vault лежат на
отдельном docker-томе `sarex-vault-init` с правами `600` и **в git не попадают**.
#### Границы текущего этапа
Сделана только подложка. Осознанно **не** сделано:
- Репозиторий в gitea содержит лишь `clusters/aero/flux-system` — то, что запушил
bootstrap. Наполнение (`apps/`, `infrastructure/`) и перенос прикладных сервисов
из compose в k3s — следующий этап.
- Vault поднят и распечатан, но **kubernetes auth не настроен**: для него нужен
Vault Agent Injector внутри k3s, а он приедет уже через Flux. Пока в Vault
можно только класть секреты, поды их ещё не читают.
- Прикладной стек (`sarex_services`) не тронут и продолжает работать в compose.
### Развёртывание sarex-стека (роль `sarex_stack`) ### Развёртывание sarex-стека (роль `sarex_stack`)
Копирует `docker-compose.yaml`, `nginx/templates` и `backend/uwsgi.ini` в Копирует `docker-compose.yaml`, `nginx/templates` и `backend/uwsgi.ini` в

View File

@ -36,6 +36,10 @@ deploy = "ansible-playbook deploy.yml"
deploy-check = "ansible-playbook deploy.yml --check --diff" deploy-check = "ansible-playbook deploy.yml --check --diff"
# Деплой + поднять стек приложения (docker compose up -d) # Деплой + поднять стек приложения (docker compose up -d)
stack = "ansible-playbook deploy.yml -e sarex_compose_up=true" stack = "ansible-playbook deploy.yml -e sarex_compose_up=true"
# Деплой + поднять GitOps-подложку: k3s + gitea + vault + bootstrap FluxCD.
# Без --tags намеренно: подложке нужны .env, docker-compose.yaml и vault/config,
# которые кладут нетегированные задачи роли (при --tags они были бы пропущены).
platform = "ansible-playbook deploy.yml -e aero_platform_up=true"
# Только генерация паролей и рендер .env на хосте (без копирования/сертификатов) # Только генерация паролей и рендер .env на хосте (без копирования/сертификатов)
gen-env = "ansible-playbook deploy.yml --tags env" gen-env = "ansible-playbook deploy.yml --tags env"
# docker login в приватный реестр по ключу json_key # docker login в приватный реестр по ключу json_key
@ -53,6 +57,14 @@ up = "ansible-playbook deploy.yml -e sarex_compose_up=true --tags up"
cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose ps chdir=/root/sarex'" cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose ps chdir=/root/sarex'"
help = "Статус стека на хосте (docker compose ps)" help = "Статус стека на хосте (docker compose ps)"
[tool.poe.tasks.flux-status]
cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose exec -T k3s-server kubectl -n flux-system get kustomizations,gitrepositories,pods chdir=/root/sarex'"
help = "Состояние FluxCD в k3s (kustomizations, источники, поды)"
[tool.poe.tasks.vault-status]
cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose exec -T vault-init vault status chdir=/root/sarex'"
help = "Состояние Vault (инициализирован / распечатан)"
[tool.poe.tasks.pull] [tool.poe.tasks.pull]
cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose pull ${service} chdir=/root/sarex'" cmd = "ansible docker_hosts -m ansible.builtin.shell -a 'docker compose pull ${service} chdir=/root/sarex'"
help = "Стянуть образы: uv run poe pull [service]" help = "Стянуть образы: uv run poe pull [service]"

View File

@ -17,6 +17,7 @@ cert_dir: "{{ deploy_dir }}/nginx/certs"
# Переменные-пароли: им генерируются стойкие значения взамен слабых дефолтов. # Переменные-пароли: им генерируются стойкие значения взамен слабых дефолтов.
sarex_secret_vars: sarex_secret_vars:
- K3S_TOKEN - K3S_TOKEN
- GITEA_ADMIN_PASSWORD
- SAREX_POSTGRES_PASSWORD - SAREX_POSTGRES_PASSWORD
- SAREX_DJANGO_DB_PASSWORD - SAREX_DJANGO_DB_PASSWORD
- SAREX_PROCESSING_DB_PASSWORD - SAREX_PROCESSING_DB_PASSWORD
@ -46,6 +47,51 @@ registry_key_src: /mnt/c/Users/user/.ssh/local/authorized_key.json
sarex_compose_up: false sarex_compose_up: false
compose_cmd: "docker compose" compose_cmd: "docker compose"
# --- GitOps-подложка контура (k3s + gitea + vault + flux) -------------------
# Параметры gitea и Flux. Попадают в .env (их читает docker-compose) — менять
# нужно здесь, а не в .env.example, иначе перезапишется при следующем деплое.
gitea_admin_user: sarex
gitea_admin_email: "{{ gitea_admin_user }}@{{ platform_domain }}"
gitea_org: infra
gitea_repo: iac
# Ветка и путь, за которыми следит Flux внутри репозитория в gitea.
aero_git_branch: master
aero_flux_path: clusters/aero
# Поднимается отдельно от прикладного стека: образы публичные, docker login в
# cr.yandex не нужен. По умолчанию выключено, включает `uv run poe platform`.
aero_platform_up: false
# Волна 1: долгоживущие сервисы, которым нужно успеть стать healthy.
# k3s-server при первом старте создаёт ./k3s/kubeconfig.yaml — его ждёт волна 2.
aero_platform_services_wave1:
- k3s-server
- k3s-worker-1
- k3s-worker-2
- k3s-worker-3
- gitea
# Волна 2: провижининг и bootstrap. gitea-init, flux-k8s-init и flux-bootstrap
# одноразовые (restart: "no"), vault-init живёт постоянно и распечатывает Vault
# после ребута хоста.
aero_platform_services_wave2:
- gitea-init
- flux-k8s-init
- vault
- vault-init
- flux-bootstrap
# Ноды k3s. Каждой нужен свой каталог PVC-хранилища на хосте: провижинер
# local-path node-local, и PV навсегда привязывается к ноде через nodeAffinity.
aero_k3s_nodes:
- server
- worker-1
- worker-2
- worker-3
# Сколько ждать появления kubeconfig от k3s-server, секунд.
aero_kubeconfig_timeout: 180
# Сервисы приложения, поднимаемые при sarex_compose_up (depends_on тянет # Сервисы приложения, поднимаемые при sarex_compose_up (depends_on тянет
# зависимости и порядок). k3s/gitea намеренно не поднимаем. # зависимости и порядок). k3s/gitea намеренно не поднимаем.
sarex_services: sarex_services:

View File

@ -50,6 +50,22 @@
dest: "{{ deploy_dir }}/engine/" dest: "{{ deploy_dir }}/engine/"
mode: "0644" mode: "0644"
# vault монтирует ./vault/config как :ro — без конфига сервер не стартует.
- name: Скопировать конфигурацию Vault
ansible.builtin.copy:
src: "{{ deploy_src_root }}/vault/config/"
dest: "{{ deploy_dir }}/vault/config/"
mode: "0644"
# Каталоги PVC-данных k3s — по одному на ноду. Создаём заранее: иначе docker
# создаст их от root с umask демона, а local-path пишет туда от имени подов.
- name: Создать каталоги PVC-хранилища k3s (по ноде)
ansible.builtin.file:
path: "{{ deploy_dir }}/k3s-storage/{{ item }}"
state: directory
mode: "0755"
loop: "{{ aero_k3s_nodes }}"
# --- Секреты: генерация с персистом на control-node (идемпотентно) -------- # --- Секреты: генерация с персистом на control-node (идемпотентно) --------
- name: Сгенерировать/загрузить пароли (persist в secrets_store, gitignored) - name: Сгенерировать/загрузить пароли (persist в secrets_store, gitignored)
ansible.builtin.set_fact: ansible.builtin.set_fact:
@ -88,6 +104,12 @@
'ADMIN_DOMAIN': admin_domain, 'ADMIN_DOMAIN': admin_domain,
'MINIO_DOMAIN': minio_domain, 'MINIO_DOMAIN': minio_domain,
'SAREX_MINIO_CONSOLE_URL': 'https://' ~ minio_domain, 'SAREX_MINIO_CONSOLE_URL': 'https://' ~ minio_domain,
'GITEA_ADMIN_USER': gitea_admin_user,
'GITEA_ADMIN_EMAIL': gitea_admin_email,
'GITEA_ORG': gitea_org,
'GITEA_REPO': gitea_repo,
'AERO_GIT_BRANCH': aero_git_branch,
'AERO_FLUX_PATH': aero_flux_path,
'SAREX_JWT_PRIVATE_KEY': "'" ~ (lookup('file', jwt_private_key_path) | replace('\n', '\\n')) ~ "'", 'SAREX_JWT_PRIVATE_KEY': "'" ~ (lookup('file', jwt_private_key_path) | replace('\n', '\\n')) ~ "'",
'SAREX_JWT_PUBLIC_KEY': "'" ~ (lookup('file', jwt_public_key_path) | replace('\n', '\\n')) ~ "'", 'SAREX_JWT_PUBLIC_KEY': "'" ~ (lookup('file', jwt_public_key_path) | replace('\n', '\\n')) ~ "'",
}) }} }) }}
@ -157,6 +179,14 @@
state: absent state: absent
tags: [login] tags: [login]
# --- GitOps-подложка: k3s + gitea + vault + Flux --------------------------
# Отдельный тег и отдельный флаг: подложка поднимается публичными образами и
# не зависит от docker login, поэтому её можно накатывать независимо от стека.
- name: Поднять GitOps-подложку контура
ansible.builtin.include_tasks: platform.yml
when: aero_platform_up | bool
tags: [platform]
# --- Поднять сервисы приложения ------------------------------------------ # --- Поднять сервисы приложения ------------------------------------------
- name: Поднять сервисы приложения (docker compose up -d) - name: Поднять сервисы приложения (docker compose up -d)
ansible.builtin.command: ansible.builtin.command:

View File

@ -0,0 +1,83 @@
---
# Подъём GitOps-подложки контура: k3s + gitea + vault + bootstrap FluxCD.
# Отделено от прикладного стека (sarex_services) намеренно: здесь только
# публичные образы, docker login в приватный реестр не требуется.
#
# Порядок важен и поэтому разбит на две волны: flux-bootstrap монтирует
# ./k3s/kubeconfig.yaml, который k3s-server создаёт только при первом старте.
# Если поднять всё одной командой, docker может создать на месте отсутствующего
# файла директорию, и bootstrap упадёт с невнятной ошибкой.
- name: Волна 1 — поднять k3s-server и gitea
ansible.builtin.command:
cmd: "{{ compose_cmd }} up -d {{ aero_platform_services_wave1 | join(' ') }}"
chdir: "{{ deploy_dir }}"
changed_when: true
- name: Дождаться kubeconfig от k3s-server
ansible.builtin.wait_for:
path: "{{ deploy_dir }}/k3s/kubeconfig.yaml"
state: present
timeout: "{{ aero_kubeconfig_timeout }}"
# Пустой файл означает, что k3s-server ещё дописывает kubeconfig. Без этой
# проверки flux-bootstrap может стартовать с нулевым конфигом.
- name: Убедиться, что kubeconfig не пустой
ansible.builtin.wait_for:
path: "{{ deploy_dir }}/k3s/kubeconfig.yaml"
search_regex: "clusters:"
timeout: 60
- name: Волна 2 — gitea-init, vault(+init) и bootstrap Flux
ansible.builtin.command:
cmd: "{{ compose_cmd }} up -d {{ aero_platform_services_wave2 | join(' ') }}"
chdir: "{{ deploy_dir }}"
changed_when: true
# flux-bootstrap одноразовый: успех = код 0. Забираем его явно, иначе ошибка
# bootstrap'а останется незамеченной — `compose up -d` о ней не сообщает.
#
# `docker compose wait` здесь не годится: для уже завершившегося контейнера он
# возвращает rc=1 и "no containers for project". Так как `up -d` не дожидается
# одноразового сервиса, состояние гоночное — поэтому опрашиваем `ps -a`,
# который корректно отвечает и для running, и для exited.
#
# Формат обёрнут в {% raw %}: это Go-шаблон docker'а, и без raw Jinja попыталась
# бы раскрыть {{.State.Status}} как свою переменную. Аргументы передаём списком —
# у `command` нет шелла, и строка со скобками разбилась бы по пробелу.
- name: Дождаться завершения flux-bootstrap
ansible.builtin.command:
argv:
- "{{ compose_cmd.split()[0] }}"
- inspect
- "-f"
- "{% raw %}{{.State.Status}} {{.State.ExitCode}}{% endraw %}"
- flux-bootstrap
register: aero_flux_bootstrap
changed_when: false
retries: 30
delay: 10
until: aero_flux_bootstrap.stdout | trim is not match('^running')
- name: Показать логи flux-bootstrap при ошибке
ansible.builtin.command:
cmd: "{{ compose_cmd }} logs --no-color --tail 50 flux-bootstrap"
chdir: "{{ deploy_dir }}"
register: aero_flux_logs
changed_when: false
when: aero_flux_bootstrap.stdout.split() | last != "0"
- name: Прервать деплой, если bootstrap не удался
ansible.builtin.fail:
msg: |
flux bootstrap завершился с кодом {{ aero_flux_bootstrap.stdout.split() | last }}.
Логи контейнера:
{{ aero_flux_logs.stdout | default('(недоступны)') }}
when: aero_flux_bootstrap.stdout.split() | last != "0"
- name: Итог
ansible.builtin.debug:
msg:
- "gitea: http://{{ ansible_host }}:3000 (пользователь {{ gitea_admin_user }}, пароль в aero/.secrets/{{ inventory_hostname }}/GITEA_ADMIN_PASSWORD)"
- "Flux реконсилирует {{ gitea_org }}/{{ gitea_repo }} → {{ aero_flux_path }}"
- "Проверка: docker compose exec k3s-server kubectl -n flux-system get kustomizations"

View File

@ -1,7 +1,33 @@
# Общая конфигурация воркер-нод k3s — см. сервисы k3s-worker-1..3.
# У каждой ноды СВОЙ том состояния и СВОЙ каталог PVC-хранилища: провижинер
# local-path кладёт данные на ту ноду, где под запустился первым, и через
# nodeAffinity навсегда привязывает к ней PV.
x-k3s-worker: &k3s-worker
image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1}
command: ["agent"]
privileged: true
cgroup: host # kubelet в cgroup v2 (Ubuntu 22.04+/RedOS)
restart: unless-stopped
depends_on:
k3s-server:
condition: service_healthy
environment:
K3S_URL: https://k3s-server:6443
K3S_TOKEN: ${K3S_TOKEN:-supersecrettoken}
tmpfs:
- /run
- /var/run
ulimits:
nproc: 65535
nofile:
soft: 65535
hard: 65535
services: services:
k3s-server: k3s-server:
image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1} image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1}
container_name: k3s-server container_name: k3s-server
hostname: k3s-server # имя ноды в кластере — см. комментарий у воркеров
command: command:
- server - server
- --tls-san=127.0.0.1 - --tls-san=127.0.0.1
@ -22,6 +48,15 @@ services:
volumes: volumes:
- k3s-server-data:/var/lib/rancher/k3s # etcd/state, образы, манифесты — только named volume - k3s-server-data:/var/lib/rancher/k3s # etcd/state, образы, манифесты — только named volume
- ./k3s:/output:z # отсюда забираем контекст k8s (:z — SELinux label для RedOS) - ./k3s:/output:z # отсюда забираем контекст k8s (:z — SELinux label для RedOS)
# PVC-данные local-path-provisioner (StorageClass local-path, он же default
# в k3s). Перекрываем ровно дефолтный путь провижинера, поэтому настраивать
# ConfigMap local-path-config не нужно. Bind-mount на хост означает, что
# файлы БД/очередей/объектов переживают пересоздание контейнера k3s и
# доступны для бэкапа обычными средствами хоста.
# Каталог намеренно не вложен в ./k3s: тот смонтирован ещё и как /output,
# и данные PVC засветились бы там же. У каждой ноды свой подкаталог, иначе
# сервер видел бы каталоги воркеров как посторонние PV в своём storage.
- ./k3s-storage/server:/var/lib/rancher/k3s/storage:z
tmpfs: tmpfs:
- /run - /run
- /var/run - /var/run
@ -37,30 +72,32 @@ services:
retries: 30 retries: 30
start_period: 30s start_period: 30s
k3s-worker: # hostname задаёт ИМЯ НОДЫ в кластере (k3s берёт его из hostname). Без него
image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1} # нодой становится ID контейнера, а он меняется при пересоздании — и тогда
container_name: k3s-worker # local-path PV, привязанные к старому имени через nodeAffinity, «повисают».
command: k3s-worker-1:
- agent <<: *k3s-worker
privileged: true container_name: k3s-worker-1
cgroup: host # kubelet в cgroup v2 (Ubuntu 22.04+/RedOS) hostname: k3s-worker-1
restart: unless-stopped
depends_on:
k3s-server:
condition: service_healthy
environment:
K3S_URL: https://k3s-server:6443
K3S_TOKEN: ${K3S_TOKEN:-supersecrettoken}
volumes: volumes:
- k3s-worker-data:/var/lib/rancher/k3s - k3s-worker-1-data:/var/lib/rancher/k3s
tmpfs: - ./k3s-storage/worker-1:/var/lib/rancher/k3s/storage:z
- /run
- /var/run k3s-worker-2:
ulimits: <<: *k3s-worker
nproc: 65535 container_name: k3s-worker-2
nofile: hostname: k3s-worker-2
soft: 65535 volumes:
hard: 65535 - k3s-worker-2-data:/var/lib/rancher/k3s
- ./k3s-storage/worker-2:/var/lib/rancher/k3s/storage:z
k3s-worker-3:
<<: *k3s-worker
container_name: k3s-worker-3
hostname: k3s-worker-3
volumes:
- k3s-worker-3-data:/var/lib/rancher/k3s
- ./k3s-storage/worker-3:/var/lib/rancher/k3s/storage:z
# Одноразовый провижининг k3s: namespace processing + DNS-мост postgres/minio. # Одноразовый провижининг k3s: namespace processing + DNS-мост postgres/minio.
# Образ k3s содержит kubectl; server/tls перекрываем на k3s-server:6443 # Образ k3s содержит kubectl; server/tls перекрываем на k3s-server:6443
@ -82,6 +119,11 @@ services:
- ./k3s/kubeconfig.yaml:/kube/config:ro,z - ./k3s/kubeconfig.yaml:/kube/config:ro,z
- ./k3s/manifests/processing:/manifests:ro,z - ./k3s/manifests/processing:/manifests:ro,z
# ---------------------------------------------------------------------------
# GitOps-подложка контура: gitea (источник) + vault (секреты) + flux (в k3s).
# Порядок: gitea → gitea-init (админ/орг/репо) → flux-bootstrap (ставит Flux
# в k3s и привязывает его к репозиторию в gitea).
# ---------------------------------------------------------------------------
gitea: gitea:
image: ${GITEA_IMAGE:-gitea/gitea:1.22} image: ${GITEA_IMAGE:-gitea/gitea:1.22}
container_name: gitea container_name: gitea
@ -91,6 +133,11 @@ services:
USER_GID: 1000 USER_GID: 1000
GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3000/} GITEA__server__ROOT_URL: ${GITEA_ROOT_URL:-http://localhost:3000/}
GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-2222} GITEA__server__SSH_PORT: ${GITEA_SSH_PORT:-2222}
# Пропускаем web-визард установки: конфигурация задаётся здесь, админа
# заводит gitea-init. Без INSTALL_LOCK первый запрос уводит на /install.
GITEA__security__INSTALL_LOCK: "true"
GITEA__database__DB_TYPE: sqlite3
GITEA__service__DISABLE_REGISTRATION: "true"
ports: ports:
- "3000:3000" # web UI (80/443 заняты k3s) - "3000:3000" # web UI (80/443 заняты k3s)
- "${GITEA_SSH_PORT:-2222}:22" # git over ssh - "${GITEA_SSH_PORT:-2222}:22" # git over ssh
@ -102,6 +149,225 @@ services:
timeout: 5s timeout: 5s
retries: 12 retries: 12
start_period: 20s start_period: 20s
# Статический IP по образцу postgres/minio. Он обязателен: source-controller
# Flux работает ВНУТРИ k3s, где своя DNS (CoreDNS), и имя compose-сети
# `gitea` там не резолвится. Поды k3s дотягиваются до 172.28.0.0/16 через
# SNAT на ноде, поэтому в URL источника используется именно этот адрес.
networks:
default:
ipv4_address: 172.28.0.12
# Одноразовый провижининг gitea: администратор + организация + пустой репозиторий.
# Делит /data с gitea (CLI пишет в ту же sqlite), поэтому запускается тем же uid.
# Все шаги идемпотентны: повторный прогон на готовой инсталляции — no-op.
gitea-init:
image: ${GITEA_IMAGE:-gitea/gitea:1.22}
container_name: gitea-init
restart: "no"
user: "1000:1000"
depends_on:
gitea:
condition: service_healthy
environment:
GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-sarex}
GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:?GITEA_ADMIN_PASSWORD is required}
GITEA_ADMIN_EMAIL: ${GITEA_ADMIN_EMAIL:-sarex@sarex.local}
GITEA_ORG: ${GITEA_ORG:-infra}
GITEA_REPO: ${GITEA_REPO:-iac}
volumes:
- ./gitea-data:/data:z
entrypoint: ["/bin/sh", "-ec"]
command:
- |
export GITEA_WORK_DIR=/data/gitea
CONF=/data/gitea/conf/app.ini
# 1. Администратор. Существующий пользователь → ненулевой код, это норма.
gitea --config "$$CONF" admin user create \
--admin --username "$$GITEA_ADMIN_USER" --password "$$GITEA_ADMIN_PASSWORD" \
--email "$$GITEA_ADMIN_EMAIL" --must-change-password=false 2>&1 \
| grep -v 'already exists' || true
API=http://gitea:3000/api/v1
AUTH="$$GITEA_ADMIN_USER:$$GITEA_ADMIN_PASSWORD"
# 2. Организация. 422 = уже существует.
curl -fsS -u "$$AUTH" -X POST "$$API/orgs" \
-H 'Content-Type: application/json' \
-d "{\"username\":\"$$GITEA_ORG\"}" >/dev/null 2>&1 || true
# 3. Репозиторий. auto_init=false — первый коммит сделает flux bootstrap.
curl -fsS -u "$$AUTH" -X POST "$$API/orgs/$$GITEA_ORG/repos" \
-H 'Content-Type: application/json' \
-d "{\"name\":\"$$GITEA_REPO\",\"private\":true,\"auto_init\":false}" \
>/dev/null 2>&1 || true
# Проверяем, что репозиторий действительно доступен — иначе flux bootstrap
# упадёт позже и менее внятно.
curl -fsS -u "$$AUTH" "$$API/repos/$$GITEA_ORG/$$GITEA_REPO" >/dev/null
echo "gitea-init: $$GITEA_ORG/$$GITEA_REPO готов"
vault:
image: ${VAULT_IMAGE:-hashicorp/vault:1.18}
container_name: vault
restart: unless-stopped
# Только "server", без -config: entrypoint образа сам добавляет
# -config=/vault/config. Явный путь к файлу внутри этого же каталога
# приводит к двойной загрузке конфига и падению на
# "listen tcp4 0.0.0.0:8200: bind: address already in use".
command: ["server"]
cap_add:
- IPC_LOCK
environment:
VAULT_ADDR: http://127.0.0.1:8200
volumes:
# :ro осознанно — entrypoint пишет в лог безвредные "chown: Read-only
# file system", но конфиг остаётся неизменяемым изнутри контейнера.
- ./vault/config:/vault/config:ro,z
- sarex-vault-data:/vault/file
- sarex-vault-init:/vault/init
healthcheck:
# /v1/sys/health отдаёт 200 только когда Vault инициализирован и распечатан
# (501 — не инициализирован, 503 — запечатан). Значит healthy здесь
# означает «готов принимать секреты», и на это можно вешать depends_on.
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8200/v1/sys/health"]
interval: 10s
timeout: 5s
retries: 30
start_period: 10s
# Инициализация и авто-распечатывание Vault. Живёт постоянно: после рестарта
# хоста Vault поднимается запечатанным, и цикл распечатывает его сам.
# Ключ и root-токен лежат на отдельном volume (sarex-vault-init), не в git.
vault-init:
image: ${VAULT_IMAGE:-hashicorp/vault:1.18}
container_name: vault-init
restart: unless-stopped
depends_on:
vault:
condition: service_started
environment:
VAULT_ADDR: http://vault:8200
volumes:
- sarex-vault-init:/vault/init
entrypoint: ["/bin/sh", "-ec"]
command:
- |
UNSEAL=/vault/init/unseal.key
ROOT=/vault/init/root.token
while :; do
# `vault operator init -status`: 0 — инициализирован, 2 — нет,
# прочее — Vault ещё не отвечает. Под `set -e` ненулевой код нужно
# перехватить явно, иначе скрипт завершится.
rc=0
vault operator init -status >/dev/null 2>&1 || rc=$$?
if [ "$$rc" -eq 0 ]; then
# Инициализирован. `vault status`: 0 — распечатан, 2 — запечатан.
if ! vault status >/dev/null 2>&1; then
if [ -s "$$UNSEAL" ]; then
vault operator unseal "$$(cat $$UNSEAL)" >/dev/null \
&& echo "vault-init: распечатан"
else
echo "vault-init: Vault запечатан, но $$UNSEAL отсутствует —" \
"распечатать нечем (том sarex-vault-init потерян?)" >&2
fi
fi
elif [ "$$rc" -eq 2 ]; then
# Отвечает, но не инициализирован — инициализируем одним ключом.
# Текстовый вывод парсим awk'ом: jq в образе нет.
if vault operator init -key-shares=1 -key-threshold=1 > /vault/init/init.txt 2>/dev/null; then
awk '/^Unseal Key 1:/{print $$4}' /vault/init/init.txt > "$$UNSEAL"
awk '/^Initial Root Token:/{print $$4}' /vault/init/init.txt > "$$ROOT"
chmod 600 "$$UNSEAL" "$$ROOT" /vault/init/init.txt
vault operator unseal "$$(cat $$UNSEAL)" >/dev/null
echo "vault-init: инициализирован и распечатан"
# kv-v2 на пути secrets/ — так же, как в k8s-контурах
# (манифесты ссылаются на secrets/data/...).
VAULT_TOKEN="$$(cat $$ROOT)" vault secrets enable -path=secrets kv-v2 \
>/dev/null 2>&1 && echo "vault-init: включён kv-v2 на secrets/"
fi
fi
sleep 10
done
# Одноразовый провижининг namespace flux-system и DNS-моста до gitea.
# Должен отработать ДО flux-bootstrap: тот дожидается готовности GitRepository,
# а она невозможна, пока source-controller не может отрезолвить источник.
flux-k8s-init:
image: ${K3S_IMAGE:-rancher/k3s:v1.36.2-k3s1}
container_name: flux-k8s-init
restart: "no"
depends_on:
k3s-server:
condition: service_healthy
environment:
GITEA_BRIDGE_IP: ${GITEA_BRIDGE_IP:-172.28.0.12}
volumes:
- ./k3s/kubeconfig.yaml:/kube/config:ro,z
- ./k3s/manifests/flux:/manifests:ro,z
entrypoint: ["/bin/sh", "-ec"]
command:
- |
sed "s|__GITEA_BRIDGE_IP__|$$GITEA_BRIDGE_IP|g" /manifests/bridge-gitea.yaml \
| kubectl --kubeconfig /kube/config \
--server https://k3s-server:6443 --insecure-skip-tls-verify \
apply -f -
# Одноразовый bootstrap FluxCD внутрь k3s с привязкой к репозиторию в gitea.
# Flux — это контроллеры Kubernetes, в compose живёт только этот установщик.
# После успешного прогона источником правды становится gitea, а не compose.
flux-bootstrap:
image: ${FLUX_CLI_IMAGE:-ghcr.io/fluxcd/flux-cli:v2.8.5}
container_name: flux-bootstrap
restart: "no"
depends_on:
k3s-server:
condition: service_healthy
gitea-init:
condition: service_completed_successfully
flux-k8s-init:
condition: service_completed_successfully
# Один и тот же URL резолвится в двух разных контекстах: внутри k3s — через
# Service флакс-моста, здесь, в compose-сети, — через эту запись в /etc/hosts.
# Без неё flux CLI не смог бы запушить манифесты в репозиторий.
extra_hosts:
- "gitea.flux-system.svc.cluster.local:${GITEA_BRIDGE_IP:-172.28.0.12}"
environment:
GITEA_ADMIN_USER: ${GITEA_ADMIN_USER:-sarex}
GITEA_ADMIN_PASSWORD: ${GITEA_ADMIN_PASSWORD:?GITEA_ADMIN_PASSWORD is required}
GITEA_ORG: ${GITEA_ORG:-infra}
GITEA_REPO: ${GITEA_REPO:-iac}
AERO_GIT_BRANCH: ${AERO_GIT_BRANCH:-master}
AERO_FLUX_PATH: ${AERO_FLUX_PATH:-clusters/aero}
# Должен совпадать с ipv4_address сервиса gitea.
GITEA_BRIDGE_IP: ${GITEA_BRIDGE_IP:-172.28.0.12}
volumes:
- ./k3s/kubeconfig.yaml:/kube/config:ro,z
entrypoint: ["/bin/sh", "-ec"]
command:
- |
# k3s пишет kubeconfig с server: https://127.0.0.1:6443 — изнутри другого
# контейнера это неверный адрес. Подменяем на имя сервиса; сертификат
# apiserver уже содержит --tls-san=k3s-server, поэтому TLS проверяется
# штатно, без --insecure.
sed 's#https://127.0.0.1:6443#https://k3s-server:6443#' /kube/config > /tmp/kubeconfig
export KUBECONFIG=/tmp/kubeconfig
# Адрес источника — имя Service'а моста (flux-k8s-init), а не IP: так
# адрес gitea задан ровно в одном месте — переменной GITEA_BRIDGE_IP.
flux bootstrap git \
--url="http://gitea.flux-system.svc.cluster.local:3000/$$GITEA_ORG/$$GITEA_REPO.git" \
--branch="$$AERO_GIT_BRANCH" \
--path="$$AERO_FLUX_PATH" \
--username="$$GITEA_ADMIN_USER" \
--password="$$GITEA_ADMIN_PASSWORD" \
--token-auth \
--allow-insecure-http
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Инфраструктурные зависимости apps/django (sarex-backend). # Инфраструктурные зависимости apps/django (sarex-backend).
@ -593,8 +859,12 @@ services:
volumes: volumes:
k3s-server-data: k3s-server-data:
k3s-worker-data: k3s-worker-1-data:
k3s-worker-2-data:
k3s-worker-3-data:
gitea-data: gitea-data:
sarex-vault-data: # file-хранилище Vault
sarex-vault-init: # unseal-ключ и root-токен (только для vault-init)
sarex-postgres-data: sarex-postgres-data:
sarex-redis-data: sarex-redis-data:
sarex-rabbitmq-data: sarex-rabbitmq-data:

View File

@ -0,0 +1,39 @@
# DNS-мост: имя `gitea` в namespace flux-system → compose-контейнер gitea.
# Service без селектора + ручной Endpoints, по образцу bridge-postgres/bridge-minio.
#
# Зачем: source-controller Flux работает ВНУТРИ k3s, где резолвит CoreDNS, а имя
# сервиса compose-сети там не существует. Без моста GitRepository падает с
# "lookup gitea on 10.43.0.10:53: no such host".
#
# __GITEA_BRIDGE_IP__ подставляет контейнер flux-k8s-init из переменной
# GITEA_BRIDGE_IP — она же задаёт ipv4_address сервиса gitea в docker-compose.
---
apiVersion: v1
kind: Namespace
metadata:
name: flux-system
---
apiVersion: v1
kind: Service
metadata:
name: gitea
namespace: flux-system
spec:
ports:
- name: http
port: 3000
targetPort: 3000
protocol: TCP
---
apiVersion: v1
kind: Endpoints
metadata:
name: gitea
namespace: flux-system
subsets:
- addresses:
- ip: __GITEA_BRIDGE_IP__
ports:
- name: http
port: 3000
protocol: TCP

22
vault/config/vault.hcl Normal file
View File

@ -0,0 +1,22 @@
// Vault контура aero: file-хранилище на named volume, TLS терминируется выше
// (верхний nginx). Внутри compose-сети слушаем открытым HTTP.
// Инициализацию/распечатывание делает сервис vault-init (см. docker-compose.yaml).
ui = true
storage "file" {
path = "/vault/file"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_disable = true
}
// Адрес, который Vault сообщает клиентам (и себе при редиректах внутри сети).
api_addr = "http://vault:8200"
// mlock требует IPC_LOCK; в compose capability выдана, но в некоторых окружениях
// (RedOS в контейнере без CAP_IPC_LOCK) Vault упадёт на старте. Отключаем
// swap на хосте контура и так выключен.
disable_mlock = true