FluxCD hub-and-spoke: управление несколькими Kubernetes-кластерами из одного git-репозитория
Published: 2026-02-02
На работе у нас шесть Kubernetes-кластеров: on-prem infra (hub), on-prem dev и test, и три managed-кластера в Yandex Cloud (sre, loadgds, demo). FluxCD работает только на infra. Все остальные — spoke-кластеры: Flux к ним напрямую не подключается.
Этот пост — детальный разбор того, как устроена схема, почему мы выбрали её вместо установки Flux на каждый кластер, и какие операционные паттерны устоялись у нас в продакшене.
Почему hub-and-spoke, а не Flux на каждом кластере
Самая очевидная альтернатива — установить FluxCD на каждый кластер и направить каждый экземпляр на отдельный путь в одном git-репозитории. Это работает, но умножает операционную нагрузку:
- Обновления Flux превращаются в операцию per-cluster. Шесть кластеров — шесть
flux bootstrapили шесть правок HelmRelease. - Отладка требует переключения kubectl-контекстов, чтобы проверить состояние Flux на каждом кластере по отдельности.
- Управление секретами — токены GitLab CI, учётные данные container registry, хуки алертинга — нужно реплицировать везде.
- RBAC для контроллеров Flux надо настраивать и проверять на каждом кластере отдельно.
При hub-and-spoke Flux устанавливается один раз. Контроллеры на hub синхронизируют всё. Команды kubectl и flux выполняются только на infra. Spoke — управляемые поверхности, а не хосты Flux.
Компромисс — доступность: если infra падает, синхронизация останавливается для всех spoke. Существующие рабочие нагрузки продолжают работать — Kubernetes не нужен Flux, чтобы держать поды живыми, — но новые git-коммиты не применятся, пока hub не восстановится. Для наших нагрузок это приемлемо. Если у вас иначе — per-cluster Flux с общим источником конфигурации правильнее.
Что такое hub-and-spoke в терминах FluxCD
FluxCD v2 позволяет HelmRelease или Kustomization указывать секрет kubeConfig. Если он задан, контроллеры Flux на hub применяют манифесты к удалённому кластеру из этого секрета — а не к самому hub.
Схема выглядит так:
hub (infra cluster)
└── Flux controllers
├── HelmRelease redis → kubeConfig: dev-kubeconfig → dev cluster
├── HelmRelease redis → kubeConfig: test-kubeconfig → test cluster
└── HelmRelease redis → kubeConfig: sre-kubeconfig → sre cluster
Всё состояние Flux хранится в одном месте. flux get helmreleases -A на hub показывает статус каждого чарта на каждом spoke.
Под капотом Flux создаёт по kubeconfig REST-клиент, направленный на удалённый API-сервер. Helm-контроллер запускает helm upgrade --install через этот клиент. С точки зрения spoke это выглядит как обычная Helm-установка — CRD и поды появляются в кластере, но драйвер находится в другом месте.
Структура репозитория
fluxcd/
├── kustomization.yaml ← корневая точка входа для Flux
├── flux-system/ ← Flux CRDs + gotk-components
├── base/ ← общие HelmRelease (без kubeConfig)
│ ├── elk/
│ ├── monitoring/
│ └── web/
├── custom/ ← опциональные компоненты для отдельных окружений
│ ├── apps/
│ ├── db/
│ ├── elk/
│ └── monitoring/
└── projects/
└── {env}/
└── kustomization/
├── hub/ ← объединяет base+custom, добавляет kubeConfig через JSON patch
├── spoke/ ← обычные k8s-объекты, применяются напрямую к spoke
├── spoke/monitoring/ ← Probes + PrometheusRules (после spoke)
└── routes/ ← ApisixTls
Ключевое архитектурное решение: base/ содержит полностью рабочие манифесты HelmRelease — с версиями чартов, values и целевыми namespace. Они не зависят от окружения. Единственное, чего не хватает, — spec.kubeConfig, его добавляет оверлей конкретного окружения.
Поэтому версия чарта обновляется в одном месте (base/) и автоматически применяется ко всем окружениям, которые включают этот базовый компонент.
Приём с добавлением kubeConfig
HelmRelease в base/ не содержат поле kubeConfig — они не зависят от окружения. Оверлей для каждого окружения добавляет его через Kustomize JSON patch:
yaml# projects/dev/kustomization/hub/kustomization.yaml
patches:
- target:
kind: HelmRelease
patch: |-
- op: add
path: /spec/kubeConfig
value:
secretRef:
name: dev-kubeconfig
Один патч покрывает все HelmRelease в оверлее. Никакого дублирования на каждый чарт.
Патч использует op: add, а не op: replace, именно потому, что поле отсутствует в base: replace завершится ошибкой, если путь не существует. Это важно помнить при рефакторинге: если случайно добавить placeholder kubeConfig в base, патч с add молча заменит значение (путь уже существует), но намерение станет непонятным. Держите base без kubeConfig.
Четыре объекта Kustomization на каждое окружение
Каждое окружение получает четыре Flux Kustomization CRD, применяемых на hub:
| Объект | Путь | kubeConfig | Примечание |
|---|---|---|---|
dev-apps |
projects/dev/kustomization/hub/ |
dev-kubeconfig (через patch) |
Все HelmRelease |
dev-spoke |
projects/dev/kustomization/spoke/ |
dev-kubeconfig |
Namespaces, secrets, CiliumLB |
dev-monitoring-spoke |
projects/dev/kustomization/spoke/monitoring/ |
dev-kubeconfig |
Probes, PrometheusRules |
dev-routes |
projects/dev/kustomization/routes/ |
dev-kubeconfig |
ApisixTls |
У dev-monitoring-spoke задан dependsOn: dev-spoke, потому что CRD для Probe поставляются вместе с kube-prom-stack, который устанавливается через dev-apps. Порядок важен.
Полное определение Kustomization для справки:
yamlapiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: dev-apps
namespace: dev
spec:
interval: 10m
path: ./projects/dev/kustomization/hub
prune: true
sourceRef:
kind: GitRepository
name: flux-system
namespace: flux-system
kubeConfig:
secretRef:
name: dev-kubeconfig
timeout: 5m
wait: true
Обратите внимание на wait: true — Flux будет опрашивать spoke, пока все применённые ресурсы не достигнут готового состояния, прежде чем пометить Kustomization как синхронизированную. Без этого цепочки dependsOn могут сработать до того, как зависимые CRD реально установлены.
Секрет с kubeconfig
kubeconfig каждого spoke-кластера хранится как SealedSecret прямо внутри файла {env}-kustomization.yaml. Flux расшифровывает его в обычный Secret, на который ссылается HelmRelease.
yaml# fluxcd/dev-kustomization.yaml (фрагмент)
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: dev-kubeconfig
namespace: dev
spec:
encryptedData:
value: AgB3...long-base64...
Публичный сертификат для запечатывания хранится в репозитории. Приватный ключ никогда не покидает hub-кластер.
Генерация kubeconfig
kubeconfig, который используется как dev-kubeconfig, должен использовать выделенный service account с минимальными правами, необходимыми Flux. Для HelmRelease на практике это cluster-admin, хотя права можно ограничить конкретными namespace, если все чарты устанавливаются в известные namespace.
bash# Создаём service account на spoke для Flux
kubectl --context=dev-k8s create serviceaccount flux-remote -n kube-system
kubectl --context=dev-k8s create clusterrolebinding flux-remote \
--clusterrole=cluster-admin \
--serviceaccount=kube-system:flux-remote
# Генерируем токен (k8s 1.24+)
TOKEN=$(kubectl --context=dev-k8s create token flux-remote -n kube-system --duration=8760h)
SERVER=$(kubectl --context=dev-k8s config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CA=$(kubectl --context=dev-k8s config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
# Собираем kubeconfig
cat > /tmp/dev-flux-kubeconfig.yaml <<EOF
apiVersion: v1
kind: Config
clusters:
- cluster:
certificate-authority-data: ${CA}
server: ${SERVER}
name: dev
contexts:
- context:
cluster: dev
user: flux-remote
name: dev
current-context: dev
users:
- name: flux-remote
user:
token: ${TOKEN}
EOF
# Запечатываем
kubectl create secret generic dev-kubeconfig \
--from-file=value=/tmp/dev-flux-kubeconfig.yaml \
--dry-run=client -o yaml | \
kubeseal --cert flux-sealed-secrets.pem --format yaml
Запечатанный секрет коммитится в репозиторий. Ротируйте токен до истечения срока — 8760h это один год.
Infra — одновременно hub и spoke
infra — единственное окружение, где Flux деплоит прямо в тот же кластер, в котором работает. У infra-Kustomization нет kubeConfig — JSON patch не применяется. Это самоуправляемый hub.
Flux обновляет себя сам: когда версия gotk-components в flux-system/ повышается, Flux применяет новые манифесты в собственный namespace и перезапускает собственные поды. Работает надёжно, но во время роллаута контроллеров возможна кратковременная пауза синхронизации.
Нюанс: если в новой версии Flux есть изменение схемы CRD, ломающее существующие ресурсы (редко, но между мажорными версиями случалось), самообновление может оставить контроллеры Flux в crashloop. Всегда проверяйте release notes FluxCD перед повышением версии в flux-system/gotk-components.yaml.
Обнаружение дрейфа и prune: true
Все наши Kustomization используют prune: true: Flux удалит ресурсы со spoke, если они убраны из git. Именно это делает git источником истины, а не просто рекомендацией.
Следствие: если кто-то вручную применит ресурс к spoke через kubectl apply, Flux удалит его при следующем цикле синхронизации (интервал по умолчанию: 10 минут). Это намеренно. На spoke не должно быть состояния, не зафиксированного в git.
В редких случаях, когда на spoke нужен временный ресурс (отладка, реагирование на инцидент), добавьте ему аннотацию:
yamlmetadata:
annotations:
kustomize.toolkit.fluxcd.io/prune: disabled
Flux проигнорирует этот ресурс при очистке. Удалите аннотацию (или сам ресурс), когда закончите.
Приостановка и возобновление
Чтобы Flux не вносил изменения в конкретное окружение — во время ручного устранения инцидента или заморозки деплоев — приостановите Kustomization:
bash# Заморозить всю активность Flux для окружения dev
flux suspend kustomization dev-apps dev-spoke dev-monitoring-spoke dev-routes -n dev --context=infra-k8s
# Возобновить после снятия заморозки
flux resume kustomization dev-apps dev-spoke dev-monitoring-spoke dev-routes -n dev --context=infra-k8s
Приостановка Kustomization не затрагивает запущенные рабочие нагрузки на spoke. Поды продолжают работать — останавливается только применение новых изменений.
Чтобы приостановить один чарт без заморозки всего окружения:
bashflux suspend helmrelease redis -n dev --context=infra-k8s
Повседневные операции
bash# Статус всех Kustomization
flux get kustomizations -A --context=infra-k8s
# Статус всех HelmRelease на всех spoke
flux get helmreleases -A --context=infra-k8s
# Принудительная синхронизация после пуша
flux reconcile source git flux-system -n flux-system --context=infra-k8s
flux reconcile kustomization dev-apps -n dev --context=infra-k8s
# Правильный порядок синхронизации для полного обновления spoke
ENV=dev
flux reconcile ks ${ENV}-apps -n ${ENV} --context=infra-k8s --timeout=5m
flux reconcile ks ${ENV}-spoke -n ${ENV} --context=infra-k8s --timeout=5m
flux reconcile ks ${ENV}-monitoring-spoke -n ${ENV} --context=infra-k8s --timeout=5m
flux reconcile ks ${ENV}-routes -n ${ENV} --context=infra-k8s --timeout=5m
Всегда явно указывайте --context. Не полагайтесь на текущий контекст kubectl при работе с несколькими кластерами.
Проверка состояния spoke с hub-кластера
bash# Все поды на spoke без переключения контекста
kubectl --context=dev-k8s get pods -A
# Или если у вас нет прямого доступа к spoke,
# проверьте состояние HelmRelease со стороны Flux
flux get helmreleases -n dev --context=infra-k8s
# События на падающем HelmRelease
flux events --for HelmRelease/redis -n dev --context=infra-k8s
Устранение неполадок
HelmRelease завис в InstallFailed или UpgradeFailed
Наиболее частая причина — конфликт values или CRD, которые ещё не установлены на spoke. Получите полную ошибку:
bashflux events --for HelmRelease/my-chart -n dev --context=infra-k8s
Если проблема в CRD, проверьте, что у Kustomization, устанавливающей CRD (dev-spoke), есть dependsOn на dev-apps, или наоборот — в зависимости от вашего порядка.
Секрет dev-kubeconfig не найден
Контроллер SealedSecret должен работать на hub, а SealedSecret должен находиться в правильном namespace. Проверьте:
bashkubectl get sealedsecret dev-kubeconfig -n dev --context=infra-k8s
kubectl get secret dev-kubeconfig -n dev --context=infra-k8s
Если SealedSecret есть, а обычный Secret — нет, значит либо контроллер sealed-secrets не запущен, либо приватный ключ не соответствует сертификату шифрования.
Kustomization показывает ReconciliationFailed: context deadline exceeded
API-сервер spoke недоступен с hub, либо токен в kubeconfig истёк. Проверьте связность и при необходимости перегенерируйте токен.
prune: true удалил что-то, что не должен был
Если ресурс был удалён ошибочно, значит он либо не отслеживался инвентарём Kustomization, либо был непреднамеренно убран из git. Проверьте события Kustomization и восстановите из git.
Что вы получаете на выходе
- Один git-репозиторий — одно место для просмотра любых изменений по всем кластерам.
- Spoke-кластеры не требуют установки Flux — никакого агента.
git pushвmainавтоматически запускает синхронизацию (GitLab CI вызываетflux reconcile source git flux-system).- Ручной
flux suspend/flux resumeдля HelmRelease позволяет заморозить конкретный чарт на конкретном кластере, не трогая остальные. - Централизованный audit trail: каждое изменение каждого spoke — это git-коммит на hub.
- Простой путь обновления: обновляете Flux один раз на hub, он управляет собственными контроллерами и одновременно управляет всеми spoke.