Последние несколько месяцев я пристально слежу за тем, как растет и меняется Flux CD. Для тех, кто с ним еще не сталкивался: Flux CD — это набор GitOps-контроллеров для Kubernetes. Он непрерывно следит за Git-репозиторием и приводит кластер к тому состоянию, которое там описано — ставит Helm-чарты, применяет Kustomize-оверлеи, подтягивает образы и секреты. Git становится единственным источником правды, а любое изменение инфраструктуры превращается в коммит: его можно отревьюить, откатить и проследить по истории.

Flux хорош модульностью. Это не монолит, а набор небольших контроллеров — source-controller, kustomize-controller, helm-controller и другие, — каждый решает свою узкую задачу и расширяется через CRD. Благодаря этому экосистема легко прирастает сторонними контроллерами. Один из них, Tofu Controller, и станет героем этой статьи.

У Flux есть более известный конкурент — ArgoCD, тоже GitOps-инструмент для Kubernetes, но с собственным веб-интерфейсом и архитектурой вокруг него. Мне ближе подход именно Flux: он нативно живёт внутри кластера, дружит с автоматизацией через CLI и не навязывает обязательный UI. 

Расскажу, как поднимаю облачную инфраструктуру связкой из трех инструментов: Flux CD, Flux Tofu Controller и Terraform-провайдера для Basis Dynamix. Сразу разложу роли, чтобы дальше было понятно, кто за что отвечает:

  • Flux CD ставит весь процесс на рельсы GitOps: то, что описано в репозитории, автоматически и непрерывно разворачивается в кластере.

  • Flux Tofu Controller — развитие известного Weave TF-controller — умеет запускать Terraform и OpenTofu прямо внутри Kubernetes. Он берет Terraform-код из git-репозитория, выполняет plan и apply, хранит state и следит за дрейфом конфигурации — и все это через привычные CRD.

  • Terraform-провайдер для Basis Dynamix — прослойка между Terraform и облачной платформой Basis Dynamix, которая создает в ней реальные ресурсы.

Под «инфраструктурой» я имею в виду ресурсы в облаке Basis Dynamix. В примере ниже это виртуальная машина, но точно так же можно создавать сети, диски, балансировщики, что угодно. Все они заказываются декларативно прямо из Git, а Kubernetes остается единственной точкой управления и выходить за его пределы не нужно. 

Почему Terraform в кластере?

Вопрос: а зачем нести Terraform в k8s, если есть Crossplane или Cluster API? Мне самому очень нравится Crossplane, и как-нибудь я расскажу про опыт работы с ним отдельно. Но такой популярности, как у Terraform (Tofu), он пока не набрал. А предложенный здесь подход гибче: ресурсы Terraform можно создавать хоть контроллером в кластере, хоть локально, а кластер заодно решает проблему совместного использования plan и state.

Есть и человеческий фактор. Очень много специалистов, для которых HCL остается основным языком описания инфраструктуры. Подход, о котором я рассказываю, позволяет им остаться в привычной экосистеме Terraform и переиспользовать уже накопленные наработки, а не переучиваться на инструмент вроде Crossplane с его собственной моделью ресурсов.

С Cluster API история другая. Он, на мой взгляд, создан для полной автоматизации, когда человека в цепочке принятия решений просто нет. Манифесты для него писать непросто: чтобы поднять даже один кластер, нужно согласованно описать целое дерево связанных объектов: Cluster, MachineDeployment, инфраструктурные ресурсы провайдера, шаблоны bootstrap и control-plane — и все они ссылаются друг на друга. Ошибка в одном месте ломает всю цепочку, а отлаживать такие связки тяжело.

Даже различия между версиями v1beta1 и v1beta2 весьма существенны, а в следующих релизах многое из этого станет deprecated. Как создатель провайдера для Cluster API могу сказать: контракты между ресурсами в спецификации сложные. Каждый ресурс обязан выставлять строго определенный набор полей и статусов. Например, status.ready, spec.providerID, failureDomains, на которые опираются другие контроллеры. Стоит провайдеру нарушить хотя бы одно ожидаемое поле в нужный момент и reconcile останавливается, причем причина не всегда очевидна из логов. Этим контрактам, к слову, посвящен отдельный раздел документации Cluster API.

В общем, дальше — простые и гибкие инструменты. Весь код примера лежит в репозитории gitops-infra.

Поднимаем тестовый кластер

Начнем с kind — локального Kubernetes в Docker. Ставим актуальную версию:

sudo curl -fsSL -o /usr/local/bin/kind https://kind.sigs.k8s.io/dl/v0.32.0/kind-linux-amd64
chmod +x /usr/local/bin/kind
kind version

Создаем кластер:

# clusters/local.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: gitops-test
nodes:
- role: control-plane
kind create cluster --config clusters/local.yaml

Устанавливаем Flux CD

Ставим Flux CLI, если его еще нет:

curl -s https://fluxcd.io/install.sh | sudo bash

Дальше — bootstrap в репозиторий:

flux bootstrap git \
--url=ssh://git@github.com/beastlex-cyber/gitops-infra \
--branch=main \
--private-key-file=/home/user/.ssh/id_rsa_new \
--path=clusters/gitops-test

Проверяем, что все поднялось:

flux get all

Собираем кастомный раннер для Tofu Controller

Базовый раннер в поставке достаточно гибкий, но кастомный провайдер Terraform в него придется внедрять самостоятельно. Как собрать такой раннер, хорошо разобрано в этой дискуссии — я в целом шел по ее мотивам.

Вот что делает Dockerfile ниже, шаг за шагом:

  • берет за основу официальный базовый образ раннера tf-runner нужной версии;

  • временно переключается на root (USER 0:0), чтобы иметь право ставить пакеты;

  • скачивает и кладет в /usr/local/bin нужную версию бинарника Terraform;

  • копирует entrypoint.sh и required_providers.tf, а затем выполняет terraform init — так базовые провайдеры (kubernetes, helm) заранее оседают в кэше TF_PLUGIN_CACHE_DIR, и раннеру не приходится ходить за ними в сеть при каждом запуске;

  • скачивает провайдер Basis Dynamix (decort) из внутреннего репозитория и кладет бинарник в /opt/decort-provider;

  • формирует файл /etc/terraformrc с блоком dev_overrides, который заставляет Terraform брать провайдер basis/decort/decort из локальной папки, а не искать его в публичном registry — там его просто нет;

  • возвращает файлы непривилегированному пользователю 65532 и переключается обратно на него — контейнер не должен работать от root;

  • назначает entrypoint.sh точкой входа.

# images/Dockerfile
ARG BASE_RUNNER_VERSION=v0.16.4

FROM ghcr.io/flux-iac/tf-runner:${BASE_RUNNER_VERSION}-base

USER 0:0

ARG TF_VERSION=1.15.6

ARG DECORT_PROVIDER_VERSION=4.11.3

ARG TARGETARCH=amd64
  
RUN wget https://releases.hashicorp.com/terraform/${TF_VERSION}/terraform_${TF_VERSION}_linux_${TARGETARCH}.zip \
&& unzip terraform_${TF_VERSION}_linux_${TARGETARCH}.zip \
&& mv -v terraform /usr/local/bin/terraform \
&& chmod +x /usr/local/bin/terraform \
&& rm -f terraform_${TF_VERSION}_linux_${TARGETARCH}.zip

  
COPY entrypoint.sh required_providers.tf /
RUN chmod +x /entrypoint.sh
 
ENV TF_PLUGIN_CACHE_DIR="/opt/terraform.d/plugin-cache"

RUN mkdir -p /opt/terraform.d/plugin-cache \
&& terraform init \
&& rm -rf required_providers.tf /.terraform /.terraform.lock.hcl /LICENSE.txt

# Устанавливаем наш провайдер Dynamix
RUN mkdir -p /opt/decort-provider \
&& wget -q https://repository.basistech.ru/BASIS/terraform-provider-decort/releases/download/${DECORT_PROVIDER_VERSION}/terraform-provider-decort_${DECORT_PROVIDER_VERSION}_linux_${TARGETARCH}.zip \
&& unzip -q terraform-provider-decort_${DECORT_PROVIDER_VERSION}_linux_${TARGETARCH}.zip -d /tmp/decort-provider \
&& mv /tmp/decort-provider/bin/* /opt/decort-provider/terraform-provider-decort \
&& chmod +x /opt/decort-provider/terraform-provider-decort \
&& rm -rf terraform-provider-decort_${DECORT_PROVIDER_VERSION}_linux_${TARGETARCH}.zip /tmp/decort-provider

# Меняем настройки terraform, чтобы он не пытался отыскать наш провайдер в официальном репозитории
RUN echo 'provider_installation {' > /etc/terraformrc \
&& echo ' dev_overrides {' >> /etc/terraformrc \
&& echo ' "basis/decort/decort" = "/opt/decort-provider"' >> /etc/terraformrc \
&& echo ' }' >> /etc/terraformrc \
&& echo ' direct {}' >> /etc/terraformrc \
&& echo '}' >> /etc/terraformrc

ENV TF_CLI_CONFIG_FILE=/etc/terraformrc

RUN chown -R 65532:65532 /opt/terraform.d /opt/decort-provider \
&& rm -rf /opt/providers \
&& ln -s /opt/terraform.d/plugin-cache /opt/providers
  
USER 65532:65532

ENTRYPOINT ["/entrypoint.sh"]

В репозитории рядом лежат еще два файла — images/entrypoint.sh и images/required_providers.tf. Оба простые:

  • entrypoint.sh — точка входа контейнера. Запускает tf-runner через tini, крошечный init-процесс, который корректно обрабатывает сигналы (например, SIGTERM при остановке пода) и подбирает «зомби»-процессы, чтобы они не накапливались внутри контейнера.

  • required_providers.tf — минимальный Terraform-конфиг, который объявляет провайдеры kubernetes и helm. Нужен только на этапе сборки: по нему terraform init заранее скачивает эти провайдеры в кэш, и раннеру не приходится тянуть их из интернета во время реальной работы.

Собираем образ:

docker build -t tf-runner-decort:v0.16.4 images/

и загружаем в kind:

kind load docker-image tf-runner-decort:v0.16.4 --name gitops-test

Убедиться, что образ действительно загрузился, можно так:

docker exec -it gitops-test-control-plane crictl images | grep tf-runner

Ставим Tofu Controller через HelmRelease

Создаем манифесты нужных ресурсов в clusters/gitops-test:

# clusters/gitops-test/helmrepository.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
  name: tofu-controller
  namespace: flux-system
spec:
  interval: 1h
  url: https://flux-iac.github.io/tofu-controller
# clusters/gitops-test/helmrelease.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: tofu-controller
  namespace: flux-system
spec:
  interval: 1h
  chart:
    spec:
      chart: tofu-controller
      sourceRef:
        kind: HelmRepository
        name: tofu-controller
        namespace: flux-system
        version: ">=0.16.4"
  values:
    image:
      tag: v0.16.4
    runner:
      image: # здесь используем наш собранный образ
        repository: tf-runner-decort
        tag: v0.16.4
        pullPolicy: IfNotPresent
      grpc:
        maxMessageSize: 30

Ждем, пока Flux CD развернет ресурсы в кластер, и проверяем, что в указанном namespace появился под контроллера:

kubectl get pods -n flux-system

Создаем ресурсы через Terraform прямо в кластере

Данные учетной записи Basis Dynamix в репозитории хранить нельзя, поэтому секрет создаем руками:

kubectl create secret generic decort-cred -n flux-system \
--from-literal=DECORT_APP_ID=<your_app_id> \
--from-literal=DECORT_APP_SECRET=<your_app_secret>

Дальше — манифест ресурса Terraform. Новый GitRepository заводить не нужно: переиспользуем тот, что уже создан при flux bootstrap:

# clusters/gitops-test/terraform-vm.yaml
apiVersion: infra.contrib.fluxcd.io/v1alpha2
kind: Terraform
metadata:
  name: decort-vm
  namespace: flux-system
spec:
  interval: 1m
  approvePlan: auto # возможны варианты с ручным апрувом
  path: ./infra
  sourceRef:
    kind: GitRepository
    name: flux-system
    namespace: flux-system
  varsFrom:
    - kind: Secret
      name: decort-cred # тот, что создали выше

Дальше остается ждать, пока Tofu Controller развернет ресурсы, — и посматривать в его логи.

Что получилось в итоге

Получился полностью декларативный конвейер. В репозитории лежит описание виртуальной машины на Terraform (infra/main.tf), а Flux CD вместе с Tofu Controller сами разворачивают ее в облаке Basis Dynamix. Через RBAC в кластере легко выдать ответственным инженерам права на создание ресурсов Terraform, чтение логов контроллера, а заодно на чтение plan и state.

Теперь, чтобы что-то поменять в инфраструктуре — добавить диск, поднять еще одну ВМ, поправить параметры существующей, — достаточно сделать коммит в Git. Контроллер подхватит изменения, выполнит plan и apply, а заодно будет постоянно следить за дрейфом состояния, возвращая ресурсы к тому виду, что описан в репозитории. Можно, конечно, продолжать гонять terraform apply руками с ноутбука — но тогда теряется весь смысл GitOps: ревью через pull request, история изменений и возможность откатить неудачный apply одним revert-коммитом.

По сути — self-service облачная инфраструктура, управляемая через привычный Git-процесс, без ручного запуска terraform с локальной машины.

Комментарии (4)


  1. redbeardster
    17.07.2026 15:48

    Большой молодец!


  1. mshlmv
    17.07.2026 15:48

    И зачем для этого кубер? Любой ci/cd инструмент это делает


    1. Beastlex Автор
      17.07.2026 15:48

      Кубер хранит состояние. Контроллер в бесконечном цикле обрабатывает состояние и сравнивает с облаком. Не думаю, что любой инструмент ci/cd делает это.


  1. yaroher
    17.07.2026 15:48

    Подскажите пожалуйста, есть ли опыт запуска например workflow рядом с поднятой инфраструктурой? Например если задача поднять условные 3 машины через tf и запустить на них полезную нагрузку в после опустить?