diff --git a/.ansible-lint b/.ansible-lint
new file mode 100644
index 0000000..04f2830
--- /dev/null
+++ b/.ansible-lint
@@ -0,0 +1,39 @@
+---
+profile: min
+
+exclude_paths:
+ - collections/
+ - .ansible/
+
+skip_list:
+ # Task names follow the project's documented "Category | Action" convention
+ # (see CLAUDE.md), which intentionally starts with a lowercase category (e.g.
+ # "kubectl | Download binary"). Renaming ~50 tasks repo-wide would break that
+ # convention for no functional benefit.
+ - name[casing]
+
+ # Long template expressions in JSON Patch paths are more readable without
+ # extra spaces. The ansible-lint var-spacing rule is overly strict for this
+ # domain-specific syntax.
+ - var-spacing
+
+ # Vars are named after their role's domain concept (e.g. k8s_version,
+ # longhorn_disks) and are shared/read across roles, defaults, group_vars and
+ # templates. Enforcing a role-name prefix here would require a repo-wide,
+ # high-risk rename touching every layer that references these variables.
+ - var-naming[no-role-prefix]
+
+ # Several tasks intentionally run shell/command modules whose "changed"
+ # state is either always true (idempotent by design, e.g. helm upgrade
+ # --install) or checked via a separate registered/failed_when guard rather
+ # than changed_when.
+ - no-changed-when
+
+ # A few changed-triggered tasks (e.g. Traefik rollout restart, GitLab fleet
+ # repo commit/push) are deliberately inline rather than handlers, since they
+ # need to run mid-playbook with immediate ordering guarantees.
+ - no-handler
+
+ # Some Helm values/service description lines exceed 160 chars and are not
+ # worth artificially wrapping.
+ - yaml[line-length]
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..ca3ebfa
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,13 @@
+LOCAL_RUN.md
+collections/
+*.retry
+/tmp/
+*.log
+host_facts/
+public/
+.ansible/
+.claude/
+.mcp.json
+/tmp/ansible_facts_cache/
+*.pyc
+__pycache__/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
new file mode 100644
index 0000000..24682a7
--- /dev/null
+++ b/.gitlab-ci.yml
@@ -0,0 +1,308 @@
+# GitLab CI pipeline for Kubernetes infrastructure (Ansible)
+#
+# Required GitLab CI/CD Variables (Settings → CI/CD → Variables):
+# ANSIBLE_SSH_USER — SSH username for target servers
+# ANSIBLE_SSHKEY_ID_RSA — private SSH key (тип: File или Variable, содержимое id_rsa)
+# ANSIBLE_BECOME_PASS — sudo password
+# GITLAB_FLUX_TOKEN — GitLab PAT (scope: api) для Flux bootstrap и клонирования fleet repo
+# GITLAB_AGENT_TOKEN — токен GitLab Agent (получить в GitLab → Operate → Kubernetes clusters)
+# LOKI_MINIO_PASSWORD — пароль пользователя loki в MinIO (создаётся при запуске setup_logging)
+# GRAFANA_ADMIN_PASSWORD — пароль администратора Grafana (задаётся в Secret вручную)
+
+stages:
+ - validate
+ - setup
+
+# ── Shared configuration ───────────────────────────────────────────────────────
+default:
+ image: python:3.11-slim
+ before_script:
+ - apt-get update -qq
+ - apt-get install -y -qq --no-install-recommends openssh-client git
+ - pip install --quiet ansible ansible-lint netaddr
+ - >
+ for i in 1 2 3; do
+ ansible-galaxy collection install -r requirements.yml -p collections/ --force && exit 0;
+ echo "ansible-galaxy collection install failed (attempt $i/3), retrying in 5s...";
+ sleep 5;
+ done;
+ exit 1
+ - mkdir -p ~/.ssh && chmod 700 ~/.ssh
+ - printf '%b\n' "$ANSIBLE_SSHKEY_ID_RSA" > ~/.ssh/id_rsa
+ - chmod 600 ~/.ssh/id_rsa
+ - eval $(ssh-agent -s)
+ - ssh-add ~/.ssh/id_rsa
+ interruptible: true
+
+variables:
+ ANSIBLE_FORCE_COLOR: "1"
+ ANSIBLE_HOST_KEY_CHECKING: "False"
+ ANSIBLE_ROLES_PATH: roles
+ ANSIBLE_COLLECTIONS_PATH: collections
+ ANSIBLE_FILTER_PLUGINS: filter_plugins
+ PIP_NO_CACHE_DIR: "1"
+ INVENTORY: inventory/prod
+
+# ── Stage: validate ────────────────────────────────────────────────────────────
+lint:
+ stage: validate
+ script:
+ - ansible-lint playbooks/ roles/
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+ - if: $CI_COMMIT_BRANCH
+
+syntax-check:
+ stage: validate
+ script:
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_manager.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_control_plane.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_worker_plane.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_longhorn.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_traefik.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_flux.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_gitlab_agent.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_minio.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_logging.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_sealed_secrets.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_dev_access.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_gpu.yml
+ - >
+ ansible-playbook
+ --syntax-check
+ -i $INVENTORY
+ playbooks/setup_gpu_model_storage.yml
+ rules:
+ - if: $CI_PIPELINE_SOURCE == "merge_request_event"
+ - if: $CI_COMMIT_BRANCH
+
+# ── Stage: setup ───────────────────────────────────────────────────────────────
+setup:manager_nodes:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_manager.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:control_plane:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_control_plane.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:workers:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_worker_plane.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:longhorn:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_longhorn.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:traefik:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_traefik.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:flux:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_flux.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:gitlab_agent:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_gitlab_agent.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:minio:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_minio.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:logging:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_logging.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:sealed_secrets:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_sealed_secrets.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:dev_access:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_dev_access.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:gpu_workers:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_gpu.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+
+setup:gpu_model_storage:
+ stage: setup
+ script:
+ - >
+ ansible-playbook
+ -i $INVENTORY
+ playbooks/setup_gpu_model_storage.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
diff --git a/README.md b/README.md
index 0be2f40..4614523 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,625 @@
-# k8s
+# Kubernetes Infrastructure
+> **Корпоративный стандарт.** Ansible-развёртывание кластера Kubernetes на Rocky Linux 9.
+> Локальный запуск через CLI и автоматизированный запуск через GitLab CI/CD.
+
+---
+
+## Содержание
+
+- [Инфраструктура](#инфраструктура)
+- [Стек технологий](#стек-технологий)
+- [Структура репозитория](#структура-репозитория)
+- [Роли и компоненты](#роли-и-компоненты)
+- - **Стандарт работы с хранилищем** [`roles/longhorn/README.md`](roles/longhorn/README.md)
+- - **Стандарт публикации сервисов** [`roles/traefik/README.md`](roles/traefik/README.md)
+- - **Стандарт GitOps-деплоя приложений** [`roles/flux/README.md`](roles/flux/README.md)
+- - **Стандарт объектного хранилища S3** [`roles/minio/README.md`](roles/minio/README.md)
+- - **Стандарт централизованного сбора логов** [`roles/logging/README.md`](roles/logging/README.md)
+- - Sealed Secrets (kubeseal)
+- [CI/CD-пайплайн](#cicd-пайплайн)
+- [Порядок развёртывания](#порядок-развёртывания)
+- [Архитектурные решения](#архитектурные-решения)
+- [Локальная разработка](#локальная-разработка)
+- [Добавление нового компонента](#добавление-нового-компонента)
+
+---
+
+## Инфраструктура
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ Operator workstation (manager_nodes) │
+│ k8s-manager-01 10.203.0.92 │
+│ kubectl · helm · k9s · kubectx/kubens │
+│ Kubernetes Dashboard token → ~/.kube/dashboard-token │
+└────────────────────────┬────────────────────────────────────────┘
+ │ kubectl / helm (kubeconfig)
+┌────────────────────────▼────────────────────────────────────────┐
+│ Kubernetes cluster (k8s_cluster) │
+│ │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Control plane k8s-master-01 10.203.0.97 │ │
+│ │ kube-apiserver · etcd · controller-manager · scheduler │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ ┌──────────────────────────────────────────────────────────┐ │
+│ │ Worker k8s-worker-01 10.203.0.96 │ │
+│ │ kubelet · kube-proxy · Longhorn disk agent │ │
+│ │ Traefik DaemonSet │ │
+│ │ :10001 → kubernetes-dashboard (ClusterIP :443) │ │
+│ │ :10002 → longhorn-frontend (ClusterIP :80) [auth] │ │
+│ │ :10003 → grafana (ClusterIP :80) │ │
+│ │ :10005 → minio (ClusterIP :9000) │ │
+│ │ :10006 → minio-console (ClusterIP :9001) │ │
+│ │ :10103 → db/bgbilling-dev (TCP :3306) │ │
+│ └──────────────────────────────────────────────────────────┘ │
+│ │
+│ CNI: Flannel v0.26.1 │ Storage: Longhorn 1.7.2 │
+└─────────────────────────────────────────────────────────────────┘
+```
+
+### Узлы кластера
+
+| Hostname | IP | Группа инвентаря | Роль |
+|---|---|---|---|
+| k8s-manager-01 | 10.203.0.92 | `manager_nodes` | Рабочая станция оператора |
+| k8s-master-01 | 10.203.0.97 | `control_plane` | API server, etcd, scheduler |
+| k8s-worker-01 | 10.203.0.96 | `workers` | Вычислительный узел |
+| k8s-worker-02 | 10.203.0.212 | `workers` → `gpu_workers` | GPU-узел (NVIDIA), Ollama-стек |
+| k8s-worker-NN | TBD | `workers` | Дополнительные вычислительные узлы |
+
+> `manager_nodes` — **не** Kubernetes-узел. Это рабочая станция оператора, с которой запускаются `kubectl` и `helm`.
+
+### Сервисы и точки доступа
+
+| Сервис | URL | Аутентификация |
+|---|---|---|
+| Kubernetes Dashboard | `http://10.203.0.96:10001` | токен из `~/.kube/dashboard-token` |
+| Longhorn UI | `http://10.203.0.96:10002` | BasicAuth (Secret `traefik-auth-longhorn` в ns `traefik`) |
+| Grafana | `http://10.203.0.96:10003` | логин Grafana (admin, пароль из Secret `grafana-admin-secret`) |
+| MinIO S3 API | `http://10.203.0.96:10005` | AWS Signature v4 (встроенная аутентификация MinIO) |
+| MinIO Console | `http://10.203.0.96:10006` | логин MinIO (rootUser из Secret `minio-root-credentials`) |
+| MariaDB BGBilling | `10.203.0.96:10103` | TCP-проброс (без HTTP); аутентификация средствами MariaDB |
+| Ollama Proxy (k8s_ai) | `http://10.203.0.96:10200` | своя аутентификация приложения |
+| Open WebUI (k8s_ai) | `http://10.203.0.96:10201` | своя аутентификация приложения |
+
+---
+
+## Стек технологий
+
+| Компонент | Версия | Управляется |
+|---|---|---|
+| ОС | Rocky Linux 9 | Ansible |
+| Kubernetes | 1.33 (pkgs.k8s.io) | Ansible → `k8s_control_plane`, `k8s_worker` |
+| Container runtime | containerd (Docker CE repo) | Ansible |
+| CNI | Flannel v0.26.1 / Calico v3.27.0 | Ansible → `k8s_control_plane` |
+| Постоянное хранилище | Longhorn 1.7.2 | Ansible → `longhorn_prereqs`, `longhorn` |
+| Ingress / proxy | Traefik 32.1.0 | Ansible → `traefik` |
+| Kubernetes Dashboard | v2.7.0 | Ansible → `k8s_manager` |
+| Объектное хранилище | MinIO 5.4.0 (chart) | Ansible → `minio` |
+| Централизованные логи | Loki 7.0.0 + Grafana Alloy 1.8.2 + Grafana 10.5.15 | Ansible → `logging` |
+| Шифрование Secret'ов | Sealed Secrets 2.16.1 | Ansible → `sealed_secrets` |
+| GitOps | Flux CD (latest stable) | Ansible → `flux` |
+| GitLab Agent | agentk (latest stable) | Ansible → `gitlab_agent` |
+| Автоматизация | Ansible (посм. requirements.yml) | — |
+| CI/CD | GitLab CI/CD | `.gitlab-ci.yml` |
+
+---
+
+## Структура репозитория
+
+```
+.gitlab-ci.yml — CI/CD-пайплайн (стадии validate → setup)
+ansible.cfg — кэширование фактов, pipelining, YAML-вывод
+requirements.yml — коллекции Ansible Galaxy
+
+inventory/prod/
+ hosts.yml — статический список хостов (3 группы)
+ group_vars/
+ all.yml — SSH-учётные данные из переменных окружения
+ k8s_cluster.yml — общие переменные для control_plane + workers
+ control_plane.yml — k8s_version, CIDR, CNI
+ workers.yml — конфигурация дисков Longhorn
+ manager_nodes.yml — версии инструментов, часовой пояс
+ traefik.yml — traefik_port_map: таблица портов и backend-сервисов
+ gpu_workers.yml — node_labels, override дисков Longhorn для GPU-узлов
+
+playbooks/ — один плейбук = одна CI/CD-задача
+ setup_manager.yml
+ setup_control_plane.yml
+ setup_worker_plane.yml
+ setup_longhorn.yml
+ setup_traefik.yml
+ setup_flux.yml
+ setup_gitlab_agent.yml
+ setup_minio.yml
+ setup_logging.yml
+ setup_sealed_secrets.yml
+ setup_gpu.yml
+
+roles/
+ k8s_manager/
+ k8s_control_plane/
+ k8s_worker/
+ longhorn_prereqs/
+ longhorn/ README.md ← стандарт работы с хранилищем
+ traefik/ README.md ← стандарт публикации сервисов
+ flux/ README.md ← стандарт GitOps-деплоя приложений
+ gitlab_agent/
+ minio/ README.md ← стандарт объектного хранилища S3
+ logging/ README.md ← стандарт централизованного сбора логов
+ sealed_secrets/
+ gpu_prereqs/ — nvidia-container-toolkit + containerd runtime на GPU-узле
+ gpu_device_plugin/ — node label + NVIDIA k8s-device-plugin с manager-ноды
+ gpu_model_storage/ README.md ← стандарт локального RAID1-хранилища под модели
+```
+
+---
+
+## Роли и компоненты
+
+### `k8s_control_plane` · `k8s_worker` · `k8s_manager`
+
+Базовые роли развёртывания кластера. Не имеют отдельного role-README — полное описание в разделе [Архитектурные решения](#архитектурные-решения).
+
+| Роль | Что делает | Плейбук |
+|---|---|---|
+| `k8s_control_plane` | containerd → kubelet → kubeadm init → CNI → kubeconfig | `setup_control_plane.yml` |
+| `k8s_worker` | containerd → kubelet → kubeadm join | `setup_worker_plane.yml` |
+| `k8s_manager` | kubectl / helm / k9s, Kubernetes Dashboard | `setup_manager.yml` |
+
+
+Переменные k8s_control_plane
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `k8s_version` | `1.33` | Версия Kubernetes |
+| `pod_network_cidr` | `10.244.0.0/16` | Сеть подов (Flannel) |
+| `service_cidr` | `10.96.0.0/12` | Сеть сервисов |
+| `cni_plugin` | `flannel` | `flannel` или `calico` |
+| `flannel_version` | `v0.26.1` | Версия манифеста Flannel |
+| `calico_version` | `v3.27.0` | Версия манифеста Calico |
+| `kubeconfig_fetch_to_managers` | `true` | Копировать kubeconfig на manager-узел |
+
+
+
+
+Переменные k8s_worker
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `k8s_version` | `1.33` | Должна совпадать с control plane |
+| `worker_join_source_host` | первый хост `control_plane` | Откуда читать join-команду |
+
+
+
+
+Переменные k8s_manager
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `dashboard_insecure` | `false` | HTTP-режим Dashboard (переопределяется в `true` в group_vars) |
+| `dashboard_manifest_url` | GitHub raw v2.7.0 | Без зависимости от CDN |
+| `k8s_manager_upgrade_packages` | `false` | Полное обновление ОС (opt-in) |
+
+
+
+---
+
+### `gpu_prereqs` · `gpu_device_plugin`
+
+Подключение GPU-узла к кластеру. Драйвер NVIDIA ставится вручную заранее — роль его не устанавливает, только проверяет наличие (`nvidia-smi`). Запускается **после** `setup_worker_plane.yml`, отдельным плейбуком `setup_gpu.yml`, только для узлов группы `gpu_workers` (подгруппа `workers`).
+
+| Роль | Запускается на | Что делает |
+|---|---|---|
+| `gpu_prereqs` | `gpu_workers` | Проверка драйвера, установка `nvidia-container-toolkit`, `nvidia-ctk runtime configure` для containerd (default runtime) |
+| `gpu_device_plugin` | `manager_nodes` | `kubectl label node gpu=nvidia` для каждого узла `gpu_workers`; `kubectl apply` манифеста NVIDIA k8s-device-plugin |
+
+
+Переменные gpu_prereqs / gpu_device_plugin
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `nvidia_container_toolkit_version` | `""` (последняя) | Версия пакета `nvidia-container-toolkit` |
+| `node_labels` | `{gpu: nvidia}` (в `gpu_workers.yml`) | Лейблы, навешиваемые на узел после join |
+| `gpu_device_plugin_version` | `v0.17.0` | Тег релиза NVIDIA k8s-device-plugin |
+| `gpu_device_plugin_manifest_url` | GitHub raw для указанной версии | Статический манифест DaemonSet |
+
+
+
+> Порядок обязателен: `setup_worker_plane.yml` перезаписывает `/etc/containerd/config.toml` с нуля, поэтому `gpu_prereqs` (донастройка containerd под nvidia runtime) должна выполняться **после**, отдельным прогоном.
+
+---
+
+### `gpu_model_storage`
+
+Выделенный локальный RAID1-раздел под файлы моделей (Ollama) на GPU-узлах. `gpu_workers` исключены из Longhorn (`longhorn_disks: []`), а корневая ФС слишком маленькая (~30 ГБ) для хранения моделей — поэтому под них нарезается отдельный раздел из неразмеченного места на тех же зеркальных дисках, что и ОС. Запускается плейбуком `setup_gpu_model_storage.yml`, независимо от порядка `kubeadm join`.
+
+
+Переменные gpu_model_storage
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `gpu_model_storage_devices` | `[]` | Диски-члены RAID1 (пусто = роль выключена); задаётся в `gpu_workers.yml` |
+| `gpu_model_storage_partition_size_gb` | `1000` | Размер новой партиции на каждом диске, ГБ |
+| `gpu_model_storage_raid_device` | `/dev/md1` | Имя нового mdadm-массива |
+| `gpu_model_storage_mountpoint` | `/mnt/ollama-models` | Точка монтирования |
+| `gpu_model_storage_fstype` | `xfs` | Файловая система |
+
+
+
+> **Standard**: [roles/gpu_model_storage/README.md](roles/gpu_model_storage/README.md)
+
+---
+
+### `longhorn_prereqs` · `longhorn`
+
+Двухэтапное развёртывание распределённого блочного хранилища.
+
+| Роль | Запускается на | Что делает |
+|---|---|---|
+| `longhorn_prereqs` | `workers` | iSCSI / NFS пакеты, модули ядра, SELinux CIL-политика, форматирование и монтирование дисков XFS |
+| `longhorn` | `manager_nodes` | Аннотации узлов с disk-config JSON, `helm upgrade --install longhorn` |
+
+После установки доступен StorageClass `longhorn` (default). Приложения используют его через `storageClassName: longhorn` в PVC.
+
+
+Переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `longhorn_chart_version` | `1.7.2` | Версия Helm-чарта |
+| `longhorn_namespace` | `longhorn-system` | Namespace |
+| `longhorn_default_replica_count` | `1` | Реплик на том; поднять до 2–3 при добавлении worker-нод |
+| `longhorn_minimal_available_storage_percentage` | `10` | Минимальный свободный % хранилища |
+| `longhorn_storage_over_provisioning_percentage` | `100` | Коэффициент over-provisioning |
+| `longhorn_disks` | `[{device: /dev/sdb, mountpoint: /mnt/longhorn-disk1}, ...]` | Диски для форматирования; переопределяется в hosts.yml |
+
+
+
+> **Стандарт работы с хранилищем** (PVC, StatefulSet, реплики, бэкапы, диагностика):
+> [`roles/longhorn/README.md`](roles/longhorn/README.md)
+
+---
+
+### `traefik`
+
+HTTP/TCP port-proxy. DaemonSet на worker-нодах. Каждый сервис получает выделенный TCP-порт из диапазона 10001–10999. Вся топология определяется через `traefik_port_map` в `inventory/prod/group_vars/traefik.yml`. Поддерживаются два режима маршрутизации: HTTP (`IngressRoute`, поле `protocol` не задаётся) и raw TCP (`IngressRouteTCP` с `HostSNI("*")`, `protocol: tcp` в port_map).
+
+| Задача | Что делает |
+|---|---|
+| `firewall.yml` | Открывает 10000–10999/tcp; помещает `flannel.1`, `cni0` в зону `trusted` |
+| `helm.yml` | `helm upgrade --install traefik` как DaemonSet с EntryPoint-ами из port_map |
+| `middleware.yml` | Применяет `Middleware` CRD (BasicAuth) для записей с `basicauth.enabled: true` |
+| `routes.yml` | Применяет `IngressRoute` (HTTP) или `IngressRouteTCP` (TCP) в зависимости от `protocol` в port_map |
+
+
+Переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `traefik_chart_version` | `32.1.0` | Версия Helm-чарта |
+| `traefik_namespace` | `traefik` | Namespace |
+| `traefik_port_map` | `[]` | Список сервисов; переопределяется в `group_vars/traefik.yml` |
+
+
+
+> **Стандарт публикации сервисов** (добавление порта, BasicAuth, диагностика, таблица активных портов):
+> [`roles/traefik/README.md`](roles/traefik/README.md)
+
+---
+
+### `flux`
+
+Устанавливает Flux CD CLI и выполняет `flux bootstrap gitlab`, подключая кластер к fleet-репозиторию `k8s-fleet` на `gitlab.gigacoms.info`.
+
+| Задача | Что делает |
+|---|---|
+| `install.yml` | Flux CLI → `/usr/local/bin/flux`, bash-completion, `flux check --pre` |
+| `bootstrap.yml` | `flux bootstrap gitlab` — deploy key, Flux-контроллеры в `flux-system`, манифесты в `clusters/production` |
+
+Fleet-репозиторий: `gitlab.gigacoms.info/k8s/k8s-fleet` · путь: `clusters/production`
+
+
+Переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `flux_version` | `""` (latest stable) | Версия Flux CLI |
+| `flux_gitlab_hostname` | `gitlab.gigacoms.info` | Хост GitLab |
+| `flux_gitlab_owner` | `k8s` | GitLab-группа fleet-репозитория |
+| `flux_gitlab_repository` | `k8s-fleet` | Имя fleet-репозитория |
+| `flux_gitlab_branch` | `main` | Ветка |
+| `flux_gitlab_path` | `clusters/production` | Путь внутри репозитория |
+| `flux_gitlab_token` | из `$GITLAB_FLUX_TOKEN` | GitLab PAT (scope: api) |
+
+
+
+> **Стандарт GitOps-деплоя приложений** (структура fleet-repo, multi-branch, примеры с Longhorn и Traefik, CI/CD-шаблон):
+> [`roles/flux/README.md`](roles/flux/README.md)
+
+---
+
+### `minio`
+
+S3-совместимое объектное хранилище на базе MinIO. Запускается как standalone StatefulSet на worker-ноде, хранилище предоставляется отдельным StorageClass `longhorn-minio` (reclaimPolicy: Retain). Доступ — через Traefik на портах 10005 (S3 API) и 10006 (Console UI).
+
+| Задача | Что делает |
+|---|---|
+| `storageclass.yml` | Создаёт StorageClass `longhorn-minio` (Retain, numberOfReplicas=1) |
+| `namespace.yml` | Создаёт namespace `minio` |
+| `helm.yml` | `helm upgrade --install minio minio/minio` с values из шаблона |
+
+**Предварительное условие:** создать Secret `minio-root-credentials` вручную в namespace `minio`:
+```bash
+kubectl create secret generic minio-root-credentials \
+ --from-literal=rootUser=minioadmin \
+ --from-literal=rootPassword='СИЛЬНЫЙ_ПАРОЛЬ' \
+ -n minio
+```
+
+
+Переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `minio_chart_version` | `5.4.0` | Версия Helm-чарта `minio/minio` |
+| `minio_image_tag` | `RELEASE.2025-04-22T22-12-26Z` | Тег образа MinIO |
+| `minio_namespace` | `minio` | Namespace |
+| `minio_storage_class` | `longhorn-minio` | StorageClass для PVC |
+| `minio_storage_size` | `1Ti` | Размер PVC |
+| `minio_console_url` | `http://10.203.0.96:10006` | URL Console (для MINIO_BROWSER_REDIRECT_URL) |
+| `minio_root_secret` | `minio-root-credentials` | Имя Secret с rootUser/rootPassword |
+| `minio_buckets` | `[backups, artifacts]` | Бакеты, создаваемые при старте |
+
+
+
+> **Стандарт объектного хранилища S3** (StorageClass, пользователи, lifecycle, интеграция с Loki, масштабирование):
+> [`roles/minio/README.md`](roles/minio/README.md)
+
+---
+
+### `logging`
+
+PLG-стек централизованного сбора логов: **Loki** (S3 backend → MinIO), **Grafana Alloy** (DaemonSet, замена EOL Promtail), **Grafana** (UI + алерты). Все три компонента разворачиваются в namespace `monitoring` из manager-узла через Helm.
+
+| Задача | Что делает |
+|---|---|
+| `namespace.yml` | Создаёт namespace `monitoring` |
+| `minio-user.yml` | Создаёт bucket `loki-chunks`, пользователя `loki` с политикой только на этот bucket, Secret `loki-minio-secret` |
+| `loki.yml` | `helm upgrade --install loki` в режиме single-binary с S3 backend (MinIO) |
+| `alloy.yml` | `helm upgrade --install alloy` как DaemonSet — сбор логов подов + journald на всех нодах |
+| `grafana.yml` | `helm upgrade --install grafana` с provisioning datasource Loki |
+
+**Предварительное условие:** создать Secret `grafana-admin-secret` вручную в namespace `monitoring`:
+```bash
+kubectl create namespace monitoring
+kubectl create secret generic grafana-admin-secret \
+ --from-literal=admin-user=admin \
+ --from-literal=admin-password='СИЛЬНЫЙ_ПАРОЛЬ' \
+ -n monitoring
+```
+
+
+Переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `loki_chart_version` | `7.0.0` | Версия Helm-чарта `grafana/loki` |
+| `alloy_chart_version` | `1.8.2` | Версия Helm-чарта `grafana/alloy` |
+| `grafana_chart_version` | `10.5.15` | Версия Helm-чарта `grafana/grafana` |
+| `loki_minio_endpoint` | `http://minio.minio.svc.cluster.local:9000` | S3 endpoint для Loki |
+| `loki_minio_bucket` | `loki-chunks` | Bucket для хранения чанков и индексов |
+| `loki_retention_days` | `31` | Срок хранения логов в днях |
+| `loki_wal_storage_size` | `10Gi` | PVC для WAL/temp Loki (не логи — они в MinIO) |
+| `grafana_storage_size` | `5Gi` | PVC для базы данных Grafana |
+| `grafana_root_url` | `http://10.203.0.96:10003` | Внешний URL Grafana (Traefik) |
+| `grafana_admin_secret` | `grafana-admin-secret` | Имя Secret с admin-user/admin-password |
+
+
+
+> **Стандарт централизованного сбора логов** (архитектура PLG, масштабирование, переход на Graylog, диагностика):
+> [`roles/logging/README.md`](roles/logging/README.md)
+
+---
+
+### `sealed_secrets`
+
+Разворачивает [Sealed Secrets](https://github.com/bitnami-labs/sealed-secrets) — контроллер для безопасного хранения зашифрованных Secret'ов в Git. Запускается из manager-узла: контроллер — в `kube-system` через Helm, CLI `kubeseal` — на manager-узле.
+
+| Задача | Что делает |
+|---|---|
+| `cli.yml` | Скачивает `kubeseal` с GitHub Releases (версия из переменной или latest), идемпотентно |
+| `helm.yml` | `helm upgrade --install sealed-secrets` из `bitnami-labs/sealed-secrets` в `kube-system` |
+
+**Использование после установки:**
+```bash
+# Зашифровать Secret
+kubectl create secret generic my-secret --from-literal=password=mysecret --dry-run=client -o yaml \
+ | kubeseal --format yaml > my-sealedsecret.yaml
+
+# Применить (можно коммитить в Git — зашифрован публичным ключом контроллера)
+kubectl apply -f my-sealedsecret.yaml
+```
+
+
+Переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `sealed_secrets_chart_version` | `2.16.1` | Версия Helm-чарта |
+| `sealed_secrets_namespace` | `kube-system` | Namespace контроллера |
+| `sealed_secrets_cli_version` | `""` (latest) | Версия kubeseal CLI; пустая — берёт latest с GitHub |
+
+
+
+---
+
+### `gitlab_agent`
+
+Устанавливает GitLab Agent for Kubernetes (agentk). Работает совместно с Flux: Flux деплоит, agentk даёт видимость в GitLab UI (Operate → Kubernetes clusters).
+
+Agentk инициирует исходящее WebSocket-соединение к KAS — входящих портов не требует.
+
+| Задача | Что делает |
+|---|---|
+| `config.yml` | Клонирует fleet-repo, создаёт `.gitlab/agents/production/config.yaml`, коммитит и пушит при изменении |
+| `helm.yml` | `helm upgrade --install gitlab-agent` с токеном и KAS-адресом |
+
+**Предварительное условие:** зарегистрировать агент в GitLab UI → Operate → Kubernetes clusters → Connect a cluster → имя `production` → токен в CI/CD переменную `GITLAB_AGENT_TOKEN`.
+
+
+Переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `gitlab_agent_hostname` | `gitlab.gigacoms.info` | Хост GitLab |
+| `gitlab_agent_name` | `production` | Имя агента (совпадает с именем в GitLab UI) |
+| `gitlab_agent_namespace` | `gitlab-agent` | Namespace |
+| `gitlab_agent_kas_address` | `wss://gitlab.gigacoms.info/-/kubernetes-agent/` | KAS WebSocket-адрес |
+| `gitlab_agent_token` | из `$GITLAB_AGENT_TOKEN` | Токен агента |
+| `gitlab_agent_ci_access_group` | `k8s` | GitLab-группа с доступом к кластеру через CI |
+
+
+
+---
+
+## CI/CD-пайплайн
+
+Стадии: **validate** → **setup**
+
+| Задача | Триггер | Описание |
+|---|---|---|
+| `validate/lint` | каждый push / MR | `ansible-lint` всех плейбуков и ролей |
+| `validate/syntax-check` | каждый push / MR | Синтаксическая проверка всех плейбуков |
+| `setup/manager_nodes` | ручной, ветка `main` | `setup_manager.yml` |
+| `setup/control_plane` | ручной, ветка `main` | `setup_control_plane.yml` |
+| `setup/workers` | ручной, ветка `main` | `setup_worker_plane.yml` |
+| `setup/longhorn` | ручной, ветка `main` | `setup_longhorn.yml` |
+| `setup/traefik` | ручной, ветка `main` | `setup_traefik.yml` |
+| `setup/flux` | ручной, ветка `main` | `setup_flux.yml` |
+| `setup/gitlab_agent` | ручной, ветка `main` | `setup_gitlab_agent.yml` |
+| `setup/minio` | ручной, ветка `main` | `setup_minio.yml` |
+| `setup/logging` | ручной, ветка `main` | `setup_logging.yml` |
+| `setup/sealed_secrets` | ручной, ветка `main` | `setup_sealed_secrets.yml` |
+| `setup/gpu_workers` | ручной, ветка `main` | `setup_gpu.yml` |
+| `setup/gpu_model_storage` | ручной, ветка `main` | `setup_gpu_model_storage.yml` |
+
+`resource_group: production` — все setup-задачи сериализованы, одновременно выполняется только одна.
+
+### Переменные GitLab CI/CD (Settings → CI/CD → Variables)
+
+| Переменная | Описание |
+|---|---|
+| `ANSIBLE_SSH_USER` | SSH-пользователь для целевых серверов |
+| `ANSIBLE_SSHKEY_ID_RSA` | Содержимое приватного SSH-ключа (тип: File) |
+| `ANSIBLE_BECOME_PASS` | Пароль sudo |
+| `GITLAB_FLUX_TOKEN` | GitLab PAT (scope: api) для Flux bootstrap и клонирования fleet-repo |
+| `GITLAB_AGENT_TOKEN` | Токен GitLab Agent (получить в GitLab → Operate → Kubernetes clusters) |
+| `MINIO_ROOT_PASSWORD` | Пароль root-пользователя MinIO (используется при ручном создании Secret) |
+| `LOKI_MINIO_PASSWORD` | Пароль пользователя `loki` в MinIO (создаётся Ansible при запуске setup_logging) |
+| `GRAFANA_ADMIN_PASSWORD` | Пароль администратора Grafana (задаётся в Secret `grafana-admin-secret` вручную) |
+
+---
+
+## Порядок развёртывания
+
+Для нового кластера — строго в следующем порядке:
+
+```
+1. setup:manager_nodes — инструменты оператора
+2. setup:control_plane — Kubernetes, CNI, Dashboard
+3. setup:workers — подключение worker-нод
+4. setup:longhorn — хранилище (prereqs на workers → Helm с manager)
+5. setup:traefik — порт-прокси (firewall на workers → Helm с manager)
+6. setup:minio — объектное хранилище S3 (StorageClass + Helm с manager)
+7. setup:flux — GitOps bootstrap
+8. setup:gitlab_agent — видимость кластера в GitLab UI
+9. setup:logging — PLG-стек (Loki + Alloy + Grafana)
+10. setup:sealed_secrets — контроллер Sealed Secrets + kubeseal CLI
+11. setup:gpu_workers — только для узлов gpu_workers, после их setup:workers
+```
+
+> **Перед запуском setup:minio** создать Secret вручную: `kubectl create secret generic minio-root-credentials --from-literal=rootUser=minioadmin --from-literal=rootPassword='...' -n minio`
+
+> **Перед запуском setup:logging** создать Secret вручную: `kubectl create namespace monitoring && kubectl create secret generic grafana-admin-secret --from-literal=admin-user=admin --from-literal=admin-password='...' -n monitoring`
+
+---
+
+## Архитектурные решения
+
+### kubeadm: version derive at runtime
+`kubernetesVersion` в конфиге kubeadm определяется из `kubeadm version -o short` в runtime. Исключает ошибки несоответствия версий при изменении пакета без изменения переменной.
+
+### single-node: снятие control-plane taint
+Taint `node-role.kubernetes.io/control-plane:NoSchedule` снимается автоматически. Dashboard и прочие workload'ы размещаются на master-узле при single-node-конфигурации.
+
+### kubeadm join: ежезапускная генерация токена
+Join-токены истекают через 24 часа. Роль `k8s_worker` генерирует свежий токен на control plane при каждом запуске; шаг join идемпотентен — пропускается, если узел уже в кластере.
+
+### Longhorn: монтирование до аннотации
+Диски форматируются как XFS и монтируются по UUID (`nofail`) **до** установки Longhorn. Longhorn обнаруживает диски через аннотации узлов — не через автоопределение. Это предотвращает переформатирование при повторном запуске плейбука.
+
+### Traefik: нет hostname, нет TLS
+Каждый сервис получает выделенный порт (10001–10999). Нет hostname-routing, нет TLS — клиент подключается по `http://:`. TLS терминируется на уровне Traefik при необходимости. Dashboard работает в HTTP-режиме (`dashboard_insecure: true`).
+
+### Flux + agentk: разделение ответственности
+Ansible управляет **ОС и кластером** (установка, конфигурация нод). Flux управляет **приложениями внутри кластера** (деплой, обновление образов). agentk обеспечивает **видимость** состояния кластера в GitLab UI.
+
+### GPU-узлы: отдельный плейбук, вручную ставится только драйвер
+`gpu_workers` — подгруппа `workers`. NVIDIA-драйвер ставится на сервер вручную и не управляется Ansible (роль `gpu_prereqs` лишь проверяет `nvidia-smi` и падает, если драйвера нет). `setup_gpu.yml` запускается отдельно и **после** `setup_worker_plane.yml`, так как роль `k8s_worker` перегенерирует `/etc/containerd/config.toml` с нуля и стёрла бы настройку nvidia runtime, если бы порядок был обратным. GPU-узлы исключены из дефолтной конфигурации Longhorn (`longhorn_disks: []` в `gpu_workers.yml`) — это compute-нода, не storage-нода.
+
+### Firewalld и CNI-интерфейсы
+
+> **Критично.** `flannel.1` и `cni0` должны быть в зоне `trusted` на каждом узле кластера.
+
+Без этого pod-to-pod-трафик от workers, приходящий на master, обрабатывается зоной `internal` — она пропускает только явно перечисленные порты. Любой порт нового сервиса молча отбрасывается, даже если порт открыт на уровне хоста.
+
+При добавлении любой роли с `firewalld` — обязательно добавлять:
+
+```yaml
+- name: Firewall | Trust CNI interfaces
+ ansible.posix.firewalld:
+ zone: trusted
+ interface: "{{ item }}"
+ permanent: true
+ state: enabled
+ loop:
+ - flannel.1
+ - cni0
+```
+
+---
+
+## Локальная разработка
+
+```bash
+# Установка коллекций Ansible Galaxy
+ansible-galaxy collection install -r requirements.yml -p collections/ --force
+
+# Проверка синтаксиса
+ansible-playbook --syntax-check -i inventory/prod playbooks/setup_manager.yml
+
+# Линтинг
+ansible-lint playbooks/ roles/
+
+# Dry-run (режим check + diff)
+ansible-playbook -i inventory/prod playbooks/setup_control_plane.yml --check --diff
+
+# Запуск только на одном хосте
+ansible-playbook -i inventory/prod playbooks/setup_worker_plane.yml --limit k8s-worker-01
+```
+
+---
+
+## Добавление нового компонента
+
+1. Добавить хост в `inventory/prod/hosts.yml` (если новая нода)
+2. Создать `inventory/prod/group_vars/.yml` (если новая группа)
+3. Создать роль `roles//` со стандартной структурой
+4. Создать `roles//README.md` — подробный корп. стандарт использования компонента
+5. Создать `playbooks/.yml`
+6. Добавить CI/CD-задачу в `.gitlab-ci.yml` (`stage: setup`, `resource_group: production`)
+7. Обновить **этот README**: краткое описание роли + ссылка на `roles//README.md`
+8. Обновить `CLAUDE.md`: добавить роль в структуру репозитория и ключевые решения
diff --git a/ansible.cfg b/ansible.cfg
new file mode 100644
index 0000000..d734ebf
--- /dev/null
+++ b/ansible.cfg
@@ -0,0 +1,17 @@
+[defaults]
+inventory = inventory/prod
+roles_path = roles
+collections_path = collections
+filter_plugins = filter_plugins
+host_key_checking = False
+stdout_callback = ansible.builtin.default
+result_format = yaml
+gathering = smart
+fact_caching = jsonfile
+fact_caching_connection = /tmp/ansible_facts_cache
+fact_caching_timeout = 86400
+retry_files_enabled = False
+
+[ssh_connection]
+pipelining = True
+ssh_args = -o ControlMaster=auto -o ControlPersist=300s
diff --git a/inventory/prod/group_vars/all.yml b/inventory/prod/group_vars/all.yml
new file mode 100644
index 0000000..e392191
--- /dev/null
+++ b/inventory/prod/group_vars/all.yml
@@ -0,0 +1,5 @@
+---
+ansible_user: "{{ lookup('env', 'ANSIBLE_SSH_USER') | default('ansible') }}"
+ansible_become: true
+ansible_become_method: sudo
+ansible_become_pass: "{{ lookup('env', 'ANSIBLE_BECOME_PASS') | default(omit) }}"
diff --git a/inventory/prod/group_vars/control_plane.yml b/inventory/prod/group_vars/control_plane.yml
new file mode 100644
index 0000000..80c70ae
--- /dev/null
+++ b/inventory/prod/group_vars/control_plane.yml
@@ -0,0 +1,4 @@
+---
+k8s_version: "1.33"
+cni_plugin: "flannel"
+kubeconfig_fetch_to_managers: true
diff --git a/inventory/prod/group_vars/gpu_workers.yml b/inventory/prod/group_vars/gpu_workers.yml
new file mode 100644
index 0000000..282e886
--- /dev/null
+++ b/inventory/prod/group_vars/gpu_workers.yml
@@ -0,0 +1,26 @@
+---
+# Settings specific to workers with an attached NVIDIA GPU.
+# This group is a child of `workers` — it inherits workers.yml and only
+# overrides/adds what differs for a GPU node.
+
+# GPU nodes are not Longhorn storage nodes — override the workers.yml default
+# (which assumes /dev/sdb + /dev/sdc are present) so setup_longhorn.yml does
+# not try to format disks that don't exist in that layout.
+longhorn_disks: []
+
+# Applied to the node object in Kubernetes by roles/gpu_device_plugin after
+# kubeadm join, so ollama/other GPU workloads can target it via nodeSelector.
+node_labels:
+ gpu: nvidia
+
+# nvidia-container-toolkit package version (empty = latest)
+nvidia_container_toolkit_version: ""
+
+# Dedicated local RAID1 storage for Ollama model files (see roles/gpu_model_storage).
+# Carved out of otherwise-unpartitioned space on the same mirrored disks used
+# for the OS (sda/sdb have ~3.6TB free beyond the ~30GB OS RAID1 partition) —
+# model data must NOT go on the root filesystem, it's far too small.
+gpu_model_storage_devices:
+ - /dev/sda
+ - /dev/sdb
+gpu_model_storage_partition_size_gb: 1000
diff --git a/inventory/prod/group_vars/k8s_cluster.yml b/inventory/prod/group_vars/k8s_cluster.yml
new file mode 100644
index 0000000..8cd2710
--- /dev/null
+++ b/inventory/prod/group_vars/k8s_cluster.yml
@@ -0,0 +1,7 @@
+---
+# Variables shared across all cluster nodes (control_plane + workers)
+
+# Pod and service CIDRs — must match kubeadm config in control_plane role.
+# Defined here so both control_plane and worker firewall rules can reference them.
+pod_network_cidr: "10.244.0.0/16"
+service_cidr: "10.96.0.0/12"
diff --git a/inventory/prod/group_vars/manager_nodes.yml b/inventory/prod/group_vars/manager_nodes.yml
new file mode 100644
index 0000000..2a5ec43
--- /dev/null
+++ b/inventory/prod/group_vars/manager_nodes.yml
@@ -0,0 +1,32 @@
+---
+# Kubernetes management node settings
+
+# GitLab Agent: additional projects allowed to use the agent via CI/CD
+gitlab_agent_ci_access_projects:
+ - it-dept/billing-mobile
+dashboard_insecure: true
+
+k8s_manager_timezone: "Europe/Moscow"
+
+# kubectl version (empty = latest stable)
+kubectl_version: ""
+
+# Helm version (empty = latest stable)
+helm_version: ""
+
+# k9s version (empty = latest stable)
+k9s_version: ""
+
+# Additional tools to install
+k8s_manager_extra_packages:
+ - bash-completion
+ - curl
+ - wget
+ - git
+ - vim
+ - htop
+ - net-tools
+ - bind-utils
+ - jq
+ - python3
+ - python3-pip
diff --git a/inventory/prod/group_vars/traefik.yml b/inventory/prod/group_vars/traefik.yml
new file mode 100644
index 0000000..9901b25
--- /dev/null
+++ b/inventory/prod/group_vars/traefik.yml
@@ -0,0 +1,146 @@
+---
+
+traefik_port_map:
+
+ - name: k8s-dashboard
+ description: "Kubernetes Dashboard"
+ port: 10001
+ backend:
+ namespace: kubernetes-dashboard
+ service: kubernetes-dashboard
+ port: 443
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: grafana
+ description: "Grafana (Logs & Metrics)"
+ port: 10003
+ backend:
+ namespace: monitoring
+ service: grafana
+ port: 80
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: longhorn-ui
+ description: "Longhorn Storage UI"
+ port: 10002
+ backend:
+ namespace: longhorn-system
+ service: longhorn-frontend
+ port: 80
+ scheme: http
+ basicauth:
+ enabled: true
+ secret_name: traefik-auth-longhorn
+
+ - name: minio-api
+ description: "MinIO S3 API"
+ port: 10005
+ backend:
+ namespace: minio
+ service: minio
+ port: 9000
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: minio-console
+ description: "MinIO Console (Web UI)"
+ port: 10006
+ backend:
+ namespace: minio
+ service: minio-console
+ port: 9001
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: billing-backend
+ description: "Mobile Billing Backend API"
+ port: 10100
+ backend:
+ namespace: gigacom-billing-mobile
+ service: billing-backend
+ port: 8080
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: bgbilling
+ description: "BGBillingServer HTTP (web UI, API)"
+ port: 10101
+ backend:
+ namespace: bgbilling-dev
+ service: bgbilling
+ port: 8080
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: bgbilling-https
+ description: "BGBillingServer HTTPS (Tomcat TLS, .keystore)"
+ port: 10102
+ backend:
+ namespace: bgbilling-dev
+ service: bgbilling
+ port: 8443
+ scheme: https
+ basicauth:
+ enabled: false
+
+ - name: bgbilling-db
+ description: "MariaDB for BGBilling (TCP)"
+ protocol: tcp
+ port: 10103
+ backend:
+ namespace: bgbilling-dev
+ service: db
+ port: 3306
+
+ - name: activemq-web
+ description: "ActiveMQ Web Console"
+ port: 10104
+ backend:
+ namespace: bgbilling-dev
+ service: activemq
+ port: 8161
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: ollama-proxy
+ description: "Ollama Proxy API (k8s_ai)"
+ port: 10200
+ backend:
+ namespace: ai
+ service: ollama-proxy
+ port: 8080
+ scheme: http
+ basicauth:
+ enabled: false
+
+ - name: open-webui
+ description: "Open WebUI (k8s_ai)"
+ port: 10201
+ backend:
+ namespace: ai
+ service: open-webui
+ port: 8080
+ scheme: http
+ basicauth:
+ enabled: false
+
+ # Template for future services:
+ # - name: my-service
+ # description: "Human-readable description"
+ # port: 10007
+ # backend:
+ # namespace: my-namespace
+ # service: my-service-name
+ # port: 8080
+ # scheme: http
+ # basicauth:
+ # enabled: false
diff --git a/inventory/prod/group_vars/workers.yml b/inventory/prod/group_vars/workers.yml
new file mode 100644
index 0000000..3fb3258
--- /dev/null
+++ b/inventory/prod/group_vars/workers.yml
@@ -0,0 +1,12 @@
+---
+# Kubernetes worker (compute) node settings
+
+# node_labels: {}
+# node_taints: []
+
+# Disks dedicated to Longhorn storage (override per host in hosts.yml if needed)
+longhorn_disks:
+ - device: /dev/sdb
+ mountpoint: /mnt/longhorn-disk1
+ - device: /dev/sdc
+ mountpoint: /mnt/longhorn-disk2
diff --git a/inventory/prod/hosts.yml b/inventory/prod/hosts.yml
new file mode 100644
index 0000000..0793a39
--- /dev/null
+++ b/inventory/prod/hosts.yml
@@ -0,0 +1,26 @@
+---
+all:
+ children:
+ # Operator workstation: kubectl, helm, k9s
+ manager_nodes:
+ hosts:
+ k8s-manager-01:
+ ansible_host: 10.203.0.92
+
+ # Kubernetes cluster nodes
+ k8s_cluster:
+ children:
+ control_plane:
+ hosts:
+ k8s-master-01:
+ ansible_host: 10.203.0.97
+ workers:
+ hosts:
+ k8s-worker-01:
+ ansible_host: 10.203.0.96
+ children:
+ # Workers with an NVIDIA GPU attached (device plugin + node label)
+ gpu_workers:
+ hosts:
+ k8s-worker-02:
+ ansible_host: 10.203.0.212
diff --git a/playbooks/setup_control_plane.yml b/playbooks/setup_control_plane.yml
new file mode 100644
index 0000000..5e2a36a
--- /dev/null
+++ b/playbooks/setup_control_plane.yml
@@ -0,0 +1,6 @@
+---
+- name: Setup Kubernetes control plane
+ hosts: control_plane
+ gather_facts: true
+ roles:
+ - k8s_control_plane
diff --git a/playbooks/setup_flux.yml b/playbooks/setup_flux.yml
new file mode 100644
index 0000000..29a5079
--- /dev/null
+++ b/playbooks/setup_flux.yml
@@ -0,0 +1,5 @@
+---
+- name: Flux | Install CLI and bootstrap with GitLab
+ hosts: manager_nodes
+ roles:
+ - flux
diff --git a/playbooks/setup_gbm_dev_access.yml b/playbooks/setup_gbm_dev_access.yml
new file mode 100644
index 0000000..8ca2a68
--- /dev/null
+++ b/playbooks/setup_gbm_dev_access.yml
@@ -0,0 +1,6 @@
+---
+- name: Setup developer access token for namespace
+ hosts: manager_nodes
+ gather_facts: false
+ roles:
+ - role: k8s_gbm_dev
diff --git a/playbooks/setup_gitlab_agent.yml b/playbooks/setup_gitlab_agent.yml
new file mode 100644
index 0000000..1d123cb
--- /dev/null
+++ b/playbooks/setup_gitlab_agent.yml
@@ -0,0 +1,5 @@
+---
+- name: GitLab Agent | Push agent config to fleet repo and install agentk
+ hosts: manager_nodes
+ roles:
+ - gitlab_agent
diff --git a/playbooks/setup_gpu.yml b/playbooks/setup_gpu.yml
new file mode 100644
index 0000000..41fc3b8
--- /dev/null
+++ b/playbooks/setup_gpu.yml
@@ -0,0 +1,13 @@
+---
+# Run AFTER setup_worker_plane.yml (needs kubeadm join + containerd already done)
+# and after the target node has joined the cluster.
+- name: GPU | Configure container runtime on GPU worker nodes
+ hosts: gpu_workers
+ become: true
+ roles:
+ - gpu_prereqs
+
+- name: GPU | Label nodes and install NVIDIA device plugin
+ hosts: manager_nodes
+ roles:
+ - gpu_device_plugin
diff --git a/playbooks/setup_gpu_model_storage.yml b/playbooks/setup_gpu_model_storage.yml
new file mode 100644
index 0000000..c26cb37
--- /dev/null
+++ b/playbooks/setup_gpu_model_storage.yml
@@ -0,0 +1,10 @@
+---
+# Provisions a dedicated local RAID1 partition for large model files (Ollama)
+# on GPU worker nodes, carved out of unpartitioned space on the same mirrored
+# OS disks. Run any time after the node's disks are in place; independent of
+# kubeadm join order.
+- name: GPU | Provision dedicated RAID1 storage for model files
+ hosts: gpu_workers
+ become: true
+ roles:
+ - gpu_model_storage
diff --git a/playbooks/setup_logging.yml b/playbooks/setup_logging.yml
new file mode 100644
index 0000000..6c6c6bb
--- /dev/null
+++ b/playbooks/setup_logging.yml
@@ -0,0 +1,23 @@
+---
+# Prerequisite (create manually before running):
+# kubectl create namespace monitoring
+# kubectl create secret generic grafana-admin-secret \
+# --from-literal=admin-user=admin \
+# --from-literal=admin-password='YOUR_PASSWORD' \
+# -n monitoring
+#
+# Required CI/CD variables:
+# LOKI_MINIO_PASSWORD — password for the loki MinIO user (created by this playbook)
+# GRAFANA_ADMIN_PASSWORD — set in grafana-admin-secret above (not read by Ansible)
+- name: Deploy PLG logging stack (Loki + Alloy + Grafana)
+ hosts: manager_nodes
+ become: false
+ vars:
+ loki_minio_password: "{{ lookup('env', 'LOKI_MINIO_PASSWORD') }}"
+ pre_tasks:
+ - name: Preflight | Assert LOKI_MINIO_PASSWORD is set
+ ansible.builtin.assert:
+ that: loki_minio_password | length > 0
+ fail_msg: "LOKI_MINIO_PASSWORD env var must be set"
+ roles:
+ - logging
diff --git a/playbooks/setup_longhorn.yml b/playbooks/setup_longhorn.yml
new file mode 100644
index 0000000..839e59d
--- /dev/null
+++ b/playbooks/setup_longhorn.yml
@@ -0,0 +1,11 @@
+---
+- name: Longhorn | Prepare cluster nodes (OS-level)
+ hosts: k8s_cluster
+ become: true
+ roles:
+ - longhorn_prereqs
+
+- name: Longhorn | Annotate nodes and install via Helm
+ hosts: manager_nodes
+ roles:
+ - longhorn
diff --git a/playbooks/setup_manager.yml b/playbooks/setup_manager.yml
new file mode 100644
index 0000000..3c944d0
--- /dev/null
+++ b/playbooks/setup_manager.yml
@@ -0,0 +1,6 @@
+---
+- name: Setup Kubernetes manager nodes
+ hosts: manager_nodes
+ gather_facts: true
+ roles:
+ - k8s_manager
diff --git a/playbooks/setup_minio.yml b/playbooks/setup_minio.yml
new file mode 100644
index 0000000..a131d8c
--- /dev/null
+++ b/playbooks/setup_minio.yml
@@ -0,0 +1,6 @@
+---
+- name: MinIO | Deploy object storage
+ hosts: manager_nodes
+ become: false
+ roles:
+ - minio
diff --git a/playbooks/setup_sealed_secrets.yml b/playbooks/setup_sealed_secrets.yml
new file mode 100644
index 0000000..3c861a6
--- /dev/null
+++ b/playbooks/setup_sealed_secrets.yml
@@ -0,0 +1,6 @@
+---
+- name: Deploy Sealed Secrets (kubeseal controller + CLI)
+ hosts: manager_nodes
+ become: true
+ roles:
+ - sealed_secrets
diff --git a/playbooks/setup_traefik.yml b/playbooks/setup_traefik.yml
new file mode 100644
index 0000000..86ab73e
--- /dev/null
+++ b/playbooks/setup_traefik.yml
@@ -0,0 +1,29 @@
+---
+- name: Traefik | Open firewall ports on workers
+ hosts: workers
+ become: true
+ tasks:
+ - name: Firewall | Open Traefik service port range
+ ansible.posix.firewalld:
+ port: "10000-10999/tcp"
+ permanent: true
+ state: enabled
+ immediate: true
+
+ - name: Firewall | Trust CNI interfaces
+ ansible.posix.firewalld:
+ zone: trusted
+ interface: "{{ item }}"
+ permanent: true
+ state: enabled
+ immediate: true
+ loop:
+ - flannel.1
+ - cni0
+
+- name: Traefik | Install via Helm and configure routes
+ hosts: manager_nodes
+ vars_files:
+ - "{{ playbook_dir }}/../inventory/prod/group_vars/traefik.yml"
+ roles:
+ - traefik
diff --git a/playbooks/setup_worker_plane.yml b/playbooks/setup_worker_plane.yml
new file mode 100644
index 0000000..bc8610a
--- /dev/null
+++ b/playbooks/setup_worker_plane.yml
@@ -0,0 +1,6 @@
+---
+- name: Setup Kubernetes worker nodes
+ hosts: workers
+ gather_facts: true
+ roles:
+ - k8s_worker
diff --git a/requirements.yml b/requirements.yml
new file mode 100644
index 0000000..97b4bbf
--- /dev/null
+++ b/requirements.yml
@@ -0,0 +1,8 @@
+---
+collections:
+ - name: ansible.posix
+ version: ">=1.5.0"
+ - name: community.general
+ version: ">=9.0.0"
+ - name: community.crypto
+ version: ">=2.10.0"
diff --git a/research/apps/hd-portal.md b/research/apps/hd-portal.md
new file mode 100644
index 0000000..7e651bb
--- /dev/null
+++ b/research/apps/hd-portal.md
@@ -0,0 +1,496 @@
+# Перенос HD Portal (device-diagnostic) в Kubernetes
+
+**Репозиторий:** `it-dept/device-diagnostic` на `gitlab.gigacoms.info`
+**Публичное название:** HD Portal
+**Текущая платформа:** Docker Swarm (нода `sandbox`)
+**Целевая платформа:** Kubernetes (кластер `k8s_infrastructure`)
+
+---
+
+## Что делает приложение
+
+HD Portal — внутренний веб-портал для инженеров техподдержки. Позволяет:
+
+1. **Найти абонента** по номеру договора через REST API BGBilling (`bill.gigacoms.ru`)
+2. **Подключиться к сетевому оборудованию** абонента по SSH или Telnet и выполнить диагностические команды
+3. **Сохранить результаты** диагностики в PostgreSQL
+4. **Создать заявку** в BGERP/BGCRM (`hd.gigacoms.ru`) с описанием проблемы
+5. Веб-интерфейс (Thymeleaf + Bootstrap) + REST API с API-Key аутентификацией (Swagger UI)
+
+### Поддерживаемое оборудование
+
+| Тип | Коды из биллинга | Протокол |
+|---|---|---|
+| BDCom | 14, 16, 119, 122 | SSH/Telnet |
+| DLink | 9, 49 | SSH/Telnet |
+| SNR | 17, 25 | SSH/Telnet |
+| TPLink | 65, 70 | SSH/Telnet |
+| ZTE EPON | 32 | SSH/Telnet |
+| ZTE GPON | 107 | SSH/Telnet |
+| СКАТ (BRAS) | 0 | SSH/Telnet |
+
+---
+
+## Технический стек
+
+| Компонент | Версия |
+|---|---|
+| Java | 17 |
+| Spring Boot | 3.2.4 |
+| Spring Security | form login + API key filter |
+| Thymeleaf | шаблонизатор UI |
+| Flyway | миграции схемы БД (9 миграций) |
+| PostgreSQL | 15 (своя БД приложения) |
+| MariaDB 10.2 | внешняя БД BGERP (read-only: пользователи, группы) |
+| sshj 0.35.0 | SSH-подключения к оборудованию |
+| commons-net 3.9.0 | Telnet-подключения |
+| springdoc-openapi 2.5.0 | Swagger UI (`/swagger-ui.html`) |
+| образ | `eclipse-temurin:17-jre-alpine` |
+
+---
+
+## Текущая архитектура (Docker Swarm)
+
+```
+Swarm (нода: sandbox, 10.203.0.211)
+│
+├── Service: diagnostic-app (replicas: 1)
+│ ├── Image: reg.gitlab.gigacoms.info/it-dept/device-diagnostic:latest
+│ ├── Port: 8181 → 8080 (ingress mode)
+│ ├── Network: diagnostic-network + nginx-proxy_nginx-network
+│ ├── JVM: -Xmx512m -Xms256m
+│ └── Env: APP_DATASOURCE_*, BGERP_DATASOURCE_*, BGERP_API_*
+│
+└── Service: diagnostic-db (replicas: 1)
+ ├── Image: postgres:15-alpine
+ ├── Volume: /mnt/swarm_quorum/diagnostic/postgresql/data → /var/lib/postgresql/data
+ └── Network: diagnostic-network
+```
+
+**Внешние зависимости приложения:**
+
+| Система | Адрес | Тип | Назначение |
+|---|---|---|---|
+| BGBilling REST API | `https://bill.gigacoms.ru/bgbilling/...` | HTTPS | Данные по договору абонента |
+| BGERP REST API | `https://hd.gigacoms.ru/` | HTTPS | Создание заявок, сообщений |
+| BGERP MariaDB | `10.203.0.221:3306/bgcrm` | TCP | Аутентификация пользователей |
+| Сетевые устройства | LAN `10.x.x.x` | SSH/Telnet | Диагностические команды |
+
+---
+
+## Ключевые наблюдения для миграции
+
+### 1. Приложение stateless — подходит для Deployment
+
+Само приложение не хранит состояния между запросами. HTTP-сессии in-memory (стандарт Spring).
+Единственное состояние — PostgreSQL. Подходит для `Deployment` с 1 репликой (масштабирование возможно при наличии shared session store, но не нужно сейчас).
+
+### 2. PostgreSQL — StatefulSet с Longhorn PVC
+
+Текущий bind-mount `/mnt/swarm_quorum/diagnostic/postgresql/data` → `PersistentVolumeClaim` на Longhorn. Данные переносятся стандартным `pg_dump` / `pg_restore`.
+
+### 3. Credentials hardcoded в коде — блокер
+
+В `ContractInfoService.java` жёстко прописаны:
+```java
+private String apiUrl = "https://bill.gigacoms.ru/bgbilling/aiDmUnxl6KC8R1ELG4QJDPDm3IPn6V";
+private String apiUser = "sbersalute";
+private String apiPassword = "ULW2KmPd6bqc";
+```
+
+Перед миграцией **обязательно** перенести эти значения в env-переменные (`BGBILLING_API_URL`, `BGBILLING_API_USER`, `BGBILLING_API_PASSWORD`) через `@Value` и `application.properties`. Без этого K8s Secret не поможет.
+
+### 4. Пароли пользователей хранятся открытым текстом
+
+`SecurityConfig` использует `NoOpPasswordEncoder`. Пользователи логинятся с паролями из BGERP MariaDB. Это работает, но является техническим долгом. Не блокирует миграцию.
+
+### 5. Сетевой доступ к оборудованию LAN
+
+Приложение SSH/Telnet-ается к коммутаторам и BRAS во внутренней сети (`10.x.x.x`). K8s-воркер (`10.203.0.96`) находится в той же сети — дополнительных маршрутов не нужно. **Важно:** flannel-интерфейсы (`flannel.1`, `cni0`) должны быть в `trusted` зоне firewalld на мастере и воркере (это уже обеспечено существующей инфраструктурой).
+
+### 6. Nginx → Traefik
+
+Сейчас приложение за Nginx reverse proxy (Swarm network `nginx-proxy_nginx-network`). В K8s — Traefik IngressRoute. UI и API требуют аутентификации, поэтому BasicAuth на уровне Traefik не нужен (приложение само управляет доступом).
+
+### 7. CI: этап `mkdir` через SSH становится не нужным
+
+Шаг `mkdir` в CI создаёт директорию `/mnt/swarm_quorum/diagnostic` на Swarm-хосте по SSH. В K8s PVC создаётся декларативно. Этап `mkdir` из `.gitlab-ci.yml` нужно убрать или заменить на `kubectl apply`.
+
+---
+
+## Целевая архитектура в Kubernetes
+
+```
+Namespace: hd-portal
+│
+├── Secret: hd-portal-env
+│ └── все переменные окружения приложения
+│
+├── Secret: hd-portal-registry-pull (docker-registry)
+│
+├── PersistentVolumeClaim: hd-portal-postgres-data
+│ └── Longhorn, RWO, 5Gi
+│
+├── StatefulSet: hd-portal-postgres
+│ ├── Image: postgres:15-alpine
+│ └── Volume: hd-portal-postgres-data → /var/lib/postgresql/data
+│
+├── Service: hd-portal-postgres (ClusterIP, 5432)
+│
+├── Deployment: hd-portal
+│ ├── Image: reg.gitlab.gigacoms.info/it-dept/device-diagnostic:latest
+│ ├── replicas: 1
+│ ├── resources: requests 256Mi/200m, limits 768Mi/1
+│ └── Env from Secret: hd-portal-env
+│
+├── Service: hd-portal (ClusterIP, 8080)
+│
+└── Traefik IngressRoute: порт 10003 → hd-portal:8080
+ (без BasicAuth — приложение имеет собственную аутентификацию)
+```
+
+---
+
+## Манифесты
+
+### Namespace
+
+```yaml
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: hd-portal
+```
+
+### Secret — переменные приложения
+
+Создаётся вручную (не хранится в Git):
+
+```bash
+kubectl create secret generic hd-portal-env \
+ --from-literal=APP_DATASOURCE_URL="jdbc:postgresql://hd-portal-postgres:5432/diagnostic" \
+ --from-literal=APP_DATASOURCE_USERNAME="postgres" \
+ --from-literal=APP_DATASOURCE_PASSWORD="<пароль>" \
+ --from-literal=BGERP_DATASOURCE_URL="jdbc:mariadb://10.203.0.221/bgcrm?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Europe/Moscow" \
+ --from-literal=BGERP_DATASOURCE_USERNAME="user.portal" \
+ --from-literal=BGERP_DATASOURCE_PASSWORD="<пароль>" \
+ --from-literal=BGERP_API_URL="https://hd.gigacoms.ru/" \
+ --from-literal=BGERP_API_USERNAME="autobot" \
+ --from-literal=BGERP_API_PASSWORD="<пароль>" \
+ --from-literal=BGBILLING_API_URL="https://bill.gigacoms.ru/bgbilling/<токен>" \
+ --from-literal=BGBILLING_API_USER="sbersalute" \
+ --from-literal=BGBILLING_API_PASSWORD="<пароль>" \
+ --from-literal=SPRING_PROFILES_ACTIVE="prod" \
+ --from-literal=JAVA_OPTS="-Xmx512m -Xms256m -Duser.timezone=Europe/Moscow" \
+ --from-literal=TZ="Europe/Moscow" \
+ -n hd-portal
+```
+
+> Строка `BGBILLING_API_URL` и `BGBILLING_API_USER/PASSWORD` — только после исправления `ContractInfoService.java`.
+
+### Secret для pull из GitLab Registry
+
+```bash
+kubectl create secret docker-registry hd-portal-registry-pull \
+ --docker-server=reg.gitlab.gigacoms.info \
+ --docker-username= \
+ --docker-password= \
+ -n hd-portal
+```
+
+### PVC для PostgreSQL
+
+```yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: hd-portal-postgres-data
+ namespace: hd-portal
+spec:
+ storageClassName: longhorn
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+```
+
+### StatefulSet — PostgreSQL
+
+```yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: hd-portal-postgres
+ namespace: hd-portal
+spec:
+ serviceName: hd-portal-postgres
+ replicas: 1
+ selector:
+ matchLabels:
+ app: hd-portal-postgres
+ template:
+ metadata:
+ labels:
+ app: hd-portal-postgres
+ spec:
+ containers:
+ - name: postgres
+ image: postgres:15-alpine
+ env:
+ - name: POSTGRES_DB
+ value: diagnostic
+ - name: POSTGRES_USER
+ valueFrom:
+ secretKeyRef:
+ name: hd-portal-env
+ key: APP_DATASOURCE_USERNAME
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: hd-portal-env
+ key: APP_DATASOURCE_PASSWORD
+ - name: TZ
+ value: Europe/Moscow
+ ports:
+ - containerPort: 5432
+ volumeMounts:
+ - name: data
+ mountPath: /var/lib/postgresql/data
+ readinessProbe:
+ exec:
+ command: ["pg_isready", "-U", "postgres"]
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ resources:
+ requests:
+ memory: 128Mi
+ cpu: 100m
+ limits:
+ memory: 512Mi
+ cpu: 500m
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: hd-portal-postgres-data
+```
+
+### Service — PostgreSQL
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: hd-portal-postgres
+ namespace: hd-portal
+spec:
+ selector:
+ app: hd-portal-postgres
+ ports:
+ - port: 5432
+ targetPort: 5432
+ clusterIP: None # headless для StatefulSet
+```
+
+### Deployment — приложение
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: hd-portal
+ namespace: hd-portal
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: hd-portal
+ template:
+ metadata:
+ labels:
+ app: hd-portal
+ spec:
+ imagePullSecrets:
+ - name: hd-portal-registry-pull
+ containers:
+ - name: app
+ image: reg.gitlab.gigacoms.info/it-dept/device-diagnostic:latest
+ ports:
+ - containerPort: 8080
+ envFrom:
+ - secretRef:
+ name: hd-portal-env
+ resources:
+ requests:
+ memory: 256Mi
+ cpu: 200m
+ limits:
+ memory: 768Mi
+ cpu: "1"
+ readinessProbe:
+ httpGet:
+ path: /actuator/health
+ port: 8080
+ initialDelaySeconds: 30
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /actuator/health
+ port: 8080
+ initialDelaySeconds: 60
+ periodSeconds: 30
+```
+
+### Service — приложение
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: hd-portal
+ namespace: hd-portal
+spec:
+ selector:
+ app: hd-portal
+ ports:
+ - port: 8080
+ targetPort: 8080
+```
+
+### Traefik IngressRoute
+
+Добавить в `inventory/prod/group_vars/traefik.yml`:
+
+```yaml
+traefik_port_map:
+ # ... существующие записи ...
+ - name: hd-portal # ≤15 символов
+ port: 10003
+ namespace: hd-portal
+ service: hd-portal
+ servicePort: 8080
+ basicauth:
+ enabled: false # приложение имеет собственную форму входа
+```
+
+---
+
+## Необходимые изменения в коде (до миграции)
+
+### Обязательно: вынести BGBilling credentials из кода
+
+Файл: `src/main/java/ru/gigacoms/net/diagnostic/bgbilling/ContractInfoService.java`
+
+**Было:**
+```java
+private String apiUrl = "https://bill.gigacoms.ru/bgbilling/aiDmUnxl6KC8R1ELG4QJDPDm3IPn6V";
+private String apiUser = "sbersalute";
+private String apiPassword = "ULW2KmPd6bqc";
+```
+
+**Стало:**
+```java
+@Value("${bgbilling.api.url}")
+private String apiUrl;
+
+@Value("${bgbilling.api.user}")
+private String apiUser;
+
+@Value("${bgbilling.api.password}")
+private String apiPassword;
+```
+
+И в `application.properties`:
+```properties
+bgbilling.api.url=${BGBILLING_API_URL}
+bgbilling.api.user=${BGBILLING_API_USER}
+bgbilling.api.password=${BGBILLING_API_PASSWORD}
+```
+
+### Желательно: вынести API-ключи из application.properties в Secret
+
+Сейчас `app.api.keys` указаны прямо в `application.properties`. При K8s-деплое лучше передавать через env-переменную:
+```properties
+app.api.keys=${APP_API_KEYS}
+```
+
+---
+
+## Перенос данных PostgreSQL
+
+```bash
+# 1. Дамп с Swarm-ноды (sandbox)
+ssh root@10.203.0.211 "docker exec \
+ pg_dump -U postgres diagnostic" > diagnostic_dump.sql
+
+# 2. Создать PVC и запустить StatefulSet (применить манифесты)
+
+# 3. Восстановить дамп в K8s PostgreSQL
+kubectl exec -n hd-portal hd-portal-postgres-0 -- \
+ psql -U postgres -d diagnostic < diagnostic_dump.sql
+```
+
+---
+
+## Изменения в CI/CD
+
+Убрать из `.gitlab-ci.yml` этап `mkdir` — он создавал директории на Swarm-хосте по SSH. В K8s PVC управляется декларативно.
+
+Опционально: добавить этап `deploy` для обновления образа в K8s после push:
+```bash
+kubectl set image deployment/hd-portal app=reg.gitlab.gigacoms.info/it-dept/device-diagnostic:$CI_COMMIT_SHA -n hd-portal
+```
+
+Или через Flux — после push в Registry Flux Image Automation обновит манифест автоматически (если настроен ImageRepository + ImagePolicy).
+
+---
+
+## Интеграция с Flux
+
+Манифесты размещаются в `k8s/k8s-fleet`:
+
+```
+clusters/production/hd-portal/
+├── namespace.yaml
+├── pvc.yaml
+├── statefulset-postgres.yaml
+├── service-postgres.yaml
+├── deployment.yaml
+└── service.yaml
+```
+
+Secrets (`hd-portal-env`, `hd-portal-registry-pull`) создаются вручную и **не хранятся в Git**.
+
+Traefik-маршрут добавляется через Ansible (`setup_traefik.yml`) при изменении `traefik_port_map`.
+
+---
+
+## Риски и ограничения
+
+| Риск | Оценка | Митигация |
+|---|---|---|
+| BGBilling credentials в коде | **высокий** | Обязательно исправить до миграции (см. секцию выше) |
+| `NoOpPasswordEncoder` (plaintext пароли) | средний | Технический долг, не блокирует миграцию |
+| SSH/Telnet к сетевым устройствам из pod | низкий | Воркер в той же LAN, маршрутизация работает |
+| Доступность BGERP MariaDB (`10.203.0.221`) | низкий | Прямой TCP, не зависит от K8s |
+| Длительные SSH-сессии к оборудованию | низкий | Диагностика занимает секунды, не минуты |
+| Flyway при старте — PostgreSQL может быть не готов | средний | `readinessProbe` на DB + `initialDelaySeconds: 30` на app |
+| Потеря данных при переносе PostgreSQL | средний | Сделать дамп прямо перед переключением |
+
+---
+
+## План миграции
+
+1. **Исправить `ContractInfoService.java`** — вынести credentials в env-переменные, собрать новый образ.
+2. **Создать deploy token** для `it-dept/device-diagnostic` (scope: `read_registry`).
+3. **Сделать дамп PostgreSQL** с ноды `sandbox`.
+4. **Применить манифесты** через Flux или `kubectl apply`:
+ Namespace → PVC → StatefulSet postgres → Service postgres → Deployment → Service
+5. **Создать секреты вручную**:
+ - `hd-portal-env`
+ - `hd-portal-registry-pull`
+6. **Восстановить дамп** в K8s PostgreSQL.
+7. **Проверить запуск** — убедиться в `/actuator/health` и доступности UI.
+8. **Добавить Traefik-маршрут** (`traefik_port_map`, порт 10003), запустить `setup_traefik.yml`.
+9. **Остановить Swarm-сервис** `diagnostic-app` и `diagnostic-db` после успешной проверки.
+10. **Удалить этап `mkdir`** из `.gitlab-ci.yml`.
diff --git a/research/apps/rkn-list-poller.md b/research/apps/rkn-list-poller.md
new file mode 100644
index 0000000..9a2b317
--- /dev/null
+++ b/research/apps/rkn-list-poller.md
@@ -0,0 +1,361 @@
+# Перенос rkn-list-poller в Kubernetes
+
+**Репозиторий:** `docker/rkn-list-poller` на `gitlab.gigacoms.info`
+**Текущая платформа:** Docker Swarm
+**Целевая платформа:** Kubernetes (кластер `k8s_infrastructure`)
+
+---
+
+## Что делает приложение
+
+`rkn-list-poller` — пакетный PHP-воркер для выгрузки реестров заблокированных ресурсов через SOAP-сервис Роскомнадзора (`vigruzki.rkn.gov.ru`).
+
+**Три скрипта, один алгоритм:**
+
+1. Читает `request.xml` и `request.xml.sig` из примонтированной директории.
+2. Отправляет SOAP-запрос (`sendRequest`), получает `$request_code`.
+3. Опрашивает сервис каждые 3 минуты (`getResult` / `getResultSocResources`) до готовности.
+4. Записывает ZIP-архив дампа в ту же директорию.
+
+| Скрипт | SOAP-метод | Выходной файл | Расписание cron |
+|---|---|---|---|
+| `rkn_get_black_list.php` | `getResult` | `dump_black.zip` | `0 * * * *` (каждый час в :00) |
+| `rkn_get_soc_list.php` | `getResultSocResources` | `dump_soc.zip` | `30 * * * *` (каждый час в :30) |
+| `rkn_get_list.php` | `getResult` | `dump.zip` | не в активном cron |
+
+---
+
+## Текущая архитектура (Docker Swarm)
+
+```
+Docker Swarm (manager node)
+│
+└── Service: rkn-poller (replicas: 1)
+ ├── Image: reg.gitlab.gigacoms.info/docker/rkn-list-poller:main-latest
+ ├── Entrypoint: cron -f (PID 1, foreground)
+ │ ├── 0 */1 * * * → php /app/rkn_get_black_list.php
+ │ └── 30 */1 * * * → php /app/rkn_get_soc_list.php
+ └── Volume: /mnt/swarm_quorum/rkn-poller → /data/rezult
+ ├── request.xml (генерируется rknutils.jar, обновляется вручную)
+ ├── request.xml.sig (GOST-подпись, обновляется вручную)
+ ├── dump_black.zip (выход)
+ ├── dump_soc.zip (выход)
+ └── rkn.log (журнал)
+```
+
+**Особенности образа:**
+- База: `rnix/openssl-gost:latest` (Debian Stretch, EOL) — нужен для GOST-криптографии при TLS с `vigruzki.rkn.gov.ru`
+- PHP 7.0, php-soap, php-xml
+- Российские корневые сертификаты Минцифры (5 штук в `certs/`) добавлены в хранилище системы и в `php.ini`
+- Лимит памяти PHP: `-1` (без ограничений) — дамп может занять сотни МБ
+
+---
+
+## Ключевые наблюдения для миграции
+
+### 1. Приложение — пакетный джоб, не сервис
+
+Контейнер сейчас держит внутри себя `cron` и работает постоянно. В Kubernetes это антипаттерн.
+**PHP-скрипты — это одиночные запуски с выходом**: `php /app/rkn_get_black_list.php` запускается, ждёт ответа РКН, сохраняет файл, завершается.
+
+Это идеально соответствует `CronJob`. Нужды в Deployment нет.
+
+### 2. Время выполнения непредсказуемо
+
+Скрипт опрашивает РКН каждые 3 минуты в цикле. РКН может отвечать от 5 минут до нескольких часов. Нужно устанавливать `activeDeadlineSeconds` с большим запасом (6 часов).
+
+### 3. Конкурентный запуск опасен
+
+Если предыдущий джоб ещё работает (РКН медленно отвечает), новый запуск перезапишет тот же `dump_black.zip`. Нужен `concurrencyPolicy: Forbid`.
+
+### 4. Персистентное хранилище — единственная точка состояния
+
+Из Swarm-тома в директорию `/data/rezult` нужно перенести:
+- `request.xml` и `request.xml.sig` — **входные файлы, которые нельзя потерять**. Обновляются вручную при смене сертификата оператора. Логично хранить как Secret.
+- Выходные ZIP-архивы — в PVC.
+
+### 5. Образ менять не нужно
+
+Образ `rnix/openssl-gost` специфичен для GOST-криптографии и уже работает. CI/CD уже публикует его в GitLab Registry. **Менять образ не нужно** — только изменить способ запуска (убрать cron, запускать скрипт напрямую).
+
+> **Технический долг:** Debian Stretch достиг EOL в 2022. `rnix/openssl-gost` — заброшенный образ. Рекомендуется проверить наличие более свежих альтернатив (например, `openssl-gost` на базе Debian Bookworm), но это отдельная задача, не блокирующая миграцию.
+
+---
+
+## Целевая архитектура в Kubernetes
+
+```
+Namespace: rkn-poller
+│
+├── Secret: rkn-request-files
+│ ├── request.xml (base64)
+│ └── request.xml.sig (base64)
+│
+├── PersistentVolumeClaim: rkn-data (Longhorn, RWO, 5Gi)
+│ └── /data/rezult/
+│ ├── dump_black.zip
+│ ├── dump_soc.zip
+│ └── rkn.log
+│
+├── CronJob: rkn-black-list
+│ ├── schedule: "0 * * * *"
+│ ├── concurrencyPolicy: Forbid
+│ ├── activeDeadlineSeconds: 21600
+│ ├── command: php /app/rkn_get_black_list.php
+│ └── volumes: Secret → /data/rezult (request файлы), PVC → /data/rezult (выход)
+│
+└── CronJob: rkn-soc-list
+ ├── schedule: "30 * * * *"
+ ├── concurrencyPolicy: Forbid
+ ├── activeDeadlineSeconds: 21600
+ ├── command: php /app/rkn_get_soc_list.php
+ └── volumes: то же самое
+```
+
+**Почему два тома в одну директорию?**
+Оба пути монтирования (`/data/rezult`) можно разрешить через `subPath` в PVC — тогда Secret с файлами запроса проецируется поверх PVC не заменяя его содержимое. Либо использовать initContainer для копирования файлов из Secret в PVC перед запуском основного контейнера (более явный вариант).
+
+---
+
+## Манифесты
+
+### Namespace
+
+```yaml
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: rkn-poller
+```
+
+### Secret с файлами запроса
+
+Создаётся **вручную** на manager-ноде (не хранится в Git):
+
+```bash
+kubectl create secret generic rkn-request-files \
+ --from-file=request.xml=/path/to/request.xml \
+ --from-file=request.xml.sig=/path/to/request.xml.sig \
+ -n rkn-poller
+```
+
+При смене сертификата оператора:
+
+```bash
+kubectl create secret generic rkn-request-files \
+ --from-file=request.xml=/path/to/new/request.xml \
+ --from-file=request.xml.sig=/path/to/new/request.xml.sig \
+ -n rkn-poller \
+ --dry-run=client -o yaml | kubectl apply -f -
+```
+
+### Secret для pull из GitLab Registry
+
+```bash
+kubectl create secret docker-registry rkn-registry-pull \
+ --docker-server=reg.gitlab.gigacoms.info \
+ --docker-username= \
+ --docker-password= \
+ -n rkn-poller
+```
+
+### PersistentVolumeClaim
+
+```yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: rkn-data
+ namespace: rkn-poller
+spec:
+ storageClassName: longhorn
+ accessModes:
+ - ReadWriteOnce
+ resources:
+ requests:
+ storage: 5Gi
+```
+
+### CronJob: rkn-black-list
+
+```yaml
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: rkn-black-list
+ namespace: rkn-poller
+spec:
+ schedule: "0 * * * *"
+ concurrencyPolicy: Forbid
+ successfulJobsHistoryLimit: 3
+ failedJobsHistoryLimit: 3
+ jobTemplate:
+ spec:
+ activeDeadlineSeconds: 21600 # 6 часов: РКН может отвечать долго
+ template:
+ spec:
+ restartPolicy: OnFailure
+ imagePullSecrets:
+ - name: rkn-registry-pull
+ initContainers:
+ - name: copy-request-files
+ image: busybox:1.36
+ command:
+ - sh
+ - -c
+ - cp /secrets/request.xml /data/rezult/request.xml &&
+ cp /secrets/request.xml.sig /data/rezult/request.xml.sig
+ volumeMounts:
+ - name: request-secret
+ mountPath: /secrets
+ readOnly: true
+ - name: rkn-data
+ mountPath: /data/rezult
+ containers:
+ - name: poller
+ image: reg.gitlab.gigacoms.info/docker/rkn-list-poller:main-latest
+ command: ["php", "/app/rkn_get_black_list.php"]
+ env:
+ - name: RKN_DATA_DIR
+ value: /data/rezult
+ resources:
+ requests:
+ memory: 256Mi
+ cpu: 100m
+ limits:
+ memory: 2Gi
+ cpu: 500m
+ volumeMounts:
+ - name: rkn-data
+ mountPath: /data/rezult
+ volumes:
+ - name: request-secret
+ secret:
+ secretName: rkn-request-files
+ - name: rkn-data
+ persistentVolumeClaim:
+ claimName: rkn-data
+```
+
+### CronJob: rkn-soc-list
+
+```yaml
+apiVersion: batch/v1
+kind: CronJob
+metadata:
+ name: rkn-soc-list
+ namespace: rkn-poller
+spec:
+ schedule: "30 * * * *"
+ concurrencyPolicy: Forbid
+ successfulJobsHistoryLimit: 3
+ failedJobsHistoryLimit: 3
+ jobTemplate:
+ spec:
+ activeDeadlineSeconds: 21600
+ template:
+ spec:
+ restartPolicy: OnFailure
+ imagePullSecrets:
+ - name: rkn-registry-pull
+ initContainers:
+ - name: copy-request-files
+ image: busybox:1.36
+ command:
+ - sh
+ - -c
+ - cp /secrets/request.xml /data/rezult/request.xml &&
+ cp /secrets/request.xml.sig /data/rezult/request.xml.sig
+ volumeMounts:
+ - name: request-secret
+ mountPath: /secrets
+ readOnly: true
+ - name: rkn-data
+ mountPath: /data/rezult
+ containers:
+ - name: poller
+ image: reg.gitlab.gigacoms.info/docker/rkn-list-poller:main-latest
+ command: ["php", "/app/rkn_get_soc_list.php"]
+ env:
+ - name: RKN_DATA_DIR
+ value: /data/rezult
+ resources:
+ requests:
+ memory: 256Mi
+ cpu: 100m
+ limits:
+ memory: 2Gi
+ cpu: 500m
+ volumeMounts:
+ - name: rkn-data
+ mountPath: /data/rezult
+ volumes:
+ - name: request-secret
+ secret:
+ secretName: rkn-request-files
+ - name: rkn-data
+ persistentVolumeClaim:
+ claimName: rkn-data
+```
+
+---
+
+## Интеграция с Flux
+
+Манифесты размещаются в fleet-репозитории `k8s/k8s-fleet` (путь: `clusters/production/rkn-poller/`):
+
+```
+clusters/production/rkn-poller/
+├── namespace.yaml
+├── pvc.yaml
+├── cronjob-black-list.yaml
+└── cronjob-soc-list.yaml
+```
+
+Secret-ресурсы (`rkn-request-files`, `rkn-registry-pull`) **не хранятся в Git** — создаются вручную один раз.
+
+Flux автоматически применит PVC и CronJob после push в fleet-репозиторий.
+
+---
+
+## Замечания по firewall и сети
+
+- `vigruzki.rkn.gov.ru` — внешний HTTPS-хост. Pods в кластере имеют выход в интернет через worker-ноды — дополнительных правил не нужно.
+- NetworkPolicy не требуется: поды не принимают входящих соединений.
+- Для ГОСТ-TLS нужны российские корневые сертификаты — они уже вшиты в образ, ничего дополнительно настраивать не нужно.
+
+---
+
+## Риски и ограничения
+
+| Риск | Оценка | Митигация |
+|---|---|---|
+| `rnix/openssl-gost` на Debian Stretch EOL | средний | Образ работает, обновление — отдельная задача. Изолирован в контейнере. |
+| Долгий старт пода (pull большого образа) | низкий | Образ уже в локальном Registry, время pull минимально |
+| РКН возвращает ошибку — pod упадёт, cron не перезапустит до следующего часа | средний | `restartPolicy: OnFailure` сделает retry. Можно добавить `backoffLimit: 3` в Job |
+| PVC `ReadWriteOnce` — только одна нода | принимается | Оба CronJob работают последовательно и на одной ноде (single-worker кластер) |
+| `request.xml.sig` истёк (сертификат оператора) — нет алертинга | средний | Добавить мониторинг по коду завершения Job (future work) |
+
+---
+
+## План миграции
+
+1. **Создать deploy token** в GitLab (`docker/rkn-list-poller` → Settings → Repository → Deploy tokens, scope `read_registry`)
+2. **Применить манифесты** через Flux или `kubectl apply`:
+ - Namespace → PVC → CronJob × 2
+3. **Создать секреты вручную** на manager-ноде:
+ - `rkn-registry-pull` (registry credentials)
+ - `rkn-request-files` (request.xml + request.xml.sig из текущего Swarm-тома)
+4. **Запустить джобы вручную** для проверки:
+ ```bash
+ kubectl create job rkn-black-list-test \
+ --from=cronjob/rkn-black-list -n rkn-poller
+ kubectl logs -f -n rkn-poller -l job-name=rkn-black-list-test
+ ```
+5. **Убедиться, что `dump_black.zip` обновился** в PVC:
+ ```bash
+ kubectl run check --rm -it --image=busybox \
+ --overrides='{"spec":{"volumes":[{"name":"d","persistentVolumeClaim":{"claimName":"rkn-data"}}],"containers":[{"name":"c","image":"busybox","command":["ls","-lh","/data"],"volumeMounts":[{"name":"d","mountPath":"/data"}]}]}}' \
+ -n rkn-poller
+ ```
+6. **Остановить Swarm-сервис** `rkn` после подтверждения работы.
diff --git a/research/flux.md b/research/flux.md
new file mode 100644
index 0000000..4f75ff6
--- /dev/null
+++ b/research/flux.md
@@ -0,0 +1,1152 @@
+# Исследование: Flux CD для GitOps-развёртывания из GitLab
+
+## Задача
+
+Обеспечить автоматическое развёртывание приложений из проектов внешнего GitLab-сервера
+(`https://gitlab.gigacoms.info`) в кластер Kubernetes (k8s-master-01 / k8s-worker-01).
+
+Flux работает совместно с уже установленными в кластере компонентами:
+- **Longhorn** — постоянное блочное хранилище (StorageClass `longhorn`). Стандарт использования: `roles/longhorn/README.md`
+- **Traefik** — HTTP-прокси для публикации сервисов наружу (порты 10001–10999). Стандарт использования: `roles/traefik/README.md`
+
+---
+
+## Что такое Flux CD
+
+Flux CD — GitOps-инструмент для Kubernetes. Работает как набор контроллеров внутри кластера,
+которые непрерывно синхронизируют состояние кластера с Git-репозиторием. Поддерживает GitLab
+(включая self-hosted), GitHub, Bitbucket, S3-совместимые хранилища.
+
+Официальный сайт: https://fluxcd.io
+
+---
+
+## Архитектура компонентов
+
+Flux состоит из нескольких независимых контроллеров, каждый управляет своим набором CRD.
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ GitLab: gitlab.gigacoms.info │
+│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
+│ │ fleet repo │ │ app repo A │ │ app repo B │ │
+│ │ (flux config)│ │ (Helm/k8s) │ │ (kustomize) │ │
+│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
+└─────────┼────────────────┼──────────────────-─┼────────────┘
+ │ pull │ pull │ pull
+┌─────────▼────────────────▼─────────────────────▼───────────┐
+│ Kubernetes cluster │
+│ │
+│ source-controller — следит за Git/Helm-источниками │
+│ kustomize-controller — применяет Kustomization манифесты │
+│ helm-controller — управляет HelmRelease │
+│ notification-controller— алерты, webhooks │
+│ image-reflector — сканирует container registry │
+│ image-automation — обновляет теги образов в Git │
+│ │
+│ ──── уже установлено Ansible ──────────────────────────── │
+│ Longhorn (longhorn-system) — StorageClass: longhorn │
+│ Traefik (traefik) — порт-прокси 10001–10999 │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Основные CRD
+
+| CRD | Контроллер | Назначение |
+|---|---|---|
+| `GitRepository` | source-controller | Отслеживает Git-репозиторий, создаёт artifact |
+| `HelmRepository` | source-controller | Отслеживает Helm chart repository |
+| `HelmRelease` | helm-controller | Устанавливает/обновляет Helm chart |
+| `Kustomization` | kustomize-controller | Применяет kustomize-манифесты из artifact |
+| `ImageRepository` | image-reflector | Сканирует container registry |
+| `ImagePolicy` | image-reflector | Выбирает тег по SemVer/паттерну |
+| `ImageUpdateAutomation` | image-automation | Коммитит новый тег в Git |
+
+---
+
+## Модели развёртывания
+
+### Модель 1: Один fleet-репозиторий (рекомендуется)
+
+Один специальный Git-репозиторий (`k8s-fleet`) хранит всю конфигурацию Flux.
+Приложения описываются через `GitRepository` + `Kustomization` или `HelmRelease`.
+
+```
+k8s-fleet/
+ clusters/
+ production/
+ flux-system/ ← bootstrap-манифесты (авто-генерируются)
+ apps.yaml ← Flux Kustomization → apps/production/
+ apps/
+ base/
+ my-app/
+ deployment.yaml
+ service.yaml
+ pvc.yaml ← storageClassName: longhorn
+ ingressroute.yaml ← IngressRoute для Traefik
+ kustomization.yaml
+ production/
+ my-app/
+ kustomization.yaml
+ patch-image.yaml
+ staging/
+ my-app/
+ kustomization.yaml
+ patch-image.yaml
+```
+
+### Модель 2: GitOps per-проект
+
+Каждый проект в GitLab хранит свои k8s-манифесты. Flux смотрит на каждый из них.
+Подходит при большом количестве независимых команд.
+
+---
+
+## Установка Flux CLI
+
+```bash
+# На k8s-manager-01 (operator workstation)
+curl -s https://fluxcd.io/install.sh | sudo bash
+
+# Проверка совместимости с кластером
+flux check --pre
+```
+
+---
+
+## Bootstrap: подключение к GitLab
+
+Bootstrap — однократная процедура, после которой Flux сам себя обслуживает через Git.
+
+### Требования
+
+- **GitLab Personal Access Token** со scope `api` (или `read_repository` + `write_repository`)
+- Права **Owner** на проект или **Maintainer** на группу в GitLab
+- `kubectl` с доступом к кластеру (kubeconfig на k8s-manager-01)
+- Flux CLI установлен
+
+### Команда bootstrap для self-hosted GitLab
+
+```bash
+export GITLAB_TOKEN=
+
+flux bootstrap gitlab \
+ --hostname=gitlab.gigacoms.info \
+ --owner= \
+ --repository=k8s-fleet \
+ --branch=main \
+ --path=clusters/production \
+ --personal # если owner — пользователь, не группа
+```
+
+Параметры:
+
+| Флаг | Описание |
+|---|---|
+| `--hostname` | Хост self-hosted GitLab |
+| `--owner` | GitLab group или username |
+| `--repository` | Имя репозитория (создастся если нет) |
+| `--branch` | Ветка для Flux-конфига |
+| `--path` | Путь внутри репо для этого кластера |
+| `--personal` | Если owner — личный аккаунт, не группа |
+| `--private` | Создать репо как приватный (по умолчанию `true`) |
+
+Bootstrap выполнит:
+1. Создаст репозиторий `k8s-fleet` в GitLab (если не существует)
+2. Сгенерирует Deploy Key и добавит в репозиторий
+3. Установит Flux-контроллеры в namespace `flux-system`
+4. Запушит начальные манифесты в `clusters/production/flux-system/`
+
+---
+
+## Подключение приложения из GitLab-проекта
+
+После bootstrap, чтобы Flux следил за конкретным проектом:
+
+### Вариант A: Kustomize (plain YAML / kustomize)
+
+```yaml
+# clusters/production/apps/my-app.yaml
+
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: GitRepository
+metadata:
+ name: my-app
+ namespace: flux-system
+spec:
+ interval: 1m
+ url: https://gitlab.gigacoms.info/mygroup/my-app.git
+ ref:
+ branch: main
+ secretRef:
+ name: my-app-gitlab-token # Secret с токеном доступа
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: my-app
+ namespace: flux-system
+spec:
+ interval: 5m
+ path: ./deploy/k8s
+ prune: true
+ sourceRef:
+ kind: GitRepository
+ name: my-app
+ targetNamespace: my-app
+```
+
+### Вариант B: Helm chart из GitLab
+
+```yaml
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: HelmRepository
+metadata:
+ name: mygroup-charts
+ namespace: flux-system
+spec:
+ interval: 10m
+ url: https://gitlab.gigacoms.info/api/v4/projects//packages/helm/stable
+ secretRef:
+ name: gitlab-helm-token
+---
+apiVersion: helm.toolkit.fluxcd.io/v2
+kind: HelmRelease
+metadata:
+ name: my-app
+ namespace: my-app
+spec:
+ interval: 5m
+ chart:
+ spec:
+ chart: my-app
+ version: ">=1.0.0"
+ sourceRef:
+ kind: HelmRepository
+ name: mygroup-charts
+ namespace: flux-system
+ values:
+ replicaCount: 2
+```
+
+### Secret для доступа к приватному репозиторию
+
+```bash
+# Создать Secret с GitLab токеном (HTTPS)
+kubectl create secret generic my-app-gitlab-token \
+ --namespace=flux-system \
+ --from-literal=username=oauth2 \
+ --from-literal=password=
+```
+
+Или через SSH deploy key:
+
+```bash
+flux create secret git my-app-gitlab-ssh \
+ --url=ssh://git@gitlab.gigacoms.info/mygroup/my-app.git \
+ --namespace=flux-system
+# Публичный ключ добавить в GitLab → Project → Settings → Repository → Deploy Keys
+```
+
+---
+
+## Интеграция с Longhorn
+
+> Полный стандарт работы с Longhorn: `roles/longhorn/README.md`
+
+Longhorn установлен Ansible и предоставляет StorageClass `longhorn` (default).
+Flux-управляемые приложения используют его напрямую через PVC — никаких дополнительных
+настроек в fleet-repo не требуется.
+
+### Правила использования хранилища в fleet-repo
+
+- Всегда указывать `storageClassName: longhorn` явно — не полагаться на default
+- Для одного Pod — `ReadWriteOnce`, `replicas: 1` в Deployment
+- Для StatefulSet с несколькими репликами — `volumeClaimTemplates` (не `volumes + PVC`)
+- Для PostgreSQL: `PGDATA` в подпапку, `fsGroup: 999` в `securityContext`
+- `ReadWriteMany` — только если нескольким Pod на разных нодах нужен общий том
+
+### Пример A: простое приложение с постоянным хранилищем
+
+```yaml
+# apps/base/file-processor/pvc.yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: file-processor-data
+ namespace: file-processor
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 20Gi
+---
+# apps/base/file-processor/deployment.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: file-processor
+ namespace: file-processor
+spec:
+ replicas: 1 # RWO — только 1 реплика
+ selector:
+ matchLabels:
+ app: file-processor
+ template:
+ metadata:
+ labels:
+ app: file-processor
+ spec:
+ containers:
+ - name: file-processor
+ image: registry.gigacoms.info/myteam/file-processor:latest
+ volumeMounts:
+ - name: data
+ mountPath: /app/data
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: file-processor-data
+```
+
+### Пример B: StatefulSet с Longhorn (база данных PostgreSQL)
+
+```yaml
+# apps/base/shop/postgres/statefulset.yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: postgres
+ namespace: shop-production
+spec:
+ serviceName: postgres
+ replicas: 1
+ selector:
+ matchLabels:
+ app: shop
+ component: postgres
+ template:
+ metadata:
+ labels:
+ app: shop
+ component: postgres
+ spec:
+ securityContext:
+ fsGroup: 999 # postgres GID
+ containers:
+ - name: postgres
+ image: postgres:16-alpine
+ env:
+ - name: POSTGRES_DB
+ value: shopdb
+ - name: POSTGRES_USER
+ value: shopuser
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: shop-postgres-secret
+ key: password
+ - name: PGDATA
+ value: /var/lib/postgresql/data/pgdata # подпапка! не корень тома
+ volumeMounts:
+ - name: data
+ mountPath: /var/lib/postgresql/data
+ resources:
+ requests:
+ cpu: 200m
+ memory: 512Mi
+ limits:
+ cpu: 1000m
+ memory: 2Gi
+ readinessProbe:
+ exec:
+ command: [pg_isready, -U, shopuser, -d, shopdb]
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ volumeClaimTemplates:
+ - metadata:
+ name: data
+ spec:
+ accessModes: ["ReadWriteOnce"]
+ storageClassName: longhorn # явно указывать всегда
+ resources:
+ requests:
+ storage: 50Gi
+```
+
+### Пример C: два компонента с общим томом (ReadWriteMany)
+
+Сценарий: CMS пишет медиафайлы, CDN-прокси их раздаёт с нескольких Pod.
+
+```yaml
+# apps/base/cms/pvc-shared-media.yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: shared-media
+ namespace: cms
+spec:
+ accessModes:
+ - ReadWriteMany # Longhorn NFS-шлюз, v1.5+
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 100Gi
+---
+# apps/base/cms/deployment-cms.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: cms
+ namespace: cms
+spec:
+ replicas: 1
+ template:
+ spec:
+ containers:
+ - name: cms
+ image: registry.gigacoms.info/myteam/cms:latest
+ volumeMounts:
+ - name: media
+ mountPath: /app/media
+ volumes:
+ - name: media
+ persistentVolumeClaim:
+ claimName: shared-media
+---
+# apps/base/cms/deployment-cdn.yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: cdn-proxy
+ namespace: cms
+spec:
+ replicas: 3 # несколько реплик читают один RWX-том
+ template:
+ spec:
+ containers:
+ - name: nginx
+ image: nginx:1.27-alpine
+ volumeMounts:
+ - name: media
+ mountPath: /usr/share/nginx/html/media
+ readOnly: true
+ volumes:
+ - name: media
+ persistentVolumeClaim:
+ claimName: shared-media
+```
+
+---
+
+## Интеграция с Traefik
+
+> Полный стандарт конфигурации Traefik: `roles/traefik/README.md`
+
+Traefik установлен Ansible и слушает порты 10001–10999 как DaemonSet на worker-нодах.
+Для публикации сервиса через Traefik есть **два подхода** — выбор зависит от характера сервиса.
+
+### Подход 1: IngressRoute в fleet-repo (рекомендуется для Flux-управляемых приложений)
+
+Flux-управляемое приложение само описывает свой маршрут в fleet-repo.
+`allowCrossNamespace: true` уже включён в Traefik — IngressRoute из namespace `traefik`
+может ссылаться на Service в любом другом namespace.
+
+Этот подход даёт полный GitOps-цикл: добавил приложение в fleet-repo — оно само появилось
+и снаружи, без отдельного запуска Ansible.
+
+**Ограничение:** порт под IngressRoute должен быть заранее добавлен в Traefik через
+`traefik_port_map` (Ansible). EntryPoint-ы в Traefik — это статическая конфигурация,
+она не обновляется через CRD. Поэтому порядок действий:
+
+```
+1. Зарезервировать порт → добавить в traefik_port_map → запустить setup_traefik.yml
+2. Закоммитить IngressRoute в fleet-repo → Flux применит его
+```
+
+Пример IngressRoute в fleet-repo (приложение `shop`, порт `10005` зарезервирован в traefik_port_map):
+
+```yaml
+# apps/base/shop/ingressroute.yaml
+apiVersion: traefik.io/v1alpha1
+kind: IngressRoute
+metadata:
+ name: shop-ui
+ namespace: traefik # всегда в namespace traefik
+spec:
+ entryPoints:
+ - shop-ui # имя из traefik_port_map (≤15 символов)
+ routes:
+ - match: PathPrefix(`/`)
+ kind: Rule
+ services:
+ - name: frontend # Service в namespace shop-production
+ namespace: shop-production
+ port: 80
+ scheme: http
+```
+
+Для BasicAuth:
+
+```yaml
+# apps/base/shop/ingressroute-admin.yaml
+apiVersion: traefik.io/v1alpha1
+kind: IngressRoute
+metadata:
+ name: shop-admin
+ namespace: traefik
+spec:
+ entryPoints:
+ - shop-admin # порт 10006 в traefik_port_map
+ routes:
+ - match: PathPrefix(`/`)
+ kind: Rule
+ services:
+ - name: backend-admin
+ namespace: shop-production
+ port: 8080
+ scheme: http
+ middlewares:
+ - name: basicauth-shop-admin
+ namespace: traefik
+---
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: basicauth-shop-admin
+ namespace: traefik
+spec:
+ basicAuth:
+ secret: traefik-auth-shop-admin # создать вручную перед деплоем
+ removeHeader: true
+```
+
+> BasicAuth Secret создаётся вручную (не в fleet-repo):
+> ```bash
+> kubectl create secret generic traefik-auth-shop-admin \
+> --from-literal=users="$(openssl passwd -apr1 'Password' | xargs -I{} echo 'admin:{}')" \
+> -n traefik
+> ```
+
+### Подход 2: traefik_port_map в Ansible (для инфраструктурных сервисов)
+
+Подходит для сервисов, которые не управляются Flux: мониторинг, дашборды, сервисы,
+установленные Ansible. Добавить запись в `inventory/prod/group_vars/traefik.yml` и
+запустить `setup_traefik.yml`.
+
+Подробно: `roles/traefik/README.md`, раздел «Единственный способ добавить сервис».
+
+### Полный пример приложения с Longhorn + Traefik в fleet-repo
+
+**Сценарий:** веб-приложение `shop` с PostgreSQL на Longhorn, UI опубликован через Traefik.
+
+Предварительные требования (выполнить один раз до первого деплоя через Flux):
+
+```bash
+# 1. Зарезервировать порты в traefik_port_map и запустить Ansible:
+# port 10005: name=shop-ui → frontend:80 (без BasicAuth)
+# port 10006: name=shop-admin → backend:8080 (с BasicAuth)
+ansible-playbook -i inventory/prod playbooks/setup_traefik.yml
+
+# 2. Создать секреты вручную (не хранятся в Git)
+kubectl create secret generic shop-postgres-secret \
+ --from-literal=password='StrongDbPassword' \
+ -n shop-production
+
+kubectl create secret generic traefik-auth-shop-admin \
+ --from-literal=users="$(openssl passwd -apr1 'AdminPass' | xargs -I{} echo 'admin:{}')" \
+ -n traefik
+```
+
+Структура fleet-repo для приложения:
+
+```
+apps/
+├── base/
+│ └── shop/
+│ ├── namespace.yaml
+│ ├── configmap.yaml
+│ ├── postgres/
+│ │ ├── statefulset.yaml ← volumeClaimTemplates: storageClassName: longhorn
+│ │ └── service.yaml
+│ ├── backend/
+│ │ ├── deployment.yaml
+│ │ └── service.yaml
+│ ├── frontend/
+│ │ ├── deployment.yaml
+│ │ └── service.yaml
+│ ├── ingressroute-ui.yaml ← IngressRoute: entryPoints: [shop-ui]
+│ ├── ingressroute-admin.yaml ← IngressRoute: entryPoints: [shop-admin] + BasicAuth
+│ ├── middleware-auth.yaml ← Middleware: basicAuth (ссылается на secret)
+│ └── kustomization.yaml
+└── production/
+ └── shop/
+ ├── kustomization.yaml
+ ├── patch-postgres.yaml ← образ postgres:16-alpine (стабильный)
+ ├── patch-frontend.yaml ← image: registry/shop-frontend:v2.1.0
+ └── patch-backend.yaml ← image: registry/shop-backend:v2.1.0
+```
+
+Ключевые манифесты:
+
+```yaml
+# apps/base/shop/postgres/statefulset.yaml — Longhorn storage
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: postgres
+ namespace: shop-production
+spec:
+ serviceName: postgres
+ replicas: 1
+ selector:
+ matchLabels:
+ app: shop
+ component: postgres
+ template:
+ metadata:
+ labels:
+ app: shop
+ component: postgres
+ spec:
+ securityContext:
+ fsGroup: 999
+ containers:
+ - name: postgres
+ image: postgres:16-alpine
+ env:
+ - name: POSTGRES_DB
+ value: shopdb
+ - name: POSTGRES_USER
+ value: shopuser
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: shop-postgres-secret
+ key: password
+ - name: PGDATA
+ value: /var/lib/postgresql/data/pgdata
+ volumeMounts:
+ - name: data
+ mountPath: /var/lib/postgresql/data
+ resources:
+ requests:
+ cpu: 200m
+ memory: 512Mi
+ limits:
+ cpu: 1000m
+ memory: 2Gi
+ readinessProbe:
+ exec:
+ command: [pg_isready, -U, shopuser, -d, shopdb]
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ volumeClaimTemplates:
+ - metadata:
+ name: data
+ spec:
+ accessModes: ["ReadWriteOnce"]
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 50Gi
+---
+# apps/base/shop/ingressroute-ui.yaml — Traefik публикация
+apiVersion: traefik.io/v1alpha1
+kind: IngressRoute
+metadata:
+ name: shop-ui
+ namespace: traefik
+spec:
+ entryPoints:
+ - shop-ui
+ routes:
+ - match: PathPrefix(`/`)
+ kind: Rule
+ services:
+ - name: frontend
+ namespace: shop-production
+ port: 80
+ scheme: http
+---
+# apps/base/shop/ingressroute-admin.yaml — Traefik с BasicAuth
+apiVersion: traefik.io/v1alpha1
+kind: IngressRoute
+metadata:
+ name: shop-admin
+ namespace: traefik
+spec:
+ entryPoints:
+ - shop-admin
+ routes:
+ - match: PathPrefix(`/`)
+ kind: Rule
+ services:
+ - name: backend
+ namespace: shop-production
+ port: 8080
+ scheme: http
+ middlewares:
+ - name: basicauth-shop-admin
+ namespace: traefik
+---
+# apps/base/shop/middleware-auth.yaml
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: basicauth-shop-admin
+ namespace: traefik
+spec:
+ basicAuth:
+ secret: traefik-auth-shop-admin
+ removeHeader: true
+```
+
+Flux Kustomization с порядком деплоя (postgres → backend → frontend):
+
+```yaml
+# clusters/production/apps.yaml
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: shop-postgres
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/shop/postgres
+ prune: true
+ healthChecks:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ name: postgres
+ namespace: shop-production
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: shop-backend
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/shop/backend
+ prune: true
+ dependsOn:
+ - name: shop-postgres
+ healthChecks:
+ - apiVersion: apps/v1
+ kind: Deployment
+ name: backend
+ namespace: shop-production
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: shop-frontend-and-routes
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/shop/frontend
+ prune: true
+ dependsOn:
+ - name: shop-backend
+ # IngressRoute и Middleware включены в этот же Kustomization:
+ # frontend/deployment.yaml, frontend/service.yaml,
+ # ../ingressroute-ui.yaml, ../ingressroute-admin.yaml,
+ # ../middleware-auth.yaml
+```
+
+---
+
+## Image Automation (автообновление образов)
+
+Flux умеет самостоятельно обновлять тег образа в Git при появлении нового в registry.
+
+```
+GitLab CI билдит образ → пушит в GitLab Container Registry
+ ↓
+image-reflector замечает новый тег
+ ↓
+image-automation коммитит обновлённый тег в fleet-репозиторий
+ ↓
+kustomize/helm-controller деплоит обновлённую версию
+```
+
+Конфигурация:
+
+```yaml
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageRepository
+metadata:
+ name: my-app
+ namespace: flux-system
+spec:
+ image: registry.gitlab.gigacoms.info/mygroup/my-app
+ interval: 1m
+ secretRef:
+ name: gitlab-registry-token
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImagePolicy
+metadata:
+ name: my-app
+ namespace: flux-system
+spec:
+ imageRepositoryRef:
+ name: my-app
+ policy:
+ semver:
+ range: ">=1.0.0"
+---
+apiVersion: image.toolkit.fluxcd.io/v1beta2
+kind: ImageUpdateAutomation
+metadata:
+ name: fleet
+ namespace: flux-system
+spec:
+ interval: 1m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ git:
+ checkout:
+ ref:
+ branch: main
+ commit:
+ author:
+ email: fluxbot@gigacoms.info
+ name: FluxBot
+ push:
+ branch: main
+ update:
+ path: ./clusters/production
+ strategy: Setters
+```
+
+---
+
+## Интеграция с существующим стеком
+
+### Что меняется в текущей инфраструктуре
+
+| Компонент | Сейчас | С Flux |
+|---|---|---|
+| Деплой инфраструктуры | Ansible playbooks (вручную/CI) | Без изменений — Ansible остаётся |
+| Деплой приложений | Нет автоматизации | Flux следит за GitLab-проектами |
+| Longhorn (хранилище) | Ansible устанавливает, `storageClassName: longhorn` — доступен всем | Приложения используют через PVC без изменений |
+| Traefik (прокси) | Ansible управляет `traefik_port_map` и EntryPoint-ами | EntryPoint — по-прежнему Ansible; IngressRoute — опционально Flux |
+| Обновление образов | Нет | Image Automation или CI-скрипт в fleet-repo |
+
+Ansible и Flux не конкурируют: Ansible управляет **OS и кластером**, Flux управляет
+**приложениями внутри кластера**.
+
+### Разделение ответственности: Ansible vs Flux
+
+```
+Ansible (roles/longhorn, roles/traefik):
+ ├── Устанавливает Longhorn, создаёт StorageClass longhorn
+ ├── Устанавливает Traefik, настраивает EntryPoint-ы (traefik_port_map)
+ └── Открывает firewalld-порты
+
+Flux (fleet-repo):
+ ├── Деплоит приложения (Deployment, StatefulSet, Service)
+ ├── Создаёт PVC с storageClassName: longhorn
+ ├── Создаёт IngressRoute/Middleware в namespace traefik
+ └── Управляет тегами образов
+```
+
+### Сетевые требования
+
+Flux-контроллеры работают внутри кластера и сами инициируют соединения наружу:
+
+- `source-controller` → `gitlab.gigacoms.info:443` (HTTPS или SSH)
+- `image-reflector` → `registry.gitlab.gigacoms.info:443` (если используется)
+- Firewalld на узлах не требует изменений (egress трафик не блокируется)
+
+---
+
+## Ansible-роль для установки Flux
+
+Роль `roles/flux/` уже реализована в проекте. Стандарт GitOps-структуры fleet-repo: `roles/flux/README.md`.
+
+```
+roles/flux/
+ defaults/main.yml — flux_version, fleet_repo, gitlab_hostname, gitlab_owner
+ tasks/main.yml — include_tasks: install → bootstrap
+ tasks/install.yml — скачать flux CLI на k8s-manager-01
+ tasks/bootstrap.yml — запустить flux bootstrap gitlab
+```
+
+Playbook `playbooks/setup_flux.yml` запускается на `manager_nodes`.
+
+GitLab CI job:
+
+```yaml
+setup:flux:
+ stage: setup
+ script:
+ - ansible-playbook -i $INVENTORY playbooks/setup_flux.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+```
+
+Дополнительная CI/CD-переменная: `GITLAB_FLUX_TOKEN` — GitLab PAT для bootstrap.
+
+---
+
+## Мониторинг и отладка
+
+```bash
+# Статус всех Flux-объектов
+flux get all -A
+
+# Принудительная синхронизация
+flux reconcile source git flux-system
+flux reconcile kustomization flux-system
+
+# Конкретное приложение
+flux reconcile kustomization shop-backend --with-source
+
+# Логи контроллеров
+kubectl logs -n flux-system deploy/source-controller
+kubectl logs -n flux-system deploy/kustomize-controller
+
+# Подробности о конкретном ресурсе
+flux get kustomization shop-backend --watch
+
+# Проверить что IngressRoute применился
+kubectl get ingressroute -n traefik
+
+# Проверить что PVC создан и привязан
+kubectl get pvc -n shop-production
+
+# Проверить том в Longhorn
+kubectl get volumes.longhorn.io -n longhorn-system
+```
+
+---
+
+## GitLab Agent для Kubernetes (agentk)
+
+GitLab Agent (agentk) — официальный способ подключить кластер Kubernetes к GitLab.
+GitLab рекомендует использовать **Flux + agentk вместе**: Flux синхронизирует состояние
+кластера с Git, agentk даёт видимость в GitLab UI и управление доступом.
+
+```
+┌─────────────────────────────────────────────────────┐
+│ GitLab: gitlab.gigacoms.info │
+│ GitLab UI → Operate → Kubernetes clusters │
+│ GitLab UI → Operate → Environments (k8s dashboard) │
+└──────────────────────┬──────────────────────────────┘
+ │ исходящее соединение (WebSocket)
+ │ агент сам подключается к GitLab KAS
+┌──────────────────────▼──────────────────────────────┐
+│ Kubernetes cluster │
+│ namespace: gitlab-agent │
+│ agentk pod ─────────────────────────────────→ │
+│ kubectl / Flux API │
+└─────────────────────────────────────────────────────┘
+```
+
+> **Важно:** agentk сам инициирует соединение **наружу** к GitLab KAS
+> (Kubernetes Agent Server) по WebSocket/gRPC. Входящих портов открывать не нужно.
+> Работает за NAT и firewall — подходит для текущей инфраструктуры.
+
+### Что даёт agentk
+
+| Возможность | Описание |
+|---|---|
+| Kubernetes dashboard в GitLab UI | Обзор подов, деплойментов, namespace-ов прямо в GitLab |
+| Статус Flux-объектов | Видимость `Kustomization` и `HelmRelease` в окружениях |
+| Ручная синхронизация | Suspend/resume Flux reconciliation из GitLab UI |
+| Логи подов | Просмотр логов контейнеров в GitLab → Environments |
+| CI/CD-доступ к кластеру | `kubectl` в GitLab CI без прямого доступа к kubeconfig |
+| RBAC-управление | Ограничение доступа к namespace-ам для разных проектов |
+
+### Установка
+
+#### Шаг 1 — Создать конфигурацию агента в GitLab
+
+В fleet-репозитории создать файл:
+
+```
+.gitlab/agents/production/config.yaml
+```
+
+Минимальная конфигурация для Flux + видимости в UI:
+
+```yaml
+# .gitlab/agents/production/config.yaml
+
+gitops:
+ reconcile_timeout: 3600s
+
+observability:
+ logging:
+ level: info
+
+ci_access:
+ # Разрешить GitLab CI-пайплайнам этого проекта использовать агент
+ projects:
+ - id: mygroup/my-app
+ - id: mygroup/shop
+ # Или разрешить всей группе:
+ # groups:
+ # - id: mygroup
+```
+
+#### Шаг 2 — Зарегистрировать агент и получить токен
+
+В GitLab: **Infrastructure → Kubernetes clusters → Connect a cluster** (или через `glab`):
+
+```bash
+# Через GitLab CLI
+glab cluster agent bootstrap production \
+ --repo mygroup/k8s-fleet
+```
+
+Или вручную: GitLab → проект → **Operate → Kubernetes clusters → Connect a cluster** →
+ввести имя `production` → скопировать токен.
+
+#### Шаг 3 — Установить agentk в кластер через Helm
+
+```bash
+helm repo add gitlab https://charts.gitlab.io
+helm repo update
+
+helm upgrade --install gitlab-agent gitlab/gitlab-agent \
+ --namespace gitlab-agent \
+ --create-namespace \
+ --set config.token= \
+ --set config.kasAddress=wss://gitlab.gigacoms.info/-/kubernetes-agent/
+```
+
+Параметры:
+
+| Параметр | Значение для вашего стека |
+|---|---|
+| `config.token` | Токен из GitLab (шаг 2) |
+| `config.kasAddress` | `wss://gitlab.gigacoms.info/-/kubernetes-agent/` |
+| `namespace` | `gitlab-agent` |
+
+> KAS (Kubernetes Agent Server) уже встроен в GitLab начиная с версии 14.4.
+> Для self-hosted GitLab он доступен по пути `/-/kubernetes-agent/`.
+
+#### Установка через Ansible-роль
+
+Роль `roles/gitlab_agent/` уже реализована в проекте.
+Playbook: `playbooks/setup_gitlab_agent.yml`.
+CI-переменная: `GITLAB_AGENT_TOKEN`.
+
+### Просмотр кластера в GitLab UI
+
+После установки agentk в GitLab появится:
+
+- **Operate → Kubernetes clusters** — список подключённых кластеров
+- **Operate → Environments** → выбрать окружение → вкладка **Kubernetes** — статус подов
+- Flux-объекты (`Kustomization`, `HelmRelease`) — в окружении после настройки namespace
+
+Для отображения Flux-объектов в окружении указать namespace в настройках environment:
+
+```yaml
+# .gitlab-ci.yml — окружение с привязкой к namespace
+deploy:
+ environment:
+ name: production
+ kubernetes:
+ namespace: flux-system
+```
+
+### Связка Flux + agentk
+
+Рекомендованная GitLab архитектура:
+
+```
+Разработчик пушит в GitLab-проект
+ ↓
+GitLab CI: сборка образа → пуш в GitLab Registry
+ ↓
+CI обновляет тег образа в fleet-repo (patch-image.yaml)
+ ↓
+Flux kustomize-controller применяет изменение:
+ - StatefulSet postgres → том из Longhorn (storageClassName: longhorn)
+ - Deployment frontend/backend → контейнеры обновлены
+ - IngressRoute → маршрут в Traefik (порт 10005)
+ ↓
+agentk транслирует статус обратно в GitLab UI
+```
+
+Flux отвечает за **деплой**, agentk — за **видимость и доступ**.
+
+### Сетевые требования
+
+Agentk устанавливает исходящее соединение, дополнительных firewalld-правил не требуется:
+
+- Кластер → `gitlab.gigacoms.info:443` (WebSocket upgrade) — уже открыто
+
+---
+
+## Альтернативы
+
+| Инструмент | Плюсы | Минусы |
+|---|---|---|
+| **Flux CD** | Нативный GitOps, много CRD, image automation | Сложнее начать, несколько контроллеров |
+| **ArgoCD** | Удобный UI, проще для начала | Один монолитный процесс, нет image automation |
+| **GitLab Agent (agentk)** | Нативная интеграция с GitLab UI | Требует постоянного соединения с GitLab |
+
+GitLab официально рекомендует связку **Flux + agentk**: Flux синхронизирует состояние,
+agentk обеспечивает видимость в GitLab UI и управление доступом.
+
+---
+
+## Рекомендуемый план внедрения
+
+1. **Создать fleet-репозиторий** `k8s-fleet` в GitLab-группе на `gitlab.gigacoms.info`
+2. **Ansible-роль `flux`** уже готова — запустить `playbooks/setup_flux.yml`
+3. **Добавить первое приложение** через `GitRepository` + `Kustomization`:
+ - PVC с `storageClassName: longhorn` если нужно хранилище
+ - IngressRoute в namespace `traefik` если нужен внешний доступ (порт предварительно зарезервировать в `traefik_port_map`)
+4. **Ознакомиться со стандартами** компонентов:
+ - `roles/flux/README.md` — структура fleet-repo, многоветочный деплой, все примеры
+ - `roles/longhorn/README.md` — PVC, StatefulSet, реплики, диагностика
+ - `roles/traefik/README.md` — port_map, IngressRoute, BasicAuth, таблица портов
+5. **Опционально**: настроить Image Automation для автодеплоя по новому тегу образа
+
+---
+
+## Источники
+
+- [Flux — официальный сайт](https://fluxcd.io/)
+- [Flux bootstrap для GitLab](https://fluxcd.io/flux/installation/bootstrap/gitlab/)
+- [flux bootstrap gitlab — справка по команде](https://fluxcd.io/flux/cmd/flux_bootstrap_gitlab/)
+- [GitOps компоненты Flux](https://fluxcd.io/flux/components/)
+- [HelmRelease CRD](https://fluxcd.io/flux/components/helm/helmreleases/)
+- [Kustomization CRD](https://fluxcd.io/flux/components/kustomize/kustomizations/)
+- [GitLab: Using GitOps with a Kubernetes cluster](https://docs.gitlab.com/user/clusters/agent/gitops/)
+- [GitLab: интеграция с Flux CD](https://about.gitlab.com/blog/why-did-we-choose-to-integrate-fluxcd-with-gitlab/)
+- [Пример flux2-kustomize-helm](https://github.com/fluxcd/flux2-kustomize-helm-example)
+- Стандарт хранилища кластера: `roles/longhorn/README.md`
+- Стандарт публикации сервисов: `roles/traefik/README.md`
+- Стандарт GitOps-структуры fleet-repo: `roles/flux/README.md`
diff --git a/research/logging/plg.md b/research/logging/plg.md
new file mode 100644
index 0000000..6eb6abd
--- /dev/null
+++ b/research/logging/plg.md
@@ -0,0 +1,1275 @@
+# Сбор логов в Kubernetes: PLG-стек и переезд на Graylog
+
+**Кластер:** Rocky Linux 9 · Kubernetes 1.33 · Flannel CNI
+**Текущая топология:** k8s-manager-01 (10.203.0.92) · k8s-master-01 (10.203.0.97) · k8s-worker-01 (10.203.0.96)
+**Дата исследования:** 2026-05-28
+**Проверено на кластере:** 2026-05-28
+
+> **⚠️ ВАЖНО — EOL-статус компонентов (проверено 2026-05-28):**
+>
+> | Компонент | Статус | Детали |
+> |---|---|---|
+> | **Promtail** | **EOL с 2 марта 2026** | [Официальное объявление Grafana Labs](https://community.grafana.com/t/promtail-end-of-life-eol-march-2026-how-to-migrate-to-grafana-alloy-for-existing-loki-server-deployments/159636). Баги и CVE не исправляются. Замена: **Grafana Alloy** |
+> | **Loki** | Активно развивается | Последняя: v3.6.7. Helm chart 7.0.0 актуален. EOL не объявлен |
+> | **Grafana** | Активно развивается | EOL не объявлен |
+>
+> **Все разделы про Promtail (5, 9, 11) обновлены на Grafana Alloy.**
+
+---
+
+## Состояние кластера на дату исследования
+
+```
+MinIO (namespace: minio)
+ pod/minio-696468d6b6-wnm7c 1/1 Running
+ service/minio ClusterIP 10.101.211.198 9000/TCP
+ service/minio-console ClusterIP 10.111.84.4 9001/TCP
+ pvc/minio Bound 1Ti longhorn-minio (Retain)
+ IngressRoutes: route-minio-api (10005), route-minio-console (10006) ✓
+ Buckets: loki-chunks ✓ backups ✓ artifacts ✓ (все пустые)
+ Пользователь loki: не создан ← нужно создать перед деплоем Loki
+
+Namespace monitoring: не существует ← PLG не развёрнут
+StorageClasses: longhorn (default), longhorn-minio (Retain), longhorn-static
+```
+
+**Вывод:** MinIO полностью готов к роли S3 backend для Loki. Filesystem-режим не нужен — Loki деплоится сразу с S3/MinIO.
+
+---
+
+## Содержание
+
+1. [Обзор проблемы](#1-обзор-проблемы)
+2. [PLG-стек: архитектура](#2-plg-стек-архитектура)
+3. [Подготовка MinIO для Loki](#3-подготовка-minio-для-loki)
+4. [Loki: хранение в MinIO (S3)](#4-loki-хранение-в-minio-s3)
+5. [Grafana Alloy: сбор логов с нод](#5-grafana-alloy-сбор-логов-с-нод) *(Promtail EOL 2026-03-02)*
+6. [Grafana: визуализация и алерты](#6-grafana-визуализация-и-алерты)
+6а. [Мониторинг ресурсов кластера: Prometheus stack](#6а-мониторинг-ресурсов-кластера-prometheus-stack)
+7. [Grafana as Code: хранение настроек в GitLab](#7-grafana-as-code-хранение-настроек-в-gitlab)
+8. [Интеграция с Traefik](#8-интеграция-с-traefik)
+9. [Ansible-роли и плейбуки](#9-ansible-роли-и-плейбуки)
+10. [Масштабирование до 10 нод / 100 сервисов](#10-масштабирование-до-10-нод--100-сервисов)
+11. [Переезд на Graylog](#11-переезд-на-graylog)
+12. [Итоговое сравнение и рекомендации](#12-итоговое-сравнение-и-рекомендации)
+
+---
+
+## 1. Обзор проблемы
+
+В кластере Kubernetes логи существуют на трёх уровнях:
+
+| Уровень | Источник | Путь на ноде |
+|---|---|---|
+| Приложения | stdout/stderr контейнеров | `/var/log/pods/__//N.log` |
+| Control plane | apiserver, etcd, scheduler, controller-manager | `journald` (Rocky Linux 9) |
+| Нода | kubelet, kube-proxy, containerd | `journald` |
+
+containerd по умолчанию ротирует файлы логов (10 MB × 5 файлов), что означает потерю истории при интенсивной нагрузке. Без централизованного сбора:
+
+- Логи недоступны после рестарта пода
+- Нет единого поиска по неймспейсам
+- Нет алертов на паттерны ошибок
+- Корреляция событий между сервисами невозможна
+
+---
+
+## 2. PLG-стек: архитектура
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ k8s-worker-01 / k8s-master-01 │
+│ │
+│ [Pod stdout] → containerd → /var/log/pods/ │
+│ ↓ │
+│ [Grafana Alloy DaemonSet] (файлы + journald) │
+└───────────────────────┬──────────────────────────────────────────┘
+ │ HTTP push (loki.write)
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ Loki (namespace: monitoring) │
+│ single-binary pod │
+│ ├── Ingester — буферизует и пишет чанки │
+│ ├── Querier — выполняет LogQL запросы │
+│ └── Compactor — уплотняет, применяет retention │
+└───────────────────────┬──────────────────────────────────────────┘
+ │ S3 API (chunks + index)
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ MinIO (namespace: minio) — УЖЕ ЗАПУЩЕН ✓ │
+│ pod/minio-696468d6b6-wnm7c Running │
+│ pvc/minio 1Ti longhorn-minio │
+│ bucket: loki-chunks ✓ (пустой, готов) │
+│ http://minio.minio.svc.cluster.local:9000 │
+└──────────────────────────────────────────────────────────────────┘
+ │ datasource
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ Grafana (namespace: monitoring) │
+│ ├── Loki datasource — LogQL explore + dashboards │
+│ └── Alerting — правила на паттерны ошибок │
+│ Traefik → port 10003 → http://10.203.0.96:10003 │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### Выбор компонентов
+
+| Компонент | Helm chart | Версия chart | Статус |
+|---|---|---|---|
+| Loki | `grafana/loki` | 7.0.0 | ✅ Актуален |
+| ~~Promtail~~ → **Grafana Alloy** | `grafana/alloy` | 1.8.2 | ⚠️ Promtail EOL 2026-03-02; Alloy — официальная замена |
+| Grafana | `grafana/grafana` | 10.5.15 | ✅ Актуален |
+
+---
+
+## 3. Подготовка MinIO для Loki
+
+MinIO уже запущен. Требуется только создать выделенного пользователя с доступом исключительно к бакету `loki-chunks`.
+
+### Создание пользователя loki
+
+```bash
+# Выполнить на k8s-manager-01 или через kubectl exec
+kubectl exec -n minio deployment/minio -- \
+ mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
+
+# Создать пользователя
+kubectl exec -n minio deployment/minio -- \
+ mc admin user add local loki ПАРОЛЬ_LOKI
+
+# Создать политику — только бакет loki-chunks
+kubectl exec -n minio deployment/minio -- \
+ mc admin policy create local loki-policy /dev/stdin <<'EOF'
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": ["s3:GetObject","s3:PutObject","s3:DeleteObject","s3:ListBucket","s3:GetBucketLocation"],
+ "Resource": [
+ "arn:aws:s3:::loki-chunks",
+ "arn:aws:s3:::loki-chunks/*"
+ ]
+ }
+ ]
+}
+EOF
+
+# Привязать политику
+kubectl exec -n minio deployment/minio -- \
+ mc admin policy attach local loki-policy --user loki
+```
+
+### Secret для Loki в namespace monitoring
+
+```bash
+kubectl create namespace monitoring
+
+kubectl create secret generic loki-minio-secret \
+ --from-literal=AWS_ACCESS_KEY_ID=loki \
+ --from-literal=AWS_SECRET_ACCESS_KEY='ПАРОЛЬ_LOKI' \
+ -n monitoring
+```
+
+### Проверка доступа
+
+```bash
+# Проверить что loki-user видит только свой бакет
+kubectl exec -n minio deployment/minio -- \
+ mc alias set loki-test http://localhost:9000 loki ПАРОЛЬ_LOKI
+
+kubectl exec -n minio deployment/minio -- \
+ mc ls loki-test
+# Ожидаем: [дата] 0B loki-chunks/
+
+kubectl exec -n minio deployment/minio -- \
+ mc ls loki-test/loki-chunks
+# Ожидаем: пустой список (бакет пустой и готов)
+```
+
+---
+
+## 4. Loki: хранение в MinIO (S3)
+
+Loki деплоится сразу в режиме **single-binary с S3 backend** — MinIO уже в кластере, filesystem не нужен.
+
+### Helm values: `loki-values.yml`
+
+```yaml
+loki:
+ auth_enabled: false
+
+ commonConfig:
+ replication_factor: 1
+
+ # S3 backend — MinIO в namespace minio
+ storage:
+ type: s3
+ s3:
+ endpoint: http://minio.minio.svc.cluster.local:9000
+ region: us-east-1 # MinIO игнорирует region, поле обязательно
+ bucketnames: loki-chunks
+ access_key_id: loki
+ secret_access_key: "${LOKI_MINIO_SECRET_KEY}" # из Secret через envFrom
+ insecure: true # http, не https
+ s3forcepathstyle: true # обязательно для MinIO
+
+ schemaConfig:
+ configs:
+ - from: "2024-01-01"
+ store: tsdb
+ object_store: s3
+ schema: v13
+ index:
+ prefix: loki_index_
+ period: 24h
+
+ limits_config:
+ retention_period: 744h # 31 день
+ ingestion_rate_mb: 16
+ ingestion_burst_size_mb: 32
+ max_streams_per_user: 10000
+ max_chunks_per_query: 2000000
+
+ compactor:
+ working_directory: /loki/compactor
+ retention_enabled: true
+ delete_request_store: s3 # ← s3, не filesystem
+
+singleBinary:
+ replicas: 1
+ # Нет PVC — данные в MinIO, только небольшой volume для WAL
+ persistence:
+ enabled: true
+ storageClass: longhorn
+ size: 10Gi # только WAL/temp, не логи
+
+ extraEnv:
+ - name: LOKI_MINIO_SECRET_KEY
+ valueFrom:
+ secretKeyRef:
+ name: loki-minio-secret
+ key: AWS_SECRET_ACCESS_KEY
+
+# Отключаем встроенные компоненты (используем Promtail отдельно)
+gateway:
+ enabled: false
+backend:
+ replicas: 0
+read:
+ replicas: 0
+write:
+ replicas: 0
+```
+
+### Объём хранилища: расчёт для текущего кластера
+
+```
+2 ноды, ~20 подов, средний lograte 1 KB/s:
+ 20 pods × 1 KB/s × 86400 s/day ≈ 1.7 GB/day (raw)
+ Loki сжимает ~10:1 → ~170 MB/day
+ 31 день retention → ~5 GB в loki-chunks бакете MinIO
+
+MinIO PVC = 1 TiB → запас до ~100 нод (≈ 500 MB/day × 31 дней ≈ 15 GB)
+```
+
+### Проверка после деплоя
+
+```bash
+# Статус пода
+kubectl get pod -n monitoring -l app.kubernetes.io/name=loki
+
+# Готовность API
+kubectl exec -n monitoring deployment/loki -- wget -qO- http://localhost:3100/ready
+
+# Появились ли объекты в MinIO
+kubectl exec -n minio deployment/minio -- mc ls local/loki-chunks --recursive | head -20
+```
+
+---
+
+## 5. Grafana Alloy: сбор логов с нод
+
+> **Promtail EOL с 2 марта 2026.** Официальная замена — **Grafana Alloy** (`grafana/alloy`, chart 1.8.2).
+> Alloy — дистрибутив OpenTelemetry Collector от Grafana Labs. Собирает логи, метрики и трейсы единым агентом.
+> Конфиг Promtail конвертируется автоматически: `alloy convert --source-format=promtail --output=config.alloy promtail.yml`
+
+### Что собирает
+
+- `/var/log/pods/**/*.log` — логи всех контейнеров (через `loki.source.kubernetes`)
+- `journald` — kubelet, kube-proxy, containerd, sshd (через `loki.source.journal`)
+
+### Как работает Alloy
+
+Alloy использует собственный язык конфигурации (River/Alloy syntax), а не YAML. Конфиг передаётся в Helm как строка в `alloy.configMap.content`.
+
+### Helm values: `alloy-values.yml`
+
+```yaml
+# Развёртывание как DaemonSet — один под на каждую ноду
+controller:
+ type: daemonset
+
+tolerations:
+ # Запускать и на control plane (k8s-master-01)
+ - key: node-role.kubernetes.io/control-plane
+ operator: Exists
+ effect: NoSchedule
+
+# Монтирование journald с хоста
+alloy:
+ mounts:
+ varlog: true # /var/log (поды)
+ dockercontainers: false
+
+extraVolumes:
+ - name: journal
+ hostPath:
+ path: /var/log/journal
+
+extraVolumeMounts:
+ - name: journal
+ mountPath: /var/log/journal
+ readOnly: true
+
+ configMap:
+ create: true
+ content: |
+ // ── Обнаружение подов Kubernetes ───────────────────────────────
+ discovery.kubernetes "pods" {
+ role = "pod"
+ }
+
+ // ── Relabeling: namespace, pod, container, app из метаданных ──
+ discovery.relabel "pod_logs" {
+ targets = discovery.kubernetes.pods.targets
+
+ rule {
+ source_labels = ["__meta_kubernetes_namespace"]
+ target_label = "namespace"
+ }
+ rule {
+ source_labels = ["__meta_kubernetes_pod_name"]
+ target_label = "pod"
+ }
+ rule {
+ source_labels = ["__meta_kubernetes_pod_container_name"]
+ target_label = "container"
+ }
+ rule {
+ source_labels = ["__meta_kubernetes_pod_label_app"]
+ target_label = "app"
+ }
+ // Отброс debug-логов на уровне агента
+ rule {
+ source_labels = ["__meta_kubernetes_pod_annotation_filter_debug"]
+ regex = "true"
+ action = "drop"
+ }
+ }
+
+ // ── Чтение файлов логов подов ──────────────────────────────────
+ loki.source.kubernetes "pods" {
+ targets = discovery.relabel.pod_logs.output
+ forward_to = [loki.process.parse.receiver]
+ }
+
+ // ── Парсинг JSON-логов, отброс debug ──────────────────────────
+ loki.process "parse" {
+ // Попытка распарсить как JSON и извлечь level
+ stage.json {
+ expressions = {level = "level", msg = "message"}
+ }
+ stage.labels {
+ values = {level = ""}
+ }
+ // Отброс debug-записей
+ stage.drop {
+ expression = ".*level=\"debug\".*"
+ drop_counter_reason = "debug_dropped"
+ }
+ forward_to = [loki.write.local.receiver]
+ }
+
+ // ── Сбор journald (kubelet, containerd, sshd) ─────────────────
+ loki.source.journal "systemd" {
+ path = "/var/log/journal"
+ max_age = "12h"
+ labels = {job = "systemd-journal"}
+ forward_to = [loki.write.local.receiver]
+
+ relabel_rules = discovery.relabel.journal.rules
+ }
+
+ discovery.relabel "journal" {
+ targets = []
+ rule {
+ source_labels = ["__journal__systemd_unit"]
+ target_label = "unit"
+ }
+ rule {
+ source_labels = ["__journal__hostname"]
+ target_label = "node"
+ }
+ }
+
+ // ── Отправка в Loki ────────────────────────────────────────────
+ loki.write "local" {
+ endpoint {
+ url = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push"
+ }
+ }
+```
+
+---
+
+## 6. Grafana: визуализация и алерты
+
+### Helm values: `grafana-values.yml`
+
+```yaml
+grafana.ini:
+ server:
+ root_url: http://10.203.0.96:10003
+ security:
+ admin_user: admin
+ auth.anonymous:
+ enabled: false
+ unified_alerting:
+ enabled: true
+ alerting:
+ enabled: false # legacy alerting отключаем
+
+admin:
+ existingSecret: grafana-admin-secret
+ userKey: admin-user
+ passwordKey: admin-password
+
+persistence:
+ enabled: true
+ storageClassName: longhorn
+ size: 5Gi
+
+# Datasources — provisioning при старте пода (Loki + Prometheus)
+datasources:
+ datasources.yaml:
+ apiVersion: 1
+ datasources:
+ - name: Loki
+ type: loki
+ url: http://loki.monitoring.svc.cluster.local:3100
+ access: proxy
+ isDefault: true
+ jsonData:
+ maxLines: 5000
+ derivedFields:
+ - name: TraceID
+ matcherRegex: '"trace_id":"(\w+)"'
+ url: '$${__value.raw}'
+ urlDisplayLabel: Open Trace
+
+ # Prometheus datasource — появляется после деплоя kube-prometheus-stack (раздел 6а)
+ - name: Prometheus
+ type: prometheus
+ url: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090
+ access: proxy
+ isDefault: false
+ jsonData:
+ timeInterval: 30s
+
+# Автоимпорт дашбордов из ConfigMap с label grafana_dashboard=1
+# Подхватывает: ручные дашборды + ConfigMaps от kube-prometheus-stack (раздел 6а)
+sidecar:
+ dashboards:
+ enabled: true
+ label: grafana_dashboard
+ labelValue: "1"
+ folder: /var/lib/grafana/dashboards/default
+ searchNamespace: ALL # искать ConfigMaps во всех namespace
+ datasources:
+ enabled: true
+ label: grafana_datasource
+
+resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+```
+
+### Создание Secret с паролем администратора
+
+```bash
+kubectl create secret generic grafana-admin-secret \
+ --from-literal=admin-user=admin \
+ --from-literal=admin-password='ВАШ_ПАРОЛЬ' \
+ -n monitoring
+```
+
+### Ключевые дашборды для Kubernetes
+
+| Дашборд | Grafana ID | Назначение |
+|---|---|---|
+| Kubernetes Cluster Logs | 15141 | Логи всех нод и неймспейсов |
+| Loki Dashboard | 13639 | Состояние самого Loki |
+| Pod Logs | 18748 | Логи конкретного пода с фильтрами |
+
+### Алерт на высокий уровень ошибок (provisioning)
+
+```yaml
+# ConfigMap: grafana-alert-rules, label: grafana_dashboard=1
+apiVersion: 1
+groups:
+ - orgId: 1
+ name: kubernetes-errors
+ folder: Kubernetes
+ interval: 1m
+ rules:
+ - uid: high-error-rate
+ title: High Error Rate
+ condition: C
+ data:
+ - refId: A
+ model:
+ expr: 'sum(rate({namespace=~".+"} |= "error" [5m])) by (namespace)'
+ - refId: C
+ type: classic_conditions
+ model:
+ conditions:
+ - evaluator:
+ type: gt
+ params: [10] # > 10 ошибок/сек
+ query:
+ params: [A]
+ for: 2m
+ annotations:
+ summary: "Высокий уровень ошибок в {{ $labels.namespace }}"
+```
+
+---
+
+## 6а. Мониторинг ресурсов кластера: Prometheus stack
+
+### Что покрывает PLG — и чего не хватает
+
+Текущий PLG-стек собирает **только логи**. Метрики (CPU, RAM, диски, сеть, состояние объектов k8s) не собираются вообще. Без метрик невозможно:
+
+- Видеть загрузку нод в реальном времени
+- Строить алерты на исчерпание RAM/диска **до** инцидента
+- Отслеживать статус Deployments, PVC, ReplicaSet
+- Мониторить latency и error rate API-сервера
+
+### Решение: kube-prometheus-stack
+
+`prometheus-community/kube-prometheus-stack` — стандартный Helm chart, который включает:
+
+| Компонент | Назначение |
+|---|---|
+| **Prometheus** | Сбор и хранение метрик (time-series DB, pull model) |
+| **node-exporter** | Метрики нод: CPU, RAM, диски, сеть (DaemonSet) |
+| **kube-state-metrics** | Метрики объектов k8s: pod count, deployment status, PVC |
+| **ServiceMonitors** | Auto-discovery k8s компонентов: kubelet, apiserver, etcd |
+| **Dashboard ConfigMaps** | Готовые дашборды Kubernetes для Grafana sidecar |
+
+Встроенную Grafana в chart отключаем (`grafana.enabled: false`) — используем уже развёрнутую. ConfigMaps с дашбордами создаём через `forceDeployDashboards: true` — Grafana sidecar подхватывает их автоматически.
+
+### Обновлённая архитектура
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ k8s-worker-01 / k8s-master-01 │
+│ │
+│ [Pod stdout] → /var/log/pods/ │
+│ [node-exporter DaemonSet] ← CPU/RAM/диск/сеть (метрики нод) │
+│ [Grafana Alloy DaemonSet] ← файлы + journald (логи) │
+└──────────┬────────────────────────┬─────────────────────────────┘
+ │ HTTP push (logs) │ HTTP scrape (metrics)
+ ▼ ▼
+┌──────────────────┐ ┌────────────────────────────┐
+│ Loki │ │ Prometheus │
+│ (monitoring) │ │ (monitoring) │
+│ S3 → MinIO │ │ PVC 20Gi (longhorn) │
+└────────┬─────────┘ └────────────┬───────────────┘
+ │ datasource │ datasource
+ └────────────┬─────────────┘
+ ▼
+ ┌────────────────────────────┐
+ │ Grafana (monitoring) │
+ │ ├── Loki datasource │ ← логи
+ │ ├── Prometheus datasource │ ← метрики
+ │ ├── Logs dashboards │ ← из ConfigMaps
+ │ └── K8s cluster dashboards│ ← из kube-prometheus-stack
+ │ Traefik → :10003 │
+ └────────────────────────────┘
+```
+
+### Helm values: `prometheus-values.yml`
+
+```yaml
+# Встроенную Grafana отключаем — используем свою
+grafana:
+ enabled: false
+ # Создать ConfigMaps с готовыми k8s дашбордами для нашего Grafana sidecar
+ forceDeployDashboards: true
+ sidecar:
+ dashboards:
+ label: grafana_dashboard
+ labelValue: "1"
+
+prometheus:
+ prometheusSpec:
+ # Хранение метрик на Longhorn PVC
+ storageSpec:
+ volumeClaimTemplate:
+ spec:
+ storageClassName: longhorn
+ accessModes: ["ReadWriteOnce"]
+ resources:
+ requests:
+ storage: 20Gi
+ retention: 30d
+ scrapeInterval: 30s
+ evaluationInterval: 30s
+
+ # Разрешить сбор метрик из всех namespace
+ ruleSelectorNilUsesHelmValues: false
+ serviceMonitorSelectorNilUsesHelmValues: false
+ podMonitorSelectorNilUsesHelmValues: false
+
+alertmanager:
+ enabled: false # алерты через Grafana Alerting
+
+nodeExporter:
+ enabled: true
+
+kubeStateMetrics:
+ enabled: true
+
+# Rocky Linux 9: явно указать IP control plane для etcd/scheduler/controller-manager
+kubeControllerManager:
+ enabled: true
+ endpoints:
+ - 10.203.0.97
+ service:
+ enabled: true
+ port: 10257
+ targetPort: 10257
+
+kubeScheduler:
+ enabled: true
+ endpoints:
+ - 10.203.0.97
+ service:
+ enabled: true
+ port: 10259
+ targetPort: 10259
+
+kubeEtcd:
+ enabled: true
+ endpoints:
+ - 10.203.0.97
+ service:
+ enabled: true
+ port: 2381
+ targetPort: 2381
+
+kubeProxy:
+ enabled: true
+ endpoints:
+ - 10.203.0.97
+ - 10.203.0.96
+```
+
+> **Rocky Linux 9 / kubeadm gotcha:** kubeadm по умолчанию настраивает `controller-manager` и `scheduler` слушать только `127.0.0.1`. Если метрики этих компонентов нужны — потребуется патч `/etc/kubernetes/manifests/kube-controller-manager.yaml` и `kube-scheduler.yaml`: заменить `--bind-address=127.0.0.1` на `--bind-address=0.0.0.0`. Остальные компоненты (kubelet, node-exporter, kube-state-metrics, etcd на порту 2381) работают без изменений.
+
+### Готовые дашборды после деплоя
+
+`forceDeployDashboards: true` создаёт ConfigMaps в namespace `monitoring`. Grafana sidecar подхватывает их автоматически при старте:
+
+| Дашборд | Что показывает |
+|---|---|
+| Kubernetes / Compute Resources / Cluster | CPU/RAM/сеть по неймспейсам |
+| Kubernetes / Compute Resources / Node (Pods) | Ресурсы по подам на конкретной ноде |
+| Kubernetes / Compute Resources / Workload | Потребление по Deployment/StatefulSet |
+| Node Exporter / Nodes | CPU, RAM, диски, сеть по нодам |
+| Kubernetes / Persistent Volumes | Статус и заполнение PVC |
+| Kubernetes / API server | Latency, error rate, RPS API-сервера |
+| Kubernetes / Kubelet | Состояние kubelet на каждой ноде |
+| Kubernetes / etcd | Состояние etcd, latency, лидер |
+
+### Datasource Prometheus в Grafana
+
+Добавляется в `grafana-values.yml.j2` рядом с Loki:
+
+```yaml
+datasources:
+ datasources.yaml:
+ apiVersion: 1
+ datasources:
+ - name: Loki
+ type: loki
+ url: http://loki.monitoring.svc.cluster.local:3100
+ access: proxy
+ isDefault: true
+ jsonData:
+ maxLines: 5000
+
+ - name: Prometheus
+ type: prometheus
+ url: http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090
+ access: proxy
+ isDefault: false
+ jsonData:
+ timeInterval: 30s
+```
+
+### Проверка после деплоя
+
+```bash
+# Статус подов Prometheus stack
+kubectl get pod -n monitoring -l release=kube-prometheus-stack
+
+# Метрики node-exporter доступны
+kubectl exec -n monitoring -l app.kubernetes.io/name=prometheus -- \
+ wget -qO- 'http://localhost:9090/api/v1/query?query=up' | python3 -m json.tool
+
+# ConfigMaps с дашбордами созданы (должно быть ~20+ штук)
+kubectl get configmap -n monitoring -l grafana_dashboard=1
+
+# Prometheus видит все таргеты (все должны быть UP)
+kubectl port-forward -n monitoring svc/kube-prometheus-stack-prometheus 9090:9090
+# → http://localhost:9090/targets
+```
+
+---
+
+## 7. Grafana as Code: хранение настроек в GitLab
+
+### Проблема
+
+Дашборды и datasource, созданные через UI Grafana, хранятся в SQLite внутри пода. При пересоздании пода — настройки теряются.
+
+### Решение: отдельный GitLab-проект `k8s/grafana-config`
+
+```
+gitlab.gigacoms.info/k8s/grafana-config
+├── dashboards/
+│ ├── kubernetes-logs.json # экспорт из UI: Share → Export JSON
+│ ├── loki-overview.json
+│ └── pod-errors.json
+├── provisioning/
+│ ├── datasources/
+│ │ └── loki.yaml
+│ ├── dashboards/
+│ │ └── provider.yaml
+│ └── alerting/
+│ ├── rules.yaml
+│ └── contact-points.yaml
+└── README.md
+```
+
+> Секреты (пароли, токены) — **не хранить в Git**. Создаются через `kubectl create secret` или GitLab CI Variables.
+
+### Flux: автосинхронизация ConfigMap из grafana-config репо
+
+Flux уже запущен в кластере (`flux bootstrap gitlab`). Добавить синхронизацию grafana-config:
+
+```yaml
+# clusters/production/monitoring/grafana-config-source.yaml
+apiVersion: source.toolkit.fluxcd.io/v1
+kind: GitRepository
+metadata:
+ name: grafana-config
+ namespace: flux-system
+spec:
+ interval: 1m
+ url: https://gitlab.gigacoms.info/k8s/grafana-config.git
+ secretRef:
+ name: flux-system # тот же PAT что используется для fleet-repo
+ ref:
+ branch: main
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: grafana-config
+ namespace: flux-system
+spec:
+ interval: 5m
+ path: ./
+ prune: true
+ sourceRef:
+ kind: GitRepository
+ name: grafana-config
+ targetNamespace: monitoring
+```
+
+Grafana sidecar подхватывает любой ConfigMap с label `grafana_dashboard: "1"` — Flux обновляет ConfigMap, Grafana перечитывает дашборды без рестарта.
+
+### Рабочий процесс изменения дашборда
+
+```
+Grafana UI → изменил дашборд
+ ↓
+Share → Export → Save to file → dashboard.json
+ ↓
+git commit в grafana-config репо
+ ↓
+Flux (~1 мин) → ConfigMap обновляется
+ ↓
+Grafana sidecar (~30 сек) → дашборд применён
+```
+
+---
+
+## 8. Интеграция с Traefik
+
+Добавить в `inventory/prod/group_vars/traefik.yml`:
+
+```yaml
+traefik_port_map:
+ # ... существующие записи (10001 dashboard, 10002 longhorn, 10005 minio-api, 10006 minio-console) ...
+
+ - name: grafana
+ description: "Grafana (Logs & Metrics)"
+ port: 10003
+ backend:
+ namespace: monitoring
+ service: grafana
+ port: 80 # service port (targetPort: 3000 — внутренний порт контейнера)
+ scheme: http
+ basicauth:
+ enabled: false # Grafana имеет собственную аутентификацию
+```
+
+После добавления запустить `setup_traefik.yml`.
+
+**Grafana:** http://10.203.0.96:10003
+
+> Порт 10003 выбран как следующий свободный после 10001 (dashboard), 10002 (longhorn), 10005 (minio-api), 10006 (minio-console).
+
+---
+
+## 9. Ansible-роли и плейбуки
+
+### Структура роли `roles/logging/`
+
+```
+roles/logging/
+├── defaults/main.yml
+├── tasks/
+│ ├── main.yml
+│ ├── namespace.yml — создать namespace monitoring
+│ ├── minio-user.yml — создать пользователя loki в MinIO + Secret
+│ ├── loki.yml — helm install loki (S3 backend)
+│ ├── alloy.yml — helm install grafana/alloy (DaemonSet)
+│ ├── prometheus.yml — helm install kube-prometheus-stack (метрики + дашборды)
+│ └── grafana.yml — helm install grafana (Loki + Prometheus datasources)
+└── templates/
+ ├── loki-values.yml.j2
+ ├── alloy-values.yml.j2
+ ├── prometheus-values.yml.j2
+ └── grafana-values.yml.j2
+```
+
+### `defaults/main.yml`
+
+```yaml
+logging_namespace: monitoring
+
+loki_chart_version: "7.0.0"
+alloy_chart_version: "1.8.2"
+grafana_chart_version: "10.5.15"
+prometheus_stack_chart_version: "68.4.4" # prometheus-community/kube-prometheus-stack
+
+# MinIO — уже в кластере, только реквизиты
+loki_minio_endpoint: "http://minio.minio.svc.cluster.local:9000"
+loki_minio_bucket: "loki-chunks"
+loki_minio_user: "loki"
+loki_minio_secret_name: "loki-minio-secret"
+
+loki_retention_days: 31
+loki_wal_storage_size: "10Gi"
+loki_wal_storage_class: longhorn
+
+prometheus_storage_size: "20Gi"
+prometheus_storage_class: longhorn
+prometheus_retention: "30d"
+
+grafana_storage_size: "5Gi"
+grafana_storage_class: longhorn
+grafana_admin_secret: "grafana-admin-secret"
+grafana_root_url: "http://10.203.0.96:10003"
+```
+
+### `tasks/minio-user.yml`
+
+```yaml
+- name: Loki | Create MinIO user for loki
+ kubernetes.core.k8s_exec:
+ namespace: minio
+ pod: "{{ minio_pod.resources[0].metadata.name }}"
+ command: >
+ mc admin user add local {{ loki_minio_user }} {{ loki_minio_password }}
+ no_log: true
+
+- name: Loki | Attach loki-policy in MinIO
+ kubernetes.core.k8s_exec:
+ namespace: minio
+ pod: "{{ minio_pod.resources[0].metadata.name }}"
+ command: mc admin policy attach local loki-policy --user {{ loki_minio_user }}
+
+- name: Loki | Create Secret with MinIO credentials
+ kubernetes.core.k8s:
+ state: present
+ definition:
+ apiVersion: v1
+ kind: Secret
+ metadata:
+ name: "{{ loki_minio_secret_name }}"
+ namespace: "{{ logging_namespace }}"
+ stringData:
+ AWS_ACCESS_KEY_ID: "{{ loki_minio_user }}"
+ AWS_SECRET_ACCESS_KEY: "{{ loki_minio_password }}"
+ no_log: true
+```
+
+### `tasks/main.yml`
+
+```yaml
+---
+- ansible.builtin.import_tasks: namespace.yml
+- ansible.builtin.import_tasks: minio-user.yml
+- ansible.builtin.import_tasks: loki.yml
+- ansible.builtin.import_tasks: alloy.yml
+- ansible.builtin.import_tasks: prometheus.yml # метрики + готовые дашборды кластера
+- ansible.builtin.import_tasks: grafana.yml
+```
+
+### `tasks/prometheus.yml`
+
+```yaml
+---
+- name: Prometheus | Add prometheus-community Helm repo
+ kubernetes.core.helm_repository:
+ name: prometheus-community
+ repo_url: https://prometheus-community.github.io/helm-charts
+
+- name: Prometheus | Render values
+ ansible.builtin.template:
+ src: prometheus-values.yml.j2
+ dest: /tmp/prometheus-values.yml
+ mode: "0600"
+
+- name: Prometheus | Install kube-prometheus-stack
+ kubernetes.core.helm:
+ name: kube-prometheus-stack
+ chart_ref: prometheus-community/kube-prometheus-stack
+ chart_version: "{{ prometheus_stack_chart_version }}"
+ release_namespace: "{{ logging_namespace }}"
+ create_namespace: false
+ values_files:
+ - /tmp/prometheus-values.yml
+ wait: true
+ wait_timeout: 600s
+```
+
+### `templates/prometheus-values.yml.j2`
+
+```yaml
+grafana:
+ enabled: false
+ forceDeployDashboards: true
+ sidecar:
+ dashboards:
+ label: grafana_dashboard
+ labelValue: "1"
+
+prometheus:
+ prometheusSpec:
+ storageSpec:
+ volumeClaimTemplate:
+ spec:
+ storageClassName: {{ prometheus_storage_class }}
+ accessModes: ["ReadWriteOnce"]
+ resources:
+ requests:
+ storage: {{ prometheus_storage_size }}
+ retention: {{ prometheus_retention }}
+ scrapeInterval: 30s
+ evaluationInterval: 30s
+ ruleSelectorNilUsesHelmValues: false
+ serviceMonitorSelectorNilUsesHelmValues: false
+ podMonitorSelectorNilUsesHelmValues: false
+
+alertmanager:
+ enabled: false
+
+nodeExporter:
+ enabled: true
+
+kubeStateMetrics:
+ enabled: true
+
+kubeControllerManager:
+ enabled: true
+ endpoints:
+{% for host in groups['control_plane'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
+ service:
+ enabled: true
+ port: 10257
+ targetPort: 10257
+
+kubeScheduler:
+ enabled: true
+ endpoints:
+{% for host in groups['control_plane'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
+ service:
+ enabled: true
+ port: 10259
+ targetPort: 10259
+
+kubeEtcd:
+ enabled: true
+ endpoints:
+{% for host in groups['control_plane'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
+ service:
+ enabled: true
+ port: 2381
+ targetPort: 2381
+
+kubeProxy:
+ enabled: true
+ endpoints:
+{% for host in groups['k8s_cluster'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
+```
+
+### `playbooks/setup_logging.yml`
+
+```yaml
+---
+- name: Deploy PLG + Prometheus monitoring stack
+ hosts: manager_nodes
+ become: false
+ roles:
+ - logging
+```
+
+### `.gitlab-ci.yml` — новый job
+
+```yaml
+setup/logging:
+ stage: setup
+ script:
+ - ansible-playbook -i inventory/prod playbooks/setup_logging.yml
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+```
+
+### GitLab CI/CD Variables (добавить)
+
+| Переменная | Описание |
+|---|---|
+| `LOKI_MINIO_PASSWORD` | Пароль пользователя `loki` в MinIO |
+| `GRAFANA_ADMIN_PASSWORD` | Пароль администратора Grafana |
+
+---
+
+## 10. Масштабирование до 10 нод / 100 сервисов
+
+### Promtail
+
+Не требует изменений — DaemonSet автоматически запустится на новых нодах. Убедиться, что `tolerations` включают control-plane taint (уже в конфиге выше).
+
+### Loki: переход на distributed-mode
+
+Триггер для перехода: объём > 50 GB/day или > 50 одновременных запросов Grafana.
+
+```yaml
+# loki-values.yml — distributed режим
+loki:
+ deploymentMode: Distributed
+
+ingester:
+ replicas: 3
+querier:
+ replicas: 2
+distributor:
+ replicas: 2
+compactor:
+ replicas: 1
+queryFrontend:
+ replicas: 2
+
+# S3 backend остаётся тем же — только меняется deploymentMode
+loki:
+ storage:
+ type: s3
+ s3:
+ endpoint: http://minio.minio.svc.cluster.local:9000
+ # ... те же параметры MinIO
+```
+
+**Преимущество текущей архитектуры:** переход single-binary → distributed не требует миграции данных — бакет `loki-chunks` в MinIO остаётся тем же, меняется только Helm release Loki.
+
+### Расчёт ресурсов при 10 нодах / 100 сервисах
+
+```
+100 сервисов × средний lograte 2 KB/s = 17 GB/day (raw)
+Сжатие Loki ~10:1 → 1.7 GB/day
+31 день → ~53 GB в loki-chunks бакете MinIO
+
+MinIO PVC = 1 TiB → запас ~19x от текущей нагрузки
+```
+
+---
+
+## 11. Переезд на Graylog
+
+### Когда переезд оправдан
+
+| Триггер | Описание |
+|---|---|
+| Нужен поиск по содержимому | Loki: grep по чанкам; Graylog: полнотекстовый индекс OpenSearch |
+| Нужны встроенные Pipelines | Нормализация, маскирование PII без Logstash |
+| Встроенные алерты без Grafana | Graylog имеет собственный alerting |
+| Объём > 100 GB/day | Loki становится менее эффективным для full-text search |
+
+### Архитектура Graylog
+
+```
+[Grafana Alloy / Fluent Bit] ← агент остаётся (Promtail EOL)
+ ↓ GELF HTTP (port 12202)
+[Graylog Server]
+ ↓ ↓
+[OpenSearch] [MongoDB]
+(хранит логи) (конфиг, метаданные)
+ ↓
+Traefik → port 10004 → http://10.203.0.96:10004
+```
+
+### MinIO при переезде на Graylog
+
+MinIO продолжает использоваться — OpenSearch поддерживает S3 snapshot repository. Бэкапы индексов OpenSearch можно хранить в бакете `backups` MinIO:
+
+```bash
+# Настройка S3 snapshot repository в OpenSearch
+curl -X PUT http://opensearch:9200/_snapshot/minio-backup \
+ -H 'Content-Type: application/json' -d '{
+ "type": "s3",
+ "settings": {
+ "bucket": "backups",
+ "endpoint": "minio.minio.svc.cluster.local:9000",
+ "protocol": "http",
+ "path_style_access": "true"
+ }
+ }'
+```
+
+### Стратегия миграции: Dual-write
+
+Alloy поддерживает несколько `loki.write` и `otelcol.exporter` — dual-write настраивается в его конфиге:
+
+```alloy
+// alloy config — dual-write период (2 недели)
+loki.write "loki_backend" {
+ endpoint {
+ url = "http://loki.monitoring.svc.cluster.local:3100/loki/api/v1/push"
+ }
+}
+
+// Экспорт в Graylog через OTLP или loki.write с GELF-форматом
+otelcol.exporter.otlphttp "graylog" {
+ client {
+ endpoint = "http://graylog.monitoring.svc.cluster.local:4318"
+ }
+}
+```
+
+### Порядок действий при переезде
+
+```
+1. Развернуть OpenSearch + MongoDB + Graylog (параллельно с PLG)
+2. Настроить Graylog inputs (OTLP HTTP port 4318 или GELF HTTP port 12202)
+3. Переключить Grafana Alloy на dual-write (loki.write + otelcol.exporter.otlphttp)
+4. Настроить Streams, Pipelines, алерты в Graylog
+5. Добавить порт 10004 в traefik_port_map → http://10.203.0.96:10004
+6. Переключить команду на Graylog UI
+7. Через 2 недели: убрать loki.write из конфига Alloy
+8. Удалить Loki Helm release (бакет loki-chunks оставить для истории)
+9. Grafana остаётся для метрик Prometheus; Alloy продолжает работать как агент Graylog
+```
+
+---
+
+## 12. Итоговое сравнение и рекомендации
+
+| Критерий | ALG (Alloy + Loki + Grafana) | Graylog |
+|---|---|---|
+| RAM стека | ~1 GB | ~6-8 GB (JVM: Graylog + OpenSearch + MongoDB) |
+| Агент сбора | **Grafana Alloy** (активен, замена EOL Promtail) | Grafana Alloy / Fluent Bit |
+| Поиск по тексту | LogQL grep по чанкам (медленнее) | Полнотекстовый индекс OpenSearch (быстрее) |
+| Обработка логов | Alloy pipeline stages (River syntax) | Встроенные Pipelines (мощнее) |
+| Алерты | Grafana Alerting | Встроены в Graylog |
+| Единый UI с метриками | Да (Prometheus + Loki + трейсы в Grafana) | Нет (отдельный UI) |
+| Переход S3 → distributed | Без миграции данных | — |
+| Сложность эксплуатации | Низкая | Высокая |
+| EOL-риски | Loki ✅, Alloy ✅ (Promtail заменён) | Нет |
+
+### Рекомендуемый путь
+
+```
+Сейчас (1-2 ноды)
+└── Loki (логи) + Prometheus/kube-prometheus-stack (метрики) + Grafana (единый UI)
+ └── Alloy DaemonSet собирает логи; node-exporter + kube-state-metrics собирают метрики
+ └── Grafana: Loki + Prometheus datasources provisioned, ~20 готовых k8s дашбордов
+ └── Promtail НЕ использовать — EOL с 2026-03-02
+
+При росте до 5+ нод
+└── Loki: distributed (только смена deploymentMode, данные в MinIO без миграции)
+└── Prometheus: увеличить PVC или перейти на Thanos для long-term storage
+└── Alloy / node-exporter: DaemonSet автоматически разворачивается на новых нодах
+
+При требовании глубокого поиска или 10+ нод
+└── Параллельный запуск Graylog (dual-write 2 недели через Alloy)
+└── Grafana остаётся для метрик Prometheus
+└── MinIO — snapshot repository для OpenSearch
+└── Alloy продолжает работать как единый агент (Loki → Graylog смена в конфиге)
+```
+
+### Следующие шаги
+
+**Подготовка (ручные действия перед запуском playbook):**
+- [ ] Создать Secret `grafana-admin-secret` в namespace `monitoring`
+- [ ] Создать GitLab-проект `k8s/grafana-config` на `gitlab.gigacoms.info` (для хранения дашбордов — раздел 7)
+
+**Ansible-роль `roles/logging/` (по структуре из раздела 9):**
+- [ ] Написать `tasks/namespace.yml`
+- [ ] Написать `tasks/minio-user.yml` + создать пользователя `loki` в MinIO
+- [ ] Написать `tasks/loki.yml` + шаблон `loki-values.yml.j2`
+- [ ] Написать `tasks/alloy.yml` + шаблон `alloy-values.yml.j2` (**Alloy, не Promtail**)
+- [ ] Написать `tasks/prometheus.yml` + шаблон `prometheus-values.yml.j2` (метрики + дашборды кластера)
+- [ ] Написать `tasks/grafana.yml` + шаблон `grafana-values.yml.j2` (Loki + Prometheus datasources)
+
+**CI/CD:**
+- [ ] Добавить `grafana` в `traefik_port_map` (порт 10003) и запустить `setup_traefik.yml`
+- [ ] Добавить переменные `LOKI_MINIO_PASSWORD`, `GRAFANA_ADMIN_PASSWORD` в GitLab CI/CD Variables
+- [ ] Добавить job `setup/logging` в `.gitlab-ci.yml` с `resource_group: production`
+
+**Проверка после деплоя:**
+- [ ] Grafana открывается на http://10.203.0.96:10003
+- [ ] В Grafana → Connections → Data sources: видны Loki и Prometheus со статусом OK
+- [ ] В Grafana → Dashboards: папка `kubernetes-mixin` с ~20 готовыми дашбордами кластера
+- [ ] Prometheus Targets: все таргеты в состоянии UP (`kubectl port-forward -n monitoring svc/kube-prometheus-stack-prometheus 9090:9090`)
diff --git a/research/longhorn.md b/research/longhorn.md
new file mode 100644
index 0000000..c7f2b15
--- /dev/null
+++ b/research/longhorn.md
@@ -0,0 +1,453 @@
+# Longhorn — исследование для развёртывания в кластере
+
+## Что такое Longhorn
+
+Longhorn — распределённое блочное хранилище для Kubernetes (CNCF-проект, изначально Rancher).
+Предоставляет PersistentVolume через CSI-драйвер с репликацией данных между нодами, инкрементальными снапшотами и встроенным бэкапом.
+
+---
+
+## На каких нодах нужны диски
+
+Longhorn Manager запускается как **DaemonSet на нодах кластера**. Каждая нода, где работает Longhorn Manager, может хранить реплики томов.
+
+**Применительно к этому кластеру:**
+
+| Нода | Группа | Роль в Longhorn |
+|---|---|---|
+| k8s-master-01 (10.203.0.97) | control_plane | Может участвовать (тейнт уже снят), но нежелательно в production |
+| k8s-worker-NN | workers | **Основные ноды для хранилища** — диски должны быть здесь |
+| k8s-manager-01 (10.203.0.92) | manager_nodes | Не входит в кластер, диски не нужны |
+
+**Вывод:** диски нужны на **worker-нодах**. Желательно не смешивать роль control plane и хранилища.
+
+---
+
+## Минимальное количество нод
+
+| Режим | Нод с дисками | Репликация | Отказоустойчивость |
+|---|---|---|---|
+| Dev / тест | 1 | 1 | Нет |
+| Minimum HA | **3** | 2 | Выдерживает отказ 1 ноды |
+| Production HA | **3** | 3 | Выдерживает отказ 2 нод |
+| Extended HA | 5+ | 3 | Рекомендуется для критичных данных |
+
+### Почему именно 3 ноды — минимум для стабильной работы
+
+Longhorn по умолчанию размещает реплики тома на **разных нодах** (replica node anti-affinity).
+- При `replication=3` требуются 3 разные ноды, иначе Longhorn деградирует том или откажет в создании.
+- При `replication=2` достаточно 2 нод, но потеря любой из них оставит данные без резерва — том продолжит работу, но уязвим.
+- 3 ноды + `replication=3` — классическая кворум-схема: можно потерять 1 ноду и данные остаются защищёнными.
+
+**Практические варианты для этого кластера:**
+- **Рекомендуется:** 3 worker-ноды + control plane (без хранилища)
+- **Допустимо (временно):** 2 worker-ноды + control plane с Longhorn — итого 3 ноды с дисками, но смешение ролей нежелательно
+
+---
+
+## Минимальное количество дисков на ноде
+
+- **Минимум: 1 диск на ноду.** Longhorn работает с одним диском.
+- **Рекомендуется: 1 выделенный диск** (отдельно от ОС-диска `/dev/sda`).
+- Longhorn поддерживает **несколько дисков на одной ноде** — реплики балансируются между ними автоматически.
+
+### ОС-диск vs выделенный диск
+
+| Параметр | ОС-диск | Выделенный диск |
+|---|---|---|
+| Изоляция от системы | Нет | Да |
+| `minimalAvailableStoragePercentage` | ≥25% | 10% |
+| Риск DiskPressure | Высокий | Низкий |
+| Рекомендация | Только dev/тест | Production |
+
+**Минимальная жизнеспособная production-конфигурация:**
+```
+3 worker-ноды × 1 выделенный диск = 3 диска суммарно
+```
+
+---
+
+## Принципы репликации
+
+Longhorn реплицирует данные **посинхронно** (synchronous replication) между репликами при записи.
+
+```
+Pod (write) → Longhorn Engine → Replica 1 (node-1 /dev/sdb)
+ → Replica 2 (node-2 /dev/sdb)
+ → Replica 3 (node-3 /dev/sdb)
+```
+
+- Запись подтверждается только когда все реплики записали данные.
+- При отказе реплики Longhorn автоматически перестраивает её на другой ноде (Rebuild).
+- Значение `replication` задаётся на уровне StorageClass или отдельного тома.
+
+### Рекомендуемые настройки anti-affinity
+
+| Настройка | Значение | Смысл |
+|---|---|---|
+| `replicaNodeLevelSoftAntiAffinity` | `false` | Запрет размещения 2 реплик на одной ноде (hard rule) |
+| `replicaDiskLevelSoftAntiAffinity` | `true` | Разрешить несколько реплик на одной ноде, но на разных дисках |
+
+---
+
+## Prerequisites — что нужно установить на все ноды кластера
+
+### Пакеты (Rocky Linux 9)
+
+```bash
+dnf install -y iscsi-initiator-utils nfs-utils cryptsetup
+```
+
+| Пакет | Зачем |
+|---|---|
+| `iscsi-initiator-utils` | iSCSI-стек для подключения томов |
+| `nfs-utils` | NFS-поддержка для RWX-томов и бэкапов |
+| `cryptsetup` | LUKS2-шифрование томов (опционально) |
+
+### Сервисы
+
+```bash
+systemctl enable --now iscsid
+```
+
+### Kernel modules
+
+```
+iscsi_tcp — iSCSI over TCP
+dm_crypt — Device-mapper шифрование
+```
+
+Добавить в `/etc/modules-load.d/longhorn.conf`:
+```
+iscsi_tcp
+dm_crypt
+```
+
+### Утилиты (должны быть в PATH)
+
+`bash`, `curl`, `findmnt`, `grep`, `awk`, `blkid`, `lsblk` — как правило, уже присутствуют на Rocky Linux 9.
+
+---
+
+## SELinux workaround (Rocky Linux 9 — обязательно)
+
+Rocky Linux 9 с `container-selinux > 2.189.0` блокирует `iscsiadm` через SELinux, что приводит к бесконечному циклу attach/detach томов.
+
+### Ручной патч (на каждой ноде)
+
+```bash
+echo '(allow iscsid_t self (capability (dac_override)))' > /tmp/local_longhorn.cil
+semodule -vi /tmp/local_longhorn.cil
+```
+
+### Автоматически через DaemonSet (после установки Longhorn)
+
+```bash
+kubectl apply -f https://raw.githubusercontent.com/longhorn/longhorn/master/deploy/prerequisite/longhorn-iscsi-selinux-workaround.yaml
+```
+
+---
+
+## Firewall (firewalld)
+
+Добавить на ноды кластера:
+
+| Порт | Протокол | Зачем |
+|---|---|---|
+| 2049 | tcp | NFS (для RWX томов и бэкапов) |
+| 111 | tcp/udp | portmapper (NFS) |
+
+Порт 10250/tcp (kubelet API) уже открыт в существующей роли `k8s_control_plane`.
+
+---
+
+## Установка — Helm chart (рекомендуемый метод)
+
+```bash
+helm repo add longhorn https://charts.longhorn.io
+helm repo update
+
+helm install longhorn longhorn/longhorn \
+ --namespace longhorn-system \
+ --create-namespace \
+ --version 1.7.2 \
+ --set defaultSettings.defaultReplicaCount=3 \
+ --set defaultSettings.replicaNodeLevelSoftAntiAffinity=false \
+ --set defaultSettings.minimalAvailableStoragePercentage=10
+```
+
+### Ключевые Helm-параметры
+
+| Параметр | Рекомендуемое значение | Описание |
+|---|---|---|
+| `defaultSettings.defaultReplicaCount` | `3` | Реплик по умолчанию на новый том |
+| `defaultSettings.replicaNodeLevelSoftAntiAffinity` | `false` | Запрет реплик на одной ноде |
+| `defaultSettings.minimalAvailableStoragePercentage` | `10` (dedicated) / `25` (OS disk) | Резерв свободного места |
+| `defaultSettings.storageOverProvisioningPercentage` | `100` | Overprovisioning (200 = двойной запас) |
+
+---
+
+## Проверка готовности нод (preflight)
+
+Longhorn предоставляет утилиту `longhornctl`:
+
+```bash
+# Скачать
+curl -sSfL -o longhornctl https://github.com/longhorn/cli/releases/latest/download/longhornctl-linux-amd64
+chmod +x longhornctl
+
+# Проверить prerequisites
+./longhornctl check preflight
+
+# Автоматически установить зависимости
+./longhornctl install preflight
+```
+
+---
+
+## StorageClass после установки
+
+Longhorn создаёт StorageClass `longhorn` автоматически:
+
+```yaml
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: longhorn
+provisioner: driver.longhorn.io
+parameters:
+ numberOfReplicas: "3"
+ staleReplicaTimeout: "2880"
+ fromBackup: ""
+reclaimPolicy: Delete
+volumeBindingMode: Immediate
+```
+
+---
+
+## Итоговые минимумы для production
+
+| Параметр | Минимум (stable) | Рекомендуется |
+|---|---|---|
+| Нод с дисками | **3** | 3+ |
+| Дисков на ноду | **1** (выделенный) | 1–2 |
+| Репликация | **2** | 3 |
+| Пакеты | open-iscsi + nfs-utils | + cryptsetup |
+| SELinux патч | **Обязательно** на Rocky Linux 9 | — |
+| Отдельный диск от ОС | **Рекомендуется** | — |
+
+---
+
+---
+
+## Масштабирование: добавление дисков и нод
+
+### Сценарий A: добавление диска к существующей ноде
+
+#### Как это работает
+
+Longhorn не отслеживает диски автоматически — каждый новый диск нужно **явно зарегистрировать** через Longhorn UI или kubectl. После регистрации диск мгновенно становится доступен для новых томов.
+
+#### Подготовка диска (на уровне ОС)
+
+```bash
+# 1. Отформатировать (ext4 или xfs — обязательно extent-based)
+mkfs.xfs /dev/sdb
+
+# 2. Создать точку монтирования
+mkdir -p /mnt/longhorn-disk2
+
+# 3. Получить UUID (надёжнее device-name)
+blkid /dev/sdb
+
+# 4. Добавить в /etc/fstab (UUID, nofail — обязательно)
+echo "UUID= /mnt/longhorn-disk2 xfs defaults,nofail 0 2" >> /etc/fstab
+
+# 5. Смонтировать
+mount -a
+```
+
+> **Важно:** не использовать symlink в пути — Longhorn-поды не разрешают символические ссылки корректно.
+
+#### Регистрация диска в Longhorn
+
+**Через kubectl (рекомендуется для Ansible):**
+```bash
+kubectl patch nodes.longhorn.io -n longhorn-system \
+ --type merge \
+ -p '{"spec":{"disks":{"disk2":{"path":"/mnt/longhorn-disk2","allowScheduling":true,"diskType":"filesystem"}}}}'
+```
+
+**Через UI:** Nodes → выбрать ноду → Edit node and disks → Add Disk.
+
+#### Автоматический ребалансинг реплик на новый диск?
+
+**Нет.** Существующие реплики **не переезжают** автоматически на новый диск.
+
+- Новый диск начинает использоваться только для **новых томов**.
+- Если включена функция `Replica Auto Balance` (режим `best-effort`), Longhorn постепенно перемещает часть реплик для выравнивания нагрузки между дисками.
+- Настройка: Longhorn UI → Settings → Replica Auto Balance → `best-effort`.
+
+#### Сложности при добавлении диска
+
+| Сложность | Причина | Решение |
+|---|---|---|
+| Диск не виден Longhorn | Не смонтирован до регистрации | `mount \| grep /mnt/longhorn-disk2` |
+| Ошибка дублирования | Тот же filesystem UUID уже в кластере | Проверить `lsblk -f` перед добавлением |
+| Disk не используется | `allowScheduling: false` | Включить через UI или patch |
+| Резерв места слишком велик | `storageReserved` ≥ объёму диска | Понизить до 10% от объёма |
+
+#### Теги дисков (routing для специфичных воркеров)
+
+Можно пометить диск тегом (например `ssd`, `fast`) и указывать его в StorageClass:
+```yaml
+# StorageClass
+parameters:
+ diskSelector: "ssd"
+```
+Тогда реплики создаются только на дисках с нужными тегами.
+
+---
+
+### Сценарий B: добавление ноды к кластеру
+
+#### Автоматическое обнаружение
+
+Когда новая нода присоединяется к Kubernetes, **Longhorn обнаруживает её автоматически** через DaemonSet — никаких ручных действий не требуется. Longhorn-manager pod поднимается на новой ноде и создаёт Longhorn Node CR.
+
+По умолчанию Longhorn сразу создаёт диск по пути из настройки `Default Data Path` (`/var/lib/longhorn`).
+
+#### Проверка после появления ноды
+
+```bash
+# Убедиться, что нода появилась в Longhorn
+kubectl get node.longhorn.io -n longhorn-system
+
+# Проверить диск на ноде
+kubectl get node.longhorn.io -n longhorn-system -o yaml
+# Ищем: spec.disks, status.diskStatus — должны быть schedulable: true
+```
+
+#### Ребалансинг существующих реплик на новую ноду?
+
+**По умолчанию — нет.** Существующие реплики не переезжают на новую ноду автоматически.
+
+Чтобы включить ребалансинг:
+```
+Longhorn UI → Settings → Replica Auto Balance → best-effort
+```
+
+| Режим | Поведение |
+|---|---|
+| `disabled` (по умолчанию) | Реплики не перемещаются |
+| `least-effort` | Минимальное перемещение — только для восстановления fault-tolerance |
+| `best-effort` | Равномерное распределение по всем нодам |
+
+#### Ноды с Выделенными дисками (не `/var/lib/longhorn`)
+
+Если диск подключён как `/dev/sdb` и смонтирован в `/mnt/longhorn`, нужно:
+1. Подготовить и смонтировать диск на новой ноде (аналогично Сценарию A).
+2. Зарегистрировать диск в Longhorn вручную или через аннотацию ноды (до или после присоединения к кластеру):
+
+```bash
+kubectl annotate node \
+ node.longhorn.io/default-disks-config='[{"path":"/mnt/longhorn","allowScheduling":true}]'
+```
+
+#### Сложности при добавлении ноды
+
+| Сложность | Причина | Решение |
+|---|---|---|
+| Нода есть в K8s, но не в Longhorn | DaemonSet не запустился (taint, ресурсы) | Проверить `kubectl get pods -n longhorn-system -o wide` |
+| Нода есть в Longhorn, но не планирует реплики | `allowScheduling: false` или нет свободного места | Включить scheduling, проверить space |
+| Реплики не переехали | Auto Balance выключен | Включить `best-effort` |
+| Existing volumes не используют новую ноду | Replication count не обновлён | Обновить replica count (см. ниже) |
+| Prerequisites не установлены | Нет `iscsid`, `nfs-utils` | Запустить роль prereqs перед join |
+
+> **Критично:** перед добавлением ноды к кластеру на ней **обязаны быть установлены** `iscsi-initiator-utils`, `nfs-utils` и применён SELinux-патч (для Rocky Linux 9). Иначе Longhorn Manager pod запустится, но тома не смогут монтироваться на этой ноде.
+
+---
+
+### Сценарий C: миграция 1 нода → 3 ноды (увеличение репликации)
+
+#### Ситуация
+
+Старт: 1 worker-нода, `replication=1` — данные есть, избыточности нет.
+Цель: 3 ноды, `replication=3` — полная HA.
+
+#### Окно риска
+
+Пока replica count увеличивается с 1 до 3, том находится в состоянии **Degraded**: старая реплика работает, новые перестраиваются. В этот момент:
+
+- Том **доступен** для чтения и записи (без даунтайма).
+- Если нода со старой единственной репликой упадёт **во время перестройки** → **данные потеряны**.
+
+Поэтому перед увеличением replica count — **сделать снапшот или бэкап**.
+
+#### Процедура
+
+```bash
+# 1. Убедиться, что новые ноды видны Longhorn
+kubectl get node.longhorn.io -n longhorn-system
+
+# 2. Включить Auto Balance (опционально, для автоматического выравнивания)
+# Longhorn UI → Settings → Replica Auto Balance → best-effort
+
+# 3. Обновить replica count для существующих томов
+kubectl patch volume -n longhorn-system \
+ --type merge \
+ -p '{"spec":{"numberOfReplicas":3}}'
+
+# 4. Мониторинг перестройки
+kubectl get volume -n longhorn-system \
+ -o jsonpath='{.status.state} {.status.replicaCount}'
+# Ожидаем: "healthy 3"
+```
+
+#### Время перестройки (ориентир)
+
+| Объём тома | Время rebuild |
+|---|---|
+| 10 GiB | 2–5 минут |
+| 100 GiB | 10–20 минут |
+| 1 TiB | 1–4 часа |
+
+#### Типичные проблемы при переходе 1→3
+
+| Проблема | Симптом | Решение |
+|---|---|---|
+| Том завис в Degraded | `replicaCount` не растёт > 1 часа | Проверить ноды: space, scheduling, taints |
+| Replica count остался 1 | `spec.numberOfReplicas=3`, но `status.replicaCount=1` | Новые ноды `unschedulable`? `kubectl get node.longhorn.io` |
+| Только 2 реплики создались | Третья нода без свободного места | Добавить диск или ноду с бо́льшим объёмом |
+| `Data Locality: guaranteed` мешает | Том не перемещается | Сменить на `best-effort` на период миграции |
+
+#### Обновить StorageClass для новых томов
+
+Изменить `numberOfReplicas` в StorageClass, чтобы все **новые** PVC создавались сразу с нужной репликацией:
+```bash
+kubectl patch storageclass longhorn \
+ -p '{"parameters":{"numberOfReplicas":"3"}}'
+```
+
+Это не влияет на уже существующие тома — только на новые.
+
+---
+
+### Итоговая таблица: сводка по масштабированию
+
+| Действие | Автоматически? | Даунтайм? | Главный риск | Что сделать |
+|---|---|---|---|---|
+| Добавить диск на ноду | Нет (ручная регистрация) | Нет | Диск не смонтирован до регистрации | Смонтировать → зарегистрировать через kubectl/UI |
+| Добавить ноду в кластер | Да (DaemonSet) | Нет | Prerequisites не установлены | Запустить prereqs-роль до join |
+| Ребалансинг реплик | Нет (нужно включить) | Нет | Реплики остаются на старых нодах/дисках | Включить `Replica Auto Balance: best-effort` |
+| Поднять replica count 1→3 | Нет (вручную) | Нет (том в Degraded) | Потеря данных при падении ноды во время rebuild | Снапшот перед изменением, мониторинг rebuild |
+
+---
+
+## Источники
+
+- [Longhorn Official Documentation 1.11](https://longhorn.io/docs/1.11.2/)
+- [Longhorn Installation Guide](https://longhorn.io/docs/1.11.2/deploy/install/)
+- [Longhorn Best Practices](https://longhorn.io/docs/1.11.2/best-practices/)
+- [Longhorn SELinux Troubleshooting](https://longhorn.io/kb/troubleshooting-volume-attachment-fails-due-to-selinux-denials/)
+- [Longhorn Multiple Disk Support](https://longhorn.io/docs/1.10.1/nodes-and-volumes/nodes/multidisk/)
diff --git a/research/minio.md b/research/minio.md
new file mode 100644
index 0000000..460fc15
--- /dev/null
+++ b/research/minio.md
@@ -0,0 +1,788 @@
+# MinIO в Kubernetes: объектное хранилище 1 ТБ с веб-интерфейсом
+
+**Кластер:** Rocky Linux 9 · Kubernetes 1.33 · Flannel CNI
+**Текущая топология:** k8s-manager-01 (10.203.0.92) · k8s-master-01 (10.203.0.97) · k8s-worker-01 (10.203.0.96)
+**Дата исследования:** 2026-05-28
+**Проверено на кластере:** 2026-05-28 (kubectl + Longhorn API)
+
+---
+
+## Содержание
+
+1. [Обзор и назначение](#1-обзор-и-назначение)
+2. [Режимы развёртывания](#2-режимы-развёртывания)
+3. [Планирование хранилища 1 ТБ](#3-планирование-хранилища-1-тб)
+4. [Helm-развёртывание (standalone)](#4-helm-развёртывание-standalone)
+5. [MinIO Console: веб-интерфейс](#5-minio-console-веб-интерфейс)
+6. [Интеграция с Traefik](#6-интеграция-с-traefik)
+7. [Безопасность: пользователи и политики](#7-безопасность-пользователи-и-политики)
+8. [Интеграция с Loki (S3 backend)](#8-интеграция-с-loki-s3-backend)
+9. [Ansible-роль и плейбук](#9-ansible-роль-и-плейбук)
+10. [Масштабирование до distributed-режима](#10-масштабирование-до-distributed-режима)
+11. [Итоги и рекомендации](#11-итоги-и-рекомендации)
+
+---
+
+## 1. Обзор и назначение
+
+MinIO — S3-совместимое объектное хранилище. Работает в Kubernetes как StatefulSet или Deployment, предоставляет:
+
+- **S3 API** (порт 9000) — совместим с любым клиентом AWS SDK, boto3, mc, s3cmd
+- **MinIO Console** (порт 9001) — встроенный веб-интерфейс: браузер объектов, управление пользователями, мониторинг
+
+### Сценарии использования в текущем кластере
+
+| Сценарий | Описание |
+|---|---|
+| Loki S3 backend | Хранилище chunks/index при переходе на distributed Loki |
+| Бэкапы баз данных | S3-target для pg_dump, mysqldump, Velero |
+| Артефакты CI/CD | GitLab Runner cache, артефакты сборки |
+| Файловое хранилище | Статика, медиафайлы приложений |
+| Резервные копии etcd | Автоматические снэпшоты control plane |
+
+---
+
+## 2. Режимы развёртывания
+
+### Текущее состояние кластера (проверено)
+
+```
+Longhorn-диски на k8s-worker-01:
+ longhorn-disk1 /mnt/longhorn-disk1 ~4.09 TiB (свободно ~4.06 TiB) allowScheduling=true
+ longhorn-disk2 /mnt/longhorn-disk2 ~4.09 TiB (свободно ~4.06 TiB) allowScheduling=true
+
+k8s-master-01: диск из Longhorn удалён — нода без дисков, в планировании не участвует ✓
+
+StorageClass longhorn (default):
+ numberOfReplicas: 1 ✓
+ allowVolumeExpansion: true
+ createDefaultDiskLabeledNodes: true
+
+Traefik hostPort (занято): 10001 (k8s-dashboard), 10002 (longhorn-ui)
+Traefik hostPort (свободно): 10003+ → MinIO: 10005 (API), 10006 (Console)
+```
+
+### Standalone (текущий кластер — 1 worker)
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ k8s-worker-01 (10.203.0.96) │
+│ │
+│ [MinIO Pod — StatefulSet 1 replica] │
+│ ├── порт 9000 (S3 API) │
+│ └── порт 9001 (Console UI) │
+│ ↓ │
+│ [PVC 1 TiB → StorageClass longhorn-minio] │
+│ [Longhorn → longhorn-disk1 (/mnt/longhorn-disk1, 4 TiB)] │
+└─────────────────────────────────────────────────────────────┘
+ ↓ ClusterIP
+ ┌─────────────────────┐
+ │ Traefik │
+ │ 10005 → API :9000 │
+ │ 10006 → UI :9001 │
+ └─────────────────────┘
+```
+
+**Ограничения standalone:** нет erasure coding. Потеря диска = потеря данных. Приемлемо при наличии Longhorn-снэпшотов по расписанию.
+
+### SNMD — Single Node Multi-Drive (будущее)
+
+Worker уже имеет два диска (~4 TiB каждый). MinIO поддерживает SNMD начиная с 4 дисков. При добавлении ещё двух дисков к worker-ноде можно перейти на SNMD без новых нод:
+
+```
+MinIO SNMD, 4 диска по ~1 TiB (из доступных 4 TiB на каждом диске):
+ - usable storage ≈ 2 TiB (EC:2 — паритет 50%)
+ - допустимая потеря: 2 из 4 дисков
+```
+
+### Distributed (4+ нод)
+
+При добавлении worker-нод: 1 под MinIO на ноду, PVC через Longhorn на каждой. Требует минимум 4 пода (4 ноды) для полноценного erasure coding.
+
+**Для текущего кластера** (1 worker, 2 диска) — только standalone.
+
+---
+
+## 3. Планирование хранилища 1 ТБ
+
+### Реальная конфигурация дисков (проверено)
+
+На `k8s-worker-01` уже настроены два Longhorn-диска:
+
+| Диск | Путь | Всего | Свободно | Статус |
+|---|---|---|---|---|
+| `longhorn-disk1` | `/mnt/longhorn-disk1` | ~4.09 TiB | ~4.06 TiB | Ready, Schedulable |
+| `longhorn-disk2` | `/mnt/longhorn-disk2` | ~4.09 TiB | ~4.06 TiB | Ready, Schedulable |
+
+Оба диска полностью свободны. PVC 1 TiB займёт ~25% одного диска, оставляя ~3 TiB в резерве на том же диске.
+
+### Выделенный StorageClass для MinIO
+
+Дефолтный `longhorn` (numberOfReplicas: 1) технически подойдёт, но для MinIO рекомендуется отдельный StorageClass по двум причинам:
+
+- **reclaimPolicy: Retain** — при случайном удалении namespace или PVC данные на диске не уничтожаются (дефолтный Longhorn использует `Delete`)
+- **diskSelector** — позволяет зафиксировать MinIO на `longhorn-disk1`, оставив `longhorn-disk2` для других workload (Loki, бэкапы и т.д.)
+
+```yaml
+# применить через роль minio/tasks/storageclass.yml
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: longhorn-minio
+provisioner: driver.longhorn.io
+reclaimPolicy: Retain # данные остаются при удалении PVC
+allowVolumeExpansion: true
+parameters:
+ numberOfReplicas: "1"
+ dataLocality: "best-effort"
+ diskSelector: "minio" # тег только на longhorn-disk1
+ fsType: "ext4"
+ dataEngine: "v1"
+```
+
+### Disk-тег для изоляции MinIO на disk1 (опционально)
+
+Если нужно зафиксировать MinIO именно на `longhorn-disk1` (рекомендуется при нескольких workload):
+
+```bash
+kubectl -n longhorn-system patch node.longhorn.io k8s-worker-01 --type=json -p='[
+ {"op":"add","path":"/spec/disks/longhorn-disk1/tags","value":["minio"]}
+]'
+```
+
+Без тега Longhorn выберет любой из двух дисков (disk1 или disk2) — это тоже корректно, просто менее предсказуемо.
+
+### Репликация на уровне Longhorn
+
+Сейчас: `numberOfReplicas: 1` — без репликации. При добавлении второго worker с Longhorn-дисками поднять до `2` — Longhorn начнёт реплицировать PVC MinIO между нодами без остановки пода.
+
+> **Важно:** репликация Longhorn защищает от потери ноды, но **не заменяет** MinIO distributed mode. Longhorn реплицирует блочное устройство целиком, erasure coding MinIO работает на уровне объектов.
+
+### Расчёт полезной ёмкости
+
+Физический диск (`longhorn-disk1`) — ~4.09 TiB. MinIO получает PVC 1 TiB — остаток диска доступен для других PVC.
+
+| Параметр | Значение |
+|---|---|
+| Физический диск | ~4.09 TiB (`longhorn-disk1`) |
+| PVC для MinIO | 1 TiB |
+| Overhead Longhorn + ext4 | ~2% |
+| Overhead MinIO (метаданные) | ~1% |
+| **Доступно для объектов** | **~990 Gi** |
+| Рекомендуемый порог заполнения | 80% → ~790 Gi |
+| Остаток на диске для других PVC | ~3.09 TiB |
+
+MinIO по умолчанию отказывается принимать данные при заполнении > 95% (настраивается через `MINIO_STORAGE_CLASS_STANDARD`).
+
+---
+
+## 4. Helm-развёртывание (standalone)
+
+### Helm chart
+
+Используется официальный chart `minio/minio` (не bitnami — он добавляет лишние зависимости).
+
+```bash
+helm repo add minio https://charts.min.io/
+helm repo update
+```
+
+### `minio-values.yml`
+
+```yaml
+# roles/minio/files/minio-values.yml
+
+## Режим: standalone
+mode: standalone
+
+## Образ
+image:
+ repository: quay.io/minio/minio
+ tag: RELEASE.2025-04-22T22-12-26Z # фиксированная версия
+ pullPolicy: IfNotPresent
+
+## Корневые учётные данные (переопределяются через Secret)
+existingSecret: minio-root-credentials
+
+## Хранилище
+persistence:
+ enabled: true
+ storageClass: longhorn-minio # выделенный SC: numberOfReplicas=1, только worker-диски
+ accessMode: ReadWriteOnce
+ size: 1Ti
+
+## Ресурсы пода
+resources:
+ requests:
+ memory: 512Mi
+ cpu: 250m
+ limits:
+ memory: 2Gi
+ cpu: 1000m
+
+## Привязка к worker-ноде (не запускать на control plane)
+nodeSelector:
+ node-role.kubernetes.io/control-plane: "" # исключить
+affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: node-role.kubernetes.io/control-plane
+ operator: DoesNotExist
+
+## Сервисы
+service:
+ type: ClusterIP
+ port: 9000
+
+consoleService:
+ type: ClusterIP
+ port: 9001
+
+## Buckets создаются автоматически при старте
+buckets:
+ - name: loki-chunks
+ policy: none
+ purge: false
+ - name: backups
+ policy: none
+ purge: false
+ - name: artifacts
+ policy: none
+ purge: false
+
+## Пользователи (пароли — через Secret, задаются ниже)
+users:
+ - accessKey: loki
+ existingSecret: minio-user-loki
+ existingSecretKey: secretKey
+ policy: readwrite
+ - accessKey: backup
+ existingSecret: minio-user-backup
+ existingSecretKey: secretKey
+ policy: readwrite
+
+## Политики для бакетов
+policies:
+ - name: loki-policy
+ statements:
+ - resources:
+ - "arn:aws:s3:::loki-chunks"
+ - "arn:aws:s3:::loki-chunks/*"
+ actions:
+ - "s3:GetObject"
+ - "s3:PutObject"
+ - "s3:DeleteObject"
+ - "s3:ListBucket"
+
+## Метрики (если Prometheus есть)
+metrics:
+ serviceMonitor:
+ enabled: false # включить когда появится Prometheus
+
+## MinIO Console — веб-интерфейс
+consoleIngress:
+ enabled: false # используем Traefik напрямую через ClusterIP
+
+## Окружение MinIO
+environment:
+ MINIO_BROWSER_REDIRECT_URL: "http://10.203.0.96:10006" # URL Console через Traefik
+ MINIO_STORAGE_CLASS_STANDARD: "EC:0" # standalone: без erasure coding
+ MINIO_UPDATE: "off" # отключить автообновление
+```
+
+### Secret с корневыми учётными данными
+
+```bash
+kubectl create secret generic minio-root-credentials \
+ --from-literal=rootUser=minioadmin \
+ --from-literal=rootPassword='СИЛЬНЫЙ_ПАРОЛЬ_МИНИМУМ_8_СИМВОЛОВ' \
+ -n minio
+```
+
+### Установка
+
+```bash
+kubectl create namespace minio
+
+helm upgrade --install minio minio/minio \
+ --namespace minio \
+ --version 5.4.0 \
+ --values roles/minio/files/minio-values.yml \
+ --wait
+```
+
+### Проверка
+
+```bash
+# Статус пода
+kubectl get pod -n minio
+
+# Логи
+kubectl logs -n minio -l app=minio
+
+# Проброс порта для быстрой проверки
+kubectl port-forward -n minio svc/minio 9000:9000 &
+kubectl port-forward -n minio svc/minio-console 9001:9001 &
+
+# Проверка S3 API через mc (MinIO Client)
+mc alias set local http://localhost:9000 minioadmin ПАРОЛЬ
+mc ls local
+mc admin info local
+```
+
+---
+
+## 5. MinIO Console: веб-интерфейс
+
+MinIO Console — встроенный React-интерфейс, запускается в том же поде на порту 9001. Отдельная установка не нужна начиная с MinIO RELEASE.2021-07-08.
+
+### Возможности Console
+
+| Раздел | Функции |
+|---|---|
+| Object Browser | Навигация по бакетам, загрузка/скачивание файлов, просмотр метаданных |
+| Buckets | Создание бакетов, версионирование, lifecycle policies, replication |
+| Identity → Users | Создание пользователей, назначение политик |
+| Identity → Groups | Группировка пользователей |
+| Identity → Policies | Редактор IAM-политик (JSON) |
+| Monitoring | Дашборд загрузки, IOPS, throughput в реальном времени |
+| Logs | Потоковый просмотр логов MinIO в браузере |
+| Audit | Журнал операций (включается через `MINIO_AUDIT_WEBHOOK_*`) |
+
+### Настройка адреса Console
+
+MinIO Console проверяет `MINIO_BROWSER_REDIRECT_URL` при формировании redirect после логина. Без правильного значения Console вернёт 401 или redirect на неверный URL.
+
+```yaml
+# В minio-values.yml — уже задано выше
+environment:
+ MINIO_BROWSER_REDIRECT_URL: "http://10.203.0.96:10006"
+```
+
+---
+
+## 6. Интеграция с Traefik
+
+Два порта в `traefik_port_map`:
+- `10005` — MinIO S3 API (для клиентов, boto3, mc)
+- `10006` — MinIO Console (веб-интерфейс)
+
+### Добавить в `inventory/prod/group_vars/traefik.yml`
+
+```yaml
+traefik_port_map:
+ # ... существующие записи ...
+
+ - name: minio-api
+ description: "MinIO S3 API"
+ port: 10005
+ backend:
+ namespace: minio
+ service: minio
+ port: 9000
+ scheme: http
+ basicauth:
+ enabled: false # MinIO использует собственную аутентификацию (AWS Signature v4)
+
+ - name: minio-console
+ description: "MinIO Console (Web UI)"
+ port: 10006
+ backend:
+ namespace: minio
+ service: minio-console
+ port: 9001
+ scheme: http
+ basicauth:
+ enabled: false # MinIO Console имеет собственный логин
+```
+
+> **Важно:** BasicAuth от Traefik + MinIO Console не совместимы — браузер не может пройти двойную аутентификацию. MinIO Console защищён своим логином, этого достаточно.
+
+После добавления запустить `setup_traefik.yml`.
+
+### Адреса после развёртывания
+
+| Сервис | URL | Назначение |
+|---|---|---|
+| MinIO S3 API | `http://10.203.0.96:10005` | Внешний доступ клиентов |
+| MinIO Console | `http://10.203.0.96:10006` | Веб-интерфейс администратора |
+| MinIO API (внутри кластера) | `http://minio.minio.svc.cluster.local:9000` | Для подов кластера |
+| MinIO Console (внутри кластера) | `http://minio-console.minio.svc.cluster.local:9001` | — |
+
+### Настройка mc (MinIO Client) для внешнего доступа
+
+```bash
+# На manager-ноде или локально
+mc alias set prod http://10.203.0.96:10005 minioadmin ПАРОЛЬ
+
+# Проверка
+mc ls prod
+mc admin info prod
+mc du prod/loki-chunks
+```
+
+---
+
+## 7. Безопасность: пользователи и политики
+
+### Принцип минимальных привилегий
+
+Не используйте root-учётные данные в приложениях. Для каждого сервиса — отдельный пользователь с ограниченной политикой.
+
+### Создание пользователей через mc
+
+```bash
+# Пользователь для Loki (только свой бакет)
+mc admin user add prod loki ПАРОЛЬ_LOKI
+
+mc admin policy create prod loki-policy /dev/stdin <<'EOF'
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": ["s3:*"],
+ "Resource": [
+ "arn:aws:s3:::loki-chunks",
+ "arn:aws:s3:::loki-chunks/*"
+ ]
+ }
+ ]
+}
+EOF
+
+mc admin policy attach prod loki-policy --user loki
+
+# Пользователь для бэкапов (только запись в backups)
+mc admin user add prod backup ПАРОЛЬ_BACKUP
+
+mc admin policy create prod backup-policy /dev/stdin <<'EOF'
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
+ "Resource": [
+ "arn:aws:s3:::backups",
+ "arn:aws:s3:::backups/*"
+ ]
+ }
+ ]
+}
+EOF
+
+mc admin policy attach prod backup-policy --user backup
+```
+
+### Lifecycle policy: автоудаление старых объектов
+
+```bash
+# Удалять объекты в backups старше 30 дней
+mc ilm rule add --expire-days 30 prod/backups
+
+# Удалять незавершённые multipart uploads старше 7 дней
+mc ilm rule add --expire-days 7 --noncurrent-expire-days 7 prod/backups
+```
+
+### Версионирование бакетов (защита от случайного удаления)
+
+```bash
+# Включить версионирование
+mc version enable prod/backups
+
+# Посмотреть все версии объекта
+mc ls --versions prod/backups/db-dump.sql.gz
+```
+
+---
+
+## 8. Интеграция с Loki (S3 backend)
+
+При переходе Loki на distributed-режим (см. [PLG исследование](../logging/plg.md)) MinIO становится S3 backend для хранения chunks и index.
+
+### Loki values для S3 backend
+
+```yaml
+# roles/logging/templates/loki-values.yml.j2 (distributed режим)
+loki:
+ storage:
+ type: s3
+ s3:
+ endpoint: http://minio.minio.svc.cluster.local:9000
+ region: us-east-1 # MinIO игнорирует region, но поле обязательно
+ bucketnames: loki-chunks
+ access_key_id: loki
+ secret_access_key: "{{ loki_minio_password }}"
+ insecure: true # http (не https)
+ s3forcepathstyle: true # обязательно для MinIO
+
+ schemaConfig:
+ configs:
+ - from: "2024-01-01"
+ store: tsdb
+ object_store: s3 # ← было filesystem
+ schema: v13
+ index:
+ prefix: loki_index_
+ period: 24h
+```
+
+### Secret для Loki в namespace monitoring
+
+```bash
+kubectl create secret generic loki-minio-secret \
+ --from-literal=AWS_ACCESS_KEY_ID=loki \
+ --from-literal=AWS_SECRET_ACCESS_KEY='ПАРОЛЬ_LOKI' \
+ -n monitoring
+```
+
+---
+
+## 9. Ansible-роль и плейбук
+
+### Структура роли `roles/minio/`
+
+```
+roles/minio/
+├── defaults/main.yml
+├── tasks/
+│ ├── main.yml
+│ ├── namespace.yml — создать namespace minio
+│ ├── secrets.yml — создать Secrets из vault/переменных
+│ ├── helm.yml — helm upgrade --install minio
+│ └── mc.yml — настройка mc alias, пользователей, политик, lifecycle
+├── files/
+│ └── minio-values.yml
+└── templates/
+ └── minio-values.yml.j2
+```
+
+### `defaults/main.yml`
+
+```yaml
+minio_namespace: minio
+minio_chart_version: "5.4.0" # minio/minio chart
+minio_image_tag: "RELEASE.2025-04-22T22-12-26Z"
+
+minio_storage_class: longhorn-minio # выделенный SC, не дефолтный longhorn (numberOfReplicas=3)
+minio_storage_size: 1Ti
+
+minio_console_url: "http://10.203.0.96:10006"
+minio_api_external_url: "http://10.203.0.96:10005"
+
+# Имена Secrets (значения создаются вручную через kubectl)
+minio_root_secret: minio-root-credentials
+
+minio_buckets:
+ - loki-chunks
+ - backups
+ - artifacts
+
+minio_resources_requests_memory: "512Mi"
+minio_resources_limits_memory: "2Gi"
+```
+
+### `tasks/helm.yml`
+
+```yaml
+- name: MinIO | Add Helm repo
+ kubernetes.core.helm_repository:
+ name: minio
+ repo_url: https://charts.min.io/
+
+- name: MinIO | Deploy via Helm
+ kubernetes.core.helm:
+ name: minio
+ chart_ref: minio/minio
+ chart_version: "{{ minio_chart_version }}"
+ release_namespace: "{{ minio_namespace }}"
+ create_namespace: true
+ values: "{{ lookup('template', 'minio-values.yml.j2') | from_yaml }}"
+ wait: true
+ wait_condition:
+ type: Ready
+ status: "True"
+ timeout: "10m0s"
+```
+
+### `playbooks/setup_minio.yml`
+
+```yaml
+---
+- name: Deploy MinIO object storage
+ hosts: manager_nodes
+ become: false
+ roles:
+ - minio
+```
+
+### `.gitlab-ci.yml` — новый job
+
+```yaml
+setup/minio:
+ stage: setup
+ script:
+ - ansible-playbook -i inventory/prod playbooks/setup_minio.yml
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+```
+
+### Переменные GitLab CI/CD (добавить)
+
+| Переменная | Описание |
+|---|---|
+| `MINIO_ROOT_PASSWORD` | Пароль root-пользователя MinIO |
+| `MINIO_LOKI_PASSWORD` | Пароль пользователя loki |
+| `MINIO_BACKUP_PASSWORD` | Пароль пользователя backup |
+
+---
+
+## 10. Масштабирование
+
+### Этапы роста: Longhorn и MinIO вместе
+
+Реальное текущее состояние (проверено): worker имеет 2 диска по ~4 TiB, оба пустые. Это даёт несколько путей масштабирования без добавления нод:
+
+| Этап | Кластер | Longhorn | MinIO |
+|---|---|---|---|
+| **Сейчас** | 1 worker, 2 диска по 4 TiB | `longhorn` SC: `numberOfReplicas: 1`, master без дисков ✓ | standalone, PVC 1 TiB на disk1 |
+| +2 диска к worker | 1 worker, 4 диска | 4 PVC по 1 TiB, `numberOfReplicas: 1` | **SNMD**: 4 drives, EC:2, ~2 TiB usable |
+| +1 worker с дисками | 2 workers | Поднять `numberOfReplicas: 2` в `longhorn-minio` | standalone + Longhorn-репликация между нодами |
+| +3 workers (4 total) | 4 workers | `numberOfReplicas: 2` | **Distributed**: 4 пода, EC:2 |
+
+### Шаг 1: добавление второго worker (без пересоздания MinIO)
+
+После добавления `k8s-worker-02` в кластер:
+
+```bash
+# Убедиться что Longhorn видит новую ноду и диск
+kubectl get nodes -o wide
+kubectl get nodes.longhorn.io -n longhorn-system
+
+# Обновить numberOfReplicas у StorageClass (или через Longhorn UI)
+kubectl edit storageclass longhorn
+# numberOfReplicas: "1" → "2"
+
+# Обновить replicas у существующего PVC MinIO
+kubectl -n longhorn-system edit volume
+# spec.numberOfReplicas: 1 → 2
+# Longhorn начнёт ребалансировку в фоне, MinIO продолжает работать
+```
+
+### Шаг 2: SNMD — 4 диска на одной ноде (реалистичный следующий шаг)
+
+Worker уже имеет 2 диска (~4 TiB каждый). При добавлении ещё 2 дисков можно перейти на SNMD **без новых нод**:
+
+```yaml
+# minio-values.yml — SNMD режим (4 диска на k8s-worker-01)
+mode: distributed # в MinIO SNMD тоже использует mode: distributed
+replicas: 1 # 1 нода
+drivesPerNode: 4 # 4 PVC на под
+
+persistence:
+ storageClass: longhorn-minio
+ size: 1Ti # 4 × 1 TiB = 4 TiB raw → ~2 TiB usable (EC:2)
+```
+
+При SNMD Longhorn создаёт 4 отдельных PVC (по одному на каждый логический диск MinIO). `numberOfReplicas: 1` в StorageClass — MinIO EC уже обеспечивает отказоустойчивость.
+
+### Шаг 3: distributed (4+ workers)
+
+```yaml
+# minio-values.yml при 4 worker-нодах
+mode: distributed
+replicas: 4 # 4 пода на 4 нодах
+drivesPerNode: 1 # 1 Longhorn PVC на под
+
+persistence:
+ storageClass: longhorn-minio
+ size: 1Ti # 4 × 1 TiB raw → ~2 TiB usable (EC:2)
+
+affinity:
+ podAntiAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ - labelSelector:
+ matchLabels:
+ app: minio
+ topologyKey: kubernetes.io/hostname
+```
+
+В distributed-режиме Longhorn предоставляет отдельный PVC каждому поду MinIO. Репликацию Longhorn для distributed оставить на `numberOfReplicas: 1` — MinIO EC уже обеспечивает отказоустойчивость.
+
+### Миграция standalone → distributed
+
+MinIO **не поддерживает** in-place миграцию. Процедура:
+
+```
+1. Запустить distributed MinIO рядом (namespace minio-dist)
+2. mc mirror minio-standalone minio-distributed --preserve --watch
+3. Дождаться синхронизации всех объектов (mc du для сверки объёмов)
+4. Переключить Traefik (изменить service в IngressRoute) на новый сервис
+5. Обновить endpoint в configs Loki, бэкапов и других клиентов
+6. Подождать 24-48 часов, убедиться что клиенты работают корректно
+7. Удалить standalone namespace и PVC
+```
+
+### Расширение объёма без миграции (только standalone)
+
+Longhorn поддерживает расширение PVC онлайн — без остановки MinIO:
+
+```bash
+kubectl patch pvc minio -n minio \
+ -p '{"spec":{"resources":{"requests":{"storage":"2Ti"}}}}'
+
+# Longhorn расширит том; MinIO увидит новое пространство автоматически
+mc admin info prod # проверить новый объём в разделе capacity
+```
+
+---
+
+## 11. Итоги и рекомендации
+
+### Почему Longhorn — правильный выбор для этого кластера
+
+Longhorn уже является стандартом хранилища в кластере. Использование его для MinIO даёт:
+
+- **Единая точка управления** — диски, снэпшоты, репликация управляются через Longhorn UI, без отдельной операционной нагрузки
+- **Онлайн-расширение** — PVC MinIO расширяется без остановки пода (`kubectl patch pvc`)
+- **Путь к отказоустойчивости** — при добавлении второго worker достаточно поднять `numberOfReplicas: 2`, MinIO продолжает работать без изменений
+- **Снэпшоты как бэкап** — Longhorn умеет делать снэпшоты PVC по расписанию; для MinIO standalone это основной механизм защиты данных до перехода на distributed
+
+### Порядок развёртывания
+
+```
+1. Применить StorageClass longhorn-minio (см. раздел 3)
+ (опц.) Longhorn UI → k8s-worker-01 → добавить тег "minio" на longhorn-disk1 для изоляции
+2. kubectl create namespace minio
+3. Создать Secret minio-root-credentials вручную
+4. Запустить setup_minio.yml (Helm install)
+5. Добавить minio-api и minio-console в traefik_port_map (порты 10005, 10006 — свободны)
+6. Запустить setup_traefik.yml
+7. Проверить Console: http://10.203.0.96:10006
+8. Создать пользователей через mc (loki, backup)
+9. Обновить loki-values.yml если Loki уже работает
+```
+
+### Итоговые адреса
+
+| Сервис | URL |
+|---|---|
+| MinIO Console (UI) | `http://10.203.0.96:10006` |
+| MinIO S3 API | `http://10.203.0.96:10005` |
+| MinIO API (внутри кластера) | `http://minio.minio.svc.cluster.local:9000` |
+
+### Следующие шаги
+
+- [ ] Применить StorageClass `longhorn-minio` (раздел 3)
+- [ ] (опц.) Longhorn UI: добавить тег `minio` на `longhorn-disk1` для изоляции диска
+- [ ] Создать Secret `minio-root-credentials` вручную
+- [ ] Написать роль `roles/minio/` по структуре из раздела 9
+- [ ] Добавить job `setup/minio` в `.gitlab-ci.yml`
+- [ ] Добавить `minio-api` и `minio-console` в `traefik_port_map` (порты 10005, 10006)
+- [ ] Добавить переменные `MINIO_ROOT_PASSWORD`, `MINIO_LOKI_PASSWORD` в GitLab CI/CD Variables
+- [ ] Добавить firewalld-правило в `roles/minio/tasks/firewall.yml` (порты 9000, 9001 в `trusted` zone для flannel.1/cni0)
diff --git a/research/traefik-forwardauth.md b/research/traefik-forwardauth.md
new file mode 100644
index 0000000..f201c09
--- /dev/null
+++ b/research/traefik-forwardauth.md
@@ -0,0 +1,173 @@
+# Traefik ForwardAuth: HTML-логин для внутренних сервисов
+
+## 1. Контекст
+
+Текущая схема использует BasicAuth — браузерный нативный диалог, не кастомизируемый.
+ForwardAuth позволяет заменить его на HTML-страницу входа с сессиями.
+
+**Что меняется:** вместо `Middleware basicAuth` → `Middleware forwardAuth` + отдельный auth-pod.
+
+---
+
+## 2. Как работает ForwardAuth
+
+```
+Клиент → Traefik :10002 → ForwardAuth Middleware
+ │
+ ▼
+ auth-сервис /auth
+ │
+ ┌───────────┴───────────┐
+ │ 200 OK │ 401 → redirect на /login
+ │ (сессия валидна) │ (HTML-страница логина)
+ ▼ ▼
+ longhorn-frontend форма → POST /login
+ │
+ проверка пароля
+ │
+ Set-Cookie session
+ │
+ redirect обратно
+```
+
+Traefik делает subrequest на `authResponseHeaders` — если auth-сервис вернул 200, пропускает запрос дальше. Если 401/302 — возвращает клиенту ответ auth-сервиса (redirect на страницу логина).
+
+---
+
+## 3. Варианты реализации
+
+### 3.1. oauth2-proxy (рекомендуется)
+
+**Что это:** легковесный reverse-proxy с поддержкой htpasswd, GitHub, Google, OIDC.
+Для внутреннего использования подходит режим `--htpasswd-file` без внешнего провайдера.
+
+**Плюсы:**
+- Один pod, ~50MB RAM
+- Htpasswd-файл через Kubernetes Secret — никаких внешних зависимостей
+- HTML-форма из коробки, можно переопределить шаблоны
+- Cookie-сессии, configurable TTL
+
+**Минусы:**
+- Один экземпляр oauth2-proxy на весь Traefik (или по одному на сервис)
+- Нет 2FA
+
+**Схема деплоя:**
+```
+roles/oauth2-proxy/
+ tasks/main.yml — Kubernetes Deployment + Service
+ tasks/secret.yml — инструкция по созданию htpasswd Secret вручную
+ tasks/middleware.yml — ForwardAuth Middleware CRD
+
+inventory/prod/group_vars/oauth2proxy.yml — cookie_secret, upstream, htpasswd ref
+```
+
+**Ключевые параметры:**
+```yaml
+# Helm values / env vars
+--provider=htpasswd
+--htpasswd-file=/etc/oauth2-proxy/htpasswd
+--cookie-secret=<32-byte-random> # генерируется вручную
+--cookie-secure=false # HTTP, без TLS
+--email-domain=*
+--upstream=static://200 # ForwardAuth: upstream не используется
+```
+
+**Middleware CRD:**
+```yaml
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: forwardauth-oauth2proxy
+ namespace: traefik
+spec:
+ forwardAuth:
+ address: http://oauth2-proxy.traefik.svc:4180/oauth2/auth
+ trustForwardHeader: true
+ authResponseHeaders:
+ - X-Auth-Request-User
+ - X-Auth-Request-Email
+```
+
+---
+
+### 3.2. Authelia
+
+**Что это:** полноценный self-hosted identity provider. HTML-логин, 2FA (TOTP, WebAuthn), LDAP, правила доступа по пользователям/группам.
+
+**Плюсы:**
+- Красивая HTML-форма с брендингом
+- 2FA из коробки
+- Гранулярный контроль: разные пользователи для разных сервисов
+
+**Минусы:**
+- Требует Redis (сессии) + хранилище пользователей (файл или LDAP)
+- ~150MB RAM минимум
+- Сложнее в настройке (YAML-конфиг, несколько секретов)
+
+**Оправдано если:** нужен 2FA или несколько пользователей с разными правами.
+
+---
+
+### 3.3. thomseddon/traefik-forward-auth
+
+**Что это:** минималистичный ForwardAuth-сервис только для Google/OIDC OAuth.
+Для htpasswd **не подходит** — требует внешний провайдер.
+
+---
+
+## 4. Рекомендация
+
+Для текущего стека (внутренняя сеть, один оператор, нет SSO) — **oauth2-proxy с htpasswd**.
+
+| Критерий | oauth2-proxy | Authelia |
+|---|---|---|
+| RAM | ~50MB | ~150MB |
+| Внешние зависимости | нет | Redis |
+| 2FA | нет | да |
+| Сложность настройки | низкая | средняя |
+| HTML-логин | да (кастомизируемый) | да (красивый) |
+
+---
+
+## 5. Интеграция с текущим port_map
+
+Схема остаётся прежней — меняется только тип Middleware:
+
+```yaml
+# inventory/prod/group_vars/traefik.yml
+traefik_port_map:
+ - name: longhorn-ui
+ port: 10002
+ backend:
+ namespace: longhorn-system
+ service: longhorn-frontend
+ port: 80
+ scheme: http
+ auth:
+ type: forwardauth # вместо basicauth
+ middleware: forwardauth-oauth2proxy # имя Middleware
+```
+
+При `type: forwardauth` шаблон `ingressroute.yml.j2` подставляет ForwardAuth Middleware вместо BasicAuth.
+При `type: basicauth` — как сейчас.
+
+---
+
+## 6. Порядок развёртывания (если решим внедрить)
+
+1. Добавить роль `oauth2-proxy` в `roles/`
+2. Добавить `playbooks/setup_oauth2proxy.yml`
+3. Создать Secret с htpasswd вручную:
+ ```bash
+ kubectl create secret generic oauth2proxy-htpasswd \
+ --from-literal=htpasswd="$(htpasswd -nb admin 'pass')" \
+ -n traefik
+ ```
+4. Создать Secret с cookie_secret:
+ ```bash
+ kubectl create secret generic oauth2proxy-cookie \
+ --from-literal=cookie-secret="$(openssl rand -base64 32)" \
+ -n traefik
+ ```
+5. Заменить `basicAuth` Middleware на `forwardAuth` в IngressRoute для нужных сервисов
+6. Удалить старые BasicAuth Middleware и Secret `traefik-auth-longhorn`
diff --git a/research/traefik.md b/research/traefik.md
new file mode 100644
index 0000000..f2d93a1
--- /dev/null
+++ b/research/traefik.md
@@ -0,0 +1,494 @@
+# Traefik: port-proxy режим для внутренних сервисов
+
+## 1. Контекст и цель
+
+**Режим работы:** Traefik как HTTP-прокси на отдельном порту для каждого внутреннего сервиса.
+
+Вместо маршрутизации по доменному имени каждый сервис получает выделенный порт на worker-ноде. Клиент обращается `http://10.203.0.96:` и попадает напрямую на нужный сервис.
+
+**Что не используем:** HTTPS, Let's Encrypt, hostname-роутинг, Ingress.
+
+**Что используем:** EntryPoint per service, HTTP Router с catch-all правилом, BasicAuth Middleware для сервисов без авторизации.
+
+---
+
+## 2. Концепция Port Map
+
+Port Map — единственный файл, описывающий всю топологию проксирования. Из него генерируется вся конфигурация Traefik: entrypoints, routers, services, middleware CRD.
+
+**Расположение:** `inventory/prod/group_vars/traefik.yml`
+
+Это inventory-файл, а не часть роли — привязка порта к сервису это инфраструктурное решение, а не логика установки.
+
+### Формат файла
+
+```yaml
+# inventory/prod/group_vars/traefik.yml
+---
+
+traefik_port_map:
+
+ - name: kubernetes-dashboard
+ description: "Kubernetes Dashboard"
+ port: 10001
+ backend:
+ namespace: kubernetes-dashboard
+ service: kubernetes-dashboard
+ port: 443 # порт Service; K8s перенаправит на pod:9090 (insecure HTTP)
+ scheme: http # dashboard работает без TLS (--insecure-port=9090 --port=0)
+ basicauth:
+ enabled: false # Dashboard имеет собственную авторизацию по токену — BasicAuth не нужен
+
+ - name: longhorn-ui
+ description: "Longhorn Storage UI"
+ port: 10002
+ backend:
+ namespace: longhorn-system
+ service: longhorn-frontend
+ port: 80
+ scheme: http
+ basicauth:
+ enabled: true
+ secret_name: traefik-auth-longhorn # Longhorn UI без авторизации — защищаем BasicAuth
+
+ # Шаблон для будущих сервисов:
+ # - name: my-service
+ # description: "Human-readable описание"
+ # port: 10003 # уникальный порт в диапазоне 10000-10999
+ # backend:
+ # namespace: my-namespace
+ # service: my-service-name
+ # port: 8080
+ # scheme: http
+ # basicauth:
+ # enabled: false # если сервис имеет собственную авторизацию
+```
+
+### Поля Port Map
+
+| Поле | Обязательное | Описание |
+|---|---|---|
+| `name` | да | Уникальный идентификатор (используется в именах K8s-ресурсов) |
+| `description` | нет | Человекочитаемое описание |
+| `port` | да | Порт на Traefik-ноде, диапазон `10000–10999` |
+| `backend.namespace` | да | Kubernetes namespace сервиса |
+| `backend.service` | да | Имя Kubernetes Service |
+| `backend.port` | да | Порт Service (не pod-а) |
+| `backend.scheme` | да | `http` или `https` (протокол, которым Traefik достучится до Service) |
+| `basicauth.enabled` | нет | `true` — прикрепить BasicAuth; дефолт `false` |
+| `basicauth.secret_name` | если enabled | Имя Secret в namespace `traefik`; **создаётся вручную** |
+
+---
+
+## 3. Управление credentials: только вручную, не через Ansible
+
+**Принцип разделения ответственности:**
+
+- **Ansible управляет инфраструктурой:** Middleware CRD, Helm-чарт, firewall, IngressRoute.
+- **Оператор управляет секретами:** Kubernetes Secret с htpasswd-строками создаётся вручную через Dashboard или kubectl.
+
+Ansible не знает о содержимом секрета — только о его имени (`secret_name` в port_map). Если Secret не существует, Traefik вернёт 401 для всех запросов к этому сервису (безопасный дефолт).
+
+### Создание Secret вручную
+
+**Через kubectl (рекомендуется для первого раза):**
+
+```bash
+# 1. Сгенерировать htpasswd-строку (требует htpasswd или openssl)
+htpasswd -nb admin 'yourpassword'
+# Вывод: admin:$apr1$xyz...
+
+# 2. Создать Secret в namespace traefik
+kubectl create secret generic traefik-auth-dashboard \
+ --from-literal=users='admin:$apr1$xyz...' \
+ -n traefik
+
+# Для второго сервиса — отдельный Secret:
+kubectl create secret generic traefik-auth-longhorn \
+ --from-literal=users='admin:$apr1$abc...' \
+ -n traefik
+```
+
+**Через Kubernetes Dashboard:**
+
+1. Открыть Dashboard → Namespace: `traefik` → Secrets → Create
+2. Тип: `Opaque`
+3. Имя: `traefik-auth-dashboard` (или как указано в `secret_name`)
+4. Ключ: `users`
+5. Значение: htpasswd-строка, например `admin:$apr1$xyz...`
+
+**Генерация htpasswd без утилиты htpasswd:**
+
+```bash
+# Через openssl (доступен везде)
+openssl passwd -apr1 'yourpassword'
+
+# Собрать строку вручную:
+echo "admin:$(openssl passwd -apr1 'yourpassword')"
+```
+
+### Обновление пароля
+
+```bash
+# Пересоздать Secret с новым паролем
+kubectl create secret generic traefik-auth-dashboard \
+ --from-literal=users='admin:$apr1$newvalue...' \
+ -n traefik \
+ --dry-run=client -o yaml | kubectl apply -f -
+```
+
+### Разные пользователи для разных сервисов
+
+Каждый Secret независим. Для одного сервиса можно дать доступ нескольким пользователям — htpasswd-формат поддерживает несколько строк:
+
+```bash
+kubectl create secret generic traefik-auth-longhorn \
+ --from-literal=users='admin:$apr1$...'$'\n''devops:$apr1$...' \
+ -n traefply
+```
+
+---
+
+## 4. Архитектура
+
+### 4.1. Схема потока
+
+```
+Клиент
+ │ http://10.203.0.96:10001 → kubernetes-dashboard (BasicAuth: traefik-auth-dashboard)
+ │ http://10.203.0.96:10002 → longhorn-ui (BasicAuth: traefik-auth-longhorn)
+ │ http://10.203.0.96:10003 → future-service (без BasicAuth)
+ ▼
+┌────────────────────────────────────┐
+│ k8s-worker-01 │
+│ 10.203.0.96 │
+│ │
+│ ┌────────────────────────────┐ │
+│ │ Traefik DaemonSet │ │
+│ │ │ │
+│ │ EP :10001 → Middleware ──────► svc kubernetes-dashboard:443 (http→pod:9090)
+│ │ basicauth-dash │ │
+│ │ │ │
+│ │ EP :10002 → Middleware ──────► svc longhorn-frontend:80
+│ │ basicauth-lhorn │ │
+│ │ │ │
+│ │ EP :10003 ───────────────────► svc future:8080 (без auth)
+│ └────────────────────────────┘ │
+└────────────────────────────────────┘
+ │ pod network (flannel 10.244.0.0/16)
+ ┌───────────┼───────────┐
+ ▼ ▼ ▼
+ dashboard longhorn future
+ pod:9090 pod:80 pod:8080
+```
+
+### 4.2. Соответствие port_map → Traefik-ресурсы
+
+Каждая запись в `traefik_port_map` генерирует:
+
+```
+"kubernetes-dashboard" (port: 10001, basicauth.enabled: true)
+ │
+ ├── EntryPoint "kubernetes-dashboard" port 10001, hostPort 10001
+ ├── Middleware "basicauth-kubernetes-dashboard"
+ │ └── basicAuth.secret: traefik-auth-dashboard (Secret создан вручную)
+ ├── HTTP Router "router-kubernetes-dashboard"
+ │ entryPoints: [kubernetes-dashboard]
+ │ rule: PathPrefix(`/`)
+ │ middlewares: [basicauth-kubernetes-dashboard]
+ └── HTTP Service "svc-kubernetes-dashboard"
+ url: http://kubernetes-dashboard.kubernetes-dashboard.svc:443
+```
+
+### 4.3. Почему Dashboard: scheme: http, port: 443
+
+В текущей конфигурации Dashboard пропатчен с `--insecure-port=9090 --port=0`:
+
+```
+Service spec: port 443 → targetPort 9090
+Pod: слушает на :9090 по HTTP (без TLS)
+```
+
+Traefik подключается к `ClusterIP:443` по протоколу HTTP. Kubernetes направляет трафик на `pod:9090`. TLS нет на всём пути.
+
+---
+
+## 5. Структура роли
+
+```
+roles/traefik/
+ defaults/main.yml — chart version, namespace, kubeconfig path
+ tasks/main.yml — include_tasks по шагам
+ tasks/helm.yml — helm upgrade --install
+ tasks/middleware.yml — Middleware CRD (один на сервис, если basicauth.enabled)
+ tasks/routes.yml — IngressRoute CRD (один на сервис)
+ tasks/firewall.yml — открыть диапазон 10000-10999 на workers (delegate_to)
+ templates/
+ traefik-values.yml.j2 — Helm values, генерируется из traefik_port_map
+ basicauth-middleware.yml.j2 — Middleware CRD (один экземпляр, рендерится в цикле)
+ ingressroute.yml.j2 — IngressRoute CRD (один экземпляр, рендерится в цикле)
+
+playbooks/
+ setup_traefik.yml
+```
+
+---
+
+## 6. Ключевые конфигурационные артефакты
+
+### 6.1. `roles/traefik/defaults/main.yml`
+
+```yaml
+---
+traefik_chart_version: "32.1.0"
+traefik_namespace: traefik
+traefik_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+traefik_port_map: [] # переопределяется в inventory/prod/group_vars/traefik.yml
+```
+
+### 6.2. `roles/traefik/templates/traefik-values.yml.j2`
+
+```yaml
+deployment:
+ kind: DaemonSet
+
+nodeSelector:
+ kubernetes.io/os: linux
+
+ports:
+{% for svc in traefik_port_map %}
+ {{ svc.name }}:
+ port: {{ svc.port }}
+ hostPort: {{ svc.port }}
+ expose:
+ default: true
+ exposedPort: {{ svc.port }}
+ protocol: TCP
+{% endfor %}
+ web:
+ expose:
+ default: false
+ websecure:
+ expose:
+ default: false
+
+ingressRoute:
+ dashboard:
+ enabled: false
+
+providers:
+ kubernetesCRD:
+ enabled: true
+ kubernetesIngress:
+ enabled: false
+
+persistence:
+ enabled: false
+
+logs:
+ general:
+ level: INFO
+ access:
+ enabled: true
+```
+
+### 6.3. `roles/traefik/templates/basicauth-middleware.yml.j2`
+
+Рендерится в цикле для каждого сервиса с `basicauth.enabled: true`:
+
+```yaml
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: basicauth-{{ item.name }}
+ namespace: {{ traefik_namespace }}
+spec:
+ basicAuth:
+ secret: {{ item.basicauth.secret_name }}
+ removeHeader: true
+```
+
+### 6.4. `roles/traefik/templates/ingressroute.yml.j2`
+
+```yaml
+apiVersion: traefik.io/v1alpha1
+kind: IngressRoute
+metadata:
+ name: route-{{ item.name }}
+ namespace: {{ traefik_namespace }}
+spec:
+ entryPoints:
+ - {{ item.name }}
+ routes:
+ - match: PathPrefix(`/`)
+ kind: Rule
+ services:
+ - name: {{ item.backend.service }}
+ namespace: {{ item.backend.namespace }}
+ port: {{ item.backend.port }}
+ scheme: {{ item.backend.scheme }}
+{% if item.basicauth.enabled | default(false) %}
+ middlewares:
+ - name: basicauth-{{ item.name }}
+ namespace: {{ traefik_namespace }}
+{% endif %}
+```
+
+### 6.5. `roles/traefik/tasks/middleware.yml`
+
+```yaml
+---
+- name: Middleware | Render BasicAuth CRD manifests
+ ansible.builtin.template:
+ src: basicauth-middleware.yml.j2
+ dest: "/tmp/traefik-middleware-{{ item.name }}.yml"
+ mode: "0600"
+ loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}"
+ loop_control:
+ label: "{{ item.name }}"
+
+- name: Middleware | Apply BasicAuth CRD manifests
+ ansible.builtin.command: >
+ kubectl apply -f /tmp/traefik-middleware-{{ item.name }}.yml
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}"
+ loop_control:
+ label: "{{ item.name }}"
+ register: _mw_apply
+ changed_when: "'configured' in _mw_apply.stdout or 'created' in _mw_apply.stdout"
+```
+
+### 6.6. `roles/traefik/tasks/firewall.yml`
+
+```yaml
+---
+- name: Firewall | Open Traefik service port range on workers
+ ansible.posix.firewalld:
+ port: "10000-10999/tcp"
+ permanent: true
+ state: enabled
+ immediate: true
+ delegate_to: "{{ item }}"
+ loop: "{{ groups['workers'] }}"
+
+- name: Firewall | Trust CNI interfaces on workers
+ ansible.posix.firewalld:
+ zone: trusted
+ interface: "{{ iface }}"
+ permanent: true
+ state: enabled
+ immediate: true
+ loop: "{{ groups['workers'] }}"
+ loop_control:
+ loop_var: worker_host
+ vars:
+ _cni_ifaces:
+ - flannel.1
+ - cni0
+ delegate_to: "{{ worker_host }}"
+ with_nested:
+ - "{{ groups['workers'] }}"
+ - [flannel.1, cni0]
+```
+
+---
+
+## 7. Сводная таблица портов
+
+Поддерживать синхронизированной с `inventory/prod/group_vars/traefik.yml`:
+
+| Порт | Сервис | BasicAuth | Secret | Описание |
+|---|---|---|---|---|
+| 10001 | kubernetes-dashboard | нет | — | K8s Dashboard (своя авторизация по токену) |
+| 10002 | longhorn-frontend | да | `traefik-auth-longhorn` | Longhorn UI |
+| 10003–10999 | (резерв) | — | — | Будущие сервисы |
+
+---
+
+## 8. Порядок развёртывания
+
+### Шаг 1: Развернуть Traefik через Ansible
+
+```bash
+ansible-playbook -i inventory/prod playbooks/setup_traefik.yml
+```
+
+Ansible создаёт: DaemonSet, Middleware CRD (ссылается на несуществующие пока Secret), IngressRoute, firewall-правила.
+
+На данном этапе Traefik вернёт **500** для сервисов с BasicAuth — Secret ещё не создан.
+
+### Шаг 2: Создать Secrets вручную (только для сервисов с basicauth.enabled: true)
+
+```bash
+# Longhorn — BasicAuth включён
+kubectl create secret generic traefik-auth-longhorn \
+ --from-literal=users="$(htpasswd -nb admin 'yourpassword')" \
+ -n traefik
+
+# Dashboard — Secret не нужен, сервис защищён собственной авторизацией по токену
+```
+
+После создания Secret Traefik подхватит его автоматически (без рестарта).
+
+### Шаг 3: Проверить доступ
+
+```bash
+# Dashboard — открывается без BasicAuth, но потребует токен внутри UI
+curl http://10.203.0.96:10001/
+
+# Longhorn — потребует BasicAuth
+curl -u admin:yourpassword http://10.203.0.96:10002/
+```
+
+---
+
+## 9. Добавление нового сервиса (runbook)
+
+1. Добавить запись в `inventory/prod/group_vars/traefik.yml`
+2. Перезапустить `setup_traefik.yml`
+3. Создать Secret, если `basicauth.enabled: true`:
+ ```bash
+ kubectl create secret generic traefik-auth- \
+ --from-literal=users="$(htpasswd -nb user 'pass')" \
+ -n traefik
+ ```
+4. Обновить таблицу портов в разделе 7 этого документа
+
+---
+
+## 10. Интеграция с CI/CD
+
+```yaml
+# .gitlab-ci.yml
+
+# Добавить в syntax-check:
+- ansible-playbook --syntax-check -i $INVENTORY playbooks/setup_traefik.yml
+
+# Новый job:
+setup:traefik:
+ stage: setup
+ script:
+ - ansible-playbook -i $INVENTORY playbooks/setup_traefik.yml
+ environment:
+ name: production
+ rules:
+ - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
+ when: manual
+ resource_group: production
+```
+
+Secrets с паролями **не передаются** через GitLab CI/CD variables — они создаются вручную напрямую в кластере.
+
+---
+
+## 11. Риски и gotchas
+
+| Риск | Митигация |
+|---|---|
+| Secret не создан — Traefik возвращает 500 | Ожидаемое поведение; создать Secret и Traefik подхватит без рестарта |
+| `flannel.1`/`cni0` не в trusted zone | Явная задача в `tasks/firewall.yml` |
+| hostPort занят | Диапазон 10000–10999 специфический; проверить `ss -tlnp` перед деплоем |
+| DaemonSet пересоздаётся при добавлении нового порта в Helm values | Краткий downtime ~10с; все существующие соединения рвутся |
+| Dashboard Service имеет `port: 443` но трафик HTTP | `scheme: http` обязателен; иначе Traefik попытается установить TLS и получит ошибку рукопожатия |
diff --git a/roles/flux/README.md b/roles/flux/README.md
new file mode 100644
index 0000000..43f2625
--- /dev/null
+++ b/roles/flux/README.md
@@ -0,0 +1,1501 @@
+# Flux CD — GitOps-эталон для команды разработчиков
+
+Этот документ описывает стандарт организации GitOps-деплоя приложений в Kubernetes через Flux CD.
+Является эталоном для всех команд, разворачивающих сервисы в кластере `gitlab.gigacoms.info / k8s`.
+
+---
+
+## Содержание
+
+1. [Концепция и термины](#1-концепция-и-термины)
+2. [Двухрепозиторная модель: app-repo и fleet-repo](#2-двухрепозиторная-модель-app-repo-и-fleet-repo)
+3. [Структура fleet-репозитория](#3-структура-fleet-репозитория)
+4. [Многоветочный деплой: main → production, dev → staging](#4-многоветочный-деплой-main--production-dev--staging)
+5. [Пример 1: Одно приложение, один Pod](#5-пример-1-одно-приложение-один-pod)
+6. [Пример 2: Одно приложение, несколько Pod (Deployment + HPA)](#6-пример-2-одно-приложение-несколько-pod-deployment--hpa)
+7. [Пример 3: Несколько компонентов одного приложения (frontend + backend + БД)](#7-пример-3-несколько-компонентов-одного-приложения-frontend--backend--бд)
+8. [Пример 4: Взаимодействующие независимые приложения (микросервисы)](#8-пример-4-взаимодействующие-независимые-приложения-микросервисы)
+9. [CI/CD в app-repo: автоматическое обновление образа](#9-cicd-в-app-repo-автоматическое-обновление-образа)
+10. [Secrets: безопасная передача секретов через Flux](#10-secrets-безопасная-передача-секретов-через-flux)
+11. [Диагностика и типичные ошибки](#11-диагностика-и-типичные-ошибки)
+12. [Чеклист перед деплоем нового приложения](#12-чеклист-перед-деплоем-нового-приложения)
+
+---
+
+## 1. Концепция и термины
+
+**GitOps** — практика, при которой Git-репозиторий является единственным источником истины о состоянии кластера. Flux CD периодически (по умолчанию каждые 1–5 минут) сравнивает желаемое состояние (Git) с фактическим (кластер) и приводит их в соответствие.
+
+| Термин | Описание |
+|---|---|
+| **fleet-repo** | Git-репозиторий с манифестами Kubernetes. Flux следит за ним и применяет изменения в кластер. В нашем случае: `gitlab.gigacoms.info/k8s/k8s-fleet` |
+| **app-repo** | Репозиторий с исходным кодом приложения. Содержит `Dockerfile` и CI/CD пайплайн, который собирает образ и обновляет тег в fleet-repo |
+| **Kustomization** | CRD Flux, описывающий: откуда брать манифесты, в каком namespace применять, от чего зависеть |
+| **GitRepository** | CRD Flux, описывающий: какой Git-репозиторий наблюдать, какую ветку/тег/коммит |
+| **HelmRelease** | CRD Flux для деплоя Helm-чарта с управлением версией и values |
+| **ImageRepository** | CRD Flux для наблюдения за образами в Container Registry |
+| **ImageUpdateAutomation** | CRD Flux для автоматического обновления тега образа в fleet-repo |
+| **Overlay** | Kustomize-слой поверх базовой конфигурации — позволяет менять значения для разных окружений без дублирования |
+
+### Принцип работы
+
+```
+┌─────────────────────────────────────────────────────────────────────┐
+│ Developer │
+│ │ │
+│ ├─ git push → app-repo (main или dev) │
+│ │ │
+│ │ CI/CD в app-repo: │
+│ │ 1. docker build + push → registry (тег: sha/version) │
+│ │ 2. git commit в fleet-repo → обновить тег образа │
+│ │ │
+│ └─ fleet-repo (k8s-fleet) │
+│ │ │
+│ │ Flux (в кластере) каждые N минут: │
+│ │ 1. git pull fleet-repo │
+│ │ 2. сравнить с кластером │
+│ │ 3. kubectl apply изменений │
+│ ▼ │
+│ Kubernetes Cluster │
+│ ├── namespace: production ← из ветки main fleet-repo │
+│ └── namespace: staging ← из ветки main fleet-repo │
+│ (другой path в том же repo) │
+└─────────────────────────────────────────────────────────────────────┘
+```
+
+> **Важно:** Flux читает **один fleet-repo**, но может следить за разными **путями** внутри него для разных окружений. Ветки `main` и `dev` app-repo влияют на разные директории fleet-repo — staging и production.
+
+---
+
+## 2. Двухрепозиторная модель: app-repo и fleet-repo
+
+### app-repo (репозиторий приложения)
+
+Содержит:
+- Исходный код приложения
+- `Dockerfile`
+- `.gitlab-ci.yml` — сборка образа и **обновление fleet-repo**
+- (опционально) базовые Kustomize-манифесты, которые копируются в fleet-repo при инициализации
+
+**Не содержит:**
+- Финальных Kubernetes-манифестов с тегами образов
+- Секретов
+
+### fleet-repo (репозиторий конфигурации кластера)
+
+Единственный источник истины для Flux. Содержит:
+- Манифесты всех приложений и инфраструктурных компонентов
+- Kustomize-оверлеи для каждого окружения
+- HelmRelease-объекты
+- Ссылки на секреты (но не сами секреты в открытом виде)
+
+**Не содержит:**
+- Исходного кода приложений
+- `Dockerfile`
+- Секретов в открытом виде
+
+---
+
+## 3. Структура fleet-репозитория
+
+Стандартная структура fleet-repo для нашего кластера:
+
+```
+k8s-fleet/
+├── clusters/
+│ └── production/ # flux bootstrap --path=clusters/production
+│ ├── flux-system/ # авто-генерируется flux bootstrap, не трогать вручную
+│ │ ├── gotk-components.yaml
+│ │ ├── gotk-sync.yaml
+│ │ └── kustomization.yaml
+│ ├── apps.yaml # Flux Kustomization → apps/production/
+│ └── infrastructure.yaml # Flux Kustomization → infrastructure/production/
+│
+├── apps/
+│ ├── base/ # базовые манифесты (без env-специфики)
+│ │ ├── myapp/
+│ │ │ ├── namespace.yaml
+│ │ │ ├── deployment.yaml
+│ │ │ ├── service.yaml
+│ │ │ └── kustomization.yaml # Kustomize kustomization.yaml (не Flux CRD)
+│ │ └── another-app/
+│ │ └── ...
+│ ├── production/ # production оверлей
+│ │ ├── kustomization.yaml # Flux Kustomization CRD
+│ │ └── myapp/
+│ │ ├── kustomization.yaml # Kustomize: patch поверх base
+│ │ └── patch-image.yaml # тег образа для production
+│ └── staging/ # staging оверлей (из dev ветки app-repo)
+│ ├── kustomization.yaml # Flux Kustomization CRD
+│ └── myapp/
+│ ├── kustomization.yaml
+│ └── patch-image.yaml # тег образа для staging
+│
+└── infrastructure/
+ ├── base/
+ │ ├── traefik/
+ │ └── longhorn/
+ ├── production/
+ │ └── kustomization.yaml
+ └── staging/
+ └── kustomization.yaml
+```
+
+### Точка входа Flux: clusters/production/apps.yaml
+
+```yaml
+# clusters/production/apps.yaml
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: apps
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system # fleet-repo, созданный flux bootstrap
+ path: ./apps/production # Flux следит за этой директорией
+ prune: true # удалять из кластера то, чего нет в Git
+ wait: true # ждать готовности перед следующим шагом
+ timeout: 5m
+```
+
+> `prune: true` — критически важный параметр. Без него удалённые из Git ресурсы останутся в кластере.
+
+---
+
+## 4. Многоветочный деплой: main → production, dev → staging
+
+### Концепция
+
+Оба окружения (production и staging) существуют в **одном кластере** в **разных namespace**.
+Flux следит за **одним fleet-repo** (ветка `main`), но за **разными путями** внутри него:
+
+- `apps/production/` → namespace `production` (образы из ветки `main` app-repo)
+- `apps/staging/` → namespace `staging` (образы из ветки `dev` app-repo)
+
+CI/CD в app-repo при пуше в `main` обновляет тег в `apps/production/myapp/patch-image.yaml`.
+CI/CD в app-repo при пуше в `dev` обновляет тег в `apps/staging/myapp/patch-image.yaml`.
+
+### Настройка двух окружений в clusters/production/
+
+```yaml
+# clusters/production/apps.yaml
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: apps-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production
+ prune: true
+ wait: true
+ timeout: 5m
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: apps-staging
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/staging
+ prune: true
+ wait: true
+ timeout: 5m
+```
+
+### Зависимости между окружениями (dependsOn)
+
+Если staging должен деплоиться только после успешного production (нетипично, но возможно):
+
+```yaml
+spec:
+ dependsOn:
+ - name: apps-production
+```
+
+### Схема взаимодействия веток и окружений
+
+```
+app-repo fleet-repo (k8s-fleet, ветка main)
+─────────────────────────────── ──────────────────────────────────────
+branch: main apps/production/myapp/patch-image.yaml
+ │ image: registry/myapp:v1.5.0
+ │ CI push → обновляет тег ──────►
+ │
+branch: dev apps/staging/myapp/patch-image.yaml
+ │ image: registry/myapp:dev-abc1234
+ │ CI push → обновляет тег ──────►
+ │
+ Flux (каждые 5 минут):
+ git pull fleet-repo
+ apply apps/production/ → ns: production
+ apply apps/staging/ → ns: staging
+```
+
+---
+
+## 5. Пример 1: Одно приложение, один Pod
+
+**Сценарий:** простой HTTP-сервис (например, API на Go), одна реплика.
+
+### Структура в fleet-repo
+
+```
+apps/
+├── base/
+│ └── simple-api/
+│ ├── namespace.yaml
+│ ├── deployment.yaml
+│ ├── service.yaml
+│ └── kustomization.yaml
+├── production/
+│ ├── kustomization.yaml
+│ └── simple-api/
+│ ├── kustomization.yaml
+│ └── patch-image.yaml
+└── staging/
+ ├── kustomization.yaml
+ └── simple-api/
+ ├── kustomization.yaml
+ └── patch-image.yaml
+```
+
+### apps/base/simple-api/namespace.yaml
+
+```yaml
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: production # для base используем production; staging переопределит через patch
+```
+
+### apps/base/simple-api/deployment.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: simple-api
+ namespace: production
+spec:
+ replicas: 1
+ selector:
+ matchLabels:
+ app: simple-api
+ template:
+ metadata:
+ labels:
+ app: simple-api
+ version: "1.0.0"
+ spec:
+ containers:
+ - name: simple-api
+ image: registry.gigacoms.info/myteam/simple-api:latest # тег заменяется патчем
+ ports:
+ - containerPort: 8080
+ env:
+ - name: APP_ENV
+ value: production
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 200m
+ memory: 128Mi
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 5
+ periodSeconds: 10
+ livenessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 15
+ periodSeconds: 20
+```
+
+### apps/base/simple-api/service.yaml
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: simple-api
+ namespace: production
+spec:
+ selector:
+ app: simple-api
+ ports:
+ - port: 80
+ targetPort: 8080
+ type: ClusterIP
+```
+
+### apps/base/simple-api/kustomization.yaml
+
+```yaml
+# Это Kustomize kustomization.yaml (не Flux CRD)
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - namespace.yaml
+ - deployment.yaml
+ - service.yaml
+```
+
+### apps/production/simple-api/patch-image.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: simple-api
+ namespace: production
+spec:
+ template:
+ spec:
+ containers:
+ - name: simple-api
+ image: registry.gigacoms.info/myteam/simple-api:v1.5.2 # обновляет CI/CD
+```
+
+### apps/production/simple-api/kustomization.yaml
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: production
+resources:
+ - ../../base/simple-api
+patches:
+ - path: patch-image.yaml
+```
+
+### apps/staging/simple-api/patch-image.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: simple-api
+ namespace: staging
+spec:
+ template:
+ spec:
+ containers:
+ - name: simple-api
+ image: registry.gigacoms.info/myteam/simple-api:dev-abc1234f
+ env:
+ - name: APP_ENV
+ value: staging
+```
+
+### apps/staging/simple-api/kustomization.yaml
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: staging # переопределяет namespace для всех ресурсов из base
+resources:
+ - ../../base/simple-api
+patches:
+ - path: patch-image.yaml
+```
+
+### apps/production/kustomization.yaml (Flux Kustomization CRD)
+
+```yaml
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: simple-api-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/simple-api
+ prune: true
+ targetNamespace: production
+```
+
+---
+
+## 6. Пример 2: Одно приложение, несколько Pod (Deployment + HPA)
+
+**Сценарий:** нагруженный HTTP-сервис с горизонтальным масштабированием.
+
+### apps/base/web-service/deployment.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web-service
+ namespace: production
+spec:
+ replicas: 2 # минимальное количество; HPA управляет масштабом
+ selector:
+ matchLabels:
+ app: web-service
+ strategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxSurge: 1
+ maxUnavailable: 0 # zero-downtime deploy
+ template:
+ metadata:
+ labels:
+ app: web-service
+ spec:
+ affinity:
+ podAntiAffinity: # разносить Pod по разным нодам
+ preferredDuringSchedulingIgnoredDuringExecution:
+ - weight: 100
+ podAffinityTerm:
+ labelSelector:
+ matchLabels:
+ app: web-service
+ topologyKey: kubernetes.io/hostname
+ containers:
+ - name: web-service
+ image: registry.gigacoms.info/myteam/web-service:latest
+ ports:
+ - containerPort: 8080
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ readinessProbe:
+ httpGet:
+ path: /ready
+ port: 8080
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ failureThreshold: 3
+```
+
+### apps/base/web-service/hpa.yaml
+
+```yaml
+apiVersion: autoscaling/v2
+kind: HorizontalPodAutoscaler
+metadata:
+ name: web-service
+ namespace: production
+spec:
+ scaleTargetRef:
+ apiVersion: apps/v1
+ kind: Deployment
+ name: web-service
+ minReplicas: 2
+ maxReplicas: 10
+ metrics:
+ - type: Resource
+ resource:
+ name: cpu
+ target:
+ type: Utilization
+ averageUtilization: 70
+ - type: Resource
+ resource:
+ name: memory
+ target:
+ type: Utilization
+ averageUtilization: 80
+ behavior:
+ scaleDown:
+ stabilizationWindowSeconds: 300 # не уменьшать быстрее чем раз в 5 минут
+ policies:
+ - type: Pods
+ value: 1
+ periodSeconds: 60
+ scaleUp:
+ stabilizationWindowSeconds: 30
+ policies:
+ - type: Pods
+ value: 2
+ periodSeconds: 60
+```
+
+### apps/base/web-service/pdb.yaml
+
+```yaml
+# PodDisruptionBudget — не допускать недоступности при обновлении нод
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+ name: web-service
+ namespace: production
+spec:
+ minAvailable: 1
+ selector:
+ matchLabels:
+ app: web-service
+```
+
+### apps/base/web-service/kustomization.yaml
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - namespace.yaml
+ - deployment.yaml
+ - service.yaml
+ - hpa.yaml
+ - pdb.yaml
+```
+
+### apps/staging/web-service/patch-replicas.yaml
+
+В staging HPA и PDB не нужны — экономим ресурсы:
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: web-service
+ namespace: staging
+spec:
+ replicas: 1 # staging всегда 1 реплика
+```
+
+### apps/staging/web-service/kustomization.yaml
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: staging
+resources:
+ - ../../base/web-service
+patches:
+ - path: patch-image.yaml
+ - path: patch-replicas.yaml
+# исключаем HPA и PDB из staging
+components: []
+```
+
+Или через `kustomization.yaml` с явным списком resources (без hpa.yaml и pdb.yaml):
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: staging
+resources:
+ - ../../base/web-service/namespace.yaml
+ - ../../base/web-service/deployment.yaml
+ - ../../base/web-service/service.yaml
+patches:
+ - path: patch-image.yaml
+ - path: patch-replicas.yaml
+```
+
+---
+
+## 7. Пример 3: Несколько компонентов одного приложения (frontend + backend + БД)
+
+**Сценарий:** веб-приложение из трёх компонентов в одном namespace. Все три деплоятся вместе, версионируются независимо.
+
+### Структура
+
+```
+apps/
+├── base/
+│ └── shop/ # одно логическое приложение "shop"
+│ ├── namespace.yaml
+│ ├── frontend/
+│ │ ├── deployment.yaml
+│ │ └── service.yaml
+│ ├── backend/
+│ │ ├── deployment.yaml
+│ │ ├── service.yaml
+│ │ └── configmap.yaml
+│ ├── postgres/
+│ │ ├── statefulset.yaml
+│ │ ├── service.yaml
+│ │ └── pvc.yaml
+│ └── kustomization.yaml
+├── production/
+│ └── shop/
+│ ├── kustomization.yaml
+│ ├── patch-frontend.yaml
+│ ├── patch-backend.yaml
+│ └── patch-postgres.yaml
+└── staging/
+ └── shop/
+ ├── kustomization.yaml
+ ├── patch-frontend.yaml
+ ├── patch-backend.yaml
+ └── patch-postgres.yaml
+```
+
+### apps/base/shop/namespace.yaml
+
+```yaml
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: shop-production
+ labels:
+ environment: production
+ app.kubernetes.io/part-of: shop
+```
+
+### apps/base/shop/backend/configmap.yaml
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: backend-config
+ namespace: shop-production
+data:
+ DB_HOST: postgres # имя Service внутри namespace
+ DB_PORT: "5432"
+ DB_NAME: shopdb
+ FRONTEND_URL: http://frontend
+ LOG_LEVEL: info
+```
+
+### apps/base/shop/backend/deployment.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: backend
+ namespace: shop-production
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: shop
+ component: backend
+ template:
+ metadata:
+ labels:
+ app: shop
+ component: backend
+ spec:
+ initContainers:
+ - name: wait-for-postgres # ждём БД перед стартом
+ image: busybox:1.36
+ command:
+ - sh
+ - -c
+ - |
+ until nc -z postgres 5432; do
+ echo "Waiting for postgres..."
+ sleep 2
+ done
+ containers:
+ - name: backend
+ image: registry.gigacoms.info/myteam/shop-backend:latest
+ ports:
+ - containerPort: 3000
+ envFrom:
+ - configMapRef:
+ name: backend-config
+ env:
+ - name: DB_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: shop-postgres-secret
+ key: password
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+```
+
+### apps/base/shop/backend/service.yaml
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: backend
+ namespace: shop-production
+spec:
+ selector:
+ app: shop
+ component: backend
+ ports:
+ - port: 80
+ targetPort: 3000
+ type: ClusterIP
+```
+
+### apps/base/shop/frontend/deployment.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: frontend
+ namespace: shop-production
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: shop
+ component: frontend
+ template:
+ metadata:
+ labels:
+ app: shop
+ component: frontend
+ spec:
+ containers:
+ - name: frontend
+ image: registry.gigacoms.info/myteam/shop-frontend:latest
+ ports:
+ - containerPort: 80
+ env:
+ - name: BACKEND_URL
+ value: http://backend # DNS-имя Service в том же namespace
+ resources:
+ requests:
+ cpu: 50m
+ memory: 64Mi
+ limits:
+ cpu: 200m
+ memory: 128Mi
+```
+
+### apps/base/shop/postgres/statefulset.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: postgres
+ namespace: shop-production
+spec:
+ serviceName: postgres
+ replicas: 1
+ selector:
+ matchLabels:
+ app: shop
+ component: postgres
+ template:
+ metadata:
+ labels:
+ app: shop
+ component: postgres
+ spec:
+ containers:
+ - name: postgres
+ image: postgres:16-alpine
+ ports:
+ - containerPort: 5432
+ env:
+ - name: POSTGRES_DB
+ value: shopdb
+ - name: POSTGRES_USER
+ value: shopuser
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: shop-postgres-secret
+ key: password
+ volumeMounts:
+ - name: data
+ mountPath: /var/lib/postgresql/data
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 1Gi
+ volumeClaimTemplates:
+ - metadata:
+ name: data
+ spec:
+ accessModes: ["ReadWriteOnce"]
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 10Gi
+```
+
+### apps/base/shop/postgres/service.yaml
+
+```yaml
+apiVersion: v1
+kind: Service
+metadata:
+ name: postgres
+ namespace: shop-production
+spec:
+ selector:
+ app: shop
+ component: postgres
+ ports:
+ - port: 5432
+ targetPort: 5432
+ type: ClusterIP
+ clusterIP: None # Headless service для StatefulSet
+```
+
+### apps/base/shop/kustomization.yaml
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+resources:
+ - namespace.yaml
+ - backend/configmap.yaml
+ - backend/deployment.yaml
+ - backend/service.yaml
+ - frontend/deployment.yaml
+ - frontend/service.yaml
+ - postgres/statefulset.yaml
+ - postgres/service.yaml
+```
+
+### apps/production/shop/kustomization.yaml
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: shop-production
+resources:
+ - ../../base/shop
+patches:
+ - path: patch-frontend.yaml
+ - path: patch-backend.yaml
+ - path: patch-postgres.yaml
+```
+
+### apps/production/shop/patch-frontend.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: frontend
+ namespace: shop-production
+spec:
+ template:
+ spec:
+ containers:
+ - name: frontend
+ image: registry.gigacoms.info/myteam/shop-frontend:v2.1.0
+```
+
+### apps/staging/shop/kustomization.yaml
+
+```yaml
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+namespace: shop-staging # staging в отдельном namespace
+resources:
+ - ../../base/shop
+namePrefix: "" # не добавлять префикс
+patches:
+ - path: patch-frontend.yaml
+ - path: patch-backend.yaml
+ - path: patch-postgres.yaml
+ - path: patch-staging-replicas.yaml # все компоненты = 1 реплика
+```
+
+### apps/staging/shop/patch-staging-replicas.yaml
+
+```yaml
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: frontend
+ namespace: shop-staging
+spec:
+ replicas: 1
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: backend
+ namespace: shop-staging
+spec:
+ replicas: 1
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: postgres
+ namespace: shop-staging
+spec:
+ replicas: 1
+```
+
+### Порядок деплоя (dependsOn в Flux Kustomization)
+
+Когда postgres должен быть готов до backend:
+
+```yaml
+# clusters/production/apps.yaml
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: shop-postgres-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/shop/postgres
+ prune: true
+ healthChecks:
+ - apiVersion: apps/v1
+ kind: StatefulSet
+ name: postgres
+ namespace: shop-production
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: shop-backend-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/shop/backend
+ prune: true
+ dependsOn:
+ - name: shop-postgres-production # backend стартует только после healthy postgres
+---
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: shop-frontend-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/shop/frontend
+ prune: true
+ dependsOn:
+ - name: shop-backend-production
+```
+
+---
+
+## 8. Пример 4: Взаимодействующие независимые приложения (микросервисы)
+
+**Сценарий:** несколько независимых сервисов в разных namespace, которые вызывают друг друга по HTTP.
+
+### Топология
+
+```
+namespace: auth-production
+ └── auth-service (порт 8080)
+
+namespace: orders-production
+ └── orders-service (порт 8080) → вызывает auth-service через cross-namespace DNS
+
+namespace: notifications-production
+ └── notifications-service (порт 8080) → вызывает orders-service
+```
+
+### DNS-имена для cross-namespace обращения
+
+Внутри Kubernetes полное DNS-имя Service:
+
+```
+..svc.cluster.local
+```
+
+Примеры:
+- `auth-service.auth-production.svc.cluster.local:8080`
+- `orders-service.orders-production.svc.cluster.local:8080`
+
+### Структура fleet-repo для микросервисов
+
+```
+apps/
+├── base/
+│ ├── auth-service/
+│ │ ├── namespace.yaml
+│ │ ├── deployment.yaml
+│ │ └── service.yaml
+│ ├── orders-service/
+│ │ ├── namespace.yaml
+│ │ ├── deployment.yaml
+│ │ ├── service.yaml
+│ │ └── configmap.yaml # URL других сервисов
+│ └── notifications-service/
+│ ├── namespace.yaml
+│ ├── deployment.yaml
+│ ├── service.yaml
+│ └── configmap.yaml
+├── production/
+│ ├── kustomization.yaml # включает все три сервиса
+│ ├── auth-service/
+│ │ ├── kustomization.yaml
+│ │ └── patch-image.yaml
+│ ├── orders-service/
+│ │ ├── kustomization.yaml
+│ │ └── patch-image.yaml
+│ └── notifications-service/
+│ ├── kustomization.yaml
+│ └── patch-image.yaml
+└── staging/
+ └── ... # аналогично, namespace: *-staging
+```
+
+### apps/base/orders-service/configmap.yaml
+
+```yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: orders-config
+ namespace: orders-production
+data:
+ # cross-namespace DNS — полный FQDN обязателен
+ AUTH_SERVICE_URL: http://auth-service.auth-production.svc.cluster.local:80
+ NOTIFICATIONS_SERVICE_URL: http://notifications-service.notifications-production.svc.cluster.local:80
+ LOG_LEVEL: info
+```
+
+### apps/base/orders-service/deployment.yaml
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: orders-service
+ namespace: orders-production
+spec:
+ replicas: 2
+ selector:
+ matchLabels:
+ app: orders-service
+ template:
+ metadata:
+ labels:
+ app: orders-service
+ spec:
+ containers:
+ - name: orders-service
+ image: registry.gigacoms.info/myteam/orders-service:latest
+ ports:
+ - containerPort: 8080
+ envFrom:
+ - configMapRef:
+ name: orders-config
+ resources:
+ requests:
+ cpu: 100m
+ memory: 128Mi
+ limits:
+ cpu: 300m
+ memory: 256Mi
+ readinessProbe:
+ httpGet:
+ path: /health
+ port: 8080
+ initialDelaySeconds: 10
+ periodSeconds: 5
+```
+
+### Staging: cross-namespace URL тоже меняется
+
+```yaml
+# apps/staging/orders-service/patch-config.yaml
+apiVersion: v1
+kind: ConfigMap
+metadata:
+ name: orders-config
+ namespace: orders-staging
+data:
+ AUTH_SERVICE_URL: http://auth-service.auth-staging.svc.cluster.local:80
+ NOTIFICATIONS_SERVICE_URL: http://notifications-service.notifications-staging.svc.cluster.local:80
+ LOG_LEVEL: debug # staging = verbose logging
+```
+
+### NetworkPolicy: разрешить inter-namespace трафик явно
+
+По умолчанию в Kubernetes трафик между namespace разрешён. Если в кластере включён запретительный NetworkPolicy, нужно явно разрешить:
+
+```yaml
+# apps/base/auth-service/networkpolicy.yaml
+apiVersion: networking.k8s.io/v1
+kind: NetworkPolicy
+metadata:
+ name: allow-from-orders
+ namespace: auth-production
+spec:
+ podSelector:
+ matchLabels:
+ app: auth-service
+ ingress:
+ - from:
+ - namespaceSelector:
+ matchLabels:
+ kubernetes.io/metadata.name: orders-production
+ podSelector:
+ matchLabels:
+ app: orders-service
+ ports:
+ - port: 8080
+```
+
+### Порядок деплоя микросервисов (dependsOn)
+
+```yaml
+# clusters/production/apps.yaml
+---
+# auth деплоится первым (ни от чего не зависит)
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: auth-service-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/auth-service
+ prune: true
+ healthChecks:
+ - apiVersion: apps/v1
+ kind: Deployment
+ name: auth-service
+ namespace: auth-production
+---
+# orders ждёт auth
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: orders-service-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/orders-service
+ prune: true
+ dependsOn:
+ - name: auth-service-production
+ healthChecks:
+ - apiVersion: apps/v1
+ kind: Deployment
+ name: orders-service
+ namespace: orders-production
+---
+# notifications ждёт orders
+apiVersion: kustomize.toolkit.fluxcd.io/v1
+kind: Kustomization
+metadata:
+ name: notifications-service-production
+ namespace: flux-system
+spec:
+ interval: 5m
+ sourceRef:
+ kind: GitRepository
+ name: flux-system
+ path: ./apps/production/notifications-service
+ prune: true
+ dependsOn:
+ - name: orders-service-production
+```
+
+---
+
+## 9. CI/CD в app-repo: автоматическое обновление образа
+
+### Принцип работы
+
+1. Разработчик делает `git push` в ветку `main` (или `dev`)
+2. CI собирает Docker-образ, тегирует его (`v1.5.2` или `dev-abc1234f`)
+3. CI пушит образ в Container Registry
+4. CI клонирует fleet-repo, обновляет тег образа в нужном оверлее, делает commit + push
+5. Flux обнаруживает изменение в fleet-repo и применяет его в кластер
+
+### .gitlab-ci.yml в app-repo (стандартный шаблон)
+
+```yaml
+variables:
+ REGISTRY: registry.gigacoms.info
+ IMAGE_NAME: $REGISTRY/myteam/simple-api
+ FLEET_REPO: https://oauth2:${GITLAB_FLEET_TOKEN}@gitlab.gigacoms.info/k8s/k8s-fleet.git
+
+stages:
+ - build
+ - deploy
+
+build:
+ stage: build
+ image: docker:27
+ services:
+ - docker:27-dind
+ script:
+ - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $REGISTRY
+ - |
+ if [ "$CI_COMMIT_BRANCH" = "main" ]; then
+ TAG="v${CI_COMMIT_SHORT_SHA}"
+ OVERLAY="production"
+ else
+ TAG="dev-${CI_COMMIT_SHORT_SHA}"
+ OVERLAY="staging"
+ fi
+ - docker build -t $IMAGE_NAME:$TAG .
+ - docker push $IMAGE_NAME:$TAG
+ - echo "TAG=$TAG" >> build.env
+ - echo "OVERLAY=$OVERLAY" >> build.env
+ artifacts:
+ reports:
+ dotenv: build.env
+ rules:
+ - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "dev"
+
+deploy-to-fleet:
+ stage: deploy
+ image: alpine/git:latest
+ needs:
+ - job: build
+ artifacts: true
+ script:
+ - git config --global user.email "ci@gitlab.gigacoms.info"
+ - git config --global user.name "GitLab CI"
+ - git clone --depth=1 $FLEET_REPO /tmp/fleet
+ - |
+ PATCH_FILE="/tmp/fleet/apps/${OVERLAY}/simple-api/patch-image.yaml"
+ cat > $PATCH_FILE << EOF
+ apiVersion: apps/v1
+ kind: Deployment
+ metadata:
+ name: simple-api
+ namespace: simple-api-${OVERLAY}
+ spec:
+ template:
+ spec:
+ containers:
+ - name: simple-api
+ image: ${IMAGE_NAME}:${TAG}
+ EOF
+ - cd /tmp/fleet
+ - git add apps/${OVERLAY}/simple-api/patch-image.yaml
+ - git diff --cached --quiet || git commit -m "ci: update simple-api ${OVERLAY} to ${TAG}"
+ - git push origin main
+ rules:
+ - if: $CI_COMMIT_BRANCH == "main" || $CI_COMMIT_BRANCH == "dev"
+```
+
+> **Важно:** `GITLAB_FLEET_TOKEN` — отдельный токен с правами `write_repository` на fleet-repo. Хранить в CI/CD Variables проекта app-repo. Не использовать персональные токены.
+
+### Соглашения по тегам образов
+
+| Окружение | Ветка app-repo | Формат тега | Пример |
+|---|---|---|---|
+| production | `main` | `v` или semver | `v1.5.2`, `vabc1234f` |
+| staging | `dev` | `dev-` | `dev-abc1234f` |
+
+Никогда не использовать тег `latest` в fleet-repo — он неиммутабелен и Flux не сможет отследить изменение.
+
+---
+
+## 10. Secrets: безопасная передача секретов через Flux
+
+### Принцип
+
+Секреты **никогда не хранятся в Git в открытом виде**. Допустимые подходы:
+
+1. **Ручное создание** (используем сейчас) — `kubectl create secret` вручную один раз
+2. **Sealed Secrets** — шифрование секрета публичным ключом кластера, зашифрованный yaml коммитится в Git
+3. **External Secrets Operator** — секрет хранится во внешнем хранилище (Vault, AWS SM), ESO синхронизирует в кластер
+
+### Подход 1: ручное создание (текущий стандарт)
+
+```bash
+# Создать секрет вручную — один раз, не через Ansible и не через Git
+kubectl create secret generic shop-postgres-secret \
+ --from-literal=password='StrongPassword123!' \
+ -n shop-production
+
+# Для staging отдельно
+kubectl create secret generic shop-postgres-secret \
+ --from-literal=password='StagingPassword456!' \
+ -n shop-staging
+```
+
+В манифесте деплоя ссылаться на секрет через `secretKeyRef` (как в примерах выше).
+
+Если Flux удалит namespace (через `prune: true`), секрет исчезнет вместе с ним. Документировать секреты в `secrets/README.md` внутри fleet-repo (без значений!):
+
+```markdown
+# Требуемые секреты (создавать вручную перед первым деплоем)
+
+## shop-production
+kubectl create secret generic shop-postgres-secret \
+ --from-literal=password='' \
+ -n shop-production
+```
+
+### Подход 2: Sealed Secrets (рекомендуется при масштабировании)
+
+```bash
+# Установить kubeseal
+brew install kubeseal # или скачать бинарь
+
+# Зашифровать секрет публичным ключом кластера
+kubectl create secret generic shop-postgres-secret \
+ --from-literal=password='StrongPassword123!' \
+ --dry-run=client -o yaml | \
+ kubeseal --controller-namespace flux-system \
+ --controller-name sealed-secrets \
+ --format yaml > apps/base/shop/postgres/sealed-secret.yaml
+
+# Полученный sealed-secret.yaml безопасно коммитить в Git
+git add apps/base/shop/postgres/sealed-secret.yaml
+git commit -m "feat: add shop postgres sealed secret"
+```
+
+---
+
+## 11. Диагностика и типичные ошибки
+
+### Основные команды
+
+```bash
+# Состояние всех Flux-объектов
+flux get all -A
+
+# Состояние конкретного Kustomization
+flux get kustomization apps-production -n flux-system
+
+# Принудительная синхронизация (не ждать interval)
+flux reconcile kustomization apps-production --with-source
+
+# Логи flux-контроллеров
+kubectl logs -n flux-system -l app=kustomize-controller --tail=50
+kubectl logs -n flux-system -l app=source-controller --tail=50
+
+# Просмотр событий в namespace
+kubectl get events -n production --sort-by='.lastTimestamp'
+
+# Детали конкретного Kustomization
+kubectl describe kustomization apps-production -n flux-system
+```
+
+### Типичные ошибки и решения
+
+| Ошибка | Причина | Решение |
+|---|---|---|
+| `Health check failed` | Pod не стал Ready в течение timeout | Проверить `kubectl describe pod`, `kubectl logs` |
+| `kustomize build failed` | Синтаксическая ошибка в yaml | `kustomize build apps/production/myapp` локально |
+| `object not found` | Ресурс удалён из Git но остался в кластере без `prune: true` | Добавить `prune: true` или удалить вручную |
+| `no such file or directory` | Неверный path в Kustomization | Проверить path в CRD, путь относительный от корня repo |
+| `resource conflict` | Два Kustomization управляют одним ресурсом | Разнести по разным namespace или убрать дублирование |
+| Flux не видит изменений | Интервал не истёк | `flux reconcile kustomization --with-source` |
+| Deployment завис на 0/2 | imagePullError | Проверить тег образа и доступность registry |
+
+### Проверка до деплоя (dry-run)
+
+```bash
+# Локальная проверка Kustomize-сборки
+kustomize build apps/production/myapp
+
+# Проверить что Flux увидит после изменения
+flux diff kustomization apps-production --path ./apps/production
+
+# Синтаксическая проверка всех yaml
+find apps/ -name '*.yaml' | xargs kubectl apply --dry-run=client -f
+```
+
+---
+
+## 12. Чеклист перед деплоем нового приложения
+
+### В fleet-repo
+
+- [ ] Создана директория `apps/base//` с namespace, deployment, service, kustomization.yaml
+- [ ] Создана директория `apps/production//` с patch-image.yaml и kustomization.yaml
+- [ ] Создана директория `apps/staging//` с patch-image.yaml и kustomization.yaml
+- [ ] Добавлен Flux Kustomization CRD в `clusters/production/apps.yaml`
+- [ ] Настроены `dependsOn` если приложение зависит от других компонентов
+- [ ] Настроены `healthChecks` если другие компоненты зависят от этого приложения
+- [ ] `prune: true` установлен во всех Kustomization CRD
+- [ ] Ресурсы `requests`/`limits` заданы для всех контейнеров
+- [ ] `readinessProbe` настроен для всех контейнеров
+- [ ] `kustomize build apps/production/` выполняется без ошибок локально
+
+### В app-repo
+
+- [ ] `.gitlab-ci.yml` содержит шаг `deploy-to-fleet`
+- [ ] Используются иммутабельные теги образов (не `latest`)
+- [ ] `GITLAB_FLEET_TOKEN` добавлен в CI/CD Variables проекта
+- [ ] Образ успешно собирается и пушится в registry
+
+### В кластере
+
+- [ ] Secrets созданы вручную в нужных namespace перед первым деплоем (documented в secrets/README.md)
+- [ ] `flux get kustomization ` показывает `Ready=True`
+- [ ] Pod в состоянии `Running`, все контейнеры `Ready`
+- [ ] Сервис доступен внутри кластера: `kubectl exec -it -- curl http://`
+- [ ] (если нужен внешний доступ) IngressRoute добавлен в `inventory/prod/group_vars/traefik.yml`
+
+---
+
+## Приложение: соглашения по именованию
+
+| Сущность | Формат | Пример |
+|---|---|---|
+| Namespace production | `-production` | `shop-production` |
+| Namespace staging | `-staging` | `shop-staging` |
+| Flux Kustomization | `-` | `shop-production` |
+| Docker-образ production | `registry/team/:v` | `registry.gigacoms.info/myteam/shop-backend:vabc1234f` |
+| Docker-образ staging | `registry/team/:dev-` | `registry.gigacoms.info/myteam/shop-backend:dev-abc1234f` |
+| Secret | `--secret` | `shop-postgres-secret` |
+| ConfigMap | `-config` | `backend-config` |
+| Service (внутр.) | `` | `backend`, `postgres` |
+
+## Приложение: минимальный ресурсный профиль
+
+Использовать как отправную точку, корректировать под реальную нагрузку:
+
+```yaml
+resources:
+ requests:
+ cpu: 50m # гарантированное CPU
+ memory: 64Mi # гарантированная память
+ limits:
+ cpu: 200m # максимальное CPU
+ memory: 256Mi # максимальная память (OOMKill при превышении)
+```
+
+> При выборе `limits.memory`: установить в 2–4x от `requests.memory`. Слишком маленький лимит приведёт к постоянным OOMKill. Слишком большой — к неэффективному использованию узла.
diff --git a/roles/flux/defaults/main.yml b/roles/flux/defaults/main.yml
new file mode 100644
index 0000000..90e911a
--- /dev/null
+++ b/roles/flux/defaults/main.yml
@@ -0,0 +1,19 @@
+---
+# Flux CLI version (empty = latest stable from GitHub)
+flux_version: ""
+
+# Path to kubeconfig on manager node
+flux_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+
+# GitLab connection settings for bootstrap
+flux_gitlab_hostname: "gitlab.gigacoms.info"
+flux_gitlab_owner: "k8s"
+flux_gitlab_repository: "k8s-fleet"
+flux_gitlab_branch: "main"
+flux_gitlab_path: "clusters/production"
+
+# Whether the owner is a personal account (true) or a group (false)
+flux_gitlab_personal: false
+
+# GitLab token passed via env in CI — never hardcode here
+flux_gitlab_token: "{{ lookup('env', 'GITLAB_FLUX_TOKEN') | default('') }}"
diff --git a/roles/flux/handlers/main.yml b/roles/flux/handlers/main.yml
new file mode 100644
index 0000000..ed97d53
--- /dev/null
+++ b/roles/flux/handlers/main.yml
@@ -0,0 +1 @@
+---
diff --git a/roles/flux/meta/main.yml b/roles/flux/meta/main.yml
new file mode 100644
index 0000000..1690725
--- /dev/null
+++ b/roles/flux/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: Install Flux CD CLI and bootstrap Flux in Kubernetes cluster via GitLab
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/flux/tasks/bootstrap.yml b/roles/flux/tasks/bootstrap.yml
new file mode 100644
index 0000000..2d29cf6
--- /dev/null
+++ b/roles/flux/tasks/bootstrap.yml
@@ -0,0 +1,25 @@
+---
+- name: Bootstrap | Verify GITLAB_FLUX_TOKEN is set
+ ansible.builtin.assert:
+ that:
+ - flux_gitlab_token | length > 0
+ fail_msg: "GITLAB_FLUX_TOKEN environment variable is not set — cannot bootstrap Flux"
+ success_msg: "GitLab token is present"
+
+- name: Bootstrap | Run flux bootstrap gitlab
+ ansible.builtin.command:
+ argv: >-
+ {{ ['flux', 'bootstrap', 'gitlab',
+ '--hostname=' + flux_gitlab_hostname,
+ '--owner=' + flux_gitlab_owner,
+ '--repository=' + flux_gitlab_repository,
+ '--branch=' + flux_gitlab_branch,
+ '--path=' + flux_gitlab_path,
+ '--private=true']
+ + (['--personal'] if flux_gitlab_personal | bool else []) }}
+ environment:
+ KUBECONFIG: "{{ flux_kubeconfig }}"
+ GITLAB_TOKEN: "{{ flux_gitlab_token }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _flux_bootstrap
+ changed_when: "'already exists' not in _flux_bootstrap.stderr"
diff --git a/roles/flux/tasks/install.yml b/roles/flux/tasks/install.yml
new file mode 100644
index 0000000..0762b41
--- /dev/null
+++ b/roles/flux/tasks/install.yml
@@ -0,0 +1,48 @@
+---
+- name: Flux CLI | Get latest version from GitHub
+ ansible.builtin.uri:
+ url: https://api.github.com/repos/fluxcd/flux2/releases/latest
+ return_content: true
+ register: _flux_latest
+ when: not flux_version
+ check_mode: false
+
+- name: Flux CLI | Set version fact (latest)
+ ansible.builtin.set_fact:
+ _flux_version: "{{ _flux_latest.json.tag_name | regex_replace('^v', '') }}"
+ when: not flux_version
+
+- name: Flux CLI | Set version fact (pinned)
+ ansible.builtin.set_fact:
+ _flux_version: "{{ flux_version | regex_replace('^v', '') }}"
+ when: flux_version
+
+- name: Flux CLI | Download and unpack
+ ansible.builtin.unarchive:
+ src: "https://github.com/fluxcd/flux2/releases/download/v{{ _flux_version }}/flux_{{ _flux_version }}_linux_amd64.tar.gz"
+ dest: /usr/local/bin
+ remote_src: true
+ include:
+ - flux
+ creates: /usr/local/bin/flux
+
+- name: Flux CLI | Set permissions
+ ansible.builtin.file:
+ path: /usr/local/bin/flux
+ mode: "0755"
+ owner: root
+ group: root
+
+- name: Flux CLI | Enable bash completion
+ ansible.builtin.shell: /usr/local/bin/flux completion bash > /etc/bash_completion.d/flux
+ args:
+ creates: /etc/bash_completion.d/flux
+
+- name: Flux CLI | Verify pre-flight checks
+ ansible.builtin.command: flux check --pre
+ environment:
+ KUBECONFIG: "{{ flux_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _flux_check
+ changed_when: false
+ failed_when: _flux_check.rc != 0
diff --git a/roles/flux/tasks/main.yml b/roles/flux/tasks/main.yml
new file mode 100644
index 0000000..5bd7fee
--- /dev/null
+++ b/roles/flux/tasks/main.yml
@@ -0,0 +1,6 @@
+---
+- name: Flux | Install CLI
+ ansible.builtin.include_tasks: install.yml
+
+- name: Flux | Bootstrap with GitLab
+ ansible.builtin.include_tasks: bootstrap.yml
diff --git a/roles/gitlab_agent/defaults/main.yml b/roles/gitlab_agent/defaults/main.yml
new file mode 100644
index 0000000..6039b28
--- /dev/null
+++ b/roles/gitlab_agent/defaults/main.yml
@@ -0,0 +1,31 @@
+---
+# GitLab hostname
+gitlab_agent_hostname: "gitlab.gigacoms.info"
+
+# Agent name — must match the name registered in GitLab UI
+# and the directory name in .gitlab/agents//config.yaml in the fleet repo
+gitlab_agent_name: "production"
+
+# Kubernetes namespace for agentk
+gitlab_agent_namespace: "gitlab-agent"
+
+# Agent token — passed via GITLAB_AGENT_TOKEN CI variable
+gitlab_agent_token: "{{ lookup('env', 'GITLAB_AGENT_TOKEN') | default('') }}"
+
+# KAS WebSocket address (auto-derived from hostname)
+gitlab_agent_kas_address: "wss://{{ gitlab_agent_hostname }}/-/kubernetes-agent/"
+
+# Path to kubeconfig on manager node
+gitlab_agent_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+
+# Fleet repo settings (for creating agent config.yaml)
+gitlab_agent_fleet_repo: "https://{{ gitlab_agent_hostname }}/k8s/k8s-fleet.git"
+gitlab_agent_fleet_token: "{{ lookup('env', 'GITLAB_FLUX_TOKEN') | default('') }}"
+gitlab_agent_fleet_branch: "main"
+
+# GitLab group to grant ci_access (all projects in this group can use the agent)
+gitlab_agent_ci_access_group: "k8s"
+
+# Additional individual projects to grant ci_access (outside the main group)
+# Format: list of GitLab project paths, e.g. ["it-dept/billing-mobile"]
+gitlab_agent_ci_access_projects: []
diff --git a/roles/gitlab_agent/handlers/main.yml b/roles/gitlab_agent/handlers/main.yml
new file mode 100644
index 0000000..ed97d53
--- /dev/null
+++ b/roles/gitlab_agent/handlers/main.yml
@@ -0,0 +1 @@
+---
diff --git a/roles/gitlab_agent/meta/main.yml b/roles/gitlab_agent/meta/main.yml
new file mode 100644
index 0000000..434e8b4
--- /dev/null
+++ b/roles/gitlab_agent/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: Install GitLab Agent (agentk) in Kubernetes cluster and register agent config in fleet repo
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/gitlab_agent/tasks/config.yml b/roles/gitlab_agent/tasks/config.yml
new file mode 100644
index 0000000..64a67af
--- /dev/null
+++ b/roles/gitlab_agent/tasks/config.yml
@@ -0,0 +1,87 @@
+---
+- name: Agent config | Verify GITLAB_FLUX_TOKEN is set
+ ansible.builtin.assert:
+ that:
+ - gitlab_agent_fleet_token | length > 0
+ fail_msg: "GITLAB_FLUX_TOKEN environment variable is not set — cannot push agent config to fleet repo"
+
+- name: Agent config | Create temp directory for fleet repo clone
+ ansible.builtin.tempfile:
+ state: directory
+ suffix: fleet
+ register: _fleet_tmpdir
+
+- name: Agent config | Clone fleet repo
+ ansible.builtin.git:
+ repo: "https://oauth2:{{ gitlab_agent_fleet_token }}@{{ gitlab_agent_hostname }}/k8s/k8s-fleet.git"
+ dest: "{{ _fleet_tmpdir.path }}/k8s-fleet"
+ version: "{{ gitlab_agent_fleet_branch }}"
+ depth: 1
+ no_log: true
+ changed_when: true
+
+- name: Agent config | Create agent config directory
+ ansible.builtin.file:
+ path: "{{ _fleet_tmpdir.path }}/k8s-fleet/.gitlab/agents/{{ gitlab_agent_name }}"
+ state: directory
+ mode: "0755"
+
+- name: Agent config | Write agent config.yaml
+ ansible.builtin.copy:
+ dest: "{{ _fleet_tmpdir.path }}/k8s-fleet/.gitlab/agents/{{ gitlab_agent_name }}/config.yaml"
+ mode: "0644"
+ content: |
+ gitops:
+ reconcile_timeout: 3600s
+
+ observability:
+ logging:
+ level: info
+
+ ci_access:
+ groups:
+ - id: {{ gitlab_agent_ci_access_group }}
+ {% if gitlab_agent_ci_access_projects | length > 0 %}
+ projects:
+ {% for project in gitlab_agent_ci_access_projects %}
+ - id: {{ project }}
+ {% endfor %}
+ {% endif %}
+ register: _agent_config
+
+- name: Agent config | Commit and push if changed
+ when: _agent_config.changed
+ block:
+ - name: Agent config | Configure git user
+ ansible.builtin.git:
+ repo: "{{ _fleet_tmpdir.path }}/k8s-fleet"
+ user_name: "Ansible"
+ user_email: "ansible@{{ gitlab_agent_hostname }}"
+ changed_when: false
+
+ - name: Agent config | Stage agent config
+ ansible.builtin.git:
+ repo: "{{ _fleet_tmpdir.path }}/k8s-fleet"
+ add:
+ - ".gitlab/agents/{{ gitlab_agent_name }}/config.yaml"
+ changed_when: false
+
+ - name: Agent config | Commit
+ ansible.builtin.git:
+ repo: "{{ _fleet_tmpdir.path }}/k8s-fleet"
+ commit:
+ msg: "feat: add GitLab agent config for {{ gitlab_agent_name }}"
+ changed_when: true
+
+ - name: Agent config | Push
+ ansible.builtin.git:
+ repo: "{{ _fleet_tmpdir.path }}/k8s-fleet"
+ push: true
+ branch: "{{ gitlab_agent_fleet_branch }}"
+ no_log: true
+ changed_when: true
+
+- name: Agent config | Remove temp directory
+ ansible.builtin.file:
+ path: "{{ _fleet_tmpdir.path }}"
+ state: absent
diff --git a/roles/gitlab_agent/tasks/helm.yml b/roles/gitlab_agent/tasks/helm.yml
new file mode 100644
index 0000000..a22ea20
--- /dev/null
+++ b/roles/gitlab_agent/tasks/helm.yml
@@ -0,0 +1,36 @@
+---
+- name: Helm | Verify GITLAB_AGENT_TOKEN is set
+ ansible.builtin.assert:
+ that:
+ - gitlab_agent_token | length > 0
+ fail_msg: "GITLAB_AGENT_TOKEN environment variable is not set — cannot install agentk"
+
+- name: Helm | Add GitLab chart repository
+ ansible.builtin.command: helm repo add gitlab https://charts.gitlab.io
+ environment:
+ KUBECONFIG: "{{ gitlab_agent_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_repo_add
+ changed_when: "'already exists' not in _helm_repo_add.stdout"
+ failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout
+
+- name: Helm | Update chart repositories
+ ansible.builtin.command: helm repo update
+ environment:
+ KUBECONFIG: "{{ gitlab_agent_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+
+- name: Helm | Install or upgrade gitlab-agent
+ ansible.builtin.command: >
+ helm upgrade --install gitlab-agent gitlab/gitlab-agent
+ --namespace {{ gitlab_agent_namespace }}
+ --create-namespace
+ --set config.token={{ gitlab_agent_token }}
+ --set config.kasAddress={{ gitlab_agent_kas_address }}
+ environment:
+ KUBECONFIG: "{{ gitlab_agent_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ no_log: true
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/gitlab_agent/tasks/main.yml b/roles/gitlab_agent/tasks/main.yml
new file mode 100644
index 0000000..d1a4d90
--- /dev/null
+++ b/roles/gitlab_agent/tasks/main.yml
@@ -0,0 +1,6 @@
+---
+- name: GitLab Agent | Push agent config to fleet repo
+ ansible.builtin.include_tasks: config.yml
+
+- name: GitLab Agent | Install agentk via Helm
+ ansible.builtin.include_tasks: helm.yml
diff --git a/roles/gpu_device_plugin/defaults/main.yml b/roles/gpu_device_plugin/defaults/main.yml
new file mode 100644
index 0000000..a6b186b
--- /dev/null
+++ b/roles/gpu_device_plugin/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+gpu_device_plugin_version: "v0.17.0"
+gpu_device_plugin_manifest_url: "https://raw.githubusercontent.com/NVIDIA/k8s-device-plugin/{{ gpu_device_plugin_version }}/deployments/static/nvidia-device-plugin.yml"
+
+# Path to kubeconfig on the manager node
+gpu_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
diff --git a/roles/gpu_device_plugin/meta/main.yml b/roles/gpu_device_plugin/meta/main.yml
new file mode 100644
index 0000000..6e52d9e
--- /dev/null
+++ b/roles/gpu_device_plugin/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: Labels GPU worker nodes and installs the NVIDIA k8s device plugin
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/gpu_device_plugin/tasks/deploy.yml b/roles/gpu_device_plugin/tasks/deploy.yml
new file mode 100644
index 0000000..69257ae
--- /dev/null
+++ b/roles/gpu_device_plugin/tasks/deploy.yml
@@ -0,0 +1,22 @@
+---
+- name: Deploy | Check kubeconfig exists
+ ansible.builtin.stat:
+ path: "{{ gpu_kubeconfig }}"
+ register: _kubeconfig_plugin
+
+- name: Deploy | Skip notice
+ ansible.builtin.debug:
+ msg: "Device plugin install skipped — kubeconfig not found at {{ gpu_kubeconfig }}."
+ when: not _kubeconfig_plugin.stat.exists
+
+- name: Deploy | Apply NVIDIA device plugin manifest
+ ansible.builtin.command:
+ cmd: "kubectl apply -f {{ gpu_device_plugin_manifest_url }}"
+ environment:
+ KUBECONFIG: "{{ gpu_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ when:
+ - _kubeconfig_plugin.stat.exists
+ - groups['gpu_workers'] | default([]) | length > 0
+ register: _plugin_apply
+ changed_when: "'configured' in _plugin_apply.stdout or 'created' in _plugin_apply.stdout"
diff --git a/roles/gpu_device_plugin/tasks/label.yml b/roles/gpu_device_plugin/tasks/label.yml
new file mode 100644
index 0000000..af33e8f
--- /dev/null
+++ b/roles/gpu_device_plugin/tasks/label.yml
@@ -0,0 +1,19 @@
+---
+- name: Label | Check kubeconfig exists
+ ansible.builtin.stat:
+ path: "{{ gpu_kubeconfig }}"
+ register: _kubeconfig_gpu
+
+- name: Label | Apply node_labels to each GPU worker
+ ansible.builtin.command:
+ argv: "{{ ['kubectl', 'label', 'node', item, '--overwrite'] + (hostvars[item].node_labels.items() | map('join', '=') | list) }}"
+ environment:
+ KUBECONFIG: "{{ gpu_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ loop: "{{ groups['gpu_workers'] | default([]) }}"
+ when:
+ - _kubeconfig_gpu.stat.exists
+ - hostvars[item].node_labels | default({}) | length > 0
+ register: _label
+ changed_when: "'labeled' in _label.stdout"
+ failed_when: _label.rc != 0
diff --git a/roles/gpu_device_plugin/tasks/main.yml b/roles/gpu_device_plugin/tasks/main.yml
new file mode 100644
index 0000000..c510c19
--- /dev/null
+++ b/roles/gpu_device_plugin/tasks/main.yml
@@ -0,0 +1,6 @@
+---
+- name: Label nodes
+ ansible.builtin.include_tasks: label.yml
+
+- name: Install device plugin
+ ansible.builtin.include_tasks: deploy.yml
diff --git a/roles/gpu_model_storage/README.md b/roles/gpu_model_storage/README.md
new file mode 100644
index 0000000..9ef9701
--- /dev/null
+++ b/roles/gpu_model_storage/README.md
@@ -0,0 +1,129 @@
+# gpu_model_storage
+
+Выделенный локальный RAID1-раздел под большие файлы моделей (Ollama и т.п.) на GPU-узлах кластера.
+
+## Зачем это нужно
+
+`gpu_workers` намеренно исключены из Longhorn (`longhorn_disks: []` в `group_vars/gpu_workers.yml`) —
+GPU-узлы являются compute-only, а не storage-нодами.
+
+При этом проект `k8s_ai` (Ollama + Ollama Proxy + Open WebUI) хранит файлы моделей через статический
+`hostPath` PV (`k8s_ai/k8s/ollama/pvc-models.yaml`), привязанный к конкретному узлу через `nodeAffinity`.
+Изначально этот `hostPath` указывал на `/root/ollama-models`, что физически находится на **корневой
+файловой системе** узла.
+
+Корневой раздел на `k8s-worker-02` — это RAID1-массив `md126` размером всего **~30 ГБ**
+(`sda4`/`sdb4`). Первая же загрузка модели среднего размера (обычно десятки гигабайт) заполнила бы
+диск полностью и уронила бы узел — на `/` живут `systemd`, `containerd`, `kubelet` и вся остальная ОС.
+Kubernetes при этом **не** проверяет заявленную ёмкость `hostPath` PV (`capacity.storage` — чисто
+декларативное поле), так что переполнение диска не было бы предотвращено на уровне Kubernetes.
+
+При этом два зеркальных диска узла (`sda`/`sdb`, по 4 ТБ каждый) используют под ОС (`boot`,
+`boot_efi`, `root`) только первые ~34 ГБ — оставшиеся ~3.6 ТБ на каждом диске были полностью
+неразмеченными и простаивали.
+
+**Решение**: роль нарезает из этого свободного места отдельный RAID1-раздел, форматирует его и
+монтирует в отдельную точку — модели больше не могут повлиять на работоспособность ОС.
+
+## Архитектура
+
+```
+sda, sdb (по 4 ТБ, зеркало)
+├─ sda1/sdb1 → md127 (boot, ext4, ~1 ГБ) — существовало до роли
+├─ sda2/sdb2 → md125 (boot_efi, vfat, ~0.6 ГБ) — существовало до роли
+├─ sda3/sdb3 → LVM (swap) — существовало до роли
+├─ sda4/sdb4 → md126 (root, ext4, ~30 ГБ) — существовало до роли
+└─ sda5/sdb5 → md1 (модели, xfs, gpu_model_storage_partition_size_gb ГБ) ← создаёт эта роль
+ └─ смонтирован в gpu_model_storage_mountpoint (по умолчанию /mnt/ollama-models)
+```
+
+Партиция №5 создаётся сразу после существующих ОС-партиций (старт фиксирован на `34GB`, конец —
+`34 + gpu_model_storage_partition_size_gb` ГБ), на каждом диске из `gpu_model_storage_devices`.
+Из этих партиций собирается новый независимый `mdadm`-массив RAID1 (не имеет отношения к
+`md126`/`md127`/`md125` — отдельный массив, отдельная точка монтирования).
+
+## Как включить для узла/группы
+
+Роль **выключена по умолчанию** (`gpu_model_storage_devices: []` в `defaults/main.yml`) — по тому
+же паттерну, что и `longhorn_disks` в `longhorn_prereqs`. Чтобы включить, переопределите список
+дисков в `group_vars/.yml` или `hosts.yml` для конкретного хоста:
+
+```yaml
+# inventory/prod/group_vars/gpu_workers.yml
+gpu_model_storage_devices:
+ - /dev/sda
+ - /dev/sdb
+gpu_model_storage_partition_size_gb: 1000
+```
+
+Сейчас это включено для всей группы `gpu_workers` (на данный момент единственный член —
+`k8s-worker-02`).
+
+## Запуск
+
+```bash
+ansible-playbook -i inventory/prod playbooks/setup_gpu_model_storage.yml
+```
+
+Плейбук нацелен на группу `gpu_workers`. Порядок относительно `setup_worker_plane.yml` /
+`setup_gpu.yml` **не важен** — роль работает только с локальными дисками узла и не трогает
+`containerd`/`kubelet`/сеть. Можно запускать в любой момент, включая до `kubeadm join`.
+
+Все задачи идемпотентны:
+- `community.general.parted` не пересоздаёт партицию, если она уже существует с нужными параметрами.
+- `raid.yml` проверяет `mdadm --detail ` перед созданием массива — повторный запуск не
+ пересоздаёт RAID.
+- `filesystem.yml` (`community.general.filesystem`) не переформатирует уже отформатированный раздел.
+- `mount.yml` использует `ansible.posix.mount` с `state: mounted` — идемпотентно монтирует по UUID.
+
+## Все переменные
+
+| Переменная | Значение по умолчанию | Описание |
+|---|---|---|
+| `gpu_model_storage_devices` | `[]` | Список блочных устройств — членов будущего RAID1 (например `/dev/sda`, `/dev/sdb`). Пустой список = роль ничего не делает. |
+| `gpu_model_storage_partition_size_gb` | `1000` | Размер новой партиции (пятой) на каждом устройстве, в гигабайтах. Партиция начинается сразу после существующих ОС-партиций (`34GB`). |
+| `gpu_model_storage_raid_device` | `/dev/md1` | Имя нового RAID1-массива. Не должно совпадать с уже существующими (`/dev/md125`, `/dev/md126`, `/dev/md127` на типовом узле). |
+| `gpu_model_storage_mountpoint` | `/mnt/ollama-models` | Точка монтирования нового раздела. |
+| `gpu_model_storage_fstype` | `xfs` | Файловая система (как в Longhorn-дисках — консистентность в репозитории). |
+
+## Ограничения и подводные камни
+
+- **Начало партиции захардкожено в `34GB`.** Это соответствует текущему размеру существующих
+ ОС-партиций (`boot` + `boot_efi` + `swap` + `root` ≈ 32.2 ГБ + небольшой запас) на узлах,
+ разворачиваемых по стандартному Rocky Linux 9 + RAID1 layout. Если разметка ОС на новом узле
+ отличается — проверьте фактическое свободное место (`parted /dev/sdX unit GB print free`) и
+ скорректируйте `part_start` в `roles/gpu_model_storage/tasks/partition.yml` перед запуском.
+- **Имя RAID-устройства должно быть свободным.** Перед первым запуском на новом узле проверьте
+ `cat /proc/mdstat`, чтобы `gpu_model_storage_raid_device` не совпадал с уже занятым `/dev/mdN`.
+- **Роль не выполняет `dracut -f`** — это осознанно: массив не является частью `root`/`boot`, не
+ участвует в загрузке системы, поэтому пересборка initramfs не требуется. Ядро подхватывает массив
+ через udev по суперблоку при каждой загрузке, а запись в `/etc/mdadm.conf` — для надёжности
+ (явное ARRAY-определение вместо авто-скана).
+- **Диски используются только на ~1/4** (при `gpu_model_storage_partition_size_gb: 1000` из ~3967 ГБ
+ свободных) — сделано намеренно, с запасом на будущее (другие датасеты, второй RAID-раздел и т.д.).
+ Увеличить размер существующего раздела после создания массива штатными средствами Ansible-роли
+ нельзя — потребуется resize партиции, RAID и файловой системы вручную (`parted resizepart`,
+ `mdadm --grow`, `xfs_growfs`).
+- **Реальная доступная ёмкость меньше заявленных 1000 ГБ** из-за разницы GB/GiB и служебных данных
+ файловой системы. В `k8s_ai/k8s/ollama/pvc-models.yaml` `hostPath`/`PVC` объявляют `900Gi` —
+ безопасный запас под это расхождение (это не enforced-лимит, просто декларативное число).
+
+## Диагностика
+
+```bash
+# Статус RAID-массива
+cat /proc/mdstat
+mdadm --detail /dev/md1
+
+# Точка монтирования
+lsblk -f
+df -h /mnt/ollama-models
+
+# Если массив не поднялся после перезагрузки — проверить запись в mdadm.conf
+grep md1 /etc/mdadm.conf
+```
+
+Если `/mnt/ollama-models` не смонтирован после перезагрузки — массив собран (`mdadm --detail`
+показывает `active`), но запись в `/etc/fstab` (созданная `ansible.posix.mount`) использует
+`nofail`, поэтому загрузка ОС не блокируется — нужно смонтировать вручную (`mount -a`) и
+разобраться, почему массив не успел подняться до попытки монтирования при следующем прогоне роли.
diff --git a/roles/gpu_model_storage/defaults/main.yml b/roles/gpu_model_storage/defaults/main.yml
new file mode 100644
index 0000000..2a0a9db
--- /dev/null
+++ b/roles/gpu_model_storage/defaults/main.yml
@@ -0,0 +1,10 @@
+---
+# Dedicated local RAID1 partition for large model files (Ollama etc.) on GPU
+# worker nodes. Carved out of otherwise-unpartitioned space on the same
+# mirrored disks used for the OS. Empty by default — override per host/group
+# (see group_vars/gpu_workers.yml) to enable.
+gpu_model_storage_devices: []
+gpu_model_storage_partition_size_gb: 1000
+gpu_model_storage_raid_device: /dev/md1
+gpu_model_storage_mountpoint: /mnt/ollama-models
+gpu_model_storage_fstype: xfs
diff --git a/roles/gpu_model_storage/meta/main.yml b/roles/gpu_model_storage/meta/main.yml
new file mode 100644
index 0000000..1932cca
--- /dev/null
+++ b/roles/gpu_model_storage/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: Dedicated local RAID1 partition + filesystem for large model files (Ollama) on GPU worker nodes
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/gpu_model_storage/tasks/filesystem.yml b/roles/gpu_model_storage/tasks/filesystem.yml
new file mode 100644
index 0000000..00c298f
--- /dev/null
+++ b/roles/gpu_model_storage/tasks/filesystem.yml
@@ -0,0 +1,5 @@
+---
+- name: Filesystem | Format RAID array
+ community.general.filesystem:
+ fstype: "{{ gpu_model_storage_fstype }}"
+ dev: "{{ gpu_model_storage_raid_device }}"
diff --git a/roles/gpu_model_storage/tasks/main.yml b/roles/gpu_model_storage/tasks/main.yml
new file mode 100644
index 0000000..72ee896
--- /dev/null
+++ b/roles/gpu_model_storage/tasks/main.yml
@@ -0,0 +1,16 @@
+---
+- name: Partition
+ ansible.builtin.include_tasks: partition.yml
+ when: gpu_model_storage_devices | length > 0
+
+- name: RAID
+ ansible.builtin.include_tasks: raid.yml
+ when: gpu_model_storage_devices | length > 0
+
+- name: Filesystem
+ ansible.builtin.include_tasks: filesystem.yml
+ when: gpu_model_storage_devices | length > 0
+
+- name: Mount
+ ansible.builtin.include_tasks: mount.yml
+ when: gpu_model_storage_devices | length > 0
diff --git a/roles/gpu_model_storage/tasks/mount.yml b/roles/gpu_model_storage/tasks/mount.yml
new file mode 100644
index 0000000..71fc758
--- /dev/null
+++ b/roles/gpu_model_storage/tasks/mount.yml
@@ -0,0 +1,19 @@
+---
+- name: Mount | Create mountpoint directory
+ ansible.builtin.file:
+ path: "{{ gpu_model_storage_mountpoint }}"
+ state: directory
+ mode: "0755"
+
+- name: Mount | Get UUID of RAID array
+ ansible.builtin.command: "blkid -s UUID -o value {{ gpu_model_storage_raid_device }}"
+ register: _md_uuid
+ changed_when: false
+
+- name: Mount | Mount persistently (UUID, nofail)
+ ansible.posix.mount:
+ path: "{{ gpu_model_storage_mountpoint }}"
+ src: "UUID={{ _md_uuid.stdout }}"
+ fstype: "{{ gpu_model_storage_fstype }}"
+ opts: defaults,nofail
+ state: mounted
diff --git a/roles/gpu_model_storage/tasks/partition.yml b/roles/gpu_model_storage/tasks/partition.yml
new file mode 100644
index 0000000..8eeb3da
--- /dev/null
+++ b/roles/gpu_model_storage/tasks/partition.yml
@@ -0,0 +1,17 @@
+---
+# The existing OS RAID1 partitions (boot, boot_efi, root) occupy the first
+# ~34GB of each mirrored disk. Partition 5 uses the free space right after
+# that, sized by gpu_model_storage_partition_size_gb, on every device listed
+# in gpu_model_storage_devices — the partitions are then mirrored into a new
+# mdadm RAID1 array in raid.yml.
+- name: Partition | Create model storage partition (raid member) on {{ item }}
+ community.general.parted:
+ device: "{{ item }}"
+ number: 5
+ state: present
+ part_start: "34GB"
+ part_end: "{{ 34 + gpu_model_storage_partition_size_gb }}GB"
+ flags: [raid]
+ loop: "{{ gpu_model_storage_devices }}"
+ loop_control:
+ label: "{{ item }}"
diff --git a/roles/gpu_model_storage/tasks/raid.yml b/roles/gpu_model_storage/tasks/raid.yml
new file mode 100644
index 0000000..3d7656d
--- /dev/null
+++ b/roles/gpu_model_storage/tasks/raid.yml
@@ -0,0 +1,28 @@
+---
+- name: RAID | Check whether array already exists
+ ansible.builtin.command: "mdadm --detail {{ gpu_model_storage_raid_device }}"
+ register: _md_detail
+ changed_when: false
+ failed_when: false
+
+- name: RAID | Create RAID1 array from partition 5 on each device
+ ansible.builtin.command: >
+ mdadm --create {{ gpu_model_storage_raid_device }}
+ --level=1 --raid-devices={{ gpu_model_storage_devices | length }}
+ --metadata=1.2 --run
+ {{ gpu_model_storage_devices | map('regex_replace', '$', '5') | join(' ') }}
+ when: _md_detail.rc != 0
+
+- name: RAID | Read current mdadm scan output
+ ansible.builtin.command: mdadm --detail --scan
+ register: _mdadm_scan
+ changed_when: false
+
+- name: RAID | Persist array definition in /etc/mdadm.conf
+ ansible.builtin.lineinfile:
+ path: /etc/mdadm.conf
+ line: "{{ item }}"
+ regexp: "^ARRAY {{ gpu_model_storage_raid_device }} "
+ loop: "{{ _mdadm_scan.stdout_lines | select('search', gpu_model_storage_raid_device) | list }}"
+ loop_control:
+ label: "{{ gpu_model_storage_raid_device }}"
diff --git a/roles/gpu_prereqs/defaults/main.yml b/roles/gpu_prereqs/defaults/main.yml
new file mode 100644
index 0000000..476b14d
--- /dev/null
+++ b/roles/gpu_prereqs/defaults/main.yml
@@ -0,0 +1,3 @@
+---
+nvidia_container_toolkit_version: "" # empty = latest
+nvidia_container_toolkit_repo_url: "https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo"
diff --git a/roles/gpu_prereqs/handlers/main.yml b/roles/gpu_prereqs/handlers/main.yml
new file mode 100644
index 0000000..be9142a
--- /dev/null
+++ b/roles/gpu_prereqs/handlers/main.yml
@@ -0,0 +1,6 @@
+---
+- name: Restart containerd
+ ansible.builtin.systemd:
+ name: containerd
+ state: restarted
+ daemon_reload: true
diff --git a/roles/gpu_prereqs/meta/main.yml b/roles/gpu_prereqs/meta/main.yml
new file mode 100644
index 0000000..3fe9828
--- /dev/null
+++ b/roles/gpu_prereqs/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: NVIDIA GPU prerequisites for a Kubernetes worker (Rocky Linux 9)
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/gpu_prereqs/tasks/containerd.yml b/roles/gpu_prereqs/tasks/containerd.yml
new file mode 100644
index 0000000..52132f5
--- /dev/null
+++ b/roles/gpu_prereqs/tasks/containerd.yml
@@ -0,0 +1,19 @@
+---
+# Must run after roles/k8s_worker has generated /etc/containerd/config.toml —
+# k8s_worker regenerates that file from scratch and would wipe this out if it
+# ran afterwards. Run setup_gpu.yml only after setup_worker_plane.yml.
+- name: Containerd | Check for nvidia runtime section
+ ansible.builtin.command: grep -q 'runtimes.nvidia' /etc/containerd/config.toml
+ register: _nvidia_runtime_present
+ changed_when: false
+ failed_when: false
+
+- name: Containerd | Configure nvidia runtime
+ ansible.builtin.command: nvidia-ctk runtime configure --runtime=containerd
+ when: _nvidia_runtime_present.rc != 0
+ notify: Restart containerd
+
+- name: Containerd | Set nvidia as default runtime
+ ansible.builtin.command: nvidia-ctk runtime configure --runtime=containerd --set-as-default
+ when: _nvidia_runtime_present.rc != 0
+ notify: Restart containerd
diff --git a/roles/gpu_prereqs/tasks/driver_check.yml b/roles/gpu_prereqs/tasks/driver_check.yml
new file mode 100644
index 0000000..6dcb0f6
--- /dev/null
+++ b/roles/gpu_prereqs/tasks/driver_check.yml
@@ -0,0 +1,15 @@
+---
+# Driver installation is out of scope for this role — Ansible only configures
+# the container runtime on top of a driver that is already present on the host.
+- name: Driver check | Look up nvidia-smi
+ ansible.builtin.command: which nvidia-smi
+ register: _nvidia_smi
+ changed_when: false
+ failed_when: false
+
+- name: Driver check | Fail if NVIDIA driver is not installed
+ ansible.builtin.fail:
+ msg: >-
+ nvidia-smi not found on {{ inventory_hostname }}. Install the NVIDIA driver
+ manually before running this role — it is not managed by Ansible.
+ when: _nvidia_smi.rc != 0
diff --git a/roles/gpu_prereqs/tasks/main.yml b/roles/gpu_prereqs/tasks/main.yml
new file mode 100644
index 0000000..295862a
--- /dev/null
+++ b/roles/gpu_prereqs/tasks/main.yml
@@ -0,0 +1,9 @@
+---
+- name: Verify NVIDIA driver
+ ansible.builtin.include_tasks: driver_check.yml
+
+- name: NVIDIA Container Toolkit
+ ansible.builtin.include_tasks: toolkit.yml
+
+- name: Containerd GPU runtime
+ ansible.builtin.include_tasks: containerd.yml
diff --git a/roles/gpu_prereqs/tasks/toolkit.yml b/roles/gpu_prereqs/tasks/toolkit.yml
new file mode 100644
index 0000000..e17be4f
--- /dev/null
+++ b/roles/gpu_prereqs/tasks/toolkit.yml
@@ -0,0 +1,12 @@
+---
+- name: Toolkit | Add NVIDIA Container Toolkit repo
+ ansible.builtin.get_url:
+ url: "{{ nvidia_container_toolkit_repo_url }}"
+ dest: /etc/yum.repos.d/nvidia-container-toolkit.repo
+ mode: "0644"
+
+- name: Toolkit | Install nvidia-container-toolkit
+ ansible.builtin.dnf:
+ name: "{{ 'nvidia-container-toolkit-' + nvidia_container_toolkit_version if nvidia_container_toolkit_version != '' else 'nvidia-container-toolkit' }}"
+ state: present
+ update_cache: true
diff --git a/roles/k8s_control_plane/defaults/main.yml b/roles/k8s_control_plane/defaults/main.yml
new file mode 100644
index 0000000..fc97abf
--- /dev/null
+++ b/roles/k8s_control_plane/defaults/main.yml
@@ -0,0 +1,24 @@
+---
+# Kubernetes version (major.minor) — used for repo URL and kubeadm
+k8s_version: "1.33"
+
+# Container runtime
+containerd_version: "" # empty = latest from Docker CE repo
+
+# Network settings
+pod_network_cidr: "10.244.0.0/16" # Flannel default
+service_cidr: "10.96.0.0/12" # kubeadm default
+apiserver_advertise_address: "{{ ansible_host }}"
+
+# CNI plugin: flannel | calico
+cni_plugin: "flannel"
+
+# Fix #6: pinned CNI versions — bump explicitly when upgrading
+flannel_version: "v0.26.1"
+calico_version: "v3.27.0"
+
+# Path where kubeconfig is placed for the ansible_user on the control plane
+kubeconfig_path: "/home/{{ ansible_user }}/.kube/config"
+
+# Copy kubeconfig to manager nodes after init
+kubeconfig_fetch_to_managers: true
diff --git a/roles/k8s_control_plane/handlers/main.yml b/roles/k8s_control_plane/handlers/main.yml
new file mode 100644
index 0000000..a2bccca
--- /dev/null
+++ b/roles/k8s_control_plane/handlers/main.yml
@@ -0,0 +1,17 @@
+---
+- name: restart containerd
+ ansible.builtin.systemd:
+ name: containerd
+ state: restarted
+ daemon_reload: true
+
+- name: restart kubelet
+ ansible.builtin.systemd:
+ name: kubelet
+ state: restarted
+ daemon_reload: true
+
+- name: reload firewalld
+ ansible.builtin.systemd:
+ name: firewalld
+ state: reloaded
diff --git a/roles/k8s_control_plane/meta/main.yml b/roles/k8s_control_plane/meta/main.yml
new file mode 100644
index 0000000..adf381b
--- /dev/null
+++ b/roles/k8s_control_plane/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: Kubernetes control plane node (single master) on Rocky Linux 9
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/k8s_control_plane/tasks/containerd.yml b/roles/k8s_control_plane/tasks/containerd.yml
new file mode 100644
index 0000000..2d49e0c
--- /dev/null
+++ b/roles/k8s_control_plane/tasks/containerd.yml
@@ -0,0 +1,57 @@
+---
+- name: Containerd | Add Docker CE repo
+ ansible.builtin.get_url:
+ url: https://download.docker.com/linux/rhel/docker-ce.repo
+ dest: /etc/yum.repos.d/docker-ce.repo
+ mode: "0644"
+
+- name: Containerd | Disable conflicting container-tools module
+ ansible.builtin.command: dnf module disable container-tools -y
+ changed_when: false
+ failed_when: false
+
+- name: Containerd | Install containerd.io
+ ansible.builtin.dnf:
+ name: "{{ 'containerd.io-' + containerd_version if containerd_version != '' else 'containerd.io' }}"
+ state: present
+ update_cache: true
+ disablerepo: kubernetes
+
+- name: Containerd | Ensure runc is available at path expected by containerd
+ ansible.builtin.shell: |
+ RUNC_BIN=$(which runc 2>/dev/null || echo "")
+ if [ -n "$RUNC_BIN" ] && [ ! -f /usr/local/bin/runc ]; then
+ ln -sf "$RUNC_BIN" /usr/local/bin/runc
+ fi
+ changed_when: false
+
+- name: Containerd | Remove default config
+ ansible.builtin.file:
+ dest: /etc/containerd/config.toml
+ state: absent
+
+- name: Containerd | Generate default config
+ ansible.builtin.shell: containerd config default > /etc/containerd/config.toml
+ args:
+ creates: /etc/containerd/config.toml
+
+- name: Containerd | Enable SystemdCgroup
+ ansible.builtin.replace:
+ path: /etc/containerd/config.toml
+ regexp: 'SystemdCgroup = false'
+ replace: 'SystemdCgroup = true'
+ notify: restart containerd
+
+- name: Containerd | Enable SystemdCgroup
+ ansible.builtin.replace:
+ path: /etc/containerd/config.toml
+ regexp: 'disabled_plugins = ["cri"]'
+ replace: 'disabled_plugins = []'
+ notify: restart containerd
+
+- name: Containerd | Enable and start service
+ ansible.builtin.systemd:
+ name: containerd
+ enabled: true
+ state: started
+ daemon_reload: true
diff --git a/roles/k8s_control_plane/tasks/kubeadm_init.yml b/roles/k8s_control_plane/tasks/kubeadm_init.yml
new file mode 100644
index 0000000..82aa1c5
--- /dev/null
+++ b/roles/k8s_control_plane/tasks/kubeadm_init.yml
@@ -0,0 +1,133 @@
+---
+- name: kubeadm init | Get full kubeadm version
+ ansible.builtin.command: kubeadm version -o short
+ register: _kubeadm_full_version
+ changed_when: false
+
+- name: kubeadm init | Check if cluster already initialized
+ ansible.builtin.stat:
+ path: /etc/kubernetes/admin.conf
+ register: _admin_conf
+
+- name: kubeadm init | Write kubeadm config
+ ansible.builtin.template:
+ src: kubeadm_config.yml.j2
+ dest: /tmp/kubeadm_config.yml
+ mode: "0600"
+ when: not _admin_conf.stat.exists
+
+- name: kubeadm init | Initialize cluster
+ ansible.builtin.command: >
+ kubeadm init
+ --config /tmp/kubeadm_config.yml
+ --upload-certs
+ when: not _admin_conf.stat.exists
+ register: _kubeadm_init
+ changed_when: true
+
+- name: kubeadm init | Ensure .kube dir for root
+ ansible.builtin.file:
+ path: /root/.kube
+ state: directory
+ mode: "0700"
+
+- name: kubeadm init | Copy admin.conf for root
+ ansible.builtin.copy:
+ src: /etc/kubernetes/admin.conf
+ dest: /root/.kube/config
+ remote_src: true
+ mode: "0600"
+
+- name: kubeadm init | Ensure .kube dir for ansible_user
+ ansible.builtin.file:
+ path: "/home/{{ ansible_user }}/.kube"
+ state: directory
+ owner: "{{ ansible_user }}"
+ group: "{{ ansible_user }}"
+ mode: "0700"
+
+- name: kubeadm init | Copy admin.conf for ansible_user
+ ansible.builtin.copy:
+ src: /etc/kubernetes/admin.conf
+ dest: "{{ kubeconfig_path }}"
+ remote_src: true
+ owner: "{{ ansible_user }}"
+ group: "{{ ansible_user }}"
+ mode: "0600"
+
+# Fix #6: pinned Flannel version — change flannel_version in defaults to upgrade
+- name: kubeadm init | Install Flannel CNI
+ ansible.builtin.command: >
+ kubectl apply -f
+ https://github.com/flannel-io/flannel/releases/download/{{ flannel_version }}/kube-flannel.yml
+ environment:
+ KUBECONFIG: /etc/kubernetes/admin.conf
+ when:
+ - cni_plugin == "flannel"
+ - not _admin_conf.stat.exists
+ changed_when: true
+
+- name: kubeadm init | Install Calico CNI
+ ansible.builtin.command: >
+ kubectl apply -f
+ https://raw.githubusercontent.com/projectcalico/calico/{{ calico_version }}/manifests/calico.yaml
+ environment:
+ KUBECONFIG: /etc/kubernetes/admin.conf
+ when:
+ - cni_plugin == "calico"
+ - not _admin_conf.stat.exists
+ changed_when: true
+
+# single-node cluster — remove taint so workloads can schedule on master
+- name: kubeadm init | Remove control-plane taint (single-node)
+ ansible.builtin.command: >
+ kubectl taint nodes {{ inventory_hostname }}
+ node-role.kubernetes.io/control-plane:NoSchedule-
+ environment:
+ KUBECONFIG: /etc/kubernetes/admin.conf
+ when: not _admin_conf.stat.exists
+ failed_when: false
+ changed_when: true
+
+# Fix #5: save join command so worker nodes can be added later (tokens expire in 24h)
+- name: kubeadm init | Save worker join command
+ ansible.builtin.shell: >
+ kubeadm token create --print-join-command
+ register: _join_command
+ changed_when: false
+
+- name: kubeadm init | Write join command to file
+ ansible.builtin.copy:
+ content: "{{ _join_command.stdout }}\n"
+ dest: /etc/kubernetes/worker_join_command.sh
+ mode: "0600"
+
+# Fix #3: create .kube dir on managers before copying kubeconfig (race condition)
+- name: kubeadm init | Ensure .kube dir on manager nodes
+ ansible.builtin.file:
+ path: "/home/{{ ansible_user }}/.kube"
+ state: directory
+ owner: "{{ ansible_user }}"
+ group: "{{ ansible_user }}"
+ mode: "0700"
+ delegate_to: "{{ item }}"
+ loop: "{{ groups['manager_nodes'] }}"
+ when: kubeconfig_fetch_to_managers | bool
+
+- name: kubeadm init | Fetch kubeconfig to controller
+ ansible.builtin.fetch:
+ src: /etc/kubernetes/admin.conf
+ dest: "/tmp/k8s_admin_{{ inventory_hostname }}.conf"
+ flat: true
+ when: kubeconfig_fetch_to_managers | bool
+
+- name: kubeadm init | Distribute kubeconfig to manager nodes
+ ansible.builtin.copy:
+ src: "/tmp/k8s_admin_{{ inventory_hostname }}.conf"
+ dest: "/home/{{ ansible_user }}/.kube/config"
+ owner: "{{ ansible_user }}"
+ group: "{{ ansible_user }}"
+ mode: "0600"
+ delegate_to: "{{ item }}"
+ loop: "{{ groups['manager_nodes'] }}"
+ when: kubeconfig_fetch_to_managers | bool
diff --git a/roles/k8s_control_plane/tasks/kubernetes.yml b/roles/k8s_control_plane/tasks/kubernetes.yml
new file mode 100644
index 0000000..70440ad
--- /dev/null
+++ b/roles/k8s_control_plane/tasks/kubernetes.yml
@@ -0,0 +1,34 @@
+---
+- name: Kubernetes | Add repo
+ ansible.builtin.yum_repository:
+ name: kubernetes
+ description: Kubernetes
+ baseurl: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/"
+ enabled: true
+ gpgcheck: true
+ gpgkey: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/repodata/repomd.xml.key"
+ exclude: "kubelet kubeadm kubectl cri-tools kubernetes-cni"
+ timeout: "300"
+
+- name: Kubernetes | Set a low but nonzero minrate so a truly stalled CDN connection times out
+ community.general.ini_file:
+ path: /etc/yum.repos.d/kubernetes.repo
+ section: kubernetes
+ option: minrate
+ value: "1000"
+ mode: "0644"
+
+- name: Kubernetes | Install kubelet, kubeadm, kubectl
+ ansible.builtin.dnf:
+ name:
+ - kubelet
+ - kubeadm
+ - kubectl
+ state: present
+ disable_excludes: kubernetes
+
+- name: Kubernetes | Enable kubelet (kubeadm will start it)
+ ansible.builtin.systemd:
+ name: kubelet
+ enabled: true
+ daemon_reload: true
diff --git a/roles/k8s_control_plane/tasks/main.yml b/roles/k8s_control_plane/tasks/main.yml
new file mode 100644
index 0000000..57d08a1
--- /dev/null
+++ b/roles/k8s_control_plane/tasks/main.yml
@@ -0,0 +1,12 @@
+---
+- name: Prerequisites
+ ansible.builtin.include_tasks: prerequisites.yml
+
+- name: Containerd
+ ansible.builtin.include_tasks: containerd.yml
+
+- name: Kubernetes packages
+ ansible.builtin.include_tasks: kubernetes.yml
+
+- name: Cluster init
+ ansible.builtin.include_tasks: kubeadm_init.yml
diff --git a/roles/k8s_control_plane/tasks/prerequisites.yml b/roles/k8s_control_plane/tasks/prerequisites.yml
new file mode 100644
index 0000000..d3d10d0
--- /dev/null
+++ b/roles/k8s_control_plane/tasks/prerequisites.yml
@@ -0,0 +1,104 @@
+---
+- name: Prerequisites | Set hostname
+ ansible.builtin.hostname:
+ name: "{{ inventory_hostname }}"
+
+- name: Prerequisites | Add hostname to /etc/hosts
+ ansible.builtin.lineinfile:
+ path: /etc/hosts
+ line: "{{ ansible_host }} {{ inventory_hostname }}"
+ regexp: ".*{{ inventory_hostname }}$"
+ state: present
+
+- name: Prerequisites | Disable swap permanently
+ ansible.builtin.replace:
+ path: /etc/fstab
+ regexp: '^([^#].*\s+swap\s+.*)$'
+ replace: '# \1'
+
+- name: Prerequisites | Disable swap now
+ ansible.builtin.command: swapoff -a
+ changed_when: false
+
+- name: Prerequisites | Set SELinux to permissive
+ ansible.posix.selinux:
+ policy: targeted
+ state: permissive
+
+- name: Prerequisites | Load kernel modules (persistent)
+ ansible.builtin.copy:
+ dest: /etc/modules-load.d/k8s.conf
+ content: |
+ overlay
+ br_netfilter
+ mode: "0644"
+
+- name: Prerequisites | Load kernel modules now
+ community.general.modprobe:
+ name: "{{ item }}"
+ state: present
+ loop:
+ - overlay
+ - br_netfilter
+
+- name: Prerequisites | Set sysctl params
+ ansible.posix.sysctl:
+ name: "{{ item.key }}"
+ value: "{{ item.value }}"
+ sysctl_file: /etc/sysctl.d/k8s.conf
+ reload: true
+ loop:
+ - { key: "net.bridge.bridge-nf-call-iptables", value: "1" }
+ - { key: "net.bridge.bridge-nf-call-ip6tables", value: "1" }
+ - { key: "net.ipv4.ip_forward", value: "1" }
+
+# Fix #9: ensure firewalld is running before opening ports
+- name: Prerequisites | Ensure firewalld is running
+ ansible.builtin.systemd:
+ name: firewalld
+ state: started
+ enabled: true
+ daemon_reload: true
+
+- name: Prerequisites | Open control plane firewall ports
+ ansible.posix.firewalld:
+ port: "{{ item }}"
+ permanent: true
+ state: enabled
+ immediate: "{{ not ansible_check_mode }}"
+ loop:
+ - 6443/tcp # kube-apiserver
+ - 2379-2380/tcp # etcd
+ - 10250/tcp # kubelet API
+ - 10251/tcp # kube-scheduler
+ - 10252/tcp # kube-controller-manager
+ - 10257/tcp # kube-controller-manager (secure)
+ - 10259/tcp # kube-scheduler (secure)
+ - 8472/udp # Flannel VXLAN (fix #4: required for pod-to-pod traffic across nodes)
+
+- name: Prerequisites | Trust overlay interfaces in firewalld
+ ansible.posix.firewalld:
+ interface: "{{ item }}"
+ zone: trusted
+ permanent: true
+ state: enabled
+ immediate: "{{ not ansible_check_mode }}"
+ loop:
+ - flannel.1
+ - cni0
+
+# Interface-based rules above only work when the interface already exists at
+# firewalld reload time. Flannel creates flannel.1/cni0 dynamically (no
+# NetworkManager integration), so they may not be in the trusted zone after
+# a fresh boot. Source-based CIDR rules are reliable regardless of interface
+# lifecycle and cover all pod-to-pod and service traffic.
+- name: Prerequisites | Trust pod and service CIDRs in firewalld
+ ansible.posix.firewalld:
+ source: "{{ item }}"
+ zone: trusted
+ permanent: true
+ state: enabled
+ immediate: "{{ not ansible_check_mode }}"
+ loop:
+ - "{{ pod_network_cidr }}"
+ - "{{ service_cidr }}"
diff --git a/roles/k8s_control_plane/templates/kubeadm_config.yml.j2 b/roles/k8s_control_plane/templates/kubeadm_config.yml.j2
new file mode 100644
index 0000000..f9af891
--- /dev/null
+++ b/roles/k8s_control_plane/templates/kubeadm_config.yml.j2
@@ -0,0 +1,20 @@
+---
+apiVersion: kubeadm.k8s.io/v1beta4
+kind: InitConfiguration
+localAPIEndpoint:
+ advertiseAddress: "{{ apiserver_advertise_address }}"
+ bindPort: 6443
+nodeRegistration:
+ criSocket: unix:///run/containerd/containerd.sock
+ name: "{{ inventory_hostname }}"
+---
+apiVersion: kubeadm.k8s.io/v1beta4
+kind: ClusterConfiguration
+kubernetesVersion: "{{ _kubeadm_full_version.stdout | trim }}"
+networking:
+ podSubnet: "{{ pod_network_cidr }}"
+ serviceSubnet: "{{ service_cidr }}"
+---
+apiVersion: kubelet.config.k8s.io/v1beta1
+kind: KubeletConfiguration
+cgroupDriver: systemd
diff --git a/roles/k8s_gbm_dev/defaults/main.yml b/roles/k8s_gbm_dev/defaults/main.yml
new file mode 100644
index 0000000..e9b05f6
--- /dev/null
+++ b/roles/k8s_gbm_dev/defaults/main.yml
@@ -0,0 +1,18 @@
+---
+dev_access_namespace: gigacom-billing-mobile
+dev_access_sa_name: developer
+dev_access_role_name: developer-role
+dev_access_rolebinding_name: developer-rolebinding
+dev_access_token_secret_name: developer-token
+dev_access_token_file: "/home/{{ ansible_user }}/.kube/dev-token-{{ dev_access_namespace }}"
+
+dev_access_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+
+# Verbs granted on namespace resources
+dev_access_verbs:
+ - get
+ - list
+ - watch
+
+# Whether to also allow exec/portforward/logs (useful for debugging)
+dev_access_allow_exec: true
diff --git a/roles/k8s_gbm_dev/tasks/main.yml b/roles/k8s_gbm_dev/tasks/main.yml
new file mode 100644
index 0000000..679e827
--- /dev/null
+++ b/roles/k8s_gbm_dev/tasks/main.yml
@@ -0,0 +1,16 @@
+---
+- name: DevAccess | Check kubeconfig exists
+ ansible.builtin.stat:
+ path: "{{ dev_access_kubeconfig }}"
+ register: _kubeconfig_dev
+
+- name: DevAccess | Fail if kubeconfig missing
+ ansible.builtin.fail:
+ msg: "kubeconfig not found at {{ dev_access_kubeconfig }}"
+ when: not _kubeconfig_dev.stat.exists
+
+- name: DevAccess | Apply RBAC
+ ansible.builtin.include_tasks: rbac.yml
+
+- name: DevAccess | Create token
+ ansible.builtin.include_tasks: token.yml
diff --git a/roles/k8s_gbm_dev/tasks/rbac.yml b/roles/k8s_gbm_dev/tasks/rbac.yml
new file mode 100644
index 0000000..94e6f82
--- /dev/null
+++ b/roles/k8s_gbm_dev/tasks/rbac.yml
@@ -0,0 +1,57 @@
+---
+- name: DevAccess | Render RBAC manifests
+ ansible.builtin.template:
+ src: "{{ item.src }}"
+ dest: "/tmp/{{ item.dest }}"
+ mode: "0600"
+ loop:
+ - { src: dev-serviceaccount.yml.j2, dest: dev-sa.yml }
+ - { src: dev-role.yml.j2, dest: dev-role.yml }
+ - { src: dev-rolebinding.yml.j2, dest: dev-rolebinding.yml }
+ - { src: dev-clusterrole.yml.j2, dest: dev-clusterrole.yml }
+ - { src: dev-clusterrolebinding.yml.j2, dest: dev-clusterrolebinding.yml }
+
+- name: DevAccess | Apply ServiceAccount
+ ansible.builtin.command:
+ cmd: kubectl apply -f /tmp/dev-sa.yml
+ environment:
+ KUBECONFIG: "{{ dev_access_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _dev_sa
+ changed_when: "'created' in _dev_sa.stdout or 'configured' in _dev_sa.stdout"
+
+- name: DevAccess | Apply Role
+ ansible.builtin.command:
+ cmd: kubectl apply -f /tmp/dev-role.yml
+ environment:
+ KUBECONFIG: "{{ dev_access_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _dev_role
+ changed_when: "'created' in _dev_role.stdout or 'configured' in _dev_role.stdout"
+
+- name: DevAccess | Apply RoleBinding
+ ansible.builtin.command:
+ cmd: kubectl apply -f /tmp/dev-rolebinding.yml
+ environment:
+ KUBECONFIG: "{{ dev_access_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _dev_rb
+ changed_when: "'created' in _dev_rb.stdout or 'configured' in _dev_rb.stdout"
+
+- name: DevAccess | Apply ClusterRole (namespace reader)
+ ansible.builtin.command:
+ cmd: kubectl apply -f /tmp/dev-clusterrole.yml
+ environment:
+ KUBECONFIG: "{{ dev_access_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _dev_cr
+ changed_when: "'created' in _dev_cr.stdout or 'configured' in _dev_cr.stdout"
+
+- name: DevAccess | Apply ClusterRoleBinding (namespace reader)
+ ansible.builtin.command:
+ cmd: kubectl apply -f /tmp/dev-clusterrolebinding.yml
+ environment:
+ KUBECONFIG: "{{ dev_access_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _dev_crb
+ changed_when: "'created' in _dev_crb.stdout or 'configured' in _dev_crb.stdout"
diff --git a/roles/k8s_gbm_dev/tasks/token.yml b/roles/k8s_gbm_dev/tasks/token.yml
new file mode 100644
index 0000000..be530f7
--- /dev/null
+++ b/roles/k8s_gbm_dev/tasks/token.yml
@@ -0,0 +1,54 @@
+---
+- name: DevAccess | Render token Secret manifest
+ ansible.builtin.template:
+ src: dev-token-secret.yml.j2
+ dest: /tmp/dev-token-secret.yml
+ mode: "0600"
+
+- name: DevAccess | Apply token Secret
+ ansible.builtin.command:
+ cmd: kubectl apply -f /tmp/dev-token-secret.yml
+ environment:
+ KUBECONFIG: "{{ dev_access_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _dev_secret
+ changed_when: "'created' in _dev_secret.stdout or 'configured' in _dev_secret.stdout"
+
+- name: DevAccess | Wait for token to be populated
+ ansible.builtin.command: >
+ kubectl get secret {{ dev_access_token_secret_name }}
+ -n {{ dev_access_namespace }}
+ -o jsonpath='{.data.token}'
+ environment:
+ KUBECONFIG: "{{ dev_access_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _token_raw
+ retries: 10
+ delay: 3
+ until: _token_raw.stdout | length > 0
+ changed_when: false
+
+- name: DevAccess | Decode token
+ ansible.builtin.set_fact:
+ _dev_token: "{{ _token_raw.stdout | b64decode }}"
+
+- name: DevAccess | Save token to file
+ ansible.builtin.copy:
+ content: "{{ _dev_token }}\n"
+ dest: "{{ dev_access_token_file }}"
+ owner: "{{ ansible_user }}"
+ group: "{{ ansible_user }}"
+ mode: "0600"
+
+- name: DevAccess | Show token info
+ ansible.builtin.debug:
+ msg: |
+ ============================================================
+ Developer token for namespace: {{ dev_access_namespace }}
+ ServiceAccount: {{ dev_access_sa_name }}
+ Token saved to: {{ dev_access_token_file }}
+ View token: cat {{ dev_access_token_file }}
+
+ Dashboard URL: http://10.203.0.96:10001
+ Login: paste token from the file above
+ ============================================================
diff --git a/roles/k8s_gbm_dev/templates/dev-clusterrole.yml.j2 b/roles/k8s_gbm_dev/templates/dev-clusterrole.yml.j2
new file mode 100644
index 0000000..1edb3fc
--- /dev/null
+++ b/roles/k8s_gbm_dev/templates/dev-clusterrole.yml.j2
@@ -0,0 +1,11 @@
+---
+# Minimal cluster-level read for Dashboard namespace selector.
+# Without namespaces/list the Dashboard shows nothing after login.
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+ name: {{ dev_access_sa_name }}-namespace-reader
+rules:
+ - apiGroups: [""]
+ resources: ["namespaces"]
+ verbs: ["get", "list", "watch"]
diff --git a/roles/k8s_gbm_dev/templates/dev-clusterrolebinding.yml.j2 b/roles/k8s_gbm_dev/templates/dev-clusterrolebinding.yml.j2
new file mode 100644
index 0000000..82f0d09
--- /dev/null
+++ b/roles/k8s_gbm_dev/templates/dev-clusterrolebinding.yml.j2
@@ -0,0 +1,13 @@
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: {{ dev_access_sa_name }}-namespace-reader
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: {{ dev_access_sa_name }}-namespace-reader
+subjects:
+ - kind: ServiceAccount
+ name: {{ dev_access_sa_name }}
+ namespace: {{ dev_access_namespace }}
diff --git a/roles/k8s_gbm_dev/templates/dev-role.yml.j2 b/roles/k8s_gbm_dev/templates/dev-role.yml.j2
new file mode 100644
index 0000000..d50fcd5
--- /dev/null
+++ b/roles/k8s_gbm_dev/templates/dev-role.yml.j2
@@ -0,0 +1,63 @@
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: Role
+metadata:
+ name: {{ dev_access_role_name }}
+ namespace: {{ dev_access_namespace }}
+rules:
+ # Core workload resources — read
+ - apiGroups: [""]
+ resources:
+ - pods
+ - services
+ - endpoints
+ - configmaps
+ - persistentvolumeclaims
+ - events
+ - replicationcontrollers
+ verbs: {{ dev_access_verbs | to_json }}
+ # Logs — always readable
+ - apiGroups: [""]
+ resources:
+ - pods/log
+ verbs: ["get", "list", "watch"]
+{% if dev_access_allow_exec | bool %}
+ # Exec and port-forward — for debugging
+ - apiGroups: [""]
+ resources:
+ - pods/exec
+ - pods/portforward
+ verbs: ["create"]
+{% endif %}
+ # Secrets — read only (developers may need env inspection)
+ - apiGroups: [""]
+ resources:
+ - secrets
+ verbs: ["get", "list"]
+ # Apps
+ - apiGroups: ["apps"]
+ resources:
+ - deployments
+ - replicasets
+ - statefulsets
+ - daemonsets
+ verbs: {{ dev_access_verbs | to_json }}
+ # Batch
+ - apiGroups: ["batch"]
+ resources:
+ - jobs
+ - cronjobs
+ verbs: {{ dev_access_verbs | to_json }}
+ # Networking
+ - apiGroups: ["networking.k8s.io"]
+ resources:
+ - ingresses
+ - networkpolicies
+ verbs: {{ dev_access_verbs | to_json }}
+ # Traefik CRDs
+ - apiGroups: ["traefik.io"]
+ resources:
+ - ingressroutes
+ - ingressroutetcps
+ - middlewares
+ verbs: {{ dev_access_verbs | to_json }}
diff --git a/roles/k8s_gbm_dev/templates/dev-rolebinding.yml.j2 b/roles/k8s_gbm_dev/templates/dev-rolebinding.yml.j2
new file mode 100644
index 0000000..cf776ee
--- /dev/null
+++ b/roles/k8s_gbm_dev/templates/dev-rolebinding.yml.j2
@@ -0,0 +1,14 @@
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: RoleBinding
+metadata:
+ name: {{ dev_access_rolebinding_name }}
+ namespace: {{ dev_access_namespace }}
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: Role
+ name: {{ dev_access_role_name }}
+subjects:
+ - kind: ServiceAccount
+ name: {{ dev_access_sa_name }}
+ namespace: {{ dev_access_namespace }}
diff --git a/roles/k8s_gbm_dev/templates/dev-serviceaccount.yml.j2 b/roles/k8s_gbm_dev/templates/dev-serviceaccount.yml.j2
new file mode 100644
index 0000000..5b9ee31
--- /dev/null
+++ b/roles/k8s_gbm_dev/templates/dev-serviceaccount.yml.j2
@@ -0,0 +1,6 @@
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: {{ dev_access_sa_name }}
+ namespace: {{ dev_access_namespace }}
diff --git a/roles/k8s_gbm_dev/templates/dev-token-secret.yml.j2 b/roles/k8s_gbm_dev/templates/dev-token-secret.yml.j2
new file mode 100644
index 0000000..00f71a6
--- /dev/null
+++ b/roles/k8s_gbm_dev/templates/dev-token-secret.yml.j2
@@ -0,0 +1,10 @@
+---
+# Long-lived token for ServiceAccount (Kubernetes 1.24+ requires explicit Secret)
+apiVersion: v1
+kind: Secret
+metadata:
+ name: {{ dev_access_token_secret_name }}
+ namespace: {{ dev_access_namespace }}
+ annotations:
+ kubernetes.io/service-account.name: {{ dev_access_sa_name }}
+type: kubernetes.io/service-account-token
diff --git a/roles/k8s_manager/defaults/main.yml b/roles/k8s_manager/defaults/main.yml
new file mode 100644
index 0000000..ea19950
--- /dev/null
+++ b/roles/k8s_manager/defaults/main.yml
@@ -0,0 +1,32 @@
+---
+k8s_manager_timezone: "Europe/Moscow"
+
+# Fix #8: opt-in full upgrade — set true only when intentionally patching the OS
+k8s_manager_upgrade_packages: false
+
+kubectl_version: ""
+helm_version: ""
+k9s_version: ""
+
+k8s_manager_extra_packages:
+ - bash-completion
+ - curl
+ - wget
+ - git
+ - vim
+ - htop
+ - net-tools
+ - bind-utils
+ - jq
+ - python3
+ - python3-pip
+
+# kubeconfig location on the manager node
+kubeconfig_dir: "/home/{{ ansible_user }}/.kube"
+
+# Kubernetes Dashboard (manifest from GitHub raw — no CDN issues)
+dashboard_enabled: true
+dashboard_namespace: "kubernetes-dashboard"
+dashboard_manifest_url: "https://raw.githubusercontent.com/kubernetes/dashboard/v2.7.0/aio/deploy/recommended.yaml"
+# Traefik terminates TLS — dashboard serves plain HTTP on port 9090
+dashboard_insecure: false
diff --git a/roles/k8s_manager/handlers/main.yml b/roles/k8s_manager/handlers/main.yml
new file mode 100644
index 0000000..f420619
--- /dev/null
+++ b/roles/k8s_manager/handlers/main.yml
@@ -0,0 +1,6 @@
+---
+- name: reload bashrc
+ ansible.builtin.command: source /etc/bashrc
+ args:
+ executable: /bin/bash
+ become: false
diff --git a/roles/k8s_manager/meta/main.yml b/roles/k8s_manager/meta/main.yml
new file mode 100644
index 0000000..07aab0f
--- /dev/null
+++ b/roles/k8s_manager/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: Setup Kubernetes management node on Rocky Linux 9
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/k8s_manager/tasks/dashboard.yml b/roles/k8s_manager/tasks/dashboard.yml
new file mode 100644
index 0000000..e94ff8e
--- /dev/null
+++ b/roles/k8s_manager/tasks/dashboard.yml
@@ -0,0 +1,91 @@
+---
+- name: Dashboard | Check kubeconfig exists
+ ansible.builtin.stat:
+ path: "{{ kubeconfig_dir }}/config"
+ register: _kubeconfig_dash
+
+- name: Dashboard | Skip notice
+ ansible.builtin.debug:
+ msg: "Dashboard install skipped — kubeconfig not found at {{ kubeconfig_dir }}/config."
+ when: not _kubeconfig_dash.stat.exists
+
+- name: Dashboard | Deploy and configure
+ when:
+ - _kubeconfig_dash.stat.exists
+ - dashboard_enabled | bool
+ environment:
+ KUBECONFIG: "{{ kubeconfig_dir }}/config"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ block:
+ - name: Dashboard | Apply manifest
+ ansible.builtin.command:
+ cmd: kubectl apply -f {{ dashboard_manifest_url }}
+ register: _dash_apply
+ changed_when: "'configured' in _dash_apply.stdout or 'created' in _dash_apply.stdout"
+
+ - name: Dashboard | Patch deployment for insecure HTTP access
+ ansible.builtin.command: >
+ kubectl patch deployment kubernetes-dashboard
+ -n {{ dashboard_namespace }}
+ --type strategic
+ -p '{"spec":{"template":{"spec":{"containers":[{"name":"kubernetes-dashboard","args":["--namespace={{ dashboard_namespace }}","--enable-insecure-login","--insecure-port=9090","--port=0"],"ports":[{"containerPort":9090,"protocol":"TCP"}],"livenessProbe":{"httpGet":{"path":"/","port":9090,"scheme":"HTTP"}}}]}}}}'
+ register: _dash_deploy_patch
+ changed_when: "'patched' in _dash_deploy_patch.stdout"
+ failed_when: _dash_deploy_patch.rc != 0 and 'unchanged' not in _dash_deploy_patch.stdout
+ when: dashboard_insecure | bool
+ check_mode: false
+
+ - name: Dashboard | Patch service to ClusterIP
+ ansible.builtin.command: >
+ kubectl patch svc kubernetes-dashboard
+ -n {{ dashboard_namespace }}
+ -p '{"spec":{"type":"ClusterIP","ports":[{"port":443,"targetPort":{{ 9090 if dashboard_insecure | bool else 8443 }},"protocol":"TCP"}]}}'
+ register: _dash_patch
+ changed_when: "'patched' in _dash_patch.stdout"
+ failed_when: _dash_patch.rc != 0 and 'not patched' not in _dash_patch.stdout
+ check_mode: false
+
+ - name: Dashboard | Apply admin ServiceAccount
+ ansible.builtin.template:
+ src: dashboard_admin.yml.j2
+ dest: /tmp/dashboard_admin.yml
+ mode: "0600"
+
+ - name: Dashboard | Create admin user
+ ansible.builtin.command:
+ cmd: kubectl apply -f /tmp/dashboard_admin.yml
+ register: _dash_admin
+ changed_when: "'created' in _dash_admin.stdout"
+
+ - name: Dashboard | Check if token file exists
+ ansible.builtin.stat:
+ path: "/home/{{ ansible_user }}/.kube/dashboard-token"
+ register: _token_file
+
+ - name: Dashboard | Generate long-lived token
+ ansible.builtin.command:
+ cmd: kubectl -n {{ dashboard_namespace }} create token admin-user --duration=8760h
+ register: _dashboard_token
+ changed_when: true
+ when: not _token_file.stat.exists
+
+ - name: Dashboard | Save token to file
+ ansible.builtin.copy:
+ content: "{{ _dashboard_token.stdout }}\n"
+ dest: "/home/{{ ansible_user }}/.kube/dashboard-token"
+ owner: "{{ ansible_user }}"
+ group: "{{ ansible_user }}"
+ mode: "0600"
+ when: not _token_file.stat.exists
+
+ - name: Dashboard | Show access info
+ ansible.builtin.debug:
+ msg: |
+ ============================================================
+ Kubernetes Dashboard: accessible via Traefik proxy.
+ {% if dashboard_insecure | bool %}
+ Mode: HTTP (insecure) — TLS terminated by Traefik
+ {% endif %}
+ Token saved to: ~/.kube/dashboard-token
+ View token: cat ~/.kube/dashboard-token
+ ============================================================
diff --git a/roles/k8s_manager/tasks/main.yml b/roles/k8s_manager/tasks/main.yml
new file mode 100644
index 0000000..85e8ff2
--- /dev/null
+++ b/roles/k8s_manager/tasks/main.yml
@@ -0,0 +1,133 @@
+---
+- name: System | Set hostname
+ ansible.builtin.hostname:
+ name: "{{ inventory_hostname }}"
+
+- name: System | Set timezone
+ community.general.timezone:
+ name: "{{ k8s_manager_timezone }}"
+
+# Fix #8: explicit full upgrade is opt-in (k8s_manager_upgrade_packages: true)
+# to prevent unintended kernel/system updates on prod runs
+- name: System | Update cache
+ ansible.builtin.dnf:
+ update_cache: true
+
+- name: System | Upgrade all packages # noqa: package-latest
+ ansible.builtin.dnf:
+ name: "*"
+ state: latest
+ when: k8s_manager_upgrade_packages | bool
+
+- name: System | Install extra packages
+ ansible.builtin.dnf:
+ name: "{{ k8s_manager_extra_packages }}"
+ state: present
+
+- name: kubectl | Get latest stable version
+ ansible.builtin.uri:
+ url: https://dl.k8s.io/release/stable.txt
+ return_content: true
+ register: _kubectl_latest
+ when: not kubectl_version
+ check_mode: false
+
+- name: kubectl | Set version fact (latest)
+ ansible.builtin.set_fact:
+ _kubectl_version: "{{ _kubectl_latest.content | trim }}"
+ when: not kubectl_version
+
+- name: kubectl | Set version fact (pinned)
+ ansible.builtin.set_fact:
+ _kubectl_version: "{{ kubectl_version }}"
+ when: kubectl_version
+
+- name: kubectl | Download binary
+ ansible.builtin.get_url:
+ url: "https://dl.k8s.io/release/{{ _kubectl_version }}/bin/linux/amd64/kubectl"
+ dest: /usr/local/bin/kubectl
+ mode: "0755"
+ owner: root
+ group: root
+
+- name: kubectl | Enable bash completion
+ ansible.builtin.shell: /usr/local/bin/kubectl completion bash > /etc/bash_completion.d/kubectl
+ args:
+ creates: /etc/bash_completion.d/kubectl
+
+- name: helm | Install EPEL repository
+ ansible.builtin.dnf:
+ name: epel-release
+ state: present
+
+- name: helm | Install via DNF
+ ansible.builtin.dnf:
+ name: helm
+ state: present
+
+- name: helm | Enable bash completion
+ ansible.builtin.shell: helm completion bash > /etc/bash_completion.d/helm
+ args:
+ creates: /etc/bash_completion.d/helm
+
+- name: k9s | Install via DNF
+ ansible.builtin.dnf:
+ name: "https://github.com/derailed/k9s/releases/latest/download/k9s_linux_amd64.rpm"
+ state: present
+ disable_gpg_check: true
+
+
+- name: k9s | Set permissions
+ ansible.builtin.file:
+ path: /usr/local/bin/k9s
+ mode: "0755"
+ owner: root
+ group: root
+
+- name: kubectx/kubens | Download kubectx
+ ansible.builtin.get_url:
+ url: https://raw.githubusercontent.com/ahmetb/kubectx/master/kubectx
+ dest: '{{ role_path }}/files/kubectx'
+ mode: "0755"
+ delegate_to: localhost
+ run_once: true
+
+- name: kubectx/kubens | Download kubens
+ ansible.builtin.get_url:
+ url: https://raw.githubusercontent.com/ahmetb/kubectx/master/kubens
+ dest: '{{ role_path }}/files/kubens'
+ mode: "0755"
+ delegate_to: localhost
+ run_once: true
+
+- name: kubectx/kubens | Install binaries
+ ansible.builtin.copy:
+ src: "{{ role_path }}/files/{{ item }}"
+ dest: /usr/local/bin/{{ item }}
+ mode: "0755"
+ owner: root
+ group: root
+ loop:
+ - kubectx
+ - kubens
+
+- name: kubeconfig | Ensure .kube directory exists
+ ansible.builtin.file:
+ path: "{{ kubeconfig_dir }}"
+ state: directory
+ owner: "{{ ansible_user }}"
+ group: "{{ ansible_user }}"
+ mode: "0700"
+
+- name: dashboard | Install Kubernetes Dashboard
+ ansible.builtin.include_tasks: dashboard.yml
+
+- name: bash | Add kubectl alias and kubeconfig to profile
+ ansible.builtin.blockinfile:
+ path: "/home/{{ ansible_user }}/.bashrc"
+ marker: "# {mark} ANSIBLE MANAGED — k8s aliases"
+ block: |
+ export KUBECONFIG="{{ kubeconfig_dir }}/config"
+ source /usr/share/bash-completion/bash_completion
+ alias k=kubectl
+ complete -o default -F __start_kubectl k
diff --git a/roles/k8s_manager/templates/dashboard_admin.yml.j2 b/roles/k8s_manager/templates/dashboard_admin.yml.j2
new file mode 100644
index 0000000..1c4660f
--- /dev/null
+++ b/roles/k8s_manager/templates/dashboard_admin.yml.j2
@@ -0,0 +1,19 @@
+---
+apiVersion: v1
+kind: ServiceAccount
+metadata:
+ name: admin-user
+ namespace: {{ dashboard_namespace }}
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+ name: admin-user
+roleRef:
+ apiGroup: rbac.authorization.k8s.io
+ kind: ClusterRole
+ name: cluster-admin
+subjects:
+ - kind: ServiceAccount
+ name: admin-user
+ namespace: {{ dashboard_namespace }}
diff --git a/roles/k8s_worker/defaults/main.yml b/roles/k8s_worker/defaults/main.yml
new file mode 100644
index 0000000..e952bad
--- /dev/null
+++ b/roles/k8s_worker/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+k8s_version: "1.33"
+containerd_version: "" # empty = latest from Docker CE repo
+
+# Control plane host to generate the join command from
+worker_join_source_host: "{{ groups['control_plane'][0] }}"
diff --git a/roles/k8s_worker/handlers/main.yml b/roles/k8s_worker/handlers/main.yml
new file mode 100644
index 0000000..3e24ed6
--- /dev/null
+++ b/roles/k8s_worker/handlers/main.yml
@@ -0,0 +1,12 @@
+---
+- name: Restart containerd
+ ansible.builtin.systemd:
+ name: containerd
+ state: restarted
+ daemon_reload: true
+
+- name: Restart kubelet
+ ansible.builtin.systemd:
+ name: kubelet
+ state: restarted
+ daemon_reload: true
diff --git a/roles/k8s_worker/meta/main.yml b/roles/k8s_worker/meta/main.yml
new file mode 100644
index 0000000..f6dbd99
--- /dev/null
+++ b/roles/k8s_worker/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ author: ops
+ description: Kubernetes worker node on Rocky Linux 9
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
+dependencies: []
diff --git a/roles/k8s_worker/tasks/containerd.yml b/roles/k8s_worker/tasks/containerd.yml
new file mode 100644
index 0000000..7ca51a7
--- /dev/null
+++ b/roles/k8s_worker/tasks/containerd.yml
@@ -0,0 +1,57 @@
+---
+- name: Containerd | Add Docker CE repo
+ ansible.builtin.get_url:
+ url: https://download.docker.com/linux/rhel/docker-ce.repo
+ dest: /etc/yum.repos.d/docker-ce.repo
+ mode: "0644"
+
+- name: Containerd | Disable conflicting container-tools module
+ ansible.builtin.command: dnf module disable container-tools -y
+ changed_when: false
+ failed_when: false
+
+- name: Containerd | Install containerd.io
+ ansible.builtin.dnf:
+ name: "{{ 'containerd.io-' + containerd_version if containerd_version != '' else 'containerd.io' }}"
+ state: present
+ update_cache: true
+ disablerepo: kubernetes
+
+- name: Containerd | Ensure runc is available at path expected by containerd
+ ansible.builtin.shell: |
+ RUNC_BIN=$(which runc 2>/dev/null || echo "")
+ if [ -n "$RUNC_BIN" ] && [ ! -f /usr/local/bin/runc ]; then
+ ln -sf "$RUNC_BIN" /usr/local/bin/runc
+ fi
+ changed_when: false
+
+- name: Containerd | Remove default config
+ ansible.builtin.file:
+ dest: /etc/containerd/config.toml
+ state: absent
+
+- name: Containerd | Generate default config
+ ansible.builtin.shell: containerd config default > /etc/containerd/config.toml
+ args:
+ creates: /etc/containerd/config.toml
+
+- name: Containerd | Enable SystemdCgroup
+ ansible.builtin.replace:
+ path: /etc/containerd/config.toml
+ regexp: 'SystemdCgroup = false'
+ replace: 'SystemdCgroup = true'
+ notify: Restart containerd
+
+- name: Containerd | Enable SystemdCgroup
+ ansible.builtin.replace:
+ path: /etc/containerd/config.toml
+ regexp: 'disabled_plugins = ["cri"]'
+ replace: 'disabled_plugins = []'
+ notify: restart containerd
+
+- name: Containerd | Enable and start service
+ ansible.builtin.systemd:
+ name: containerd
+ enabled: true
+ state: started
+ daemon_reload: true
diff --git a/roles/k8s_worker/tasks/kubeadm_join.yml b/roles/k8s_worker/tasks/kubeadm_join.yml
new file mode 100644
index 0000000..4562d50
--- /dev/null
+++ b/roles/k8s_worker/tasks/kubeadm_join.yml
@@ -0,0 +1,19 @@
+---
+- name: kubeadm join | Check if node already joined
+ ansible.builtin.stat:
+ path: /etc/kubernetes/kubelet.conf
+ register: _kubelet_conf
+
+- name: kubeadm join | Generate fresh join command on control plane
+ ansible.builtin.command: kubeadm token create --print-join-command
+ delegate_to: "{{ worker_join_source_host }}"
+ register: _join_command
+ changed_when: false
+ when: not _kubelet_conf.stat.exists
+
+- name: kubeadm join | Execute join command
+ ansible.builtin.command: "{{ _join_command.stdout | trim }}"
+ when: not _kubelet_conf.stat.exists
+ changed_when: true
+ async: 300
+ poll: 10
diff --git a/roles/k8s_worker/tasks/kubernetes.yml b/roles/k8s_worker/tasks/kubernetes.yml
new file mode 100644
index 0000000..2ece3df
--- /dev/null
+++ b/roles/k8s_worker/tasks/kubernetes.yml
@@ -0,0 +1,33 @@
+---
+- name: Kubernetes | Add repo
+ ansible.builtin.yum_repository:
+ name: kubernetes
+ description: Kubernetes
+ baseurl: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/"
+ enabled: true
+ gpgcheck: true
+ gpgkey: "https://pkgs.k8s.io/core:/stable:/v{{ k8s_version }}/rpm/repodata/repomd.xml.key"
+ exclude: "kubelet kubeadm kubectl cri-tools kubernetes-cni"
+ timeout: "300"
+
+- name: Kubernetes | Set a low but nonzero minrate so a truly stalled CDN connection times out
+ community.general.ini_file:
+ path: /etc/yum.repos.d/kubernetes.repo
+ section: kubernetes
+ option: minrate
+ value: "1000"
+ mode: "0644"
+
+- name: Kubernetes | Install kubelet and kubeadm
+ ansible.builtin.dnf:
+ name:
+ - kubelet
+ - kubeadm
+ state: present
+ disable_excludes: kubernetes
+
+- name: Kubernetes | Enable kubelet (kubeadm join will start it)
+ ansible.builtin.systemd:
+ name: kubelet
+ enabled: true
+ daemon_reload: true
diff --git a/roles/k8s_worker/tasks/main.yml b/roles/k8s_worker/tasks/main.yml
new file mode 100644
index 0000000..afb4349
--- /dev/null
+++ b/roles/k8s_worker/tasks/main.yml
@@ -0,0 +1,12 @@
+---
+- name: Prerequisites
+ ansible.builtin.include_tasks: prerequisites.yml
+
+- name: Containerd
+ ansible.builtin.include_tasks: containerd.yml
+
+- name: Kubernetes packages
+ ansible.builtin.include_tasks: kubernetes.yml
+
+- name: Join cluster
+ ansible.builtin.include_tasks: kubeadm_join.yml
diff --git a/roles/k8s_worker/tasks/prerequisites.yml b/roles/k8s_worker/tasks/prerequisites.yml
new file mode 100644
index 0000000..e5cc54d
--- /dev/null
+++ b/roles/k8s_worker/tasks/prerequisites.yml
@@ -0,0 +1,94 @@
+---
+- name: Prerequisites | Set hostname
+ ansible.builtin.hostname:
+ name: "{{ inventory_hostname }}"
+
+- name: Prerequisites | Add hostname to /etc/hosts
+ ansible.builtin.lineinfile:
+ path: /etc/hosts
+ line: "{{ ansible_host }} {{ inventory_hostname }}"
+ regexp: ".*{{ inventory_hostname }}$"
+ state: present
+
+- name: Prerequisites | Disable swap permanently
+ ansible.builtin.replace:
+ path: /etc/fstab
+ regexp: '^([^#].*\s+swap\s+.*)$'
+ replace: '# \1'
+
+- name: Prerequisites | Disable swap now
+ ansible.builtin.command: swapoff -a
+ changed_when: false
+
+- name: Prerequisites | Set SELinux to permissive
+ ansible.posix.selinux:
+ policy: targeted
+ state: permissive
+
+- name: Prerequisites | Load kernel modules (persistent)
+ ansible.builtin.copy:
+ dest: /etc/modules-load.d/k8s.conf
+ content: |
+ overlay
+ br_netfilter
+ mode: "0644"
+
+- name: Prerequisites | Load kernel modules now
+ community.general.modprobe:
+ name: "{{ item }}"
+ state: present
+ loop:
+ - overlay
+ - br_netfilter
+
+- name: Prerequisites | Set sysctl params
+ ansible.posix.sysctl:
+ name: "{{ item.key }}"
+ value: "{{ item.value }}"
+ sysctl_file: /etc/sysctl.d/k8s.conf
+ reload: true
+ loop:
+ - { key: "net.bridge.bridge-nf-call-iptables", value: "1" }
+ - { key: "net.bridge.bridge-nf-call-ip6tables", value: "1" }
+ - { key: "net.ipv4.ip_forward", value: "1" }
+
+- name: Prerequisites | Ensure firewalld is running
+ ansible.builtin.systemd:
+ name: firewalld
+ state: started
+ enabled: true
+ daemon_reload: true
+
+- name: Prerequisites | Open worker firewall ports
+ ansible.posix.firewalld:
+ port: "{{ item }}"
+ permanent: true
+ state: enabled
+ immediate: "{{ not ansible_check_mode }}"
+ loop:
+ - 10250/tcp # kubelet API
+ - 10256/tcp # kube-proxy healthz
+ - 30000-32767/tcp # NodePort services
+ - 8472/udp # Flannel VXLAN
+
+- name: Prerequisites | Trust overlay interfaces in firewalld
+ ansible.posix.firewalld:
+ interface: "{{ item }}"
+ zone: trusted
+ permanent: true
+ state: enabled
+ immediate: "{{ not ansible_check_mode }}"
+ loop:
+ - flannel.1
+ - cni0
+
+- name: Prerequisites | Trust pod and service CIDRs in firewalld
+ ansible.posix.firewalld:
+ source: "{{ item }}"
+ zone: trusted
+ permanent: true
+ state: enabled
+ immediate: "{{ not ansible_check_mode }}"
+ loop:
+ - "{{ pod_network_cidr }}"
+ - "{{ service_cidr }}"
diff --git a/roles/logging/README.md b/roles/logging/README.md
new file mode 100644
index 0000000..9031191
--- /dev/null
+++ b/roles/logging/README.md
@@ -0,0 +1,357 @@
+# Стандарт централизованного сбора логов: PLG + Prometheus
+
+**Стек:** Loki · Grafana Alloy · Grafana · kube-prometheus-stack
+**Namespace:** `monitoring`
+**Доступ:** `http://10.203.0.96:10003` (Traefik port 10003)
+**Исследование:** [`research/logging/plg.md`](../../research/logging/plg.md)
+
+---
+
+## Архитектура
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ k8s-worker-01 / k8s-master-01 │
+│ [Pod stdout] → /var/log/pods/ ← Alloy: loki.source.k8s │
+│ [journald] → kubelet, containerd ← Alloy: loki.source.journal│
+│ [node-exporter DaemonSet] ← CPU / RAM / диск / сеть │
+│ │
+│ [Grafana Alloy DaemonSet] (namespace: monitoring) │
+└────────────┬──────────────────────────────────────────────────────┘
+ │ HTTP push (loki.write)
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ Loki single-binary (namespace: monitoring) │
+│ S3 backend: MinIO bucket loki-chunks │
+│ 10 GiB PVC longhorn — только WAL/temp (не логи) │
+│ service/loki:3100 │
+└─────────────────────┬────────────────────────────────────────────┘
+ │ S3 API
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ MinIO (namespace: minio) — bucket: loki-chunks │
+│ StorageClass: longhorn-minio (Retain, 1 TiB) │
+│ Пользователь: loki (только bucket loki-chunks) │
+└──────────────────────────────────────────────────────────────────┘
+
+┌──────────────────────────────────────────────────────────────────┐
+│ kube-prometheus-stack (namespace: monitoring) │
+│ Prometheus PVC 20 GiB · node-exporter · kube-state-metrics │
+│ ~27 готовых дашбордов кластера → ConfigMaps → Grafana sidecar │
+└─────────────────────┬────────────────────────────────────────────┘
+ │ datasource
+ ▼
+┌──────────────────────────────────────────────────────────────────┐
+│ Grafana (namespace: monitoring) │
+│ Datasource: Loki (isDefault) + Prometheus (provisioned) │
+│ Sidecar: подхватывает ConfigMaps grafana_dashboard=1 (ALL ns) │
+│ PVC: 5 GiB longhorn │
+│ Traefik → port 10003 → http://10.203.0.96:10003 │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+### Компоненты
+
+| Компонент | Chart | Версия | Примечание |
+|---|---|---|---|
+| Loki | `grafana/loki` | 7.0.0 | single-binary, S3 backend, retention 31d |
+| Grafana Alloy | `grafana/alloy` | 1.8.2 | DaemonSet; **Promtail EOL с 2026-03-02** |
+| kube-prometheus-stack | `prometheus-community/kube-prometheus-stack` | 68.4.4 | Prometheus + node-exporter + kube-state-metrics |
+| Grafana | `grafana/grafana` | 10.5.15 | provisioned Loki + Prometheus datasources, sidecar |
+
+> **Promtail не используется** — объявлен EOL 2 марта 2026. Grafana Alloy — официальная замена.
+
+---
+
+## Предварительные условия
+
+### 1. Создать namespace и Secret с паролем Grafana
+
+```bash
+kubectl create namespace monitoring
+
+kubectl create secret generic grafana-admin-secret \
+ --from-literal=admin-user=admin \
+ --from-literal=admin-password='СИЛЬНЫЙ_ПАРОЛЬ' \
+ -n monitoring
+```
+
+Секрет создаётся **вручную** — пароль не хранится в Ansible-переменных и не попадает в Git.
+Если секрет не создать до запуска playbook, задача `Grafana | Assert grafana-admin-secret exists` упадёт с подробным сообщением об ошибке.
+
+### 2. Задать переменную окружения
+
+| Переменная | Описание |
+|---|---|
+| `LOKI_MINIO_PASSWORD` | Пароль пользователя `loki` в MinIO — Ansible создаст пользователя с этим паролем |
+| `GRAFANA_ADMIN_PASSWORD` | Не читается Ansible — только для справки при ручном создании Secret выше |
+
+Пароль `LOKI_MINIO_PASSWORD` можно посмотреть в любой момент:
+
+```bash
+kubectl get secret loki-minio-secret -n monitoring \
+ -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d
+```
+
+---
+
+## Запуск
+
+```bash
+# Локально (с заданной переменной окружения)
+LOKI_MINIO_PASSWORD='ПАРОЛЬ_LOKI' \
+ ansible-playbook -i inventory/prod playbooks/setup_logging.yml
+
+# Через GitLab CI/CD
+# → Pipelines → setup:logging (ручной запуск, ветка main)
+```
+
+Playbook выполняет задачи в порядке:
+
+1. Создаёт namespace `monitoring`
+2. Создаёт bucket `loki-chunks` в MinIO, пользователя `loki`, политику доступа, Secret `loki-minio-secret`
+3. Устанавливает Loki (Helm)
+4. Устанавливает Grafana Alloy (Helm)
+5. Устанавливает kube-prometheus-stack (Helm) — Prometheus + node-exporter + kube-state-metrics + ~27 дашбордов
+6. Устанавливает Grafana (Helm) — проверяет наличие `grafana-admin-secret` перед установкой
+
+Playbook идемпотентен: повторный запуск пропустит уже выполненные шаги.
+
+---
+
+## Конфигурация
+
+### Loki: параметры хранения
+
+Loki работает в режиме **single-binary**. Логи хранятся в MinIO (`loki-chunks`), не на диске.
+PVC 10 GiB используется только для WAL и временных файлов компактора.
+
+```yaml
+# roles/logging/defaults/main.yml (переопределять в group_vars при необходимости)
+loki_retention_days: 31 # срок хранения логов
+loki_wal_storage_size: "10Gi" # PVC только для WAL
+loki_wal_storage_class: longhorn
+loki_minio_bucket: loki-chunks
+```
+
+Расчёт объёма для текущего кластера (2 ноды, ~20 подов):
+- ~170 MB/day после сжатия Loki (~10:1)
+- 31 дней → ~5 GB в bucket `loki-chunks`
+- MinIO PVC 1 TiB → запас до ~100 нод
+
+### Alloy: сбор логов
+
+Alloy запускается как **DaemonSet** (по одному поду на каждую ноду, включая control plane).
+
+Что собирает:
+- `/var/log/pods/**/*.log` — все контейнеры через `loki.source.kubernetes`
+- `journald` — kubelet, containerd, sshd через `loki.source.journal`
+
+Фактические метки на каждой записи в Loki:
+
+| Метка | Источник | Описание |
+|---|---|---|
+| `namespace` | Kubernetes API | namespace пода |
+| `pod` | Kubernetes API | имя пода |
+| `container` | Kubernetes API | имя контейнера |
+| `app` | label пода | значение `app` label |
+| `level` | JSON-парсинг | уровень лога (info/warn/error) |
+| `job` | константа | `systemd-journal` для journald-логов |
+| `node` | journald | hostname ноды (только journald) |
+| `unit` | journald | systemd unit (только journald) |
+| `stream` | containerd | `stdout` или `stderr` |
+
+Debug-записи (label `level=debug`) отбрасываются на уровне агента — не попадают в Loki.
+
+### Prometheus: метрики кластера
+
+```yaml
+# roles/logging/defaults/main.yml
+prometheus_stack_chart_version: "68.4.4"
+prometheus_storage_size: "20Gi"
+prometheus_storage_class: longhorn
+prometheus_retention: "30d"
+```
+
+kube-prometheus-stack устанавливается с отключённой встроенной Grafana (`grafana.enabled: false`) и `forceDeployDashboards: true` — создаёт ~27 ConfigMaps с дашбордами, которые Grafana sidecar подхватывает автоматически.
+
+> **Rocky Linux 9 / kubeadm gotcha:** `controller-manager` и `scheduler` по умолчанию слушают только `127.0.0.1` → их метрики в Prometheus будут недоступны (таргеты Down). Остальные таргеты работают без изменений. Для исправления нужен ручной патч `/etc/kubernetes/manifests/kube-controller-manager.yaml` и `kube-scheduler.yaml`: замена `--bind-address=127.0.0.1` на `--bind-address=0.0.0.0`.
+
+### Grafana: доступ и дашборды
+
+URL: `http://10.203.0.96:10003`
+Логин: `admin` / пароль из Secret `grafana-admin-secret`
+
+Datasources provisioned при старте — не требуют ручной настройки:
+- **Loki** — `http://loki.monitoring.svc.cluster.local:3100` (isDefault)
+- **Prometheus** — `http://kube-prometheus-stack-prometheus.monitoring.svc.cluster.local:9090`
+
+**Дашборды (загружаются автоматически через sidecar):**
+
+| Дашборд | Источник | Описание |
+|---|---|---|
+| **Kubernetes Logs** | ConfigMap `grafana-dashboard-kubernetes-logs` | Фильтрация по namespace / pod / container / level, график интенсивности + поток логов |
+| Kubernetes / Compute Resources / Cluster | kube-prometheus-stack | CPU/RAM по namespace |
+| Kubernetes / Compute Resources / Node (Pods) | kube-prometheus-stack | Ресурсы по подам на ноде |
+| Node Exporter / Nodes | kube-prometheus-stack | CPU, RAM, диски, сеть нод |
+| Kubernetes / Persistent Volumes | kube-prometheus-stack | Статус и заполнение PVC |
+| Kubernetes / API server | kube-prometheus-stack | Latency, error rate, RPS |
+| ...и ещё ~22 дашборда | kube-prometheus-stack | Kubelet, etcd, сеть, workloads |
+
+> **Дашборды Grafana.com с ID 15141, 13639, 18748 не работают** с Grafana 12.x — используют устаревший API плагина. Не импортировать.
+
+---
+
+## Диагностика
+
+### Проверка после установки
+
+```bash
+# Статус всех подов стека
+kubectl get pod -n monitoring
+
+# Loki готов (запрос через временный под — в контейнере нет curl/wget)
+kubectl run loki-check --image=curlimages/curl --restart=Never -n monitoring \
+ -- curl -s http://loki:3100/ready
+sleep 5 && kubectl logs -n monitoring loki-check
+kubectl delete pod loki-check -n monitoring
+
+# Метки, которые реально есть в Loki
+kubectl run loki-labels --image=curlimages/curl --restart=Never -n monitoring \
+ -- curl -s 'http://loki:3100/loki/api/v1/labels'
+sleep 5 && kubectl logs -n monitoring loki-labels
+kubectl delete pod loki-labels -n monitoring
+
+# Alloy открыл потоки логов со всех подов (нет level=error)
+kubectl logs -n monitoring -l app.kubernetes.io/name=alloy --tail=50 | grep -v "opened log stream"
+
+# Данные появились в MinIO
+kubectl exec -n minio deployment/minio -- \
+ mc ls local/loki-chunks --recursive | head -10
+```
+
+### Loki CrashLoopBackOff при старте
+
+**Симптом 1:** `mkdir /loki/compactor: read-only file system`
+Причина: неверный путь compactor. Должен быть `/var/loki/compactor`, не `/loki/compactor`.
+Проверить в ConfigMap: `kubectl get configmap loki -n monitoring -o jsonpath='{.data.config\.yaml}' | grep working_directory`
+
+**Симптом 2:** `SignatureDoesNotMatch` или `InvalidAccessKeyId`
+Причина: env var expansion `${VAR}` в Loki 3.x не работает для S3 credentials в config-файле.
+Решение: пароль передаётся через `--set loki.storage.s3.secret_access_key=...` в helm-команде (реализовано в `tasks/loki.yml`).
+
+**Симптом 3:** `error running loki` при повторном запуске после CrashLoop + `another operation in progress`
+Причина: helm upgrade завис в предыдущей попытке.
+```bash
+helm rollback loki -n monitoring
+# затем повторить helm upgrade
+```
+
+**Предупреждение `loki-memberlist: no such host`** — некритично для single-binary, можно игнорировать.
+
+### Loki 500 при запросе через Grafana
+
+```bash
+# Проверить пользователя loki в MinIO
+MINIO_POD=$(kubectl get pod -n minio -l app=minio -o jsonpath='{.items[0].metadata.name}')
+kubectl exec -n minio $MINIO_POD -- mc admin user info local loki
+
+# Проверить Secret и его содержимое
+kubectl get secret loki-minio-secret -n monitoring
+kubectl get secret loki-minio-secret -n monitoring \
+ -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d
+
+# Тест доступа с теми же учётными данными
+SECRET=$(kubectl get secret loki-minio-secret -n monitoring \
+ -o jsonpath='{.data.AWS_SECRET_ACCESS_KEY}' | base64 -d)
+kubectl exec -n minio $MINIO_POD -- mc alias set lokitest http://localhost:9000 loki "$SECRET"
+kubectl exec -n minio $MINIO_POD -- mc ls lokitest/
+```
+
+### Alloy не отправляет логи
+
+```bash
+# Ошибки в логах Alloy
+kubectl logs -n monitoring -l app.kubernetes.io/name=alloy --tail=100 \
+ | grep -i "error\|level=error"
+
+# Loki endpoint доступен из пода Alloy
+kubectl exec -n monitoring -l app.kubernetes.io/name=alloy -c alloy -- \
+ wget -qO- http://loki.monitoring.svc.cluster.local:3100/ready 2>&1
+```
+
+### Grafana не видит данные (No Data)
+
+```bash
+# Проверить что Loki datasource здоров после рестарта Grafana
+kubectl rollout restart deployment/grafana -n monitoring
+kubectl rollout status deployment/grafana -n monitoring
+
+# Проверить UID datasource (нужен для ручного исправления импортированных дашбордов)
+GRAFANA_PASS=$(kubectl get secret grafana-admin-secret -n monitoring \
+ -o jsonpath='{.data.admin-password}' | base64 -d)
+kubectl run gf-check --image=curlimages/curl --restart=Never -n monitoring \
+ -- curl -s -u "admin:${GRAFANA_PASS}" http://grafana/api/datasources
+sleep 5 && kubectl logs -n monitoring gf-check | python3 -m json.tool | grep -E '"uid"|"name"'
+kubectl delete pod gf-check -n monitoring
+```
+
+> Если Grafana была запущена в момент когда Loki находился в CrashLoop — datasource кэширует нерабочее состояние. Перезапуск пода исправляет.
+
+### Grafana не открывается
+
+```bash
+# Состояние подов
+kubectl get pod -n monitoring -l app.kubernetes.io/name=grafana
+
+# IngressRoute создан
+kubectl get ingressroute route-grafana -n traefik
+
+# Если IngressRoute отсутствует — перезапустить setup_traefik.yml
+# (порт 10003 уже есть в Traefik DaemonSet)
+ansible-playbook -i inventory/prod playbooks/setup_traefik.yml
+```
+
+### MinIO `kubectl cp` завершается ошибкой `tar not found`
+
+MinIO-контейнер — минимальный образ без `tar`. `kubectl cp` использует `tar` внутри.
+Решение: использовать `kubectl exec -i ... -- mc ... /dev/stdin` для передачи файлов.
+Уже реализовано в `tasks/minio-user.yml`.
+
+---
+
+## Масштабирование
+
+### До 5+ нод: Loki distributed mode
+
+Триггер: объём > 50 GB/day или > 50 одновременных запросов.
+Изменить в `loki-values.yml.j2`: `deploymentMode: Distributed` + задать реплики ingester/querier/distributor.
+**Данные в MinIO не мигрируют** — bucket `loki-chunks` остаётся тем же.
+
+### Prometheus: Thanos для long-term storage
+
+Триггер: retention > 30d или несколько кластеров.
+Thanos sidecar к Prometheus pod + remote_write в объектное хранилище.
+
+### Alloy и node-exporter
+
+Не требуют изменений — DaemonSet автоматически запускается на новых нодах.
+
+---
+
+## Известные ограничения
+
+| Проблема | Описание |
+|---|---|
+| `deploymentMode` обязателен на верхнем уровне | В Loki chart 7.x `deploymentMode: SingleBinary` должен быть вне блока `loki:`, иначе деплоится distributed-режим с 0 репликами |
+| Env var expansion не работает для S3 credentials | Loki 3.x не раскрывает `${VAR}` в полях `access_key_id` / `secret_access_key`. Пароль передаётся через `helm --set` |
+| Controller-manager / Scheduler метрики недоступны | kubeadm привязывает их к `127.0.0.1`. Требует ручного патча static pod манифестов |
+| Дашборды Grafana.com 15141 / 13639 / 18748 сломаны | Несовместимы с Grafana 12.x. Использовать встроенный дашборд **Kubernetes Logs** |
+
+---
+
+## Переход на Graylog
+
+Alloy поддерживает dual-write: одновременная отправка в Loki и Graylog. Порядок переезда описан в [`research/logging/plg.md`](../../research/logging/plg.md) (раздел 11).
+
+MinIO остаётся востребованным при Graylog — как S3 snapshot repository для OpenSearch (bucket `backups`).
diff --git a/roles/logging/defaults/main.yml b/roles/logging/defaults/main.yml
new file mode 100644
index 0000000..6f736c6
--- /dev/null
+++ b/roles/logging/defaults/main.yml
@@ -0,0 +1,26 @@
+---
+logging_namespace: monitoring
+logging_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+
+loki_chart_version: "7.0.0"
+alloy_chart_version: "1.8.2"
+grafana_chart_version: "10.5.15"
+
+loki_minio_endpoint: "http://minio.minio.svc.cluster.local:9000"
+loki_minio_bucket: "loki-chunks"
+loki_minio_user: "loki"
+loki_minio_secret_name: "loki-minio-secret"
+
+loki_retention_days: 31
+loki_wal_storage_size: "10Gi"
+loki_wal_storage_class: longhorn
+
+prometheus_stack_chart_version: "68.4.4"
+prometheus_storage_size: "20Gi"
+prometheus_storage_class: longhorn
+prometheus_retention: "30d"
+
+grafana_storage_size: "5Gi"
+grafana_storage_class: longhorn
+grafana_admin_secret: "grafana-admin-secret"
+grafana_root_url: "http://10.203.0.96:10003"
diff --git a/roles/logging/tasks/alloy.yml b/roles/logging/tasks/alloy.yml
new file mode 100644
index 0000000..aaf8070
--- /dev/null
+++ b/roles/logging/tasks/alloy.yml
@@ -0,0 +1,20 @@
+---
+- name: Alloy | Render Alloy values
+ ansible.builtin.template:
+ src: alloy-values.yml.j2
+ dest: /tmp/alloy-values.yml
+ mode: "0600"
+
+- name: Alloy | Install or upgrade Grafana Alloy
+ ansible.builtin.command: >
+ helm upgrade --install alloy grafana/alloy
+ --namespace {{ logging_namespace }}
+ --version {{ alloy_chart_version }}
+ --values /tmp/alloy-values.yml
+ --timeout 5m0s
+ --wait
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/logging/tasks/grafana.yml b/roles/logging/tasks/grafana.yml
new file mode 100644
index 0000000..1557b6c
--- /dev/null
+++ b/roles/logging/tasks/grafana.yml
@@ -0,0 +1,42 @@
+---
+- name: Grafana | Check grafana-admin-secret exists
+ ansible.builtin.command: >
+ kubectl get secret {{ grafana_admin_secret }}
+ -n {{ logging_namespace }}
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+ failed_when: false
+ register: _grafana_secret
+
+- name: Grafana | Assert grafana-admin-secret exists
+ ansible.builtin.assert:
+ that: _grafana_secret.rc == 0
+ fail_msg: >-
+ Secret '{{ grafana_admin_secret }}' not found in namespace '{{ logging_namespace }}'.
+ Create it manually before running this playbook:
+ kubectl create namespace {{ logging_namespace }}
+ kubectl create secret generic {{ grafana_admin_secret }}
+ --from-literal=admin-user=admin --from-literal=admin-password='YOUR_PASSWORD'
+ -n {{ logging_namespace }}
+
+- name: Grafana | Render Grafana values
+ ansible.builtin.template:
+ src: grafana-values.yml.j2
+ dest: /tmp/grafana-values.yml
+ mode: "0600"
+
+- name: Grafana | Install or upgrade Grafana
+ ansible.builtin.command: >
+ helm upgrade --install grafana grafana/grafana
+ --namespace {{ logging_namespace }}
+ --version {{ grafana_chart_version }}
+ --values /tmp/grafana-values.yml
+ --timeout 5m0s
+ --wait
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/logging/tasks/loki.yml b/roles/logging/tasks/loki.yml
new file mode 100644
index 0000000..909c987
--- /dev/null
+++ b/roles/logging/tasks/loki.yml
@@ -0,0 +1,37 @@
+---
+- name: Helm | Add Grafana chart repository
+ ansible.builtin.command: helm repo add grafana https://grafana.github.io/helm-charts
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_repo_add
+ changed_when: "'already exists' not in _helm_repo_add.stdout"
+ failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout
+
+- name: Helm | Update chart repositories
+ ansible.builtin.command: helm repo update
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+
+- name: Loki | Render Loki values
+ ansible.builtin.template:
+ src: loki-values.yml.j2
+ dest: /tmp/loki-values.yml
+ mode: "0600"
+
+- name: Loki | Install or upgrade Loki
+ ansible.builtin.command: >
+ helm upgrade --install loki grafana/loki
+ --namespace {{ logging_namespace }}
+ --version {{ loki_chart_version }}
+ --values /tmp/loki-values.yml
+ --set loki.storage.s3.secret_access_key={{ loki_minio_password }}
+ --timeout 10m0s
+ --wait
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/logging/tasks/main.yml b/roles/logging/tasks/main.yml
new file mode 100644
index 0000000..5535d6c
--- /dev/null
+++ b/roles/logging/tasks/main.yml
@@ -0,0 +1,18 @@
+---
+- name: Create logging namespace
+ ansible.builtin.include_tasks: namespace.yml
+
+- name: Configure MinIO for Loki
+ ansible.builtin.include_tasks: minio-user.yml
+
+- name: Install Loki via Helm
+ ansible.builtin.include_tasks: loki.yml
+
+- name: Install Grafana Alloy via Helm
+ ansible.builtin.include_tasks: alloy.yml
+
+- name: Install kube-prometheus-stack via Helm
+ ansible.builtin.include_tasks: prometheus.yml
+
+- name: Install Grafana via Helm
+ ansible.builtin.include_tasks: grafana.yml
diff --git a/roles/logging/tasks/minio-user.yml b/roles/logging/tasks/minio-user.yml
new file mode 100644
index 0000000..515f7c5
--- /dev/null
+++ b/roles/logging/tasks/minio-user.yml
@@ -0,0 +1,152 @@
+---
+- name: MinIO | Get MinIO pod name
+ ansible.builtin.command: >
+ kubectl get pod -n minio -l app=minio
+ -o jsonpath='{.items[0].metadata.name}'
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _minio_pod
+ changed_when: false
+
+- name: MinIO | Get root user from secret
+ ansible.builtin.command: >
+ kubectl get secret minio-root-credentials -n minio
+ -o jsonpath='{.data.rootUser}'
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _minio_root_user_b64
+ changed_when: false
+ no_log: true
+
+- name: MinIO | Get root password from secret
+ ansible.builtin.command: >
+ kubectl get secret minio-root-credentials -n minio
+ -o jsonpath='{.data.rootPassword}'
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _minio_root_pass_b64
+ changed_when: false
+ no_log: true
+
+- name: MinIO | Set mc alias with root credentials
+ ansible.builtin.command: >
+ kubectl exec -n minio {{ _minio_pod.stdout }} --
+ mc alias set local http://localhost:9000
+ {{ _minio_root_user_b64.stdout | b64decode }}
+ {{ _minio_root_pass_b64.stdout | b64decode }}
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+ no_log: true
+
+- name: MinIO | Create loki-chunks bucket
+ ansible.builtin.command: >
+ kubectl exec -n minio {{ _minio_pod.stdout }} --
+ mc mb --ignore-existing local/{{ loki_minio_bucket }}
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _mb_result
+ changed_when: "'Bucket created' in _mb_result.stdout"
+
+- name: MinIO | Check if loki user exists
+ ansible.builtin.command: >
+ kubectl exec -n minio {{ _minio_pod.stdout }} --
+ mc admin user info local {{ loki_minio_user }}
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _loki_user_check
+ failed_when: false
+ changed_when: false
+
+- name: MinIO | Create loki user
+ ansible.builtin.command: >
+ kubectl exec -n minio {{ _minio_pod.stdout }} --
+ mc admin user add local {{ loki_minio_user }} {{ loki_minio_password }}
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ when: _loki_user_check.rc != 0
+ no_log: true
+
+- name: MinIO | Check if loki policy exists
+ ansible.builtin.command: >
+ kubectl exec -n minio {{ _minio_pod.stdout }} --
+ mc admin policy info local loki-policy
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _loki_policy_check
+ failed_when: false
+ changed_when: false
+
+- name: MinIO | Create loki policy
+ ansible.builtin.command: >
+ kubectl exec -i -n minio {{ _minio_pod.stdout }} --
+ mc admin policy create local loki-policy /dev/stdin
+ args:
+ stdin: |
+ {
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
+ "s3:ListBucket", "s3:GetBucketLocation"
+ ],
+ "Resource": [
+ "arn:aws:s3:::{{ loki_minio_bucket }}",
+ "arn:aws:s3:::{{ loki_minio_bucket }}/*"
+ ]
+ }
+ ]
+ }
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ when: _loki_policy_check.rc != 0
+
+- name: MinIO | Attach loki policy to loki user
+ ansible.builtin.command: >
+ kubectl exec -n minio {{ _minio_pod.stdout }} --
+ mc admin policy attach local loki-policy --user {{ loki_minio_user }}
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _attach_result
+ failed_when: >
+ _attach_result.rc != 0 and
+ 'already attached' not in (_attach_result.stderr | lower) and
+ 'already attached' not in (_attach_result.stdout | lower)
+ changed_when: _attach_result.rc == 0
+
+- name: MinIO | Build loki-minio-secret manifest
+ ansible.builtin.command: >
+ kubectl create secret generic {{ loki_minio_secret_name }}
+ --from-literal=AWS_ACCESS_KEY_ID={{ loki_minio_user }}
+ --from-literal=AWS_SECRET_ACCESS_KEY={{ loki_minio_password }}
+ --namespace {{ logging_namespace }}
+ --dry-run=client -o yaml
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _loki_secret_manifest
+ changed_when: false
+ no_log: true
+
+- name: MinIO | Apply loki-minio-secret
+ ansible.builtin.command: kubectl apply -f -
+ args:
+ stdin: "{{ _loki_secret_manifest.stdout }}"
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _secret_apply
+ changed_when: "'created' in _secret_apply.stdout or 'configured' in _secret_apply.stdout"
+ no_log: true
diff --git a/roles/logging/tasks/namespace.yml b/roles/logging/tasks/namespace.yml
new file mode 100644
index 0000000..5a18824
--- /dev/null
+++ b/roles/logging/tasks/namespace.yml
@@ -0,0 +1,18 @@
+---
+- name: Namespace | Create monitoring namespace
+ ansible.builtin.command: kubectl create namespace {{ logging_namespace }} --dry-run=client -o yaml
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _ns_manifest
+ changed_when: false
+
+- name: Namespace | Apply monitoring namespace
+ ansible.builtin.command: kubectl apply -f -
+ args:
+ stdin: "{{ _ns_manifest.stdout }}"
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _ns_apply
+ changed_when: "'created' in _ns_apply.stdout"
diff --git a/roles/logging/tasks/prometheus.yml b/roles/logging/tasks/prometheus.yml
new file mode 100644
index 0000000..3cb0747
--- /dev/null
+++ b/roles/logging/tasks/prometheus.yml
@@ -0,0 +1,36 @@
+---
+- name: Prometheus | Add prometheus-community Helm repo
+ ansible.builtin.command: helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_repo_add
+ changed_when: "'already exists' not in _helm_repo_add.stdout"
+ failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout
+
+- name: Prometheus | Update chart repositories
+ ansible.builtin.command: helm repo update
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+
+- name: Prometheus | Render values
+ ansible.builtin.template:
+ src: prometheus-values.yml.j2
+ dest: /tmp/prometheus-values.yml
+ mode: "0600"
+
+- name: Prometheus | Install or upgrade kube-prometheus-stack
+ ansible.builtin.command: >
+ helm upgrade --install kube-prometheus-stack prometheus-community/kube-prometheus-stack
+ --namespace {{ logging_namespace }}
+ --version {{ prometheus_stack_chart_version }}
+ --values /tmp/prometheus-values.yml
+ --timeout 10m0s
+ --wait
+ environment:
+ KUBECONFIG: "{{ logging_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/logging/templates/alloy-values.yml.j2 b/roles/logging/templates/alloy-values.yml.j2
new file mode 100644
index 0000000..8cd9852
--- /dev/null
+++ b/roles/logging/templates/alloy-values.yml.j2
@@ -0,0 +1,105 @@
+controller:
+ type: daemonset
+ tolerations:
+ - key: node-role.kubernetes.io/control-plane
+ operator: Exists
+ effect: NoSchedule
+
+alloy:
+ configMap:
+ create: true
+ content: |
+ // ── Обнаружение подов Kubernetes ─────────────────────────────
+ discovery.kubernetes "pods" {
+ role = "pod"
+ }
+
+ // ── Relabeling: namespace, pod, container, app ────────────────
+ discovery.relabel "pod_logs" {
+ targets = discovery.kubernetes.pods.targets
+
+ rule {
+ source_labels = ["__meta_kubernetes_namespace"]
+ target_label = "namespace"
+ }
+ rule {
+ source_labels = ["__meta_kubernetes_pod_name"]
+ target_label = "pod"
+ }
+ rule {
+ source_labels = ["__meta_kubernetes_pod_container_name"]
+ target_label = "container"
+ }
+ rule {
+ source_labels = ["__meta_kubernetes_pod_label_app"]
+ target_label = "app"
+ }
+ rule {
+ source_labels = ["__meta_kubernetes_pod_annotation_filter_debug"]
+ regex = "true"
+ action = "drop"
+ }
+ }
+
+ // ── Чтение файлов логов подов ─────────────────────────────────
+ loki.source.kubernetes "pods" {
+ targets = discovery.relabel.pod_logs.output
+ forward_to = [loki.process.parse.receiver]
+ }
+
+ // ── Парсинг JSON-логов, отброс debug ─────────────────────────
+ loki.process "parse" {
+ stage.json {
+ expressions = {level = "level", msg = "message"}
+ }
+ stage.labels {
+ values = {level = ""}
+ }
+ stage.drop {
+ expression = ".*level=\"debug\".*"
+ drop_counter_reason = "debug_dropped"
+ }
+ forward_to = [loki.write.local.receiver]
+ }
+
+ // ── Сбор journald (kubelet, containerd, sshd) ─────────────────
+ discovery.relabel "journal" {
+ targets = []
+ rule {
+ source_labels = ["__journal__systemd_unit"]
+ target_label = "unit"
+ }
+ rule {
+ source_labels = ["__journal__hostname"]
+ target_label = "node"
+ }
+ }
+
+ loki.source.journal "systemd" {
+ path = "/var/log/journal"
+ max_age = "12h"
+ labels = {job = "systemd-journal"}
+ forward_to = [loki.write.local.receiver]
+ relabel_rules = discovery.relabel.journal.rules
+ }
+
+ // ── Отправка в Loki ───────────────────────────────────────────
+ loki.write "local" {
+ endpoint {
+ url = "http://loki.{{ logging_namespace }}.svc.cluster.local:3100/loki/api/v1/push"
+ }
+ }
+
+ mounts:
+ varlog: true
+ dockercontainers: false
+
+extraVolumes:
+ - name: journal
+ hostPath:
+ path: /var/log/journal
+
+extraVolumeMounts:
+ - name: journal
+ mountPath: /var/log/journal
+ readOnly: true
diff --git a/roles/logging/templates/grafana-values.yml.j2 b/roles/logging/templates/grafana-values.yml.j2
new file mode 100644
index 0000000..b604743
--- /dev/null
+++ b/roles/logging/templates/grafana-values.yml.j2
@@ -0,0 +1,49 @@
+grafana.ini:
+ server:
+ root_url: {{ grafana_root_url }}
+ security:
+ admin_user: admin
+ auth.anonymous:
+ enabled: false
+ unified_alerting:
+ enabled: true
+ alerting:
+ enabled: false
+
+admin:
+ existingSecret: {{ grafana_admin_secret }}
+ userKey: admin-user
+ passwordKey: admin-password
+
+persistence:
+ enabled: true
+ storageClassName: {{ grafana_storage_class }}
+ size: {{ grafana_storage_size }}
+
+datasources:
+ datasources.yaml:
+ apiVersion: 1
+ datasources:
+ - name: Loki
+ type: loki
+ url: http://loki.{{ logging_namespace }}.svc.cluster.local:3100
+ access: proxy
+ isDefault: true
+ jsonData:
+ maxLines: 5000
+
+ - name: Prometheus
+ type: prometheus
+ url: http://kube-prometheus-stack-prometheus.{{ logging_namespace }}.svc.cluster.local:9090
+ access: proxy
+ isDefault: false
+ jsonData:
+ timeInterval: 30s
+
+resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
diff --git a/roles/logging/templates/loki-values.yml.j2 b/roles/logging/templates/loki-values.yml.j2
new file mode 100644
index 0000000..159ffd3
--- /dev/null
+++ b/roles/logging/templates/loki-values.yml.j2
@@ -0,0 +1,64 @@
+deploymentMode: SingleBinary
+
+loki:
+ auth_enabled: false
+
+ commonConfig:
+ replication_factor: 1
+
+ storage:
+ type: s3
+ bucketNames:
+ chunks: {{ loki_minio_bucket }}
+ ruler: {{ loki_minio_bucket }}
+ admin: {{ loki_minio_bucket }}
+ s3:
+ endpoint: {{ loki_minio_endpoint }}
+ region: us-east-1
+ bucketnames: {{ loki_minio_bucket }}
+ access_key_id: loki
+ insecure: true
+ s3forcepathstyle: true
+
+ schemaConfig:
+ configs:
+ - from: "2024-01-01"
+ store: tsdb
+ object_store: s3
+ schema: v13
+ index:
+ prefix: loki_index_
+ period: 24h
+
+ limits_config:
+ retention_period: {{ loki_retention_days * 24 }}h
+ ingestion_rate_mb: 16
+ ingestion_burst_size_mb: 32
+ max_streams_per_user: 10000
+ max_chunks_per_query: 2000000
+
+ compactor:
+ working_directory: /var/loki/compactor
+ retention_enabled: true
+ delete_request_store: s3
+
+singleBinary:
+ replicas: 1
+ persistence:
+ enabled: true
+ storageClass: {{ loki_wal_storage_class }}
+ size: {{ loki_wal_storage_size }}
+
+ resources: {}
+
+gateway:
+ enabled: false
+
+backend:
+ replicas: 0
+
+read:
+ replicas: 0
+
+write:
+ replicas: 0
diff --git a/roles/logging/templates/prometheus-values.yml.j2 b/roles/logging/templates/prometheus-values.yml.j2
new file mode 100644
index 0000000..5a1568f
--- /dev/null
+++ b/roles/logging/templates/prometheus-values.yml.j2
@@ -0,0 +1,69 @@
+grafana:
+ enabled: false
+ forceDeployDashboards: false
+
+prometheus:
+ prometheusSpec:
+ storageSpec:
+ volumeClaimTemplate:
+ spec:
+ storageClassName: {{ prometheus_storage_class }}
+ accessModes: ["ReadWriteOnce"]
+ resources:
+ requests:
+ storage: {{ prometheus_storage_size }}
+ retention: {{ prometheus_retention }}
+ scrapeInterval: 30s
+ evaluationInterval: 30s
+ ruleSelectorNilUsesHelmValues: false
+ serviceMonitorSelectorNilUsesHelmValues: false
+ podMonitorSelectorNilUsesHelmValues: false
+
+alertmanager:
+ enabled: false
+
+nodeExporter:
+ enabled: true
+
+kubeStateMetrics:
+ enabled: true
+
+kubeControllerManager:
+ enabled: true
+ endpoints:
+{% for host in groups['control_plane'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
+ service:
+ enabled: true
+ port: 10257
+ targetPort: 10257
+
+kubeScheduler:
+ enabled: true
+ endpoints:
+{% for host in groups['control_plane'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
+ service:
+ enabled: true
+ port: 10259
+ targetPort: 10259
+
+kubeEtcd:
+ enabled: true
+ endpoints:
+{% for host in groups['control_plane'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
+ service:
+ enabled: true
+ port: 2381
+ targetPort: 2381
+
+kubeProxy:
+ enabled: true
+ endpoints:
+{% for host in groups['k8s_cluster'] %}
+ - {{ hostvars[host]['ansible_host'] }}
+{% endfor %}
diff --git a/roles/longhorn/README.md b/roles/longhorn/README.md
new file mode 100644
index 0000000..a540d51
--- /dev/null
+++ b/roles/longhorn/README.md
@@ -0,0 +1,832 @@
+# Longhorn — стандарт постоянного хранилища в кластере
+
+Этот документ описывает стандарт работы с Longhorn в нашем кластере:
+как подключать хранилище к приложениям, добавлять диски, управлять репликами и резервными копиями.
+Является эталоном для разработчиков и DevOps.
+
+---
+
+## Содержание
+
+1. [Архитектура: как Longhorn работает в кластере](#1-архитектура-как-longhorn-работает-в-кластере)
+2. [Два этапа деплоя: longhorn_prereqs и longhorn](#2-два-этапа-деплоя-longhorn_prereqs-и-longhorn)
+3. [Конфигурация дисков](#3-конфигурация-дисков)
+4. [Пример 1: PVC для одного Pod (ReadWriteOnce)](#4-пример-1-pvc-для-одного-pod-readwriteonce)
+5. [Пример 2: StatefulSet с несколькими репликами](#5-пример-2-statefulset-с-несколькими-репликами)
+6. [Пример 3: база данных (PostgreSQL) с Longhorn](#6-пример-3-база-данных-postgresql-с-longhorn)
+7. [Пример 4: несколько компонентов с общим хранилищем](#7-пример-4-несколько-компонентов-с-общим-хранилищем)
+8. [Реплики: сколько и когда менять](#8-реплики-сколько-и-когда-менять)
+9. [Резервное копирование и восстановление](#9-резервное-копирование-и-восстановление)
+10. [Расширение тома](#10-расширение-тома)
+11. [Добавление нового worker-узла с дисками](#11-добавление-нового-worker-узла-с-дисками)
+12. [Диагностика и типичные ошибки](#12-диагностика-и-типичные-ошибки)
+13. [Чеклист перед использованием хранилища](#13-чеклист-перед-использованием-хранилища)
+
+---
+
+## 1. Архитектура: как Longhorn работает в кластере
+
+### Схема
+
+```
+┌─────────────────────────────────────────────────────────────────┐
+│ k8s-worker-01 (10.203.0.96) │
+│ │
+│ ┌────────────────┐ ┌────────────────┐ │
+│ │ /dev/sdb │ │ /dev/sdc │ │
+│ │ XFS │ │ XFS │ │
+│ │ /mnt/longhorn-│ │ /mnt/longhorn-│ │
+│ │ disk1 │ │ disk2 │ │
+│ └───────┬────────┘ └───────┬────────┘ │
+│ │ │ │
+│ Longhorn Manager Pod ─────────┘ │
+│ Longhorn Engine Pod (управляет репликами) │
+│ Instance Manager Pod (iSCSI-таргет для kubelet) │
+│ │
+│ kubelet → iSCSI → Longhorn Engine → Longhorn Replica │
+│ (файлы на диске) │
+└─────────────────────────────────────────────────────────────────┘
+
+PersistentVolumeClaim (ns: myapp) ──► StorageClass: longhorn
+ │
+ ▼
+ PersistentVolume
+ (Longhorn Volume)
+ │
+ Replicas: 1 (сейчас)
+ → расположены на worker-нодах
+```
+
+### Компоненты
+
+| Компонент | Назначение |
+|---|---|
+| **longhorn-manager** | Оркестрирует тома, реплики, бэкапы; CRD-контроллер |
+| **longhorn-engine** | Процесс ввода-вывода для каждого тома |
+| **instance-manager** | Управляет engine и replica инстансами |
+| **longhorn-ui** | Web-интерфейс (доступен через Traefik на порту 10002) |
+| **CSI driver** | Kubernetes CSI-интерфейс; обрабатывает PVC → PV |
+
+### StorageClass
+
+Longhorn создаёт StorageClass `longhorn` при установке. Это **StorageClass по умолчанию** в кластере.
+
+```bash
+kubectl get storageclass
+# NAME PROVISIONER RECLAIMPOLICY VOLUMEBINDINGMODE ...
+# longhorn (default) driver.longhorn.io Delete Immediate ...
+```
+
+`ReclaimPolicy: Delete` — при удалении PVC том удаляется. Если нужно сохранить данные — делайте снапшот или бэкап перед удалением PVC.
+
+---
+
+## 2. Два этапа деплоя: longhorn_prereqs и longhorn
+
+Longhorn разворачивается через два плейбука, которые нужно запускать в порядке:
+
+```
+1. setup_longhorn.yml
+ ├── role: longhorn_prereqs (runs on: workers)
+ │ ├── packages.yml — iscsi-initiator-utils, nfs-utils, cryptsetup
+ │ ├── kernel.yml — iscsi_tcp, dm_crypt модули
+ │ ├── firewall.yml — NFS (2049/tcp), portmapper (111/tcp, 111/udp)
+ │ ├── selinux.yml — CIL-политика для iscsid (Rocky Linux 9)
+ │ └── disks.yml — форматирование XFS, монтирование по UUID
+ │
+ └── role: longhorn (runs on: manager_nodes)
+ ├── annotate.yml — kubectl annotate node: disk config JSON
+ └── helm.yml — helm upgrade --install longhorn/longhorn
+```
+
+### Когда перезапускать плейбук
+
+| Ситуация | Что запускать |
+|---|---|
+| Первичная установка | `setup_longhorn.yml` полностью |
+| Добавление нового worker-узла | `setup_longhorn.yml` с `--limit k8s-worker-NN` для prereqs, затем полностью для annotate |
+| Добавление диска к существующей ноде | `setup_longhorn.yml` (prereqs: disks.yml пропустится для существующих; annotate обновит аннотацию) |
+| Обновление версии Longhorn | Изменить `longhorn_chart_version`, запустить только `longhorn` role |
+| Изменение параметров Helm | Изменить переменные, запустить только `longhorn` role |
+
+---
+
+## 3. Конфигурация дисков
+
+### inventory/prod/group_vars/workers.yml — глобальный дефолт
+
+```yaml
+longhorn_disks:
+ - device: /dev/sdb
+ mountpoint: /mnt/longhorn-disk1
+ - device: /dev/sdc
+ mountpoint: /mnt/longhorn-disk2
+```
+
+Это применяется ко **всем** worker-нодам.
+Если у разных нод разные диски — переопределить в `hosts.yml` (host_vars).
+
+### inventory/prod/hosts.yml — переопределение для конкретной ноды
+
+```yaml
+workers:
+ hosts:
+ k8s-worker-01:
+ ansible_host: 10.203.0.96
+ longhorn_disks: # переопределяет group_vars для этой ноды
+ - device: /dev/sdb
+ mountpoint: /mnt/longhorn-disk1
+ - device: /dev/nvme0n1
+ mountpoint: /mnt/longhorn-nvme # SSD на этой ноде
+
+ k8s-worker-02:
+ ansible_host: 10.203.0.X
+ longhorn_disks:
+ - device: /dev/sdb
+ mountpoint: /mnt/longhorn-disk1 # только один диск
+```
+
+### Что происходит с дисками при деплое
+
+1. `filesystem` форматирует устройство в XFS (идемпотентно — пропускает уже отформатированные)
+2. Создаётся директория mountpoint
+3. `blkid` получает UUID устройства
+4. `/etc/fstab` добавляется запись `UUID=... /mnt/... xfs defaults,nofail`
+5. Диск монтируется немедленно
+6. Аннотация ноды: `node.longhorn.io/default-disks-config=[{"path":"/mnt/longhorn-disk1",...}]`
+7. Longhorn читает аннотацию при старте и регистрирует диски
+
+> `nofail` в fstab критически важен: если диск недоступен при загрузке — нода загрузится, а не зависнет.
+
+### Проверка дисков в Longhorn
+
+```bash
+# Посмотреть ноды и их диски в Longhorn
+kubectl get nodes.longhorn.io -n longhorn-system
+
+# Детали ноды
+kubectl describe node.longhorn.io k8s-worker-01 -n longhorn-system
+
+# Через UI: http://10.203.0.96:10002 → Node
+```
+
+---
+
+## 4. Пример 1: PVC для одного Pod (ReadWriteOnce)
+
+**Сценарий:** одиночный Pod с постоянным хранилищем (файловый кэш, локальные данные).
+
+### PersistentVolumeClaim
+
+```yaml
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: app-data
+ namespace: my-app
+spec:
+ accessModes:
+ - ReadWriteOnce # один узел в режиме чтения-записи
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 5Gi
+```
+
+### Deployment с PVC
+
+```yaml
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: my-app
+ namespace: my-app
+spec:
+ replicas: 1 # ВАЖНО: ReadWriteOnce — только 1 реплика!
+ selector:
+ matchLabels:
+ app: my-app
+ template:
+ metadata:
+ labels:
+ app: my-app
+ spec:
+ containers:
+ - name: my-app
+ image: registry.gigacoms.info/myteam/my-app:v1.0.0
+ volumeMounts:
+ - name: data
+ mountPath: /app/data
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: app-data
+```
+
+> **ReadWriteOnce** означает: том примонтирован к одному узлу одновременно.
+> Несколько Pod могут использовать один RWO-том, **только если все они на одном узле**.
+> С `replicas: >1` и DaemonSet это создаст проблемы при планировании на разные ноды.
+
+### Проверить что PVC создан и привязан
+
+```bash
+kubectl get pvc -n my-app
+# NAME STATUS VOLUME CAPACITY ACCESS MODES STORAGECLASS AGE
+# app-data Bound pvc-abc12345 5Gi RWO longhorn 30s
+
+# Посмотреть на том в Longhorn
+kubectl get volumes.longhorn.io -n longhorn-system | grep app-data
+```
+
+---
+
+## 5. Пример 2: StatefulSet с несколькими репликами
+
+**Сценарий:** очередь сообщений или key-value хранилище (Redis, RabbitMQ) с несколькими репликами.
+Каждая реплика StatefulSet получает **свой отдельный PVC**.
+
+### StatefulSet с volumeClaimTemplates
+
+```yaml
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: redis
+ namespace: cache
+spec:
+ serviceName: redis
+ replicas: 3
+ selector:
+ matchLabels:
+ app: redis
+ template:
+ metadata:
+ labels:
+ app: redis
+ spec:
+ containers:
+ - name: redis
+ image: redis:7-alpine
+ ports:
+ - containerPort: 6379
+ command:
+ - redis-server
+ - --appendonly yes
+ - --dir /data
+ volumeMounts:
+ - name: data
+ mountPath: /data
+ resources:
+ requests:
+ cpu: 100m
+ memory: 256Mi
+ limits:
+ cpu: 500m
+ memory: 512Mi
+ volumeClaimTemplates: # Kubernetes создаёт PVC для каждой реплики
+ - metadata:
+ name: data
+ spec:
+ accessModes: ["ReadWriteOnce"] # каждая реплика = свой том
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 10Gi
+```
+
+При `replicas: 3` Kubernetes создаст три PVC:
+- `data-redis-0` → 10Gi
+- `data-redis-1` → 10Gi
+- `data-redis-2` → 10Gi
+
+```bash
+kubectl get pvc -n cache
+# NAME STATUS VOLUME CAPACITY ACCESS MODES
+# data-redis-0 Bound pvc-aaa11111 10Gi RWO
+# data-redis-1 Bound pvc-bbb22222 10Gi RWO
+# data-redis-2 Bound pvc-ccc33333 10Gi RWO
+```
+
+> **Важно:** при уменьшении `replicas` PVC **не удаляются автоматически**. Это намеренное поведение Kubernetes — защита от потери данных. Удалять PVC вручную после проверки.
+
+---
+
+## 6. Пример 3: база данных (PostgreSQL) с Longhorn
+
+**Сценарий:** продакшн PostgreSQL с выделенным Longhorn-томом.
+
+### Объекты
+
+```yaml
+---
+# PVC отдельно от StatefulSet — для явного управления жизненным циклом
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: postgres-data
+ namespace: myapp
+ annotations:
+ # Документировать содержимое тома для оператора
+ longhorn.io/description: "PostgreSQL data for myapp — created 2026-01-01"
+spec:
+ accessModes:
+ - ReadWriteOnce
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 50Gi
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: postgres
+ namespace: myapp
+spec:
+ serviceName: postgres
+ replicas: 1
+ selector:
+ matchLabels:
+ app: postgres
+ template:
+ metadata:
+ labels:
+ app: postgres
+ spec:
+ securityContext:
+ fsGroup: 999 # postgres GID — Longhorn создаст том с нужными правами
+ containers:
+ - name: postgres
+ image: postgres:16-alpine
+ ports:
+ - containerPort: 5432
+ env:
+ - name: POSTGRES_DB
+ value: myapp
+ - name: POSTGRES_USER
+ value: myuser
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: postgres-secret
+ key: password
+ - name: PGDATA
+ value: /var/lib/postgresql/data/pgdata # подпапка, не корень тома
+ volumeMounts:
+ - name: data
+ mountPath: /var/lib/postgresql/data
+ resources:
+ requests:
+ cpu: 200m
+ memory: 512Mi
+ limits:
+ cpu: 1000m
+ memory: 2Gi
+ readinessProbe:
+ exec:
+ command: [pg_isready, -U, myuser, -d, myapp]
+ initialDelaySeconds: 10
+ periodSeconds: 5
+ volumes:
+ - name: data
+ persistentVolumeClaim:
+ claimName: postgres-data
+```
+
+> Переменная `PGDATA=/var/lib/postgresql/data/pgdata` — подпапка внутри mountPath.
+> PostgreSQL требует пустую директорию при инициализации. Если смонтировать том прямо в `/var/lib/postgresql/data`, Longhorn создаёт там `lost+found` и PostgreSQL падает с ошибкой.
+
+### Резервное копирование PostgreSQL
+
+Два уровня резервирования:
+
+1. **Longhorn snapshot** — снапшот файловой системы (быстрый, crash-consistent)
+2. **pg_dump** — логический дамп (переносимый, consistent)
+
+```bash
+# Логический дамп из Pod
+kubectl exec -n myapp postgres-0 -- \
+ pg_dump -U myuser myapp | gzip > backup-$(date +%Y%m%d).sql.gz
+
+# Восстановление
+kubectl exec -i -n myapp postgres-0 -- \
+ psql -U myuser myapp < backup-20260101.sql
+```
+
+---
+
+## 7. Пример 4: несколько компонентов с общим хранилищем
+
+**Сценарий:** несколько Pod должны читать один и тот же набор файлов (например, общий медиа-каталог между CMS и CDN-прокси).
+
+### Проблема: ReadWriteOnce не подходит
+
+`ReadWriteOnce` разрешает запись только с одного узла. При нескольких Pod на разных узлах — ошибка монтирования.
+
+### Решение A: все Pod на одном узле (nodeSelector)
+
+```yaml
+# Принудительно разместить все Pod на одной ноде
+spec:
+ nodeSelector:
+ kubernetes.io/hostname: k8s-worker-01
+```
+
+Тогда PVC типа `ReadWriteOnce` работает с несколькими Pod на одном узле.
+
+### Решение B: NFS через Longhorn (ReadWriteMany)
+
+Longhorn поддерживает `ReadWriteMany` через встроенный NFS-шлюз начиная с версии 1.5.
+
+```yaml
+---
+apiVersion: v1
+kind: PersistentVolumeClaim
+metadata:
+ name: shared-media
+ namespace: cms
+spec:
+ accessModes:
+ - ReadWriteMany # несколько узлов одновременно
+ storageClassName: longhorn
+ resources:
+ requests:
+ storage: 100Gi
+```
+
+```yaml
+---
+# CMS Pod — пишет медиафайлы
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: cms
+ namespace: cms
+spec:
+ replicas: 1
+ template:
+ spec:
+ containers:
+ - name: cms
+ image: registry.gigacoms.info/myteam/cms:v2.0.0
+ volumeMounts:
+ - name: media
+ mountPath: /app/media
+ volumes:
+ - name: media
+ persistentVolumeClaim:
+ claimName: shared-media
+---
+# CDN-прокси Pod — читает медиафайлы с того же тома
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+ name: cdn-proxy
+ namespace: cms
+spec:
+ replicas: 3 # несколько реплик, разные ноды — RWX позволяет
+ template:
+ spec:
+ containers:
+ - name: cdn-proxy
+ image: nginx:1.27-alpine
+ volumeMounts:
+ - name: media
+ mountPath: /usr/share/nginx/html/media
+ readOnly: true # прокси только читает
+ volumes:
+ - name: media
+ persistentVolumeClaim:
+ claimName: shared-media
+```
+
+> **Ограничение RWX в Longhorn:** производительность ниже, чем RWO (идёт через NFS). Для баз данных использовать только RWO. RWX — для файлов с редкой записью и частым чтением.
+
+---
+
+## 8. Реплики: сколько и когда менять
+
+### Текущая конфигурация кластера
+
+```
+Кластер: 1 worker-нода (k8s-worker-01)
+Реплики: 1 (longhorn_default_replica_count: 1)
+```
+
+Одна реплика означает: **данные не реплицируются**. При потере ноды — данные недоступны до восстановления узла.
+
+### Когда увеличивать количество реплик
+
+| Число worker-нод | Рекомендуемые реплики | Причина |
+|---|---|---|
+| 1 | **1** | Некуда реплицировать |
+| 2 | **2** | Реплика на каждой ноде; потеря одной ноды — нет прерывания |
+| 3+ | **3** | Quorum; потеря одной ноды — данные доступны |
+
+### Как изменить количество реплик
+
+**Глобально** (для новых томов):
+
+```yaml
+# inventory/prod/group_vars/longhorn.yml или group_vars/k8s_cluster.yml
+longhorn_default_replica_count: 2 # изменить при добавлении worker-нод
+```
+
+```bash
+ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml
+# Применит только Helm-апгрейд, не затронет prereqs и диски
+```
+
+**Для конкретного тома** (через UI или kubectl):
+
+```bash
+# Через Longhorn API
+kubectl patch volume.longhorn.io pvc-abc12345 -n longhorn-system \
+ --type=merge -p '{"spec":{"numberOfReplicas":2}}'
+```
+
+**Для конкретного PVC через StorageClass**:
+
+```yaml
+# Создать отдельный StorageClass с другим количеством реплик
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: longhorn-replicated
+provisioner: driver.longhorn.io
+parameters:
+ numberOfReplicas: "2"
+ staleReplicaTimeout: "30"
+ dataLocality: "disabled"
+reclaimPolicy: Delete
+allowVolumeExpansion: true
+volumeBindingMode: Immediate
+```
+
+```yaml
+# PVC с кастомным StorageClass
+spec:
+ storageClassName: longhorn-replicated # вместо longhorn
+```
+
+### soft anti-affinity (при числе нод < числа реплик)
+
+Если `numberOfReplicas: 2`, но доступна только 1 нода — Longhorn не создаст том (нет места для реплики).
+Для обхода в dev-окружении включить `replicaNodeLevelSoftAntiAffinity`:
+
+```yaml
+longhorn_replica_node_soft_anti_affinity: "true" # НЕ использовать в production
+```
+
+---
+
+## 9. Резервное копирование и восстановление
+
+### Longhorn Snapshot (мгновенный снапшот тома)
+
+Снапшот — point-in-time копия тома на том же диске. Защищает от случайного удаления данных, но не от потери диска.
+
+```bash
+# Создать снапшот через kubectl
+kubectl create -f - < → Create Snapshot.
+
+### Backup в S3-совместимое хранилище
+
+Для полноценного backup необходимо настроить внешнее хранилище (S3, NFS):
+
+```yaml
+# Настройка backup target через Longhorn Settings
+# UI: http://10.203.0.96:10002 → Settings → Backup Target
+# Значение: s3://bucket-name@region/prefix (для S3)
+# nfs://server/path (для NFS)
+```
+
+```bash
+# Создать backup существующего снапшота
+kubectl create -f - < → Snapshots → Revert
+# Или через kubectl (том должен быть detached)
+
+# 1. Масштабировать Deployment в 0 (отмонтировать том)
+kubectl scale deployment my-app -n my-app --replicas=0
+
+# 2. Revert через UI или API
+
+# 3. Вернуть Deployment
+kubectl scale deployment my-app -n my-app --replicas=1
+```
+
+---
+
+## 10. Расширение тома
+
+Longhorn поддерживает расширение тома **без остановки Pod** (online expansion).
+
+### Расширить PVC
+
+```bash
+# Отредактировать PVC — увеличить storage (уменьшение не поддерживается)
+kubectl patch pvc app-data -n my-app \
+ --type=merge -p '{"spec":{"resources":{"requests":{"storage":"20Gi"}}}}'
+
+# Проверить прогресс
+kubectl get pvc app-data -n my-app
+# STATUS: пока идёт расширение, будет Bound с условием FileSystemResizePending
+
+# После завершения
+kubectl describe pvc app-data -n my-app | grep -A5 Conditions
+```
+
+Расширение на уровне файловой системы происходит автоматически при следующем монтировании Pod (или сразу, если `allowVolumeExpansion: true` в StorageClass — а у Longhorn это включено по умолчанию).
+
+### Проверить размер тома внутри Pod
+
+```bash
+kubectl exec -n my-app $(kubectl get pod -n my-app -l app=my-app -o name) \
+ -- df -h /app/data
+```
+
+---
+
+## 11. Добавление нового worker-узла с дисками
+
+При добавлении `k8s-worker-02`:
+
+### Шаг 1: добавить в инвентарь
+
+```yaml
+# inventory/prod/hosts.yml
+workers:
+ hosts:
+ k8s-worker-01:
+ ansible_host: 10.203.0.96
+ k8s-worker-02: # новый узел
+ ansible_host: 10.203.0.XX
+ longhorn_disks: # если диски отличаются от дефолта
+ - device: /dev/sdb
+ mountpoint: /mnt/longhorn-disk1
+```
+
+### Шаг 2: запустить prereqs только для нового узла
+
+```bash
+# Подготовка ОС только для новой ноды
+ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml \
+ --limit k8s-worker-02 \
+ --tags longhorn_prereqs
+```
+
+### Шаг 3: запустить join нового воркера в кластер
+
+```bash
+ansible-playbook -i inventory/prod playbooks/setup_worker_plane.yml \
+ --limit k8s-worker-02
+```
+
+### Шаг 4: аннотировать ноду и обновить Longhorn
+
+```bash
+# Полный плейбук обновит аннотации для всех нод включая новую
+ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml
+```
+
+### Шаг 5: увеличить количество реплик
+
+```yaml
+# group_vars/k8s_cluster.yml или longhorn.yml
+longhorn_default_replica_count: 2 # было 1
+```
+
+```bash
+ansible-playbook -i inventory/prod playbooks/setup_longhorn.yml
+```
+
+---
+
+## 12. Диагностика и типичные ошибки
+
+### Основные команды
+
+```bash
+# Состояние всех томов
+kubectl get volumes.longhorn.io -n longhorn-system
+
+# Детали тома
+kubectl describe volume.longhorn.io -n longhorn-system
+
+# Состояние реплик
+kubectl get replicas.longhorn.io -n longhorn-system | grep
+
+# Состояние нод Longhorn
+kubectl get nodes.longhorn.io -n longhorn-system
+
+# Логи manager
+kubectl logs -n longhorn-system -l app=longhorn-manager --tail=100
+
+# Логи CSI driver
+kubectl logs -n longhorn-system -l app=longhorn-csi-plugin --tail=50
+
+# PVC не привязывается — посмотреть события
+kubectl describe pvc -n
+kubectl get events -n --sort-by='.lastTimestamp'
+
+# Посмотреть все PVC в кластере
+kubectl get pvc -A
+```
+
+### Таблица ошибок
+
+| Симптом | Причина | Решение |
+|---|---|---|
+| PVC в статусе `Pending` | Нет доступных дисков / нода не готова | `kubectl get nodes.longhorn.io -n longhorn-system` — проверить schedulable |
+| PVC `Pending`: `no schedulable node` | Включён soft anti-affinity=false, только 1 нода, replicas=2 | Уменьшить реплики до 1 или включить soft anti-affinity |
+| Pod не стартует: `volume not found` | Том деградировал или удалён | Проверить `kubectl get volumes.longhorn.io -n longhorn-system` |
+| Том в статусе `Degraded` | Нода с репликой недоступна | Подождать восстановления ноды или rebuild replica |
+| Том в статусе `Faulted` | Все реплики потеряны | Восстановить из бэкапа |
+| `iscsiadm` ошибки в логах | SELinux блокирует iscsid | Перепроверить CIL-политику: `semodule -l \| grep local_longhorn` |
+| Медленная запись | Все реплики на одном диске | Разнести реплики по дискам (Disk Tags) |
+| `lost+found` в PostgreSQL data dir | PGDATA смонтирован прямо на корень тома | Установить `PGDATA=/var/lib/postgresql/data/pgdata` (подпапка) |
+| Диск не виден в Longhorn | Аннотация ноды устарела | `ansible-playbook setup_longhorn.yml` — обновит annotate |
+
+### Проверка iSCSI на ноде
+
+```bash
+# Выполнить через SSH на worker-ноде или kubectl exec
+
+# Статус iscsid
+systemctl status iscsid
+
+# Список активных iSCSI-сессий (примонтированные тома)
+iscsiadm -m session
+
+# Загружены ли нужные модули ядра
+lsmod | grep iscsi_tcp
+lsmod | grep dm_crypt
+```
+
+### Принудительный detach застрявшего тома
+
+Если Pod удалён, но том остался в статусе attached:
+
+```bash
+# Через UI: Longhorn → Volumes → → Detach
+
+# Или через kubectl
+kubectl patch volume.longhorn.io -n longhorn-system \
+ --type=merge -p '{"spec":{"nodeID":""}}'
+```
+
+---
+
+## 13. Чеклист перед использованием хранилища
+
+### Первое использование Longhorn в кластере
+
+- [ ] `setup_longhorn.yml` выполнен успешно (prereqs + helm install)
+- [ ] Все worker-ноды показывают статус `Ready` в UI Longhorn (`http://10.203.0.96:10002`)
+- [ ] Диски видны в UI Longhorn: Node → Disks
+- [ ] StorageClass `longhorn` существует: `kubectl get storageclass`
+- [ ] Тестовый PVC создаётся и переходит в `Bound`: `kubectl apply -f test-pvc.yaml`
+
+### Перед каждым новым PVC / StatefulSet
+
+- [ ] Выбран правильный `accessMode`:
+ - `ReadWriteOnce` — один узел, обычные Pod и StatefulSet
+ - `ReadWriteMany` — несколько узлов, только через Longhorn NFS
+- [ ] `storageClassName: longhorn` указан явно (не полагаться на default при нескольких StorageClass)
+- [ ] Указан разумный размер тома (расширить можно, уменьшить — нет)
+- [ ] Для PostgreSQL: `PGDATA` установлен в подпапку, `fsGroup: 999` в securityContext
+- [ ] Для StatefulSet: использован `volumeClaimTemplates`, не `volumes + PVC`
+- [ ] `resources.requests` и `limits` заданы для контейнеров
+
+### После деплоя
+
+- [ ] PVC в статусе `Bound`: `kubectl get pvc -n `
+- [ ] Pod в статусе `Running`: `kubectl get pods -n `
+- [ ] Том виден в Longhorn UI со статусом `Healthy`
+- [ ] Данные доступны внутри Pod: `kubectl exec -n -- ls /mountpath`
+- [ ] (для продакшн-данных) Настроен регулярный snapshot или backup
diff --git a/roles/longhorn/defaults/main.yml b/roles/longhorn/defaults/main.yml
new file mode 100644
index 0000000..4710569
--- /dev/null
+++ b/roles/longhorn/defaults/main.yml
@@ -0,0 +1,14 @@
+---
+longhorn_chart_version: "1.7.2"
+longhorn_namespace: longhorn-system
+
+# Number of replicas per volume. Set to 1 while only 1 worker node exists.
+# Raise to 2 or 3 after adding more worker nodes.
+longhorn_default_replica_count: 1
+
+longhorn_replica_node_soft_anti_affinity: "false"
+longhorn_minimal_available_storage_percentage: "10"
+longhorn_storage_over_provisioning_percentage: "100"
+
+# Path to kubeconfig on manager node
+longhorn_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
diff --git a/roles/longhorn/handlers/main.yml b/roles/longhorn/handlers/main.yml
new file mode 100644
index 0000000..ed97d53
--- /dev/null
+++ b/roles/longhorn/handlers/main.yml
@@ -0,0 +1 @@
+---
diff --git a/roles/longhorn/meta/main.yml b/roles/longhorn/meta/main.yml
new file mode 100644
index 0000000..b792d58
--- /dev/null
+++ b/roles/longhorn/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ role_name: longhorn
+ author: ops
+ description: Installs Longhorn via Helm and registers worker node disks via node annotations
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
diff --git a/roles/longhorn/tasks/annotate.yml b/roles/longhorn/tasks/annotate.yml
new file mode 100644
index 0000000..3a5ab79
--- /dev/null
+++ b/roles/longhorn/tasks/annotate.yml
@@ -0,0 +1,28 @@
+---
+# Annotate each worker node with its Longhorn disk config BEFORE helm install.
+# Longhorn reads node.longhorn.io/default-disks-config on first node discovery
+# and automatically registers the listed mountpoints as storage disks.
+- name: Annotate | Set Longhorn disk config on worker nodes
+ ansible.builtin.command:
+ argv:
+ - kubectl
+ - annotate
+ - node
+ - "{{ item }}"
+ - node.longhorn.io/create-default-disk=config
+ - "node.longhorn.io/default-disks-config={{ _disk_config }}"
+ - --overwrite
+ vars:
+ _disk_config: >-
+ {{ '[' + (hostvars[item].longhorn_disks
+ | map(attribute='mountpoint')
+ | map('regex_replace', '(.+)', '{"path":"\1","allowScheduling":true,"diskType":"filesystem"}')
+ | list
+ | join(',')) + ']' }}
+ environment:
+ KUBECONFIG: "{{ longhorn_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ loop: "{{ groups['workers'] }}"
+ register: _annotate
+ changed_when: "'annotated' in _annotate.stdout"
+ failed_when: _annotate.rc != 0
diff --git a/roles/longhorn/tasks/disks.yml b/roles/longhorn/tasks/disks.yml
new file mode 100644
index 0000000..b71f033
--- /dev/null
+++ b/roles/longhorn/tasks/disks.yml
@@ -0,0 +1,145 @@
+---
+# Idempotently registers dedicated disks on Longhorn worker nodes and removes
+# the auto-created default disk at /var/lib/longhorn.
+# Runs after helm.yml — Longhorn must already be installed.
+
+# ── Step 1: wait for Longhorn to discover all worker nodes ────────────────────
+
+- name: Disks | Wait for worker nodes to appear in Longhorn
+ ansible.builtin.command:
+ argv: [kubectl, get, node.longhorn.io, "{{ item }}"]
+ loop: "{{ groups['workers'] }}"
+ register: _lh_node_wait
+ until: _lh_node_wait.rc == 0
+ retries: 24
+ delay: 5
+ changed_when: false
+ failed_when: _lh_node_wait.rc != 0 and _lh_node_wait.attempts | default(0) >= 24
+ environment:
+ KUBECONFIG: "{{ longhorn_kubeconfig }}"
+ PATH: /usr/local/bin:/usr/bin:/bin
+
+# ── Step 2: register dedicated disks via k8s_json_patch ──
+
+- name: Disks | Build flat worker-disk list
+ ansible.builtin.set_fact:
+ _lh_worker_disks: "{{ _lh_worker_disks | default([]) + [hostvars[item].longhorn_disks | default([]) | map('combine', {'node': item}) | list] | flatten }}"
+ loop: "{{ groups['workers'] }}"
+
+- name: Disks | Register dedicated disk on Longhorn node
+ kubernetes.core.k8s_json_patch:
+ api_version: longhorn.io/v1beta1
+ kind: Node
+ name: "{{ item.node }}"
+ patches:
+ - op: add
+ path: "/spec/disks/{{ item.mountpoint | basename | replace('/', '~1') }}"
+ value:
+ path: "{{ item.mountpoint }}"
+ allowScheduling: "{{ item.allowScheduling | default(true) }}"
+ diskType: "{{ item.diskType | default('filesystem') }}"
+ storageReserved: "{{ item.storageReserved | default(0) }}"
+ loop: "{{ _lh_worker_disks | default([]) }}"
+ loop_control:
+ label: "{{ item.node }}/{{ item.mountpoint | basename }}"
+ register: _disk_patch
+ changed_when: false
+
+# ── Step 3: remove auto-created default disk at /var/lib/longhorn ─────────────
+
+- name: Disks | Get current disk spec for each worker node
+ kubernetes.core.k8s_info:
+ api_version: longhorn.io/v1beta1
+ kind: Node
+ name: "{{ item }}"
+ loop: "{{ groups['workers'] }}"
+ register: _lh_node_info
+ changed_when: false
+ environment:
+ KUBECONFIG: "{{ longhorn_kubeconfig }}"
+ PATH: /usr/local/bin:/usr/bin:/bin
+
+- name: Disks | Disable auto-created default disk (required before removal)
+ kubernetes.core.k8s_json_patch:
+ api_version: longhorn.io/v1beta1
+ kind: Node
+ name: "{{ item.item }}"
+ patches:
+ - op: add
+ path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({key: ''}) | key | replace('/', '~1') }}"
+ value:
+ path: "{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({value: {value: {}}}) | value | path | default('/var/lib/longhorn/') }}"
+ allowScheduling: false
+ diskType: "filesystem"
+ loop: "{{ _lh_node_info.results }}"
+ when:
+ - item.resources | length > 0
+ - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | selectattr('value.allowScheduling', 'equalto', true) | list | length > 0
+ register: _disable_default
+ changed_when: false
+
+- name: Disks | Remove auto-created default disk
+ kubernetes.core.k8s_json_patch:
+ api_version: longhorn.io/v1beta1
+ kind: Node
+ name: "{{ item.item }}"
+ patches:
+ - op: remove
+ path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | map(attribute='key') | first | default('') | replace('/', '~1') }}"
+ loop: "{{ _lh_node_info.results }}"
+ when:
+ - item.resources | length > 0
+ - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | list | length > 0
+ register: _remove_default
+ changed_when: false
+
+# ── Step 4: remove auto-created default disk from control_plane nodes ─────────
+# Control plane nodes have no dedicated storage disks; Longhorn must not
+# schedule replicas on the small OS disk (/var/lib/longhorn/).
+
+- name: Disks | Get current disk spec for control_plane nodes
+ kubernetes.core.k8s_info:
+ api_version: longhorn.io/v1beta1
+ kind: Node
+ name: "{{ item }}"
+ loop: "{{ groups['control_plane'] }}"
+ register: _lh_cp_node_info
+ changed_when: false
+ failed_when: false
+ environment:
+ KUBECONFIG: "{{ longhorn_kubeconfig }}"
+ PATH: /usr/local/bin:/usr/bin:/bin
+
+- name: Disks | Disable auto-created default disk on control_plane nodes
+ kubernetes.core.k8s_json_patch:
+ api_version: longhorn.io/v1beta1
+ kind: Node
+ name: "{{ item.item }}"
+ patches:
+ - op: add
+ path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({key: ''}) | key | replace('/', '~1') }}"
+ value:
+ path: "{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | first | default({value: {}}) | value | path | default('/var/lib/longhorn/') }}"
+ allowScheduling: false
+ diskType: "filesystem"
+ loop: "{{ _lh_cp_node_info.results }}"
+ when:
+ - item.resources | length > 0
+ - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | selectattr('value.allowScheduling', 'equalto', true) | list | length > 0
+ register: _disable_cp_default
+ changed_when: false
+
+- name: Disks | Remove auto-created default disk from control_plane nodes
+ kubernetes.core.k8s_json_patch:
+ api_version: longhorn.io/v1beta1
+ kind: Node
+ name: "{{ item.item }}"
+ patches:
+ - op: remove
+ path: "/spec/disks/{{ item.resources[0].spec.disks | dict2items | selectattr('key', 'match', 'default-disk-.*') | map(attribute='key') | first | default('') | replace('/', '~1') }}"
+ loop: "{{ _lh_cp_node_info.results }}"
+ when:
+ - item.resources | length > 0
+ - item.resources[0].spec.disks | default({}) | dict2items | selectattr('key', 'match', 'default-disk-.*') | list | length > 0
+ register: _remove_cp_default
+ changed_when: false
diff --git a/roles/longhorn/tasks/helm.yml b/roles/longhorn/tasks/helm.yml
new file mode 100644
index 0000000..f9fc631
--- /dev/null
+++ b/roles/longhorn/tasks/helm.yml
@@ -0,0 +1,35 @@
+---
+- name: Helm | Add Longhorn chart repository
+ ansible.builtin.command: helm repo add longhorn https://charts.longhorn.io
+ environment:
+ KUBECONFIG: "{{ longhorn_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_repo_add
+ changed_when: "'already exists' not in _helm_repo_add.stdout"
+ failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout
+
+- name: Helm | Update chart repositories
+ ansible.builtin.command: helm repo update
+ environment:
+ KUBECONFIG: "{{ longhorn_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+
+- name: Helm | Install or upgrade Longhorn
+ ansible.builtin.command: >
+ helm upgrade --install longhorn longhorn/longhorn
+ --namespace {{ longhorn_namespace }}
+ --create-namespace
+ --version {{ longhorn_chart_version }}
+ --set defaultSettings.defaultReplicaCount={{ longhorn_default_replica_count }}
+ --set defaultSettings.replicaNodeLevelSoftAntiAffinity={{ longhorn_replica_node_soft_anti_affinity }}
+ --set defaultSettings.minimalAvailableStoragePercentage={{ longhorn_minimal_available_storage_percentage }}
+ --set defaultSettings.storageOverProvisioningPercentage={{ longhorn_storage_over_provisioning_percentage }}
+ --set defaultSettings.createDefaultDiskLabeledNodes=true
+ --set persistence.defaultClassReplicaCount={{ longhorn_default_replica_count }}
+ --set service.ui.type=ClusterIP
+ environment:
+ KUBECONFIG: "{{ longhorn_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/longhorn/tasks/main.yml b/roles/longhorn/tasks/main.yml
new file mode 100644
index 0000000..d1c7cb9
--- /dev/null
+++ b/roles/longhorn/tasks/main.yml
@@ -0,0 +1,9 @@
+---
+- name: Annotate worker nodes
+ ansible.builtin.include_tasks: annotate.yml
+
+- name: Install Longhorn via Helm
+ ansible.builtin.include_tasks: helm.yml
+
+- name: Register disks and remove default disk
+ ansible.builtin.include_tasks: disks.yml
diff --git a/roles/longhorn_prereqs/defaults/main.yml b/roles/longhorn_prereqs/defaults/main.yml
new file mode 100644
index 0000000..6b5ce5e
--- /dev/null
+++ b/roles/longhorn_prereqs/defaults/main.yml
@@ -0,0 +1,6 @@
+---
+longhorn_disks: []
+
+longhorn_kernel_modules:
+ - iscsi_tcp
+ - dm_crypt
diff --git a/roles/longhorn_prereqs/handlers/main.yml b/roles/longhorn_prereqs/handlers/main.yml
new file mode 100644
index 0000000..ed97d53
--- /dev/null
+++ b/roles/longhorn_prereqs/handlers/main.yml
@@ -0,0 +1 @@
+---
diff --git a/roles/longhorn_prereqs/meta/main.yml b/roles/longhorn_prereqs/meta/main.yml
new file mode 100644
index 0000000..32eb01a
--- /dev/null
+++ b/roles/longhorn_prereqs/meta/main.yml
@@ -0,0 +1,11 @@
+---
+galaxy_info:
+ role_name: longhorn_prereqs
+ author: ops
+ description: Prepares worker nodes for Longhorn storage (packages, kernel modules, firewall, SELinux, disk format and mount)
+ license: MIT
+ min_ansible_version: "2.14"
+ platforms:
+ - name: EL
+ versions:
+ - "9"
diff --git a/roles/longhorn_prereqs/tasks/disks.yml b/roles/longhorn_prereqs/tasks/disks.yml
new file mode 100644
index 0000000..858871b
--- /dev/null
+++ b/roles/longhorn_prereqs/tasks/disks.yml
@@ -0,0 +1,37 @@
+---
+- name: Disks | Format disk as XFS (idempotent)
+ community.general.filesystem:
+ fstype: xfs
+ dev: "{{ item.device }}"
+ loop: "{{ longhorn_disks }}"
+ loop_control:
+ label: "{{ item.device }}"
+
+- name: Disks | Create mountpoint directories
+ ansible.builtin.file:
+ path: "{{ item.mountpoint }}"
+ state: directory
+ mode: "0755"
+ loop: "{{ longhorn_disks }}"
+ loop_control:
+ label: "{{ item.mountpoint }}"
+
+- name: Disks | Get UUID of each disk
+ ansible.builtin.command: blkid -s UUID -o value {{ item.device }}
+ register: _disk_uuids
+ changed_when: false
+ check_mode: false
+ loop: "{{ longhorn_disks }}"
+ loop_control:
+ label: "{{ item.device }}"
+
+- name: Disks | Mount disks persistently (UUID, nofail)
+ ansible.posix.mount:
+ path: "{{ item.item.mountpoint }}"
+ src: "UUID={{ item.stdout }}"
+ fstype: xfs
+ opts: defaults,nofail
+ state: mounted
+ loop: "{{ _disk_uuids.results }}"
+ loop_control:
+ label: "{{ item.item.mountpoint }}"
diff --git a/roles/longhorn_prereqs/tasks/firewall.yml b/roles/longhorn_prereqs/tasks/firewall.yml
new file mode 100644
index 0000000..16aed95
--- /dev/null
+++ b/roles/longhorn_prereqs/tasks/firewall.yml
@@ -0,0 +1,11 @@
+---
+- name: Firewall | Open NFS and portmapper ports for Longhorn
+ ansible.posix.firewalld:
+ port: "{{ item }}"
+ permanent: true
+ state: enabled
+ immediate: "{{ not ansible_check_mode }}"
+ loop:
+ - 2049/tcp # NFS (RWX volumes and backups)
+ - 111/tcp # portmapper (NFS)
+ - 111/udp # portmapper (NFS)
diff --git a/roles/longhorn_prereqs/tasks/kernel.yml b/roles/longhorn_prereqs/tasks/kernel.yml
new file mode 100644
index 0000000..11511b3
--- /dev/null
+++ b/roles/longhorn_prereqs/tasks/kernel.yml
@@ -0,0 +1,15 @@
+---
+- name: Kernel | Load Longhorn modules (persistent)
+ ansible.builtin.copy:
+ dest: /etc/modules-load.d/longhorn.conf
+ content: |
+ {% for mod in longhorn_kernel_modules %}
+ {{ mod }}
+ {% endfor %}
+ mode: "0644"
+
+- name: Kernel | Load Longhorn modules now
+ community.general.modprobe:
+ name: "{{ item }}"
+ state: present
+ loop: "{{ longhorn_kernel_modules }}"
diff --git a/roles/longhorn_prereqs/tasks/main.yml b/roles/longhorn_prereqs/tasks/main.yml
new file mode 100644
index 0000000..f6ef942
--- /dev/null
+++ b/roles/longhorn_prereqs/tasks/main.yml
@@ -0,0 +1,16 @@
+---
+- name: Packages
+ ansible.builtin.include_tasks: packages.yml
+
+- name: Kernel modules
+ ansible.builtin.include_tasks: kernel.yml
+
+- name: Firewall
+ ansible.builtin.include_tasks: firewall.yml
+
+- name: SELinux patch
+ ansible.builtin.include_tasks: selinux.yml
+
+- name: Disks
+ ansible.builtin.include_tasks: disks.yml
+ when: longhorn_disks | default([]) | length > 0
diff --git a/roles/longhorn_prereqs/tasks/packages.yml b/roles/longhorn_prereqs/tasks/packages.yml
new file mode 100644
index 0000000..89c88cb
--- /dev/null
+++ b/roles/longhorn_prereqs/tasks/packages.yml
@@ -0,0 +1,20 @@
+---
+- name: Packages | Install Longhorn dependencies
+ ansible.builtin.dnf:
+ name:
+ - iscsi-initiator-utils
+ - nfs-utils
+ - cryptsetup
+ state: present
+
+- name: Packages | Reload systemd after package install
+ ansible.builtin.systemd:
+ daemon_reload: true
+ when: not ansible_check_mode
+
+- name: Packages | Enable and start iscsid
+ ansible.builtin.systemd:
+ name: iscsid
+ state: started
+ enabled: true
+ when: not ansible_check_mode
diff --git a/roles/longhorn_prereqs/tasks/selinux.yml b/roles/longhorn_prereqs/tasks/selinux.yml
new file mode 100644
index 0000000..7a7bc06
--- /dev/null
+++ b/roles/longhorn_prereqs/tasks/selinux.yml
@@ -0,0 +1,20 @@
+---
+# Rocky Linux 9 with container-selinux > 2.189.0 blocks iscsiadm via SELinux,
+# causing infinite attach/detach loops on Longhorn volumes.
+- name: SELinux | Write iscsid CIL policy file
+ ansible.builtin.copy:
+ dest: /tmp/local_longhorn.cil
+ content: "(allow iscsid_t self (capability (dac_override)))\n"
+ mode: "0644"
+
+- name: SELinux | Check if Longhorn policy module is already loaded
+ ansible.builtin.command: semodule -l
+ register: _semodule_list
+ changed_when: false
+ check_mode: false
+
+- name: SELinux | Install iscsid CIL policy module
+ ansible.builtin.command: semodule -vi /tmp/local_longhorn.cil
+ when: "'local_longhorn' not in _semodule_list.stdout"
+ register: _semodule_install
+ changed_when: _semodule_install.rc == 0
diff --git a/roles/minio/README.md b/roles/minio/README.md
new file mode 100644
index 0000000..851b4d8
--- /dev/null
+++ b/roles/minio/README.md
@@ -0,0 +1,347 @@
+# MinIO — стандарт объектного хранилища S3 в кластере
+
+MinIO — S3-совместимое объектное хранилище. Предоставляет S3 API (порт 9000) и веб-интерфейс Console (порт 9001). Запускается как standalone StatefulSet на worker-ноде, использует отдельный StorageClass `longhorn-minio` с политикой Retain.
+
+---
+
+## Содержание
+
+1. [Архитектура](#1-архитектура)
+2. [Предварительные условия](#2-предварительные-условия)
+3. [Развёртывание](#3-развёртывание)
+4. [StorageClass longhorn-minio](#4-storageclass-longhorn-minio)
+5. [Traefik: порты 10005 и 10006](#5-traefik-порты-10005-и-10006)
+6. [Пользователи и политики](#6-пользователи-и-политики)
+7. [Бакеты и lifecycle](#7-бакеты-и-lifecycle)
+8. [Интеграция с Loki](#8-интеграция-с-loki)
+9. [Масштабирование](#9-масштабирование)
+10. [Диагностика](#10-диагностика)
+
+---
+
+## 1. Архитектура
+
+```
+k8s-worker-01 (10.203.0.96)
+│
+├── MinIO Pod (StatefulSet, 1 replica, namespace: minio)
+│ ├── порт 9000 — S3 API (aws s3, boto3, mc, s3cmd)
+│ └── порт 9001 — Console UI (браузер)
+│
+├── PVC 1 TiB → StorageClass longhorn-minio
+│ └── Longhorn volume → longhorn-disk1 (/mnt/longhorn-disk1, ~4 TiB)
+│
+└── Traefik DaemonSet
+ ├── :10005 → minio:9000 (S3 API, внешний доступ)
+ └── :10006 → minio-console:9001 (Console UI, внешний доступ)
+```
+
+### Адреса
+
+| Адрес | Назначение |
+|---|---|
+| `http://10.203.0.96:10005` | S3 API (внешний, через Traefik) |
+| `http://10.203.0.96:10006` | Console UI (внешний, через Traefik) |
+| `http://minio.minio.svc.cluster.local:9000` | S3 API (внутри кластера) |
+| `http://minio-console.minio.svc.cluster.local:9001` | Console (внутри кластера) |
+
+---
+
+## 2. Предварительные условия
+
+- Longhorn установлен и работает (`setup_longhorn.yml` выполнен)
+- Traefik установлен (`setup_traefik.yml` выполнен; записи `minio-api` и `minio-console` добавлены в `traefik_port_map`)
+- Secret `minio-root-credentials` создан вручную в namespace `minio`:
+
+```bash
+kubectl create namespace minio
+
+kubectl create secret generic minio-root-credentials \
+ --from-literal=rootUser=minioadmin \
+ --from-literal=rootPassword='СИЛЬНЫЙ_ПАРОЛЬ_МИНИМУМ_8_СИМВОЛОВ' \
+ -n minio
+```
+
+> Пароль — минимум 8 символов. MinIO не примет короткий пароль и не запустится.
+
+---
+
+## 3. Развёртывание
+
+```bash
+# Запуск через CI/CD (рекомендуется)
+# GitLab → CI/CD → Pipelines → setup:minio → Run manually
+
+# Запуск локально
+ansible-playbook -i inventory/prod playbooks/setup_minio.yml
+
+# После установки Traefik нужно перезапустить setup_traefik.yml,
+# если записи minio-api и minio-console ещё не применены
+ansible-playbook -i inventory/prod playbooks/setup_traefik.yml
+```
+
+### Проверка после развёртывания
+
+```bash
+# Статус пода
+kubectl get pod -n minio
+
+# Логи
+kubectl logs -n minio -l app=minio --tail=50
+
+# Проброс порта для локальной проверки
+kubectl port-forward -n minio svc/minio 9000:9000 &
+
+# Проверка S3 API через mc (MinIO Client)
+mc alias set local http://localhost:9000 minioadmin ПАРОЛЬ
+mc ls local
+mc admin info local
+```
+
+---
+
+## 4. StorageClass longhorn-minio
+
+Отдельный StorageClass с двумя ключевыми отличиями от дефолтного `longhorn`:
+
+| Параметр | `longhorn` (default) | `longhorn-minio` |
+|---|---|---|
+| `reclaimPolicy` | `Delete` | **`Retain`** |
+| `numberOfReplicas` | `1` | `1` |
+| `dataLocality` | `disabled` | `best-effort` |
+
+**Retain** — при удалении PVC или namespace `minio` данные на диске **не уничтожаются**. Для восстановления: пересоздать PVC с тем же `volumeName`, указав имя существующего Longhorn volume.
+
+### (Опционально) Изоляция MinIO на disk1 через тег
+
+Если нужно зафиксировать MinIO именно на `longhorn-disk1`, добавить тег через Longhorn API:
+
+```bash
+kubectl -n longhorn-system patch node.longhorn.io k8s-worker-01 --type=json -p='[
+ {"op":"add","path":"/spec/disks/longhorn-disk1/tags","value":["minio"]}
+]'
+```
+
+Затем раскомментировать `diskSelector: "minio"` в `roles/minio/templates/storageclass.yml.j2` и перезапустить `setup_minio.yml`.
+
+Без тега Longhorn выбирает любой из доступных дисков — это тоже корректно.
+
+---
+
+## 5. Traefik: порты 10005 и 10006
+
+Записи в `inventory/prod/group_vars/traefik.yml`:
+
+```yaml
+- name: minio-api
+ port: 10005
+ backend:
+ namespace: minio
+ service: minio
+ port: 9000
+ scheme: http
+ basicauth:
+ enabled: false # MinIO использует AWS Signature v4
+
+- name: minio-console
+ port: 10006
+ backend:
+ namespace: minio
+ service: minio-console
+ port: 9001
+ scheme: http
+ basicauth:
+ enabled: false # Console защищён собственным логином
+```
+
+> BasicAuth от Traefik несовместим с MinIO Console — браузер не может пройти двойную аутентификацию. Console имеет свой логин.
+
+После изменения `traefik_port_map` перезапустить `setup_traefik.yml`.
+
+---
+
+## 6. Пользователи и политики
+
+Не используйте root-учётные данные в приложениях. Для каждого сервиса — отдельный пользователь с ограниченной политикой.
+
+### Создание пользователей через mc
+
+```bash
+# Настройка alias (через Traefik)
+mc alias set prod http://10.203.0.96:10005 minioadmin ПАРОЛЬ
+
+# Пользователь для Loki (только бакет loki-chunks)
+mc admin user add prod loki ПАРОЛЬ_LOKI
+
+mc admin policy create prod loki-policy /dev/stdin <<'EOF'
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": ["s3:*"],
+ "Resource": [
+ "arn:aws:s3:::loki-chunks",
+ "arn:aws:s3:::loki-chunks/*"
+ ]
+ }
+ ]
+}
+EOF
+
+mc admin policy attach prod loki-policy --user loki
+
+# Пользователь для бэкапов (только запись в backups)
+mc admin user add prod backup ПАРОЛЬ_BACKUP
+
+mc admin policy create prod backup-policy /dev/stdin <<'EOF'
+{
+ "Version": "2012-10-17",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": ["s3:PutObject", "s3:GetObject", "s3:ListBucket"],
+ "Resource": [
+ "arn:aws:s3:::backups",
+ "arn:aws:s3:::backups/*"
+ ]
+ }
+ ]
+}
+EOF
+
+mc admin policy attach prod backup-policy --user backup
+```
+
+---
+
+## 7. Бакеты и lifecycle
+
+Бакеты создаются автоматически при старте MinIO через `values.buckets`. Дефолтный список: `loki-chunks`, `backups`, `artifacts`. Изменить список — через переменную `minio_buckets` в `defaults/main.yml` или `group_vars`.
+
+### Lifecycle: автоудаление старых объектов
+
+```bash
+# Удалять объекты в backups старше 30 дней
+mc ilm rule add --expire-days 30 prod/backups
+
+# Включить версионирование бакета
+mc version enable prod/backups
+
+# Посмотреть все версии объекта
+mc ls --versions prod/backups/db-dump.sql.gz
+```
+
+---
+
+## 8. Интеграция с Loki
+
+При переходе Loki на distributed-режим MinIO становится S3 backend. Внутрикластерный endpoint:
+
+```yaml
+# В loki-values.yml (distributed режим)
+loki:
+ storage:
+ type: s3
+ s3:
+ endpoint: http://minio.minio.svc.cluster.local:9000
+ region: us-east-1 # MinIO игнорирует region, но поле обязательно
+ bucketnames: loki-chunks
+ access_key_id: loki
+ secret_access_key: "ПАРОЛЬ_LOKI"
+ insecure: true # http, не https
+ s3forcepathstyle: true # обязательно для MinIO
+```
+
+Secret для Loki в namespace monitoring:
+
+```bash
+kubectl create secret generic loki-minio-secret \
+ --from-literal=AWS_ACCESS_KEY_ID=loki \
+ --from-literal=AWS_SECRET_ACCESS_KEY='ПАРОЛЬ_LOKI' \
+ -n monitoring
+```
+
+---
+
+## 9. Масштабирование
+
+### Расширение PVC без остановки MinIO
+
+Longhorn поддерживает online-расширение PVC:
+
+```bash
+kubectl patch pvc minio -n minio \
+ -p '{"spec":{"resources":{"requests":{"storage":"2Ti"}}}}'
+
+# MinIO увидит новое пространство автоматически
+mc admin info prod
+```
+
+### Переход на SNMD (4 диска на 1 ноде)
+
+При добавлении ещё 2 дисков к worker-ноде (итого 4 диска):
+
+```yaml
+# minio-values.yml — SNMD (mode: distributed для 1 ноды с 4 дисками)
+mode: distributed
+replicas: 1
+drivesPerNode: 4
+persistence:
+ storageClass: longhorn-minio
+ size: 1Ti # 4 × 1 TiB raw → ~2 TiB usable (EC:2)
+```
+
+### Миграция standalone → distributed
+
+MinIO **не поддерживает** in-place миграцию. Процедура:
+
+```
+1. Запустить distributed MinIO рядом (namespace minio-dist)
+2. mc mirror prod prod-dist --preserve --watch
+3. Дождаться синхронизации (mc du для сверки объёмов)
+4. Переключить Traefik (изменить service в traefik_port_map)
+5. Обновить endpoint в Loki, бэкапах и других клиентах
+6. Через 24–48 ч удалить standalone namespace и PVC
+```
+
+---
+
+## 10. Диагностика
+
+```bash
+# Статус пода и PVC
+kubectl get pod,pvc -n minio
+
+# Логи MinIO
+kubectl logs -n minio -l app=minio --tail=100
+
+# Проверка S3 API напрямую
+kubectl port-forward -n minio svc/minio 9000:9000 &
+mc alias set local http://localhost:9000 minioadmin ПАРОЛЬ
+mc admin info local
+
+# Проверка через Traefik
+curl -I http://10.203.0.96:10005/minio/health/live
+
+# StorageClass
+kubectl get storageclass longhorn-minio -o yaml
+
+# Longhorn volume для MinIO
+kubectl get pvc -n minio
+kubectl -n longhorn-system get volume
+
+# Console недоступна (redirect на неверный URL)
+# → проверить MINIO_BROWSER_REDIRECT_URL в values
+kubectl get pod -n minio -o yaml | grep MINIO_BROWSER_REDIRECT_URL
+```
+
+### Типичные ошибки
+
+| Симптом | Причина | Решение |
+|---|---|---|
+| Pod в состоянии `Pending` | PVC не создан (StorageClass не найден) | Проверить `kubectl get sc`, перезапустить `setup_minio.yml` |
+| `Access Denied` при входе | Неверные данные в Secret | Пересоздать `minio-root-credentials` |
+| Console → 401 после логина | Неверный `MINIO_BROWSER_REDIRECT_URL` | Исправить URL в values, перезапустить `setup_minio.yml` |
+| Traefik → 404 на порту 10005/10006 | IngressRoute не применён | Перезапустить `setup_traefik.yml` |
+| PVC завис в `Released` после удаления | reclaimPolicy: Retain (ожидаемо) | Удалить PV вручную или привязать к новому PVC |
diff --git a/roles/minio/defaults/main.yml b/roles/minio/defaults/main.yml
new file mode 100644
index 0000000..99a41c1
--- /dev/null
+++ b/roles/minio/defaults/main.yml
@@ -0,0 +1,23 @@
+---
+minio_namespace: minio
+minio_chart_version: "5.4.0"
+minio_image_tag: "RELEASE.2025-04-22T22-12-26Z"
+
+minio_storage_class: longhorn-minio
+minio_storage_size: 1Ti
+
+minio_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+
+minio_console_url: "http://10.203.0.96:10006"
+minio_api_external_url: "http://10.203.0.96:10005"
+
+minio_root_secret: minio-root-credentials
+
+minio_buckets:
+ - backups
+ - artifacts
+
+minio_resources_requests_memory: "512Mi"
+minio_resources_requests_cpu: "250m"
+minio_resources_limits_memory: "2Gi"
+minio_resources_limits_cpu: "1000m"
diff --git a/roles/minio/tasks/helm.yml b/roles/minio/tasks/helm.yml
new file mode 100644
index 0000000..af62e1b
--- /dev/null
+++ b/roles/minio/tasks/helm.yml
@@ -0,0 +1,37 @@
+---
+- name: Helm | Add MinIO chart repository
+ ansible.builtin.command: helm repo add minio https://charts.min.io/
+ environment:
+ KUBECONFIG: "{{ minio_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_repo_add
+ changed_when: "'already exists' not in _helm_repo_add.stdout"
+ failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout
+
+- name: Helm | Update chart repositories
+ ansible.builtin.command: helm repo update
+ environment:
+ KUBECONFIG: "{{ minio_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+
+- name: Helm | Render MinIO values
+ ansible.builtin.template:
+ src: minio-values.yml.j2
+ dest: /tmp/minio-values.yml
+ mode: "0600"
+
+- name: Helm | Install or upgrade MinIO
+ ansible.builtin.command: >
+ helm upgrade --install minio minio/minio
+ --namespace {{ minio_namespace }}
+ --create-namespace
+ --version {{ minio_chart_version }}
+ --values /tmp/minio-values.yml
+ --timeout 10m0s
+ --wait
+ environment:
+ KUBECONFIG: "{{ minio_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/minio/tasks/main.yml b/roles/minio/tasks/main.yml
new file mode 100644
index 0000000..5d76e50
--- /dev/null
+++ b/roles/minio/tasks/main.yml
@@ -0,0 +1,9 @@
+---
+- name: Apply StorageClass for MinIO
+ ansible.builtin.include_tasks: storageclass.yml
+
+- name: Create MinIO namespace
+ ansible.builtin.include_tasks: namespace.yml
+
+- name: Install MinIO via Helm
+ ansible.builtin.include_tasks: helm.yml
diff --git a/roles/minio/tasks/namespace.yml b/roles/minio/tasks/namespace.yml
new file mode 100644
index 0000000..a62510f
--- /dev/null
+++ b/roles/minio/tasks/namespace.yml
@@ -0,0 +1,18 @@
+---
+- name: Namespace | Create minio namespace
+ ansible.builtin.command: kubectl create namespace {{ minio_namespace }} --dry-run=client -o yaml
+ environment:
+ KUBECONFIG: "{{ minio_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _ns_manifest
+ changed_when: false
+
+- name: Namespace | Apply minio namespace
+ ansible.builtin.command: kubectl apply -f -
+ args:
+ stdin: "{{ _ns_manifest.stdout }}"
+ environment:
+ KUBECONFIG: "{{ minio_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _ns_apply
+ changed_when: "'created' in _ns_apply.stdout"
diff --git a/roles/minio/tasks/storageclass.yml b/roles/minio/tasks/storageclass.yml
new file mode 100644
index 0000000..998d6e9
--- /dev/null
+++ b/roles/minio/tasks/storageclass.yml
@@ -0,0 +1,14 @@
+---
+- name: StorageClass | Render longhorn-minio manifest
+ ansible.builtin.template:
+ src: storageclass.yml.j2
+ dest: /tmp/minio-storageclass.yml
+ mode: "0600"
+
+- name: StorageClass | Apply longhorn-minio
+ ansible.builtin.command: kubectl apply -f /tmp/minio-storageclass.yml
+ environment:
+ KUBECONFIG: "{{ minio_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _sc_apply
+ changed_when: "'configured' in _sc_apply.stdout or 'created' in _sc_apply.stdout"
diff --git a/roles/minio/templates/minio-values.yml.j2 b/roles/minio/templates/minio-values.yml.j2
new file mode 100644
index 0000000..015f2c4
--- /dev/null
+++ b/roles/minio/templates/minio-values.yml.j2
@@ -0,0 +1,68 @@
+## Режим: standalone (1 worker)
+mode: standalone
+
+## Образ
+image:
+ repository: quay.io/minio/minio
+ tag: {{ minio_image_tag }}
+ pullPolicy: IfNotPresent
+
+## Корневые учётные данные из Secret
+existingSecret: {{ minio_root_secret }}
+
+## Хранилище
+persistence:
+ enabled: true
+ storageClass: {{ minio_storage_class }}
+ accessMode: ReadWriteOnce
+ size: {{ minio_storage_size }}
+
+## Ресурсы пода
+resources:
+ requests:
+ memory: {{ minio_resources_requests_memory }}
+ cpu: {{ minio_resources_requests_cpu }}
+ limits:
+ memory: {{ minio_resources_limits_memory }}
+ cpu: {{ minio_resources_limits_cpu }}
+
+## Запускать только на worker-нодах (не на control plane)
+affinity:
+ nodeAffinity:
+ requiredDuringSchedulingIgnoredDuringExecution:
+ nodeSelectorTerms:
+ - matchExpressions:
+ - key: node-role.kubernetes.io/control-plane
+ operator: DoesNotExist
+
+## Сервисы
+service:
+ type: ClusterIP
+ port: 9000
+
+consoleService:
+ type: ClusterIP
+ port: 9001
+
+## Бакеты создаются автоматически при старте
+buckets:
+{% for bucket in minio_buckets %}
+ - name: {{ bucket }}
+ policy: none
+ purge: false
+{% endfor %}
+
+## Метрики (включить когда появится Prometheus)
+metrics:
+ serviceMonitor:
+ enabled: false
+
+## Console Ingress отключён — используем Traefik
+consoleIngress:
+ enabled: false
+
+## Окружение MinIO
+environment:
+ MINIO_BROWSER_REDIRECT_URL: "{{ minio_console_url }}"
+ MINIO_STORAGE_CLASS_STANDARD: "EC:0"
+ MINIO_UPDATE: "off"
diff --git a/roles/minio/templates/storageclass.yml.j2 b/roles/minio/templates/storageclass.yml.j2
new file mode 100644
index 0000000..15620af
--- /dev/null
+++ b/roles/minio/templates/storageclass.yml.j2
@@ -0,0 +1,12 @@
+apiVersion: storage.k8s.io/v1
+kind: StorageClass
+metadata:
+ name: longhorn-minio
+provisioner: driver.longhorn.io
+reclaimPolicy: Retain
+allowVolumeExpansion: true
+parameters:
+ numberOfReplicas: "1"
+ dataLocality: "best-effort"
+ fsType: "ext4"
+ dataEngine: "v1"
diff --git a/roles/sealed_secrets/defaults/main.yml b/roles/sealed_secrets/defaults/main.yml
new file mode 100644
index 0000000..7358396
--- /dev/null
+++ b/roles/sealed_secrets/defaults/main.yml
@@ -0,0 +1,11 @@
+---
+sealed_secrets_namespace: kube-system
+sealed_secrets_chart_version: "2.19.0"
+
+# kubeseal CLI version (empty = latest from GitHub)
+sealed_secrets_cli_version: ""
+
+sealed_secrets_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+
+# Path on the manager node where the master key backup is saved (mode 0600)
+sealed_secrets_backup_path: "/home/{{ ansible_user }}/.kube/sealed-secrets-key-backup.yaml"
diff --git a/roles/sealed_secrets/tasks/backup.yml b/roles/sealed_secrets/tasks/backup.yml
new file mode 100644
index 0000000..1840721
--- /dev/null
+++ b/roles/sealed_secrets/tasks/backup.yml
@@ -0,0 +1,33 @@
+---
+- name: Backup | Wait for sealed-secrets key Secret to be created
+ ansible.builtin.command: >
+ kubectl get secret
+ -n {{ sealed_secrets_namespace }}
+ -l sealedsecrets.bitnami.com/sealed-secrets-key
+ --no-headers
+ environment:
+ KUBECONFIG: "{{ sealed_secrets_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _key_check
+ until: _key_check.stdout != ""
+ retries: 12
+ delay: 10
+ changed_when: false
+
+- name: Backup | Export sealed-secrets master key
+ ansible.builtin.command: >
+ kubectl get secret
+ -n {{ sealed_secrets_namespace }}
+ -l sealedsecrets.bitnami.com/sealed-secrets-key
+ -o yaml
+ environment:
+ KUBECONFIG: "{{ sealed_secrets_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _key_yaml
+ changed_when: false
+
+- name: Backup | Save key to {{ sealed_secrets_backup_path }}
+ ansible.builtin.copy:
+ content: "{{ _key_yaml.stdout }}"
+ dest: "{{ sealed_secrets_backup_path }}"
+ mode: "0600"
diff --git a/roles/sealed_secrets/tasks/cli.yml b/roles/sealed_secrets/tasks/cli.yml
new file mode 100644
index 0000000..1b20d7d
--- /dev/null
+++ b/roles/sealed_secrets/tasks/cli.yml
@@ -0,0 +1,44 @@
+---
+- name: kubeseal CLI | Get latest version from GitHub
+ ansible.builtin.uri:
+ url: https://api.github.com/repos/bitnami-labs/sealed-secrets/releases/latest
+ return_content: true
+ register: _kubeseal_latest
+ when: not sealed_secrets_cli_version
+ check_mode: false
+
+- name: kubeseal CLI | Set version fact (latest)
+ ansible.builtin.set_fact:
+ _kubeseal_version: "{{ _kubeseal_latest.json.tag_name | regex_replace('^v', '') }}"
+ when: not sealed_secrets_cli_version
+
+- name: kubeseal CLI | Set version fact (pinned)
+ ansible.builtin.set_fact:
+ _kubeseal_version: "{{ sealed_secrets_cli_version | regex_replace('^v', '') }}"
+ when: sealed_secrets_cli_version
+
+- name: kubeseal CLI | Check installed version
+ ansible.builtin.command: kubeseal --version
+ register: _kubeseal_installed
+ changed_when: false
+ failed_when: false
+
+- name: kubeseal CLI | Download and unpack
+ ansible.builtin.unarchive:
+ src: "https://github.com/bitnami-labs/sealed-secrets/releases/download/v{{ _kubeseal_version }}/kubeseal-{{ _kubeseal_version }}-linux-amd64.tar.gz"
+ dest: /usr/local/bin
+ remote_src: true
+ include:
+ - kubeseal
+ mode: "0755"
+ owner: root
+ group: root
+ when: >
+ _kubeseal_installed.rc != 0 or
+ _kubeseal_version not in _kubeseal_installed.stdout
+
+- name: kubeseal CLI | Enable bash completion
+ ansible.builtin.shell: /usr/local/bin/kubeseal --help > /dev/null && echo done
+ args:
+ creates: /etc/bash_completion.d/kubeseal
+ changed_when: false
diff --git a/roles/sealed_secrets/tasks/helm.yml b/roles/sealed_secrets/tasks/helm.yml
new file mode 100644
index 0000000..5822c05
--- /dev/null
+++ b/roles/sealed_secrets/tasks/helm.yml
@@ -0,0 +1,31 @@
+---
+- name: Helm | Add Sealed Secrets chart repository
+ ansible.builtin.command: >
+ helm repo add sealed-secrets https://bitnami.github.io/sealed-secrets
+ environment:
+ KUBECONFIG: "{{ sealed_secrets_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_repo_add
+ changed_when: "'already exists' not in _helm_repo_add.stdout"
+ failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout
+
+- name: Helm | Update chart repositories
+ ansible.builtin.command: helm repo update
+ environment:
+ KUBECONFIG: "{{ sealed_secrets_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+
+- name: Helm | Install or upgrade Sealed Secrets controller
+ ansible.builtin.command: >
+ helm upgrade --install sealed-secrets sealed-secrets/sealed-secrets
+ --namespace {{ sealed_secrets_namespace }}
+ --version {{ sealed_secrets_chart_version }}
+ --set fullnameOverride=sealed-secrets-controller
+ --timeout 5m0s
+ --wait
+ environment:
+ KUBECONFIG: "{{ sealed_secrets_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
diff --git a/roles/sealed_secrets/tasks/main.yml b/roles/sealed_secrets/tasks/main.yml
new file mode 100644
index 0000000..6a056c0
--- /dev/null
+++ b/roles/sealed_secrets/tasks/main.yml
@@ -0,0 +1,9 @@
+---
+- name: Install kubeseal CLI
+ ansible.builtin.include_tasks: cli.yml
+
+- name: Deploy Sealed Secrets controller via Helm
+ ansible.builtin.include_tasks: helm.yml
+
+- name: Backup sealed-secrets master key
+ ansible.builtin.include_tasks: backup.yml
diff --git a/roles/traefik/README.md b/roles/traefik/README.md
new file mode 100644
index 0000000..6a2cbef
--- /dev/null
+++ b/roles/traefik/README.md
@@ -0,0 +1,485 @@
+# Traefik — стандарт публикации сервисов в кластере
+
+Этот документ описывает стандарт подключения новых HTTP-сервисов к Traefik в нашем кластере.
+Является эталоном для разработчиков и DevOps при добавлении любого нового сервиса.
+
+---
+
+## Содержание
+
+1. [Архитектура: как Traefik работает в кластере](#1-архитектура-как-traefik-работает-в-кластере)
+2. [Единственный способ добавить сервис: traefik_port_map](#2-единственный-способ-добавить-сервис-traefik_port_map)
+3. [Пример 1: сервис без аутентификации](#3-пример-1-сервис-без-аутентификации)
+4. [Пример 2: сервис с BasicAuth](#4-пример-2-сервис-с-basicauth)
+5. [Пример 3: сервис в том же namespace что и Traefik](#5-пример-3-сервис-в-том-же-namespace-что-и-traefik)
+6. [Пример 4: несколько маршрутов для одного приложения](#6-пример-4-несколько-маршрутов-для-одного-приложения)
+7. [Создание BasicAuth-секрета](#7-создание-basicauth-секрета)
+8. [Добавление нового сервиса: пошаговая инструкция](#8-добавление-нового-сервиса-пошаговая-инструкция)
+9. [Ограничения и правила именования](#9-ограничения-и-правила-именования)
+10. [Диагностика и типичные ошибки](#10-диагностика-и-типичные-ошибки)
+11. [Таблица активных сервисов](#11-таблица-активных-сервисов)
+
+---
+
+## 1. Архитектура: как Traefik работает в кластере
+
+### Схема
+
+```
+Пользователь
+ │
+ │ HTTP :10001, :10002, :10003, ... (порты 10000-10999)
+ ▼
+k8s-worker-01 (10.203.0.96)
+ │
+ │ hostPort (DaemonSet Pod слушает на хосте)
+ ▼
+Traefik Pod (namespace: traefik)
+ │
+ │ IngressRoute CRD → EntryPoint → Service
+ │
+ ├──► kubernetes-dashboard.kubernetes-dashboard:443
+ ├──► longhorn-frontend.longhorn-system:80
+ └──► my-service.my-namespace:8080
+```
+
+### Ключевые решения архитектуры
+
+| Параметр | Значение | Причина |
+|---|---|---|
+| Режим деплоя | **DaemonSet** | Pod на каждой worker-ноде; при добавлении нод — автоматически |
+| Тип доступа | **hostPort** | Прямое связывание порта Pod с портом хоста; не нужен NodePort или LoadBalancer |
+| Маршрутизация | **IngressRoute CRD** | Не Ingress; только CRD Traefik — полный контроль над entryPoint |
+| Namespace | `traefik` | Изолированный namespace; IngressRoute живут здесь, сервисы — в своих |
+| TLS | **выключен** | Внутренняя сеть, доверенные клиенты; TLS добавить при необходимости |
+| hostname routing | **нет** | Каждый сервис на своём порту; нет нужды в DNS-именах |
+| `allowCrossNamespace` | **true** | IngressRoute в `traefik` ссылается на Service в других namespace |
+
+### Диапазон портов
+
+```
+10000–10999 — зарезервировано для Traefik-сервисов
+ открыт на firewalld на всех worker-нодах
+```
+
+Порт `10000` — зарезервирован (не использовать).
+Порты `10001–10999` — свободны для сервисов, назначаются последовательно.
+
+---
+
+## 2. Единственный способ добавить сервис: traefik_port_map
+
+Вся топология Traefik определяется через одну переменную в `inventory/prod/group_vars/traefik.yml`.
+**Не создавайте IngressRoute и Middleware вручную** — они будут перезаписаны при следующем запуске плейбука.
+
+### Структура записи в traefik_port_map
+
+```yaml
+traefik_port_map:
+ - name: my-service # ≤15 символов! становится именем entryPoint и ресурсов
+ description: "Описание" # только для читаемости, не влияет на деплой
+ port: 10003 # уникальный порт из диапазона 10001-10999
+ backend:
+ namespace: my-namespace # namespace, где живёт целевой Service
+ service: my-service-svc # имя Service (не Pod, не Deployment)
+ port: 8080 # порт Service (не containerPort)
+ scheme: http # http или https (если backend использует TLS)
+ basicauth:
+ enabled: false # true — включить BasicAuth для этого маршрута
+ # secret_name: traefik-auth-my-service # нужно только если enabled: true
+```
+
+### Что происходит после добавления записи
+
+При запуске `ansible-playbook playbooks/setup_traefik.yml`:
+
+1. **Helm** перерендеривает `traefik-values.yml` — добавляет новый entryPoint `my-service` на порт 10003
+2. **Middleware** (если `basicauth.enabled: true`) — создаёт объект `Middleware` в namespace `traefik`
+3. **IngressRoute** — создаёт объект `IngressRoute` в namespace `traefik`, ссылающийся на backend Service
+4. **Helm upgrade** применяет изменения; Traefik Pod перезапускается (DaemonSet rolling update)
+
+---
+
+## 3. Пример 1: сервис без аутентификации
+
+**Сценарий:** развернули веб-приложение `my-app` в namespace `my-app`, хотим сделать его доступным на порту 10003.
+
+### Шаг 1: убедиться что Service существует
+
+```yaml
+# Это должно быть в манифестах приложения (fleet-repo или kubectl apply)
+apiVersion: v1
+kind: Service
+metadata:
+ name: my-app
+ namespace: my-app
+spec:
+ selector:
+ app: my-app
+ ports:
+ - port: 80
+ targetPort: 8080
+ type: ClusterIP # ClusterIP достаточно — Traefik обращается по внутренней сети
+```
+
+### Шаг 2: добавить запись в traefik_port_map
+
+```yaml
+# inventory/prod/group_vars/traefik.yml
+traefik_port_map:
+ # ... существующие записи ...
+
+ - name: my-app # ≤15 символов
+ description: "My Application"
+ port: 10003
+ backend:
+ namespace: my-app
+ service: my-app
+ port: 80
+ scheme: http
+ basicauth:
+ enabled: false
+```
+
+### Шаг 3: запустить плейбук
+
+```bash
+ansible-playbook -i inventory/prod playbooks/setup_traefik.yml
+```
+
+### Шаг 4: проверить доступность
+
+```bash
+# С любой машины в сети
+curl http://10.203.0.96:10003/
+
+# Проверить что IngressRoute создан
+kubectl get ingressroute -n traefik
+kubectl describe ingressroute route-my-app -n traefik
+```
+
+---
+
+## 4. Пример 2: сервис с BasicAuth
+
+**Сценарий:** Grafana в namespace `monitoring`, доступ только с паролем.
+
+### Шаг 1: создать Secret с учётными данными
+
+```bash
+# Сгенерировать хэш пароля и создать Secret вручную (один раз)
+kubectl create secret generic traefik-auth-grafana \
+ --from-literal=users="$(openssl passwd -apr1 'SecurePassword' | xargs -I{} echo 'admin:{}')" \
+ -n traefik
+```
+
+> Secret создаётся **в namespace `traefik`**, а не в namespace приложения.
+
+### Шаг 2: добавить запись
+
+```yaml
+# inventory/prod/group_vars/traefik.yml
+traefik_port_map:
+ - name: grafana
+ description: "Grafana Monitoring Dashboard"
+ port: 10004
+ backend:
+ namespace: monitoring
+ service: grafana
+ port: 3000
+ scheme: http
+ basicauth:
+ enabled: true
+ secret_name: traefik-auth-grafana # имя Secret из шага 1
+```
+
+### Шаг 3: запустить плейбук
+
+```bash
+ansible-playbook -i inventory/prod playbooks/setup_traefik.yml
+```
+
+Плейбук создаст `Middleware` объект:
+
+```yaml
+# Автогенерируется из basicauth-middleware.yml.j2
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: basicauth-grafana
+ namespace: traefik
+spec:
+ basicAuth:
+ secret: traefik-auth-grafana
+ removeHeader: true # не передавать Authorization header в backend
+```
+
+### Шаг 4: проверить
+
+```bash
+# Без пароля — должен вернуть 401
+curl -v http://10.203.0.96:10004/
+
+# С паролем — должен вернуть 200
+curl -u admin:SecurePassword http://10.203.0.96:10004/
+```
+
+---
+
+## 5. Пример 3: сервис в том же namespace что и Traefik
+
+**Сценарий:** какое-то приложение развёрнуто прямо в namespace `traefik` (нетипично, но возможно).
+
+Для сервисов в том же namespace `traefik` параметр `allowCrossNamespace` не нужен — он уже есть.
+Запись в `traefik_port_map` стандартная, просто `namespace: traefik`:
+
+```yaml
+ - name: whoami
+ description: "Debug: whoami service"
+ port: 10010
+ backend:
+ namespace: traefik # тот же namespace
+ service: whoami
+ port: 80
+ scheme: http
+ basicauth:
+ enabled: false
+```
+
+---
+
+## 6. Пример 4: несколько маршрутов для одного приложения
+
+**Сценарий:** у приложения есть отдельный API и UI, их нужно опубликовать на разных портах независимо (например, для разных уровней доступа).
+
+```yaml
+traefik_port_map:
+ # UI — с BasicAuth
+ - name: myapp-ui
+ description: "MyApp Frontend"
+ port: 10005
+ backend:
+ namespace: myapp
+ service: myapp-frontend
+ port: 80
+ scheme: http
+ basicauth:
+ enabled: true
+ secret_name: traefik-auth-myapp
+
+ # API — без BasicAuth (аутентификация на уровне приложения)
+ - name: myapp-api
+ description: "MyApp Backend API"
+ port: 10006
+ backend:
+ namespace: myapp
+ service: myapp-backend
+ port: 8080
+ scheme: http
+ basicauth:
+ enabled: false
+```
+
+> **Почему не один порт с path-routing?** Traefik в нашей конфигурации использует `PathPrefix(/)` для всех маршрутов — это намеренное упрощение. Каждый сервис получает свой порт. Path-routing возможен, но усложняет конфигурацию и не соответствует нашему стандарту порт-прокси.
+
+---
+
+## 7. Создание BasicAuth-секрета
+
+### Формат
+
+Secret должен содержать ключ `users` с htpasswd-строками (один пользователь на строку):
+
+```
+admin:$apr1$xyz$hashedpassword
+```
+
+### Один пользователь
+
+```bash
+kubectl create secret generic traefik-auth- \
+ --from-literal=users="$(openssl passwd -apr1 'PASSWORD' | xargs -I{} echo 'USERNAME:{}')" \
+ -n traefik
+```
+
+### Несколько пользователей
+
+```bash
+# Сгенерировать файл htpasswd
+htpasswd -c /tmp/auth admin # первый пользователь (создаёт файл)
+htpasswd /tmp/auth developer # второй пользователь (добавляет)
+
+# Создать Secret из файла
+kubectl create secret generic traefik-auth-myapp \
+ --from-file=users=/tmp/auth \
+ -n traefik
+
+rm /tmp/auth
+```
+
+### Обновить пароль существующего пользователя
+
+```bash
+# Удалить старый Secret и создать новый — Traefik подхватит без перезапуска
+kubectl delete secret traefik-auth-myapp -n traefik
+
+kubectl create secret generic traefik-auth-myapp \
+ --from-literal=users="$(openssl passwd -apr1 'NewPassword' | xargs -I{} echo 'admin:{}')" \
+ -n traefik
+```
+
+> Traefik читает Secret динамически. После замены Secret новые пароли начинают работать в течение 30-60 секунд без перезапуска Pod.
+
+### Именование секретов
+
+Формат: `traefik-auth-`, где `` совпадает с полем `name` в `traefik_port_map`.
+Пример: `name: grafana` → `secret_name: traefik-auth-grafana`.
+
+---
+
+## 8. Добавление нового сервиса: пошаговая инструкция
+
+```
+1. Выбрать свободный порт из диапазона 10001-10999
+ └─ Проверить таблицу активных сервисов в этом README
+
+2. Придумать name (≤15 символов, только строчные буквы, цифры, дефис)
+ └─ Пример: my-service, grafana, pgadmin, api-v2
+
+3. Убедиться что Service существует в кластере
+ └─ kubectl get svc -n
+
+4. (если BasicAuth нужен) Создать Secret в namespace traefik
+ └─ kubectl create secret generic traefik-auth- ...
+
+5. Добавить запись в inventory/prod/group_vars/traefik.yml
+
+6. Обновить таблицу в этом README (раздел 11)
+
+7. Запустить плейбук:
+ └─ ansible-playbook -i inventory/prod playbooks/setup_traefik.yml
+
+8. Проверить:
+ └─ curl http://10.203.0.96:/
+ └─ kubectl get ingressroute -n traefik
+ └─ kubectl describe ingressroute route- -n traefik
+```
+
+---
+
+## 9. Ограничения и правила именования
+
+### Критические ограничения
+
+| Ограничение | Причина | Последствие нарушения |
+|---|---|---|
+| `name` ≤ 15 символов | Используется как имя Kubernetes container port | `helm upgrade` падает с ошибкой валидации |
+| `name` только `[a-z0-9-]` | Kubernetes naming convention | Ошибка применения манифеста |
+| Порты уникальны | hostPort — один порт = один Pod на ноде | Конфликт портов, Pod не стартует |
+| Secret в namespace `traefik` | IngressRoute и Middleware в `traefik` | Middleware не найдёт Secret, маршрут недоступен (404) |
+| Service тип ClusterIP | Достаточно для Traefik → Service связки | NodePort и LoadBalancer избыточны |
+
+### Что происходит при отсутствии BasicAuth-секрета
+
+Если Secret указан в `secret_name`, но не существует:
+- Traefik **полностью отключает маршрут** (возвращает 404, не 401)
+- Другие маршруты продолжают работать
+- В логах Traefik: `middleware "traefik/basicauth-X" does not exist`
+
+**Всегда создавайте Secret до запуска плейбука.**
+
+### Rolling update DaemonSet: возможное зависание
+
+При обновлении Traefik (новый Helm values) DaemonSet пересоздаёт Pod на каждой ноде.
+Если старый Pod не успел завершиться, новый не может занять hostPort:
+
+```bash
+# Симптом: Pod застрял в Pending
+kubectl get pods -n traefik -o wide
+
+# Решение: принудительно удалить старые Pod
+kubectl delete pod -n traefik -l app.kubernetes.io/name=traefik
+```
+
+---
+
+## 10. Диагностика и типичные ошибки
+
+### Основные команды
+
+```bash
+# Список всех IngressRoute
+kubectl get ingressroute -n traefik
+
+# Детали конкретного маршрута
+kubectl describe ingressroute route-my-service -n traefik
+
+# Список Middleware
+kubectl get middleware -n traefik
+
+# Логи Traefik (последние 100 строк)
+kubectl logs -n traefik -l app.kubernetes.io/name=traefik --tail=100
+
+# Access логи (каждый запрос)
+kubectl logs -n traefik -l app.kubernetes.io/name=traefik --tail=50 | grep '"method"'
+
+# Traefik Dashboard (если включён — у нас выключен по умолчанию)
+# kubectl port-forward -n traefik svc/traefik 9000:9000
+# open http://localhost:9000/dashboard/
+
+# Проверить что порт слушается на хосте
+# (выполнить через SSH на worker-ноде)
+ss -tlnp | grep 10003
+
+# Проверить firewalld
+firewall-cmd --list-ports | grep 10000-10999
+```
+
+### Таблица ошибок
+
+| Симптом | Причина | Решение |
+|---|---|---|
+| 404 на порту | IngressRoute не создан | Проверить `kubectl get ingressroute -n traefik` |
+| 404 при включённом BasicAuth | Secret не существует | Создать Secret в namespace `traefik` |
+| 401 без запроса пароля | BasicAuth не применился | Проверить `kubectl get middleware -n traefik` |
+| Connection refused | Порт не открыт на firewall | `firewall-cmd --add-port=10003/tcp --permanent && firewall-cmd --reload` |
+| Connection refused | Traefik Pod не запущен | `kubectl get pods -n traefik` |
+| Bad Gateway (502) | Backend Service недоступен | `kubectl get svc -n `, проверить Endpoints |
+| `helm upgrade` ошибка | Имя `name` > 15 символов | Сократить имя в `traefik_port_map` |
+| Pod застрял в Pending | hostPort конфликт | Удалить старый Pod вручную |
+
+### Проверка backend: доступен ли Service из Pod Traefik
+
+```bash
+# Запустить временный Pod и обратиться к backend
+kubectl run -it --rm debug --image=curlimages/curl --restart=Never \
+ -- curl -v http://my-service.my-namespace.svc.cluster.local:80/health
+
+# Или через exec в Pod Traefik
+kubectl exec -n traefik \
+ $(kubectl get pod -n traefik -l app.kubernetes.io/name=traefik -o name | head -1) \
+ -- wget -qO- http://my-service.my-namespace.svc.cluster.local:80/health
+```
+
+### Проверка конфигурации без применения
+
+```bash
+# Посмотреть что сгенерирует шаблон (dry-run Ansible)
+ansible-playbook -i inventory/prod playbooks/setup_traefik.yml --check --diff
+
+# Просмотреть текущий Helm values в кластере
+helm get values traefik -n traefik
+```
+
+---
+
+## 11. Таблица активных сервисов
+
+Обновлять при каждом добавлении или удалении сервиса.
+
+| Порт | Name | Сервис | Namespace | BasicAuth | Secret |
+|---|---|---|---|---|---|
+| 10001 | `k8s-dashboard` | `kubernetes-dashboard:443` | `kubernetes-dashboard` | нет | — |
+| 10002 | `longhorn-ui` | `longhorn-frontend:80` | `longhorn-system` | **да** | `traefik-auth-longhorn` |
+
+**Следующий свободный порт: 10003**
diff --git a/roles/traefik/defaults/main.yml b/roles/traefik/defaults/main.yml
new file mode 100644
index 0000000..74e04d5
--- /dev/null
+++ b/roles/traefik/defaults/main.yml
@@ -0,0 +1,5 @@
+---
+traefik_chart_version: "32.1.0"
+traefik_namespace: traefik
+traefik_kubeconfig: "/home/{{ ansible_user }}/.kube/config"
+traefik_port_map: []
diff --git a/roles/traefik/tasks/firewall.yml b/roles/traefik/tasks/firewall.yml
new file mode 100644
index 0000000..d8bb110
--- /dev/null
+++ b/roles/traefik/tasks/firewall.yml
@@ -0,0 +1,21 @@
+---
+- name: Firewall | Open Traefik service port range on workers
+ ansible.posix.firewalld:
+ port: "10000-10999/tcp"
+ permanent: true
+ state: enabled
+ immediate: true
+ delegate_to: "{{ item }}"
+ loop: "{{ groups['workers'] }}"
+
+- name: Firewall | Trust CNI interfaces on workers
+ ansible.posix.firewalld:
+ zone: trusted
+ interface: "{{ item.1 }}"
+ permanent: true
+ state: enabled
+ immediate: true
+ loop: "{{ groups['workers'] | product(['flannel.1', 'cni0']) | list }}"
+ loop_control:
+ label: "{{ item.0 }} {{ item.1 }}"
+ delegate_to: "{{ item.0 }}"
diff --git a/roles/traefik/tasks/helm.yml b/roles/traefik/tasks/helm.yml
new file mode 100644
index 0000000..37d6143
--- /dev/null
+++ b/roles/traefik/tasks/helm.yml
@@ -0,0 +1,51 @@
+---
+- name: Helm | Add Traefik chart repository
+ ansible.builtin.command: helm repo add traefik https://traefik.github.io/charts
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_repo_add
+ changed_when: "'already exists' not in _helm_repo_add.stdout"
+ failed_when: _helm_repo_add.rc != 0 and 'already exists' not in _helm_repo_add.stdout
+
+- name: Helm | Update chart repositories
+ ansible.builtin.command: helm repo update
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ changed_when: false
+
+- name: Helm | Render Traefik values
+ ansible.builtin.template:
+ src: traefik-values.yml.j2
+ dest: /tmp/traefik-values.yml
+ mode: "0600"
+
+- name: Helm | Install or upgrade Traefik
+ ansible.builtin.command: >
+ helm upgrade --install traefik traefik/traefik
+ --namespace {{ traefik_namespace }}
+ --create-namespace
+ --version {{ traefik_chart_version }}
+ --values /tmp/traefik-values.yml
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ register: _helm_install
+ changed_when: "'STATUS: deployed' in _helm_install.stdout or 'has been upgraded' in _helm_install.stdout"
+
+- name: Helm | Force rollout restart to apply new hostPorts
+ ansible.builtin.command: >
+ kubectl rollout restart daemonset/traefik -n {{ traefik_namespace }}
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ when: _helm_install.changed
+
+- name: Helm | Wait for Traefik rollout to complete
+ ansible.builtin.command: >
+ kubectl rollout status daemonset/traefik -n {{ traefik_namespace }} --timeout=120s
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ when: _helm_install.changed
diff --git a/roles/traefik/tasks/main.yml b/roles/traefik/tasks/main.yml
new file mode 100644
index 0000000..0ac1158
--- /dev/null
+++ b/roles/traefik/tasks/main.yml
@@ -0,0 +1,9 @@
+---
+- name: Install Traefik via Helm
+ ansible.builtin.include_tasks: helm.yml
+
+- name: Apply BasicAuth Middleware CRDs
+ ansible.builtin.include_tasks: middleware.yml
+
+- name: Apply IngressRoute CRDs
+ ansible.builtin.include_tasks: routes.yml
diff --git a/roles/traefik/tasks/middleware.yml b/roles/traefik/tasks/middleware.yml
new file mode 100644
index 0000000..6f485f1
--- /dev/null
+++ b/roles/traefik/tasks/middleware.yml
@@ -0,0 +1,21 @@
+---
+- name: Middleware | Render BasicAuth CRD manifests
+ ansible.builtin.template:
+ src: basicauth-middleware.yml.j2
+ dest: "/tmp/traefik-middleware-{{ item.name }}.yml"
+ mode: "0600"
+ loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}"
+ loop_control:
+ label: "{{ item.name }}"
+
+- name: Middleware | Apply BasicAuth CRD manifests
+ ansible.builtin.command: >
+ kubectl apply -f /tmp/traefik-middleware-{{ item.name }}.yml
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ loop: "{{ traefik_port_map | selectattr('basicauth.enabled', 'defined') | selectattr('basicauth.enabled') | list }}"
+ loop_control:
+ label: "{{ item.name }}"
+ register: _mw_apply
+ changed_when: "'configured' in _mw_apply.stdout or 'created' in _mw_apply.stdout"
diff --git a/roles/traefik/tasks/routes.yml b/roles/traefik/tasks/routes.yml
new file mode 100644
index 0000000..1bf90eb
--- /dev/null
+++ b/roles/traefik/tasks/routes.yml
@@ -0,0 +1,42 @@
+---
+- name: Routes | Render HTTP IngressRoute CRD manifests
+ ansible.builtin.template:
+ src: ingressroute.yml.j2
+ dest: "/tmp/traefik-route-{{ item.name }}.yml"
+ mode: "0600"
+ loop: "{{ (traefik_port_map | rejectattr('protocol', 'defined') | list) + (traefik_port_map | selectattr('protocol', 'defined') | rejectattr('protocol', 'equalto', 'tcp') | list) }}"
+ loop_control:
+ label: "{{ item.name }}"
+
+- name: Routes | Apply HTTP IngressRoute CRD manifests
+ ansible.builtin.command: >
+ kubectl apply -f /tmp/traefik-route-{{ item.name }}.yml
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ loop: "{{ (traefik_port_map | rejectattr('protocol', 'defined') | list) + (traefik_port_map | selectattr('protocol', 'defined') | rejectattr('protocol', 'equalto', 'tcp') | list) }}"
+ loop_control:
+ label: "{{ item.name }}"
+ register: _route_apply
+ changed_when: "'configured' in _route_apply.stdout or 'created' in _route_apply.stdout"
+
+- name: Routes | Render TCP IngressRouteTCP CRD manifests
+ ansible.builtin.template:
+ src: ingressroute-tcp.yml.j2
+ dest: "/tmp/traefik-route-{{ item.name }}.yml"
+ mode: "0600"
+ loop: "{{ traefik_port_map | selectattr('protocol', 'defined') | selectattr('protocol', 'equalto', 'tcp') | list }}"
+ loop_control:
+ label: "{{ item.name }}"
+
+- name: Routes | Apply TCP IngressRouteTCP CRD manifests
+ ansible.builtin.command: >
+ kubectl apply -f /tmp/traefik-route-{{ item.name }}.yml
+ environment:
+ KUBECONFIG: "{{ traefik_kubeconfig }}"
+ PATH: "/usr/local/bin:/usr/bin:/bin"
+ loop: "{{ traefik_port_map | selectattr('protocol', 'defined') | selectattr('protocol', 'equalto', 'tcp') | list }}"
+ loop_control:
+ label: "{{ item.name }}"
+ register: _route_tcp_apply
+ changed_when: "'configured' in _route_tcp_apply.stdout or 'created' in _route_tcp_apply.stdout"
diff --git a/roles/traefik/templates/basicauth-middleware.yml.j2 b/roles/traefik/templates/basicauth-middleware.yml.j2
new file mode 100644
index 0000000..a541145
--- /dev/null
+++ b/roles/traefik/templates/basicauth-middleware.yml.j2
@@ -0,0 +1,9 @@
+apiVersion: traefik.io/v1alpha1
+kind: Middleware
+metadata:
+ name: basicauth-{{ item.name }}
+ namespace: {{ traefik_namespace }}
+spec:
+ basicAuth:
+ secret: {{ item.basicauth.secret_name }}
+ removeHeader: true
diff --git a/roles/traefik/templates/ingressroute-tcp.yml.j2 b/roles/traefik/templates/ingressroute-tcp.yml.j2
new file mode 100644
index 0000000..ff48b83
--- /dev/null
+++ b/roles/traefik/templates/ingressroute-tcp.yml.j2
@@ -0,0 +1,14 @@
+apiVersion: traefik.io/v1alpha1
+kind: IngressRouteTCP
+metadata:
+ name: route-{{ item.name }}
+ namespace: {{ traefik_namespace }}
+spec:
+ entryPoints:
+ - {{ item.name }}
+ routes:
+ - match: HostSNI(`*`)
+ services:
+ - name: {{ item.backend.service }}
+ namespace: {{ item.backend.namespace }}
+ port: {{ item.backend.port }}
diff --git a/roles/traefik/templates/ingressroute.yml.j2 b/roles/traefik/templates/ingressroute.yml.j2
new file mode 100644
index 0000000..7e44193
--- /dev/null
+++ b/roles/traefik/templates/ingressroute.yml.j2
@@ -0,0 +1,21 @@
+apiVersion: traefik.io/v1alpha1
+kind: IngressRoute
+metadata:
+ name: route-{{ item.name }}
+ namespace: {{ traefik_namespace }}
+spec:
+ entryPoints:
+ - {{ item.name }}
+ routes:
+ - match: PathPrefix(`/`)
+ kind: Rule
+ services:
+ - name: {{ item.backend.service }}
+ namespace: {{ item.backend.namespace }}
+ port: {{ item.backend.port }}
+ scheme: {{ item.backend.scheme }}
+{% if item.basicauth.enabled | default(false) %}
+ middlewares:
+ - name: basicauth-{{ item.name }}
+ namespace: {{ traefik_namespace }}
+{% endif %}
diff --git a/roles/traefik/templates/traefik-values.yml.j2 b/roles/traefik/templates/traefik-values.yml.j2
new file mode 100644
index 0000000..5708b54
--- /dev/null
+++ b/roles/traefik/templates/traefik-values.yml.j2
@@ -0,0 +1,52 @@
+deployment:
+ kind: DaemonSet
+ podLabels: {}
+
+updateStrategy:
+ type: RollingUpdate
+ rollingUpdate:
+ maxUnavailable: 1
+ maxSurge: 0
+
+nodeSelector:
+ kubernetes.io/os: linux
+
+ports:
+{% for svc in traefik_port_map %}
+ {{ svc.name }}:
+ port: {{ svc.port }}
+ hostPort: {{ svc.port }}
+ expose:
+ default: true
+ exposedPort: {{ svc.port }}
+ protocol: TCP
+{% endfor %}
+ web:
+ expose:
+ default: false
+ websecure:
+ expose:
+ default: false
+
+service:
+ enabled: false
+
+ingressRoute:
+ dashboard:
+ enabled: false
+
+providers:
+ kubernetesCRD:
+ enabled: true
+ allowCrossNamespace: true
+ kubernetesIngress:
+ enabled: false
+
+persistence:
+ enabled: false
+
+logs:
+ general:
+ level: INFO
+ access:
+ enabled: true