initial commit from gigacoms

This commit is contained in:
a.kazantsev 2026-07-15 11:14:21 +03:00
parent 4142aaea60
commit a82c1ca025
155 changed files with 13549 additions and 1 deletions

39
.ansible-lint Normal file
View file

@ -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]

13
.gitignore vendored Normal file
View file

@ -0,0 +1,13 @@
LOCAL_RUN.md
collections/
*.retry
/tmp/
*.log
host_facts/
public/
.ansible/
.claude/
.mcp.json
/tmp/ansible_facts_cache/
*.pyc
__pycache__/

308
.gitlab-ci.yml Normal file
View file

@ -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

625
README.md
View file

@ -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` |
<details>
<summary>Переменные k8s_control_plane</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `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-узел |
</details>
<details>
<summary>Переменные k8s_worker</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `k8s_version` | `1.33` | Должна совпадать с control plane |
| `worker_join_source_host` | первый хост `control_plane` | Откуда читать join-команду |
</details>
<details>
<summary>Переменные k8s_manager</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `dashboard_insecure` | `false` | HTTP-режим Dashboard (переопределяется в `true` в group_vars) |
| `dashboard_manifest_url` | GitHub raw v2.7.0 | Без зависимости от CDN |
| `k8s_manager_upgrade_packages` | `false` | Полное обновление ОС (opt-in) |
</details>
---
### `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 <host> gpu=nvidia` для каждого узла `gpu_workers`; `kubectl apply` манифеста NVIDIA k8s-device-plugin |
<details>
<summary>Переменные gpu_prereqs / gpu_device_plugin</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `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 |
</details>
> Порядок обязателен: `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`.
<details>
<summary>Переменные gpu_model_storage</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `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` | Файловая система |
</details>
> **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.
<details>
<summary>Переменные</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `longhorn_chart_version` | `1.7.2` | Версия Helm-чарта |
| `longhorn_namespace` | `longhorn-system` | Namespace |
| `longhorn_default_replica_count` | `1` | Реплик на том; поднять до 23 при добавлении 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 |
</details>
> **Стандарт работы с хранилищем** (PVC, StatefulSet, реплики, бэкапы, диагностика):
> [`roles/longhorn/README.md`](roles/longhorn/README.md)
---
### `traefik`
HTTP/TCP port-proxy. DaemonSet на worker-нодах. Каждый сервис получает выделенный TCP-порт из диапазона 1000110999. Вся топология определяется через `traefik_port_map` в `inventory/prod/group_vars/traefik.yml`. Поддерживаются два режима маршрутизации: HTTP (`IngressRoute`, поле `protocol` не задаётся) и raw TCP (`IngressRouteTCP` с `HostSNI("*")`, `protocol: tcp` в port_map).
| Задача | Что делает |
|---|---|
| `firewall.yml` | Открывает 1000010999/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 |
<details>
<summary>Переменные</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `traefik_chart_version` | `32.1.0` | Версия Helm-чарта |
| `traefik_namespace` | `traefik` | Namespace |
| `traefik_port_map` | `[]` | Список сервисов; переопределяется в `group_vars/traefik.yml` |
</details>
> **Стандарт публикации сервисов** (добавление порта, 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`
<details>
<summary>Переменные</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `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) |
</details>
> **Стандарт 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
```
<details>
<summary>Переменные</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `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]` | Бакеты, создаваемые при старте |
</details>
> **Стандарт объектного хранилища 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
```
<details>
<summary>Переменные</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `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 |
</details>
> **Стандарт централизованного сбора логов** (архитектура 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
```
<details>
<summary>Переменные</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `sealed_secrets_chart_version` | `2.16.1` | Версия Helm-чарта |
| `sealed_secrets_namespace` | `kube-system` | Namespace контроллера |
| `sealed_secrets_cli_version` | `""` (latest) | Версия kubeseal CLI; пустая — берёт latest с GitHub |
</details>
---
### `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`.
<details>
<summary>Переменные</summary>
| Переменная | Значение по умолчанию | Описание |
|---|---|---|
| `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 |
</details>
---
## 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
Каждый сервис получает выделенный порт (1000110999). Нет hostname-routing, нет TLS — клиент подключается по `http://<worker-ip>:<port>`. 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/<group>.yml` (если новая группа)
3. Создать роль `roles/<role>/` со стандартной структурой
4. Создать `roles/<role>/README.md` — подробный корп. стандарт использования компонента
5. Создать `playbooks/<operation>.yml`
6. Добавить CI/CD-задачу в `.gitlab-ci.yml` (`stage: setup`, `resource_group: production`)
7. Обновить **этот README**: краткое описание роли + ссылка на `roles/<role>/README.md`
8. Обновить `CLAUDE.md`: добавить роль в структуру репозитория и ключевые решения

17
ansible.cfg Normal file
View file

@ -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

View file

@ -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) }}"

View file

@ -0,0 +1,4 @@
---
k8s_version: "1.33"
cni_plugin: "flannel"
kubeconfig_fetch_to_managers: true

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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

26
inventory/prod/hosts.yml Normal file
View file

@ -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

View file

@ -0,0 +1,6 @@
---
- name: Setup Kubernetes control plane
hosts: control_plane
gather_facts: true
roles:
- k8s_control_plane

5
playbooks/setup_flux.yml Normal file
View file

@ -0,0 +1,5 @@
---
- name: Flux | Install CLI and bootstrap with GitLab
hosts: manager_nodes
roles:
- flux

View file

@ -0,0 +1,6 @@
---
- name: Setup developer access token for namespace
hosts: manager_nodes
gather_facts: false
roles:
- role: k8s_gbm_dev

View file

@ -0,0 +1,5 @@
---
- name: GitLab Agent | Push agent config to fleet repo and install agentk
hosts: manager_nodes
roles:
- gitlab_agent

13
playbooks/setup_gpu.yml Normal file
View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,6 @@
---
- name: Setup Kubernetes manager nodes
hosts: manager_nodes
gather_facts: true
roles:
- k8s_manager

View file

@ -0,0 +1,6 @@
---
- name: MinIO | Deploy object storage
hosts: manager_nodes
become: false
roles:
- minio

View file

@ -0,0 +1,6 @@
---
- name: Deploy Sealed Secrets (kubeseal controller + CLI)
hosts: manager_nodes
become: true
roles:
- sealed_secrets

View file

@ -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

View file

@ -0,0 +1,6 @@
---
- name: Setup Kubernetes worker nodes
hosts: workers
gather_facts: true
roles:
- k8s_worker

8
requirements.yml Normal file
View file

@ -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"

496
research/apps/hd-portal.md Normal file
View file

@ -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=<deploy_token_user> \
--docker-password=<deploy_token_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 <postgres_container_id> \
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`.

View file

@ -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=<gitlab_deploy_token_user> \
--docker-password=<gitlab_deploy_token_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` после подтверждения работы.

1152
research/flux.md Normal file

File diff suppressed because it is too large Load diff

1275
research/logging/plg.md Normal file

File diff suppressed because it is too large Load diff

453
research/longhorn.md Normal file
View file

@ -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** (выделенный) | 12 |
| Репликация | **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=<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 <node-name> -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 <node-name> -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-name> \
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 <volume-name> -n longhorn-system \
--type merge \
-p '{"spec":{"numberOfReplicas":3}}'
# 4. Мониторинг перестройки
kubectl get volume <volume-name> -n longhorn-system \
-o jsonpath='{.status.state} {.status.replicaCount}'
# Ожидаем: "healthy 3"
```
#### Время перестройки (ориентир)
| Объём тома | Время rebuild |
|---|---|
| 10 GiB | 25 минут |
| 100 GiB | 1020 минут |
| 1 TiB | 14 часа |
#### Типичные проблемы при переходе 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/)

788
research/minio.md Normal file
View file

@ -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 <minio-pvc-volume-name>
# 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)

View file

@ -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`

494
research/traefik.md Normal file
View file

@ -0,0 +1,494 @@
# Traefik: port-proxy режим для внутренних сервисов
## 1. Контекст и цель
**Режим работы:** Traefik как HTTP-прокси на отдельном порту для каждого внутреннего сервиса.
Вместо маршрутизации по доменному имени каждый сервис получает выделенный порт на worker-ноде. Клиент обращается `http://10.203.0.96:<PORT>` и попадает напрямую на нужный сервис.
**Что не используем:** 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-ноде, диапазон `1000010999` |
| `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 |
| 1000310999 | (резерв) | — | — | Будущие сервисы |
---
## 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-<name> \
--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 занят | Диапазон 1000010999 специфический; проверить `ss -tlnp` перед деплоем |
| DaemonSet пересоздаётся при добавлении нового порта в Helm values | Краткий downtime ~10с; все существующие соединения рвутся |
| Dashboard Service имеет `port: 443` но трафик HTTP | `scheme: http` обязателен; иначе Traefik попытается установить TLS и получит ошибку рукопожатия |

1501
roles/flux/README.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -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('') }}"

View file

@ -0,0 +1 @@
---

11
roles/flux/meta/main.yml Normal file
View file

@ -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: []

View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -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/<name>/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: []

View file

@ -0,0 +1 @@
---

View file

@ -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: []

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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"

View file

@ -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: []

View file

@ -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"

View file

@ -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

View file

@ -0,0 +1,6 @@
---
- name: Label nodes
ansible.builtin.include_tasks: label.yml
- name: Install device plugin
ansible.builtin.include_tasks: deploy.yml

View file

@ -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/<group>.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 <device>` перед созданием массива — повторный запуск не
пересоздаёт 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`) и
разобраться, почему массив не успел подняться до попытки монтирования при следующем прогоне роли.

View file

@ -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

View file

@ -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: []

View file

@ -0,0 +1,5 @@
---
- name: Filesystem | Format RAID array
community.general.filesystem:
fstype: "{{ gpu_model_storage_fstype }}"
dev: "{{ gpu_model_storage_raid_device }}"

View file

@ -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

View file

@ -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

View file

@ -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 }}"

View file

@ -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 }}"

View file

@ -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"

View file

@ -0,0 +1,6 @@
---
- name: Restart containerd
ansible.builtin.systemd:
name: containerd
state: restarted
daemon_reload: true

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 }}"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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
============================================================

View file

@ -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"]

View file

@ -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 }}

View file

@ -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 }}

View file

@ -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 }}

View file

@ -0,0 +1,6 @@
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ dev_access_sa_name }}
namespace: {{ dev_access_namespace }}

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,6 @@
---
- name: reload bashrc
ansible.builtin.command: source /etc/bashrc
args:
executable: /bin/bash
become: false

View file

@ -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: []

View file

@ -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
============================================================

View file

@ -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

View file

@ -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 }}

View file

@ -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] }}"

View file

@ -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

View file

@ -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: []

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

Some files were not shown because too many files have changed in this diff Show more