golang

Kubernetes и FluxCD: Восстановление кластера с хранением состояния, дампов и секретов в S3

  • четверг, 5 февраля 2026 г. в 00:00:06
https://habr.com/ru/articles/992482/

При проектировании инфраструктуры часто возникает задача поднять кластер «с чистого листа». Безусловно, Terraform и Ansible — это стандарт индустрии. Однако мне нужен был процесс быстрого получения готового кластера K8s, полностью независимый от локального окружения, чтобы запуск не требовал предварительной подготовки версий библиотек, интерпретаторов или наличия локальных файлов конфигурации.

В этой реализации я намеренно упростил работу с конфигурацией: параметры виртуальных машин (CPU, RAM, диски) описаны прямо в коде (см. config.go). Я решил, что проектирование гибкой системы внешних конфигов можно оставить на потом, чтобы на данном этапе не отвлекаться и сфокусироваться на главной задаче — отладке самого механизма восстановления и связности компонентов.

Я реализовал подход полной автоматизации с использованием технологий, которыми увлекаюсь. Суть проста: на машине CI-раннера нет ничего, кроме одного бинарного файла. Всё состояние кластера (IP-адреса, ID дисков), дампы баз данных и зашифрованные секреты хранятся в S3. Это позволяет восстановить кластер в исходное состояние даже после полного удаления, просто вытянув актуальное состояние из облачного хранилища.

В этой статье я поделюсь опытом создания кастомного CLI для подготовки инфраструктуры и использования FluxCD для развертывания приложений. А для проверки результата я использую Yandex Cloud Managed Kubernetes, чтобы запустить K6 Operator и дать внешнюю нагрузку в 1000 пользователей на восстановленные сервисы.


Часть 1: Инфраструктура и CLI

Философия «Чистого клиента» и роль S3

Главная идея — перенести всю сложность настройки с локальной машины в облако. Но если я не храню ключи и конфиги локально, где они должны жить?

Разделение кода и секретов
Я решил не хранить секреты в Git-репозитории даже в зашифрованном виде. Вместо этого я использую S3.

Схема работы выглядит так:

  1. Сохраненные объекты: Сами секреты (пароли БД, токены API) лежат в бакете S3, предварительно зашифрованные с помощью SOPS.

  2. Ключи доступа: При запуске пайплайна необходимые ключи (AGE для дешифровки и креды для доступа к S3) передаются в CLI через переменные окружения. CLI не генерирует их, а лишь транслирует в кластер.

  3. FluxCD: Используя эти ключи, Flux подключается к S3-бакету как к источнику (Source), скачивает зашифрованные файлы, расшифровывает их на лету и применяет в кластер.

Таким образом, в Git остаются только чистые манифесты, а все чувствительные данные в s3.

Runner: Свой контейнерный рантайм в 300 строк кода

Второй аспект — удаленное исполнение. Обычно, чтобы запустить Ansible или Kubespray, на машине нужен Docker. Но я хотел нулевых зависимостей.

Я реализовал интересный паттерн в cmd/runner/main.go: мой Go-бинарник сам является контейнерным движком.

  • Self-Execution: При запуске бинарник «клонирует» сам себя, используя системные вызовы Linux (syscall.CLONE_NEWNS, CLONE_NEWPID). Это создает изолированное пространство имен.

  • Свой RootFS: Runner скачивает подготовленный tar-архив с образом (содержащим Ansible, Python и все библиотеки) из нашего S3 или Container Registry.

  • Chroot: Программа распаковывает образ в память/tmpfs и делает syscall.Chroot.

В итоге я получаю полноценное изолированное окружение для запуска Kubespray без установки Docker, Podman или containerd. Всё, что нужно — это ядро Linux.

Фактор надежности: GNU Screen
Развертывание кластера — процесс долгий (30–130 минут). Если сеть дисконектится, SSH-сессия оборвется, и Ansible остановится на полпути.
Мой CLI запускает Runner внутри сессии GNU Screen на Bastion-хосте:

  1. CLI подключается по SSH и создает сессию screen -dmS.

  2. Внутри скрина стартует Runner.

  3. CLI отключается, но процесс живет на сервере. Я могу в любой момент переподключиться и посмотреть логи.

Этап 1: Синхронизация состояния (State Persistence)

Одна из проблем динамической инфраструктуры — разрыв контекста. Мы создали сервер, но Ansible еще не знает его IP. Облако выдало диск, но нам нужно знать, как оно назвало устройство (/dev/vdb или /dev/vdc), чтобы корректно его отформатировать.

CLI решает это через слой состояния в S3 (state.json):

  1. Создание: Запрос в API облака.

  2. Фиксация: Сбор фактических данных (IP, UUID дисков) и сохранение в S3.

  3. Использование: Генерация inventory для Ansible на основе стейта.

Инвентори рендерится из константы inventoryTmpl

Это гарантирует, что команда -mount точно знает, какой диск форматировать, потому что эта информация была получена напрямую от провайдера.

Подготовка топологии для Stateful-нагрузок

FluxCD отлично справляется с деплоем, но базе данных нужна гарантия производительности и строгая привязка к данным. Я заложил логику Service Discovery на уровне инфраструктуры прямо в конфигурацию (config.go):

//...
			{
				NamePrefix: "postgresql",
				Role:       "worker",
				Instances: map[int]InstanceConfig{
					1: {
						Enabled: true,
						// Лейбл для привязки конкретного Pod-а к конкретному диску
						Labels: map[string]string{ "postgresqlinstance": "num1" },
					},
					// ... node 2, node 3
				},
				Disks: []Disk{
					{Size: 20, Bootable: true, Type: "storage"},
					{Size: 9, Bootable: false, Type: "local"}, // Диск для данных
				},
				Labels: map[string]string{ "postgresqlnode": "yesnaff" },
				// Taint, чтобы чужие приложения (API, Frontend) сюда не попали
				Taints: []string{ "postgresqltaint=yestaint:NoSchedule" },
			},
// ...

Зачем это нужно для FluxCD?

  1. Изоляция (Taints): Строка postgresqltaint создает «забор». Обычные приложения не смогут запуститься на этих нодах и отнять ресурсы у базы. В манифестах Flux для PostgreSQL прописаны соответствующие tolerations.

  2. Привязка к данным (Affinity): Метка postgresqlinstance: num1 критически важна. Мой скрипт монтирует диск с данными именно на ноду num1. Flux, применяя Helm-чарт, использует nodeAffinity, чтобы гарантированно запустить реплику базы именно там, где лежат её данные.


Часть 2: FluxCD и Оркестрация

Этап 2: Bootstrap секретов и Flux

Самый тонкий момент — старт. Чтобы Flux начал работать, ему нужны секреты (доступ к S3, ключ дешифровки SOPS), но их нет в пустом кластере. CLI решает это императивно, используя client-go для создания Kubernetes Secrets из переменных окружения раннера (SOPS_AGE_KEY, S3_ACCESS_KEY и т.д.).

Процесс подготовки ключей (локально):

Генерация ключа в каталоге отличающегося от ./sops

age-keygen -o keys.txt

cat keys.txt|grep "public key"
public key: age1XXXXXXXXXXXXXXXXXXXX

например наш рабочий каталог ./sops
В нём создаём файл .sops.yaml и добавляем в него “public key” из keys.txt

# .sops.yaml 
creation_rules:
 - path_regex: .*.secret.yaml
   encrypted_regex: ^(data|stringData)$ # Шифровать только эти поля
   age: >-
    age1XXXXXXXXXXXXXXXXXXXX

Шифрование файла секретов (всегда делаю копию без "secret" в имени файла)

sops --encrypt --in-place my-secret.yaml

Загрузка в S3

s3cmd put my-secret.yaml s3://bucket/test/mynamespace/my-secret.yaml

Во время деплоя (в пайплайне):

Одной командой: установка Flux -> инъекция ключей в неймспейс flux-system

ops-cli -flux -name test

Переменные окружения прокидываются из CI (GitHub Secrets)

SOPS_AGE_KEY="<Содержимое keys.txt>"

Вариант если секрет создавать через kubectl 

cat ~/age-sops/keys.txt | kubectl create secret generic sops-age --namespace=flux-system --from-file=age.agekey=/dev/stdin

Доступы для скачивания и восстановления всех остальных секретов

export S3_ACCESS_KEY="..."

export S3_SECRET_KEY="..."

Манифесты для загрузки и расшифровки секретов здесь

Эти два секрета(ACME_EMAIL, DOMAIN) можно было бы загрузить на s3 и описать в fluxcd/test/infra/base/sops, но оставил во время отладки CLI на шаге c флагом “-flux”, можно перенести в манифесты для Flux потом  

export ACME_EMAIL="..." # прячем email для “Let's Encrypt”

export DOMAIN="..." # если нужно прячем домен, в манифестах тогда так ${DOMAIN}

Это позволяет разорвать замкнутый круг: мы даем Flux минимально необходимые права и ключи, после чего он самостоятельно выкачивает остальной массив секретов из S3, расшифровывает их и настраивает кластер.

Этап 3: Управление зависимостями (Пример на Saleor)

Для проверки подхода я развернул стек Saleor (e-commerce). Здесь важен порядок запуска: инфраструктурные сервисы (PostgreSQL, Redis) должны быть готовы до старта приложения.

FluxCD через манифесты (dependsOn) позволяет декларативно описать этот порядок. Чтобы поды приложения не ушли в циклическую перезагрузку (CrashLoopBackOff) в ожидании базы данных, нужна “Динамическая конфигурация” про неё далее(Этап 4).

Этап 4: Пайплайн и Динамическая конфигурация

GitHub Actions в моей схеме управляет режимами работы кластера. В Git хранятся базовые манифесты, но поведение (например, "Восстановление из бэкапа" или "Обычная работа") меняется динамически.

Пайплайн использует CLI для создания ConfigMap-ов, которые перекрывают настройки Helm-чартов «на лету» через команду -cmcreate.

Сценарий восстановления:

  1. Режим восстановления: CLI создает ConfigMap, который переопределяет настройки Percona Operator, заставляя его загрузить дамп из S3.

    ../ops-cli -name ${{ inputs.cluster_name }} -cmcreate 'pg:pg-db-values-switch:{"dataSource":{..."options":["--type=immediate"]...}}'

  2. Заморозка деплоя: Приложения Saleor временно выключаются (enabled: false), пока база занята.

  3. Мониторинг: CLI через флаг -statusrestoredb ждет готовности базы.

  4. Штатный режим: Как только база готова, пайплайн очищает ConfigMap pg-db-values-switch и включает приложения обратно.

Дополнительные возможности CLI

  • -critical-disk UUID: Позволяет пометить диск (например, с метриками PMM) как "неудаляемый". При очистке дисков(-clean-disks) он останется в облаке, и при новом развертывании CLI найдет его и подключит к нужной ноде (PMM), сохранив историю метрик.

  • -clean-all: Полная очистка виртуальных машин и балансировщика.

  • -clean-disks: Вот так удаляются все диски из профиле CLO
    go run ./cmd/cli -name test -clean-disks # ОПАСНАЯ ОПЕРАЦИЯ

лучше не использовать этот флаг “-clean-disks”, всегда необходимо аккуратно удалять диски 

Немного про дополнительные возможности которые я добавил в CLI

Вот так можно зафиксировать диск в state 

go run ./cmd/cli -name test -critical-disk a1234b56-c7de-XXXX-XXXX-XXXXXXXXXXXX

этот диск будет проигнорирован для флага -clean-disks

получить идентификатор диска описано в документации “интерфейс API” облачного провайдера

Вот так чищу все виртуальные машины и балансировщик CLO

go run ./cmd/cli -name test -clean-all

Вот так удаляются все диски из профиле CLO
go run ./cmd/cli -name test -clean-disks 

не рекомендую использовать такие механизмы как “-clean-disks”, лучше вручную удалять

помеченный диск флагом -critical-disk будет восстановлен при новом развертывании с нуля, если диск никто не удалит из облака

я применил -critical-disk только для PMM

Эта система собирает метрики БД “PostgreSQL от percona”


Ручной дамп БД

-create-backup Флаг для создания дампа БД  и сохранит его в s3

cli -name test -create-backup

Для восстановления БД я решил использовать встроенный механизм оператора percona PostgreSQL

Этот шаг в пайплайне называется “Trigger Database Restore from Backup

-cmcreate  Возможно выглядит не просто, но для быстрого решения управлять кластером динамически я вижу решение таким

<cli> -name ${{ inputs.cluster_name }}  -cmcreate 'pg:pg-db-values-switch:{"dataSource":{"postgresCluster":{"clusterName":"pg-db","repoName":"repo1","options":["--set=20251226-133733F_20251226-140728I","--type=immediate"],"tolerations":[{"key":"postgresqltaint","operator":"Equal","value":"yestaint","effect":"NoSchedule"}]},"pgbackrest":{"stanza":"db","configuration":[{"secret":{"name":"clo-s3-secret"}}],"tolerations":[{"key":"postgresqltaint","operator":"Equal","value":"yestaint","effect":"NoSchedule"}],"repo":{"name":"repo1","s3":{"bucket":"pgbak","endpoint":"storage.clo.ru","region":"us-east-1"}}}}}'

pg:pg-db-values-switch:<значение/контент ключа data> “pg” это имя неймспейса и через двоеточие “pg-db-values-switch” имя “ConfigMap”

Если просто yaml манифест для объекта “ConfigMap” то он выглядит так

apiVersion: v1
kind: ConfigMap
metadata:
  name: pg-db-values-switch
  namespace: pg
data:
  values-switch.yaml: |-
    dataSource:
        pgbackrest:
            configuration:
                - secret:
                    name: clo-s3-secret
            repo:
                name: repo1
                s3:
                    bucket: pgbak
                    endpoint: storage.clo.ru
                    region: us-east-1
            stanza: db
            tolerations:
                - effect: NoSchedule
                  key: postgresqltaint
                  operator: Equal
                  value: yestaint
        postgresCluster:
            clusterName: pg-db
            options:
                - --set=20251226-133733F_20251226-140728I
                - --type=immediate
            repoName: repo1
            tolerations:
                - effect: NoSchedule
                  key: postgresqltaint
                  operator: Equal
                  value: yestaint

Бэкапы хранятся тоже в s3

Блок по настройкам s3 для бэкапов здесь

Финальная проверка (Load Test)

После того как FluxCD сообщил о готовности кластера, мне нужно было убедиться, что бэкенд действительно доступен из внешней сети.

Для этого в пайплайне предусмотрен шаг с нагрузкой бэкенда:

  1. CLI через API создает временный Managed Kubernetes кластер в Yandex Cloud.

  2. Туда устанавливается K6 Operator.

  3. Запускается сценарий нагрузки: 1000 одновременных пользователей обращаются к только что восстановленному Saleor в тестово K8s.

Это позволяет проверить всю цепочку (DNS -> Ingress -> Сертификаты -> Приложение -> БД) в условиях рабочего кластера K8s.


CLI

Мне удалось построить систему, которая позволяет быстро восстановить точную копию кластера, имея под рукой только кастомную CLI утилиту.

  • S3 как центр данных: Хранение ключей шифрования, дампов БД, стейта инфраструктуры и секретов приложений в облаке обеспечивает полную переносимость кластера.

  • Изоляция: Процесс не зависит от настроек локального компьютера.

  • Гибкость: FluxCD держит желаемое состояние, а CLI обеспечивает связность между виртуальной облачной инфраструктурой и Kubernetes.

Лог пайплайна можно посмотеть в github-actions

FluxCD

Анатомия GitOps-репозитория: Как устроен Flux

Структура нашего репозитория разделена на два логических слоя: Bootstrap (системный слой самого Flux) и Payload (инфраструктура и приложения). Это позволяет четко разделить ответственность: «как управлять» и «чем управлять».

1. Bootstrap Layer (./flux)

Это точка входа. Когда CLI выполняет команду ops-cli -flux, он применяет манифесты именно из этой директории.

  • gotk-components.yaml: Здесь живут CRD и контроллеры Flux (Source, Kustomize, Helm, Notification). Это «движок» GitOps.

  • gotk-sync.yaml: Ключевой файл. Он объясняет Flux’у, где лежит этот самый репозиторий.

    • Создает объект GitRepository (ссылка на GitHub).

    • Создает корневой Kustomization, который говорит: «Следи за папкой ./flux/test и применяй изменения каждые 10 минут».

  • infra.yaml: Это мост ко второму слою. Это объект Kustomization, который указывает на папку ./fluxcd/test/infra. Именно через него Flux начинает видеть остальную часть кластера.

2. Payload Layer (./fluxcd)

Здесь описано само состояние кластера. Мы используем стандартную структуру Kustomize: разделение на Base (фундамент) и Custom (бизнес-логика).

Base (./fluxcd/test/infra/base) — Системные сервисы

Здесь живут сервисы, без которых приложения не смогут работать.

  1. namespaces: Просто создает неймспейсы (pg, slr, monitoring), защищая их от удаления аннотацией prune: disabled.

  2. sops (Управление секретами через S3):
    Это уникальная часть нашей архитектуры. Вместо того чтобы хранить зашифрованные файлы секретов в Git, мы описываем объект Bucket.

    • Flux подключается к S3-бакету s3clo-secrets.

    • Скачивает оттуда зашифрованные YAML-файлы.

    • Расшифровывает их на лету с помощью ключа sops-age (который мы инжектировали через CLI) и применяет как Secret в Kubernetes.

  3. pg (Базы данных):
    Здесь разворачивается Percona Operator.

    • pg-pv.yaml: Создает PersistentVolumes, жестко привязанные к конкретным нодам (через nodeAffinity). Это то, о чем мы говорили в разделе про state — диск /dev/vdb ждет свою базу.

    • pg-db.yaml: HelmRelease самой базы данных.

  4. istio, cert-manager, monitoring: Стандартный набор для сети и наблюдаемости. Интересен letsencrypt-issuer-k.yaml — он использует PostBuild подстановку переменных ${ACME_EMAIL} и ${DOMAIN}, которые берутся из секретов, созданных CLI.

Custom (./fluxcd/test/infra/custom) — Приложения (Saleor)

Здесь живет наш e-commerce стек.

  • saleor:

    • slr.yaml: HelmRelease основного бэкенда.

    • slr-cm.yaml: конфигурационный файл для патчинга параметров Django.

  • storefront:

    • Фронтенд приложения, который деплоится аналогичным образом.

Ключевой паттерн: Динамическая конфигурация Helm

Самое интересное в этой структуре — как мы управляем настройками Helm-чартов без правки Git.

Взгляните на HelmRelease для Saleor (./saleor/slr.yaml):

valuesFrom:
    - kind: ConfigMap
      name: slr-values        # Статические значения из Git
    - kind: ConfigMap
      name: slr-values-switch # Динамические значения
      valuesKey: values-switch.yaml
      optional: true
  • slr-values: Это базовый файл values.yaml, который лежит в Git. Там прописаны ресурсы, имиджи и порты.

  • slr-values-switch: Этого ConfigMap нет в Git. Он создается нашим CI/CD пайплайном в момент деплоя.

Если нам нужно отключить воркеры при восстановлении базы, пайплайн создает slr-values-switch с содержимым worker: { enabled: false }. Flux видит этот ConfigMap, сливает его с базовым конфигом (по принципу merge override) и обновляет релиз.

Это позволяет нам императивно управлять декларативным инструментом, не нарушая принципов GitOps.

Цепочка зависимостей (dependsOn) и её ограничения

Чтобы выстроить последовательность развертывания компонентов, я использую механизм dependsOn в объектах Kustomization и HelmRelease:

  1. Level 0: sops-components (Секреты) и namespaces.

  2. Level 1: pg-components (База данных).

  3. Level 2: redis-operator.

  4. Level 3: Приложение slr (Saleor) зависит от pg-db и redis-replication.

Важное уточнение:
Нужно понимать, что dependsOn не решает проблему полностью. Flux переходит к следующему шагу, как только зависимый ресурс (например, HelmRelease бэкенда) переходит в статус Ready. С точки зрения Kubernetes, Ready означает лишь то, что поды запустились. Это не гарантирует, что приложение загрузило данные, провело миграции и готово обрабатывать запросы.

Решение проблемы:
Именно здесь в игру вступает описанный выше механизм динамических ConfigMap.
В пайплайне восстановления я намеренно держу флаг storefront: { enabled: false } в ConfigMap до самого конца. Я программно блокирую запуск фронтенда через CI/CD, пока мои проверки (через CLI) не подтвердят, что бэкенд и база данных не просто "зеленые" в Kubernetes, а реально отвечают на запросы.

Таким образом, dependsOn создает структуру, а ConfigMap выступает в роли управляемого «рубильника».

Финальный штрих: Сборка и динамическое обновление

Фронтенд-приложение (storefront) я использую для демонстрации того, как можно связать процесс CI (сборку кода) и CD (доставку в кластер), используя всё тот же механизм переопределения через ConfigMap.

Пайплайн можно настроить чтобы после коммита при каждом билде передавалась версия динамически в процессе работы пайплайна:

  1. Сборка: GitHub Actions собирает Docker-образ приложения.

  2. Пуш: Образ отправляется в Harbor с тегом, привязанным к уникальному номеру запуска (${{ github.run_number }}).

  3. Деплой: Я использую CLI, чтобы одним действием "разморозить" сервис и указать ему свежую версию образа:

../ops-cli -name test-cluster \
-cmcreate 'dsf:dsf-values-switch:{"storefront":{"enabled":true},"image":{"tag":"123.1"}}'

FluxCD обнаруживает изменение в dsf-values-switch, видит новый тег и обновляет Deployment. Это позволяет прокидывать результаты сборки в кластер «на лету» для смены версии.

Правда, здесь есть своя ложка дёгтя: если обновление «упадёт» (например, контейнер уйдет в CrashLoopBackOff), быстрый откат потребует вмешательства. Поскольку версия изменена через ConfigMap, а не через Git-коммит, для оперативного восстановления придется снова править ConfigMap, возвращая значение тега на предыдущую рабочую версию.

Классический git revert в этом случае сработает слишком медленно, так как запустит полный цикл пересборки сервисов в CI. Поэтому в такой архитектуре критически важно иметь грамотно настроенную стратегию RollingUpdate (чтобы не положить прод битым образом) и держать под рукой команду flux reconcile helmrelease dsf -n dsf для синхронизации исправленного конфига.