Перейти к основному содержимому

Инфраструктура как код: OpenTofu

Презентация​

Ссылка на презентацию Последняя редакция: 06.10.26

Теория​

OpenTofu: инфраструктура как код​

Всё то, что в 06-K8s-cloud-cluster и 07-K8s-cloud-ingress делалось отдельными yc/консольными шагами - сеть, сервисные аккаунты, кластер, группа узлов, DNS-зона, ключи для cert-manager и external-dns - можно один раз описать декларативно и дальше поднимать и сносить одной командой

Разница в самом подходе: команды yc - императивные - каждая описывает одно действие ("создай вот это"), и результат зависит от того, в каком порядке и сколько раз их выполнили
Файл .tf - декларативный - описывает, что должно существовать в итоге ("вот сеть, вот кластер"), а разбираться, что для этого создать, изменить или удалить, - работа самого инструмента

Здесь используется OpenTofu - открытый форк Terraform (появился после того, как в 2023 году Terraform сменил лицензию на BSL), синтаксис и провайдеры у них общие, команда tofu - прямая замена terraform

Зачем это в учебном контексте: в 06-K8s-cloud-cluster уже говорилось, что дороже всего - не конфигурация узлов, а время простоя мастера, а после 07-K8s-cloud-ingress к нему добавились DNS-зона, аккаунты с ключами и балансировщик перед Ingress
Ручных шагов на удаление становится больше, и забыть один из них - значит продолжать платить за него, даже не используя
tofu destroy сносит всё, что было создано tofu apply, одной командой, независимо от того, сколько ресурсов в итоге получилось - с одной оговоркой про то, что Tofu не создавал, она разобрана ниже в разделе "Что Tofu не знает: порядок сноса"

Установка на macOS - через Homebrew

brew install opentofu
tofu -version

Аутентификация - отдельный шаг, yc init её не даёт: провайдеру yandex нужен собственный токен, даже если yc уже настроен

export YC_TOKEN=$(yc iam create-token)

YC_TOKEN - переменная окружения, которую провайдер yandex подхватывает сам, без явного упоминания в .tf-файлах
Токен из yc iam create-token живёт ограниченное время (несколько часов) - если tofu apply посреди работы вдруг перестал аутентифицироваться, хотя в начале всё работало, скорее всего дело именно в нём, и команду нужно повторить

Анатомия .tf-файла: ресурсы, ссылки, состояние​

Прежде чем читать целый main.tf, полезно разобрать один блок resource отдельно

resource "yandex_vpc_subnet" "this" {
name = "conduit-subnet"
zone = "ru-central1-a"
network_id = yandex_vpc_network.this.id
v4_cidr_blocks = ["10.10.0.0/24"]
}

resource "<тип>" "<локальное имя>" - тип - конкретный вид ресурса у провайдера (какой именно объект будет создан в облаке), локальное имя - имя ресурса только внутри .tf-файлов, для ссылок на него из других блоков, а не имя объекта в самом облаке (то отдельно, полем name)

Ссылка на другой ресурс - <тип>.<локальное имя>.<атрибут>
yandex_vpc_network.this.id значит "id той сети, что описана выше как yandex_vpc_network с локальным именем this" - по таким ссылкам Tofu сам строит граф зависимостей и создаёт ресурсы в нужном порядке, вручную прописывать "сначала сеть, потом подсеть" не нужно
Когда зависимость есть, а через атрибут не выражается (роль должна быть выдана раньше, чем создаётся кластер, хотя сам кластер её id никуда не подставляет) - для этого явный depends_on = [...], он и в примере ниже

Ещё одна вещь, которой не было у yc-команд, - состояние (terraform.tfstate)
После первого tofu apply появляется файл, в котором Tofu записывает, каким именно облачным объектам соответствует каждый блок resource - по нему plan/apply в следующий раз понимают, что уже существует и не нужно создавать заново, а destroy - что именно нужно удалить

Отсюда два практических следствия

  • Если удалить что-то из созданного руками (yc ... delete или консолью), Tofu об этом сам не узнает - состояние продолжит считать ресурс существующим, и следующий apply либо попытается пересоздать его, либо сообщит об ошибке несовпадения
  • Если потерять файл состояния, Tofu "забывает", что он создал - tofu destroy больше не сможет снести эти ресурсы автоматически, и именно то, ради чего вообще стоило вводить Tofu (гарантированная уборка одной командой), перестаёт работать - удалять придётся руками, ровно как и без Tofu

Каталог и облако, в которых всё это создаётся, - тоже через переменные, а не как захардкоженные строки

variable "cloud_id" {}
variable "folder_id" {}

Значения - в terraform.tfvars (файл рядом с main.tf, не должен уходить в git, если в нём может оказаться что-то приватное) или через переменные окружения TF_VAR_cloud_id/ TF_VAR_folder_id

Какие файлы нужны и что в каждом​

Tofu читает все .tf из каталога как один набор ресурсов, поэтому деление на файлы нужно только людям - так проще найти нужное
Для нашего стенда своих файлов шесть, а после tofu init и tofu apply рядом появляются ещё три, которые создаёт сам Tofu

conduit-tofu/
├── .gitignore - что не должно попасть в git
├── terraform.tfvars - значения переменных (cloud_id, folder_id)
├── variables.tf - объявление этих переменных
├── main.tf - провайдеры, сеть, группа безопасности, аккаунты, кластер, узлы
├── dns.tf - DNS-зона, аккаунт и ключ для cert-manager и external-dns
├── gwin.tf - аккаунт, ключ и группа безопасности для gwin
├── .terraform/ - после tofu init: скачанные провайдеры
├── .terraform.lock.hcl - после tofu init: версии провайдеров
└── terraform.tfstate - после tofu apply: состояние

Порядок файлов и строк на порядок создания не влияет - его определяют ссылки между ресурсами, как в разделе выше
Ссылки при этом свободно ходят между файлами: gwin.tf берёт yandex_vpc_network.this и yandex_vpc_subnet.this из main.tf, а все три файла берут var.folder_id из variables.tf

.gitignore​

.terraform/
*.tfstate*
terraform.tfvars
*-key.json
  • .terraform/ - скачанные провайдеры, они тяжёлые и восстанавливаются командой tofu init
  • *.tfstate* - состояние и его резервные копии, в них приватные ключи аккаунтов открытым текстом
  • terraform.tfvars - ваши cloud_id и folder_id
  • *-key.json - файлы ключей, которые создаёт Tofu

.terraform.lock.hcl в список не входит специально: в нём только версии провайдеров и их контрольные суммы, секретов там нет, и этот файл принято коммитить

variables.tf и terraform.tfvars​

В variables.tf лежат два объявления из раздела выше - variable "cloud_id" {} и variable "folder_id" {}
Значения подставляет terraform.tfvars: Tofu подхватывает файл с таким именем сам, а в остальных файлах они читаются как var.cloud_id и var.folder_id
Проще всего собрать его командой, чтобы не копировать id руками

cat > terraform.tfvars <<EOF
cloud_id = "$(yc config get cloud-id)"
folder_id = "$(yc config get folder-id)"
EOF

Если значения нигде нет, tofu plan спросит его в терминале
yc config get folder-id возвращает каталог, который сейчас выбран в yc, поэтому перед запуском стоит убедиться, что это тот каталог, где должен жить стенд

От yc-команд к OpenTofu​

Минимальный main.tf под сеть, группу безопасности, сервисные аккаунты, кластер и группу узлов из 06-K8s-cloud-cluster (провайдер yandex-cloud/yandex)
Всё, что относится к 07-K8s-cloud-ingress - DNS-зона, аккаунт и ключ для cert-manager и external-dns, а затем аккаунт и группа безопасности для gwin - вынесено в отдельные файлы dns.tf и gwin.tf ниже
Сам main.tf разобран по частям: это один файл, части просто идут друг за другом, а после каждой стоит проверка, чтобы ошибку было видно сразу, а не в конце

Часть 1 - какие плагины нужны и как подключаться к облаку

terraform {
required_providers {
yandex = {
source = "yandex-cloud/yandex"
}
local = {
source = "hashicorp/local"
}
}
}

provider "yandex" {
cloud_id = var.cloud_id
folder_id = var.folder_id
zone = "ru-central1-a"
}
  • required_providers - список плагинов (провайдеров), которые скачает tofu init
    Про Yandex Cloud сам Tofu ничего не знает, все ресурсы yandex_* описаны в провайдере yandex-cloud/yandex, а адрес записывается как автор/имя
  • Провайдер hashicorp/local нужен, чтобы Tofu мог записать файл ключа на диск (ресурс local_sensitive_file в dns.tf и gwin.tf)
  • provider "yandex" - настройки подключения: облако и каталог берутся из переменных, а zone - зона по умолчанию для ресурсов, у которых своя не указана
  • Токена в файле нет специально: провайдер берёт его из YC_TOKEN, как описано выше

Проверка: tofu init, затем tofu validate - должно быть Success! The configuration is valid

Часть 2 - сеть и группа безопасности

resource "yandex_vpc_network" "this" {
name = "conduit-network"
}

resource "yandex_vpc_subnet" "this" {
name = "conduit-subnet"
zone = "ru-central1-a"
network_id = yandex_vpc_network.this.id
v4_cidr_blocks = ["10.10.0.0/24"]
}

resource "yandex_vpc_security_group" "this" {
name = "conduit-sg"
network_id = yandex_vpc_network.this.id

ingress {
protocol = "TCP"
predefined_target = "loadbalancer_healthchecks"
from_port = 0
to_port = 65535
}

ingress {
protocol = "ANY"
predefined_target = "self_security_group"
from_port = 0
to_port = 65535
}

egress {
protocol = "ANY"
predefined_target = "self_security_group"
from_port = 0
to_port = 65535
}

ingress {
protocol = "TCP"
v4_cidr_blocks = ["0.0.0.0/0"]
port = 6443
}

ingress {
protocol = "TCP"
v4_cidr_blocks = ["0.0.0.0/0"]
port = 443
}

ingress {
protocol = "TCP"
v4_cidr_blocks = ["0.0.0.0/0"]
from_port = 30000
to_port = 32767
}

ingress {
protocol = "TCP"
v4_cidr_blocks = yandex_vpc_subnet.this.v4_cidr_blocks
port = 10501
}

egress {
protocol = "ANY"
v4_cidr_blocks = ["0.0.0.0/0"]
from_port = 0
to_port = 65535
}
}
  • v4_cidr_blocks = ["10.10.0.0/24"] - адресный диапазон подсети, это список в квадратных скобках, потому что диапазонов у подсети может быть несколько
  • Вложенные блоки ingress { ... } и egress { ... } - по одному на правило группы безопасности, это те же флаги --rule из yc vpc security-group create
    В правиле либо один порт (port), либо диапазон (from_port и to_port)
    Источник - либо готовая цель (predefined_target), либо список адресов (v4_cidr_blocks)
  • Правила по порядку
    • loadbalancer_healthchecks на все порты - проверки состояния от балансировщиков
    • self_security_group на вход и на выход - трафик внутри одной группы, между мастером и узлами
    • 6443 и 443 из интернета - доступ kubectl к API-серверу
    • 30000-32767 из интернета - диапазон портов NodePort
    • 10501 из подсети кластера - проверки состояния бэкендов от ALB
    • исходящий трафик куда угодно - чтобы узлы могли скачивать образы
  • yandex_vpc_subnet.this.v4_cidr_blocks в правиле 10501 - ссылка на атрибут подсети
    Диапазон берётся из подсети, а не дублируется руками, и при смене адресов правило подстроится само

Проверка: tofu plan - в конце Plan: 3 to add, 0 to change, 0 to destroy

Часть 3 - сервисные аккаунты и роли

resource "yandex_iam_service_account" "cluster" {
name = "k8s-resources-sa"
}

resource "yandex_iam_service_account" "nodes" {
name = "k8s-nodes-sa"
}

resource "yandex_resourcemanager_folder_iam_member" "cluster_agent" {
folder_id = var.folder_id
role = "k8s.clusters.agent"
member = "serviceAccount:${yandex_iam_service_account.cluster.id}"
}

resource "yandex_resourcemanager_folder_iam_member" "cluster_public_ip" {
folder_id = var.folder_id
role = "vpc.publicAdmin"
member = "serviceAccount:${yandex_iam_service_account.cluster.id}"
}

resource "yandex_resourcemanager_folder_iam_member" "nodes_puller" {
folder_id = var.folder_id
role = "container-registry.images.puller"
member = "serviceAccount:${yandex_iam_service_account.nodes.id}"
}
  • yandex_iam_service_account - сам аккаунт, от его имени кластер и узлы работают с облаком
  • yandex_resourcemanager_folder_iam_member - выдача одной роли одному участнику на каталог, то есть yc resource-manager folder add-access-binding
    Один ресурс - одна пара "роль и участник", поэтому три роли - три ресурса
  • "serviceAccount:${yandex_iam_service_account.cluster.id}" - строка с подстановкой
    Всё внутри ${ ... } вычисляется и вставляется в строку, получается serviceAccount: и id аккаунта
  • Роли по смыслу
    • k8s.clusters.agent - аккаунт кластера создаёт и обслуживает ресурсы кластера
    • vpc.publicAdmin - аккаунт кластера выдаёт мастеру публичный адрес
    • container-registry.images.puller - аккаунт узлов скачивает образы из Container Registry

Проверка: tofu plan - Plan: 8 to add, 0 to change, 0 to destroy

Часть 4 - кластер и группа узлов

resource "yandex_kubernetes_cluster" "this" {
name = "conduit-cluster"
network_id = yandex_vpc_network.this.id

master {
zonal {
zone = "ru-central1-a"
subnet_id = yandex_vpc_subnet.this.id
}
public_ip = true
security_group_ids = [yandex_vpc_security_group.this.id]
}

service_account_id = yandex_iam_service_account.cluster.id
node_service_account_id = yandex_iam_service_account.nodes.id
release_channel = "REGULAR"

depends_on = [
yandex_resourcemanager_folder_iam_member.cluster_agent,
yandex_resourcemanager_folder_iam_member.cluster_public_ip,
]
}

resource "yandex_kubernetes_node_group" "this" {
cluster_id = yandex_kubernetes_cluster.this.id
name = "conduit-nodes"

instance_template {
platform_id = "standard-v3"
resources {
cores = 2
core_fraction = 20
memory = 2
}
boot_disk {
type = "network-hdd"
size = 64
}
network_interface {
subnet_ids = [yandex_vpc_subnet.this.id]
nat = true
security_group_ids = [yandex_vpc_security_group.this.id]
}
scheduling_policy {
preemptible = true
}
}

scale_policy {
fixed_scale {
size = 2
}
}

depends_on = [
yandex_resourcemanager_folder_iam_member.nodes_puller,
]
}
  • master { zonal { ... } } - где живёт мастер: одна зона и подсеть
    public_ip = true даёт ему внешний адрес, чтобы kubectl подключался снаружи
    security_group_ids - список групп, поэтому ссылка стоит в квадратных скобках
  • service_account_id и node_service_account_id - ссылки на два аккаунта из части 3
  • release_channel = "REGULAR" - канал обновлений версии Kubernetes
  • depends_on у кластера - ссылки на роли в самом блоке нет, но создавать кластер раньше ролей нельзя: аккаунту не хватит прав
    Поэтому порядок "роль, потом кластер" прописан руками, у группы узлов то же самое с ролью container-registry.images.puller
  • cluster_id = yandex_kubernetes_cluster.this.id - группа узлов привязывается к кластеру по ссылке, и этим же задаётся порядок: сначала кластер
  • instance_template - шаблон виртуальной машины узла
    • platform_id = "standard-v3" - платформа (поколение процессоров)
    • cores, core_fraction, memory - 2 ядра с гарантированной долей 20% и 2 ГБ памяти, недорогой вариант для учебного стенда
    • boot_disk - загрузочный диск 64 ГБ на HDD
    • network_interface - сеть узла
      nat = true выдаёт узлу публичный адрес, без него узлы не смогут скачивать образы
      security_group_ids - та же группа, что у мастера
    • scheduling_policy.preemptible = true - прерываемые машины, они дешевле, но облако может остановить их в любой момент
  • scale_policy.fixed_scale.size = 2 - фиксированное число узлов

Проверка: tofu plan - Plan: 10 to add, 0 to change, 0 to destroy, это и есть весь main.tf

Сопоставление с уже знакомыми флагами yc - тот же смысл, просто описан один раз в файле, а не отдельной командой

  • yc k8s cluster create --network-name → network_id = yandex_vpc_network.this.id
  • --master-location zone=...,subnet-id=... → master.zonal.zone / master.zonal.subnet_id
  • --service-account-name / --node-service-account-name → service_account_id / node_service_account_id (ссылки на ресурсы yandex_iam_service_account)
  • --public-ip → master.public_ip = true
  • yc k8s node-group create --platform-id/--cores/--core-fraction/--memory → instance_template.platform_id / resources.cores / resources.core_fraction / resources.memory
  • --network-interface "...,ipv4-address=nat" → instance_template.network_interface.nat = true
  • --preemptible → instance_template.scheduling_policy.preemptible = true
  • --fixed-size → scale_policy.fixed_scale.size
  • yc vpc security-group create --rule "...,predefined=loadbalancer_healthchecks" → ingress { predefined_target = "loadbalancer_healthchecks" } (и так же для self_security_group и v4-cidrs → v4_cidr_blocks)
  • --security-group-ids у cluster create и security-group-ids=... в --network-interface у группы узлов → master.security_group_ids / instance_template.network_interface.security_group_ids

Сами роли (k8s.clusters.agent, vpc.publicAdmin, container-registry.images.puller) в облаке выдаются командой yc resource-manager folder add-access-binding --service-account-id ... --role ..., по разу на каждую роль - в main.tf выше это те же три назначения, только объявленные ресурсами yandex_resourcemanager_folder_iam_member

Рабочий цикл

tofu init # один раз - скачивает провайдер
tofu plan # что именно будет создано/изменено/удалено - ничего не меняет в облаке
tofu apply # тот же план + запрос подтверждения + реальное создание
# ...проверка, практика...
tofu destroy # снести всё, что записано в состоянии, в обратном порядке

В выводе plan/apply каждый ресурс помечен знаком - + создать, - удалить, ~ изменить на месте, -/+ удалить и пересоздать (когда поле нельзя обновить без пересоздания)
apply всегда сначала показывает этот план и ждёт yes - есть возможность остановиться до того, как что-то реально создалось, если план выглядит не так, как ожидалось

dns.tf: DNS-зона, аккаунт и ключ для cert-manager и external-dns​

Вторая часть конфигурации - то, что в 07-K8s-cloud-ingress делалось командами yc dns zone create, yc iam service-account create, yc resource-manager folder add-access-binding и yc iam key create
Лежит отдельным файлом рядом с main.tf, var.folder_id берётся из тех же переменных

resource "yandex_dns_zone" "this" {
name = "conduit-zone"
zone = "example.com."
public = true
}

resource "yandex_iam_service_account" "dns_editor" {
name = "dns-editor-sa"
}

resource "yandex_resourcemanager_folder_iam_member" "dns_editor" {
folder_id = var.folder_id
role = "dns.editor"
member = "serviceAccount:${yandex_iam_service_account.dns_editor.id}"
}

resource "yandex_iam_service_account_key" "dns_editor" {
service_account_id = yandex_iam_service_account.dns_editor.id
}

resource "local_sensitive_file" "dns_editor_key" {
filename = "${path.module}/dns-editor-key.json"
file_permission = "0600"

content = jsonencode({
id = yandex_iam_service_account_key.dns_editor.id
service_account_id = yandex_iam_service_account_key.dns_editor.service_account_id
created_at = yandex_iam_service_account_key.dns_editor.created_at
key_algorithm = yandex_iam_service_account_key.dns_editor.key_algorithm
public_key = yandex_iam_service_account_key.dns_editor.public_key
private_key = yandex_iam_service_account_key.dns_editor.private_key
})
}

Что здесь происходит

  • zone = "example.com." - домен, который нужно заменить на свой
    Точка в конце обязательна: так в DNS записывается полное имя, в yc dns zone create --zone то же самое
    public = true делает зону публичной, то есть видимой из интернета
  • yandex_iam_service_account_key создаёт ключ для аккаунта dns_editor
    Приватная часть ключа существует только в состоянии Tofu, потом достать её из облака нельзя, поэтому файл для Marketplace собирается сразу
  • local_sensitive_file - ресурс провайдера hashicorp/local, он пишет файл на диск там, где запущен tofu
    Слово sensitive значит, что содержимое не печатается в plan и apply
    file_permission = "0600" оставляет права на чтение и запись только владельцу
  • "${path.module}/dns-editor-key.json" - путь к файлу
    path.module - каталог, где лежит этот .tf, поэтому ключ окажется рядом с файлами конфигурации, а не в том каталоге, откуда запустили команду
  • jsonencode({ ... }) превращает объект HCL в строку JSON
    Поля внутри - те же, что в key.json из yc iam key create, значения берутся из атрибутов ключа yandex_iam_service_account_key.dns_editor
  • Локальное имя dns_editor повторяется у аккаунта, роли и ключа - это нормально, имя должно быть уникальным только среди ресурсов одного типа

Проверка: tofu plan - если main.tf ещё не применялся, в конце будет Plan: 15 to add, 0 to change, 0 to destroy

Соответствие yc-командам из 07

  • yc dns zone create --name --zone --public-visibility → yandex_dns_zone (name, zone, public = true)
  • yc iam service-account create --name → yandex_iam_service_account
  • yc resource-manager folder add-access-binding --role → yandex_resourcemanager_folder_iam_member
  • yc iam key create --output → yandex_iam_service_account_key, а сам файл для форм установки cert-manager и external-dns в Marketplace собирает local_sensitive_file

dns-editor-key.json - тот же авторизованный ключ, что и key.json из yc iam key create (поля id, service_account_id, created_at, key_algorithm, public_key, private_key), его содержимое вставляется в те же поля Marketplace
Формат стоит сверить один раз при первом применении: если вебхук cert-manager не принимает ключ, первым делом сравните поля двух файлов

Токен из yc iam create-token действует от имени того же пользователя, что и yc, поэтому для yandex_resourcemanager_folder_iam_member нужны те же права на выдачу ролей, что и для yc resource-manager folder add-access-binding в 06

Приватный ключ при этом оказывается в двух местах - в dns-editor-key.json и открытым текстом в terraform.tfstate
Ни файл ключа, ни состояние нельзя коммитить в git: в .gitignore нужны как минимум *.tfstate*, terraform.tfvars и *-key.json

У yandex_dns_zone есть побочный эффект, которого не было, пока зона создавалась только yc-командой отдельно: tofu destroy теперь снесёт и её
NS-записи у регистратора от этого не откатятся сами - они как были, так и останутся указывать на ns1.yandexcloud.net/ns2.yandexcloud.net, а зоны с этим доменом в Yandex Cloud уже не будет, и домен перестанет резолвиться целиком, а не только сервисы кластера
Чтобы домен ожил обратно, нужно либо руками вернуть NS-записи у регистратора на его собственные (там же, где NS меняли при делегировании - см. раздел "Домен и перенос NS" в 07-K8s-cloud-ingress), либо пересоздать зону с тем же --zone/zone

gwin.tf: аккаунт и группа безопасности для gwin​

Третья часть конфигурации - то, что нужно gwin из 07-K8s-cloud-ingress: сервисный аккаунт с ролями, ключ для него и группа безопасности самого балансировщика
Роли те же, что в 07, но вместо цикла в bash - один for_each по списку
Роль certificate-manager.editor обязательна: по ней gwin создаёт в Certificate Manager сертификат по Secret от cert-manager, а без неё ALB не строится, и ошибка видна только в логах контроллера
Каталог в folder_id - тот же, что потом указывается в параметре "ID каталога" при установке gwin, иначе у контроллера не окажется прав

locals {
gwin_roles = [
"alb.editor",
"vpc.publicAdmin",
"certificate-manager.certificates.downloader",
"certificate-manager.editor",
"compute.viewer",
"k8s.viewer",
]
}

resource "yandex_iam_service_account" "gwin" {
name = "gwin-sa"
}

resource "yandex_resourcemanager_folder_iam_member" "gwin" {
for_each = toset(local.gwin_roles)

folder_id = var.folder_id
role = each.value
member = "serviceAccount:${yandex_iam_service_account.gwin.id}"
}

resource "yandex_iam_service_account_key" "gwin" {
service_account_id = yandex_iam_service_account.gwin.id
}

resource "local_sensitive_file" "gwin_key" {
filename = "${path.module}/gwin-sa-key.json"
file_permission = "0600"

content = jsonencode({
id = yandex_iam_service_account_key.gwin.id
service_account_id = yandex_iam_service_account_key.gwin.service_account_id
created_at = yandex_iam_service_account_key.gwin.created_at
key_algorithm = yandex_iam_service_account_key.gwin.key_algorithm
public_key = yandex_iam_service_account_key.gwin.public_key
private_key = yandex_iam_service_account_key.gwin.private_key
})
}

resource "yandex_vpc_security_group" "alb" {
name = "alb-sg"
network_id = yandex_vpc_network.this.id

ingress {
protocol = "TCP"
v4_cidr_blocks = ["0.0.0.0/0"]
port = 80
}

ingress {
protocol = "TCP"
v4_cidr_blocks = ["0.0.0.0/0"]
port = 443
}

egress {
protocol = "TCP"
v4_cidr_blocks = yandex_vpc_subnet.this.v4_cidr_blocks
from_port = 0
to_port = 65535
}
}

output "alb_security_group_id" {
value = yandex_vpc_security_group.alb.id
}

Что здесь происходит

  • locals { ... } - блок локальных значений: имя gwin_roles объявляется один раз внутри файла и дальше читается как local.gwin_roles
    Блок называется во множественном числе, а ссылка идёт в единственном - так принято
  • for_each = toset(local.gwin_roles) - один блок resource превращается в несколько экземпляров, по одному на каждую роль из списка
    for_each принимает множество или словарь, а не список, поэтому toset превращает список в множество
    Внутри блока текущий элемент доступен как each.value, и роль в каждом экземпляре своя
  • Экземпляры в плане называются по значению, например yandex_resourcemanager_folder_iam_member.gwin["alb.editor"]
    Если добавить в список седьмую роль, Tofu создаст ровно один новый ресурс, а остальные не тронет
  • Остальное - те же конструкции, что в dns.tf: аккаунт, ключ и local_sensitive_file с jsonencode
  • В группе alb-sg ссылки идут в main.tf: yandex_vpc_network.this.id и yandex_vpc_subnet.this.v4_cidr_blocks
    Поэтому gwin.tf без main.tf не соберётся
  • output "alb_security_group_id" - значение, которое Tofu печатает после apply и отдаёт по запросу через tofu output
    Флаг -raw выводит строку без кавычек, так её удобно подставлять в аннотацию Ingress

Проверка: tofu plan - если ничего ещё не применялось, в конце будет Plan: 25 to add, 0 to change, 0 to destroy
Из них 10 ресурсов из main.tf, 5 из dns.tf и 10 из gwin.tf (шесть из них - роли)

Содержимое gwin-sa-key.json вставляется в то же поле установки gwin в Marketplace, что и sa-key.json в 07, *-key.json из .gitignore выше его уже покрывает

alb-sg повторяет правила из yc-команды в 07 - приём 80 и 443 из интернета и исходящий трафик к узлам из подсети кластера
Правило для порта 10501 (проверки состояния бэкендов) лежит не здесь, а прямо в conduit-sg в main.tf, потому что оно открывает порт на узлах
Id группы для аннотации gwin.yandex.cloud/securityGroups в Ingress больше не нужно искать через yc, его отдаёт output

tofu output -raw alb_security_group_id

Что Tofu не знает: порядок сноса​

Tofu сносит только то, что создал сам
Всё, что создали приложения внутри кластера, в его состоянии не числится

  • Балансировщик перед Ingress - сетевой (его облако создаёт для Service типа LoadBalancer) или L7 ALB (его создаёт gwin)
  • DNS-записи, которые external-dns добавил в зону
  • Сами приложения из Marketplace и сертификаты, выпущенные cert-manager

cert-manager, external-dns и контроллер Ingress из 07-K8s-cloud-ingress в этих файлах специально не описаны - они ставятся через Marketplace, а не напрямую ресурсом Terraform/OpenTofu
Управлять ими декларативно тоже можно (у провайдера yandex есть ресурсы под приложения Marketplace, у Helm - свой провайдер hashicorp/helm), но это уже выходит за рамки вводного знакомства

Пока такой балансировщик жив, он держит за собой подсеть и группы безопасности (в том числе alb-sg), и tofu destroy может упереться в ошибку на их удалении
А если снести кластер вместе с живым Service типа LoadBalancer, балансировщик может остаться висеть в облаке и дальше тарифицироваться

Поэтому порядок сноса такой

  1. Удалить в кластере всё, что создаёт балансировщики - Ingress (ALB от gwin уйдёт вместе с ним) и Service типа LoadBalancer, а приложение из Marketplace со своим Service этого типа - удалить в консоли
  2. Убедиться, что в каталоге не осталось балансировщиков
  3. Только после этого tofu destroy

Команды для первого шага

kubectl delete ingress --all --all-namespaces

kubectl get svc --all-namespaces -o json \
| jq -r '.items[] | select(.spec.type=="LoadBalancer") | "\(.metadata.namespace) \(.metadata.name)"' \
| while read -r ns name; do kubectl delete svc "$name" -n "$ns"; done

Первая команда удаляет все Ingress во всех пространствах имён, вторая - все Service типа LoadBalancer (jq отбирает их по типу, цикл удаляет по одному), в учебном кластере это то, что нужно
Выполнять их нужно, пока gwin ещё запущен: ALB убирает сам контроллер, и если удалить приложение gwin или кластер раньше Ingress, балансировщик останется в облаке и продолжит тарифицироваться
Приложения из Marketplace, которые мы ставим (cert-manager, external-dns, gwin), Service типа LoadBalancer обычно не создают, но если у вас есть приложение с таким Service, удалять его нужно в консоли, иначе оно создаст Service заново

Проверка перед tofu destroy

yc load-balancer network-load-balancer list
yc alb load-balancer list

Оба вывода должны быть пустыми, при этом ALB контроллер убирает уже после удаления Ingress, поэтому список может опустеть не сразу - повторите проверку через пару минут
ALB, который создал gwin, принадлежит самому gwin (в его документации просят не менять и не удалять такой ALB в обход контроллера), поэтому и убирать его нужно удалением Ingress, а не вручную в консоли

Атрибуты yandex_kubernetes_cluster/yandex_kubernetes_node_group выше даны по схеме провайдера на момент написания - перед занятием стоит свериться с registry.terraform.io/providers/yandex-cloud/yandex/latest, у облачных провайдеров схема ресурсов время от времени меняется

Примеры​

Весь цикл одного занятия - от пустого каталога до чистого облака (файлы main.tf, dns.tf и gwin.tf из теории лежат в текущем каталоге, в terraform.tfvars заполнены cloud_id и folder_id)

export YC_TOKEN=$(yc iam create-token)

tofu init
tofu plan
tofu apply
tofu output -raw alb_security_group_id # id для аннотации gwin.yandex.cloud/securityGroups

yc k8s cluster get-credentials conduit-cluster --external
kubectl config use-context yc-conduit-cluster
# ...практика из 06 и 07...

# перед сносом - убрать то, что Tofu не создавал
kubectl delete -f nginx-test.yaml
yc load-balancer network-load-balancer list
yc alb load-balancer list
# если в списках что-то осталось - удалить то, что это создало (Service, приложение из
# Marketplace), и проверить списки ещё раз

tofu destroy

После tofu destroy стоит проверить, что в облаке ничего не осталось

yc k8s cluster list
yc vpc network list

Проверь себя​

1. Если OpenTofu умеет одной командой снести вообще всё, почему статья про него идёт после 06 и 07, а не заменяет yc-команды с самого начала?

Здесь важно сначала один раз пройти по отдельным шагам руками и понять, что стоит за каждым из них - зачем нужны отдельные сервисные аккаунты, что делает каждый флаг
Tofu спрятал бы эти детали за HCL раньше, чем они станут понятны - его ценность именно в надёжной уборке одной командой, когда ресурсов уже много (кластер, узлы, DNS-зона, несколько сервисных аккаунтов) и что-то из них легко забыть удалить руками

2. Удалил группу узлов командой yc k8s node-group delete вместо tofu destroy - что произойдёт при следующем tofu apply?

Tofu не узнает про это удаление сам - в состоянии (terraform.tfstate) группа узлов всё ещё числится существующей
Следующий apply либо попытается создать её заново (раз по факту её нет), либо, если что-то не сойдётся, сообщит об ошибке - состояние и реальность разошлись, и почему они разошлись, Tofu не знает

3. Посреди tofu apply команда вдруг перестала проходить аутентификацию, хотя в начале работы всё было в порядке - в чём вероятная причина?

YC_TOKEN из yc iam create-token живёт ограниченное время (несколько часов), а не бессрочно, как настройки из yc init
Скорее всего, токен просто истёк - нужно получить новый (export YC_TOKEN=$(yc iam create-token)) и повторить команду

4. Почему terraform.tfstate и файл dns-editor-key.json нельзя коммитить в git, хотя сами main.tf и dns.tf коммитить можно?

В main.tf и dns.tf описано, что должно существовать, секретов в них нет
Состояние же хранит значения атрибутов созданных ресурсов, включая приватный ключ сервисного аккаунта из yandex_iam_service_account_key, открытым текстом, а dns-editor-key.json - тот же ключ в виде файла
Любой, кто получит ключ, сможет менять DNS-записи в каталоге от имени аккаунта dns-editor-sa

5. Перед tofu destroy вы удалили Ingress, но не проверили списки балансировщиков - что может пойти не так?

Балансировщик, который создали приложения внутри кластера, в состоянии Tofu не числится, поэтому tofu destroy сам его не удалит
Если Ingress или Service типа LoadBalancer ещё живы, балансировщик может держать подсеть и группу безопасности, и destroy упрётся в ошибку на их удалении - либо он останется после сноса кластера и продолжит тарифицироваться
Поэтому после удаления объектов в кластере нужно проверить yc load-balancer network-load-balancer list и yc alb load-balancer list, и только затем сносить

Практика​

  1. Поставь OpenTofu (brew install opentofu) и получи токен для провайдера (export YC_TOKEN=$(yc iam create-token))
  2. Опиши сеть, сервисные аккаунты, кластер и группу узлов в main.tf (пример в теории выше), подними их tofu apply вместо ручных yc-команд из 06 и сравни, сколько команд потребовалось на тот же результат
  3. Удали руками (yc/консолью) что-то одно из созданного через Tofu, затем выполни tofu plan - убедись, что он это заметил, а не промолчал
  4. Добавь dns.tf, примени и сравни dns-editor-key.json с ключом, который выдаёт yc iam key create - набор полей должен совпасть
    Файл ключа и состояние добавь в .gitignore
  5. Добавь gwin.tf, примени и проверь, что tofu output -raw alb_security_group_id отдаёт id группы alb-sg
    Содержимое gwin-sa-key.json понадобится при установке gwin
  6. Пройди практику из 07-K8s-cloud-ingress на этом кластере, id группы для аннотации возьми из tofu output
  7. Перед сносом удали Ingress и Service типа LoadBalancer, убедись, что списки балансировщиков пусты (yc load-balancer network-load-balancer list, yc alb load-balancer list), и только затем выполни tofu destroy - сразу после проверки, не откладывая на потом: мастер стоит фиксированно (~₽8.80/час) вне зависимости от узлов
    Если домен был делегирован в Cloud DNS, верни NS-записи у регистратора

Итог / чек-лист​

  • OpenTofu - открытый форк Terraform, тот же синтаксис и провайдеры, декларативный подход вместо императивных команд yc - tofu apply/tofu destroy поднимают и надёжно сносят всю связку (сеть, сервисные аккаунты, кластер, узлы, DNS-зону) одной командой, что особенно ценно, когда компонентов уже много и часть из них легко забыть удалить руками
  • Аутентификация yandex-провайдера отдельна от yc init - нужен YC_TOKEN (yc iam create-token), и он протухает быстрее, чем длится занятие
  • Tofu помнит, что он создал, в файле состояния (terraform.tfstate) - ресурс, удалённый в обход Tofu, состояние не замечает само, а потерянный файл состояния лишает tofu destroy возможности снести созданное автоматически
  • Все .tf из каталога читаются как один набор ресурсов - main.tf и dns.tf разделены только для удобства
  • Приватный ключ сервисного аккаунта, выпущенный Tofu, лежит открытым текстом в terraform.tfstate, в dns-editor-key.json и в gwin-sa-key.json - ни состояние, ни файлы ключей нельзя коммитить в git
  • Для gwin в Tofu описаны аккаунт с шестью ролями (в том числе certificate-manager.editor), его ключ и группа безопасности ALB alb-sg, id которой отдаёт tofu output, а правило для порта 10501 лежит в conduit-sg
  • Tofu не знает про балансировщики, DNS-записи и приложения, созданные внутри кластера - перед tofu destroy их нужно убрать в кластере и проверить списки балансировщиков в облаке

Дополнительно​