Всем привет! Меня зовут Виктор, я DevOps‑инженер компании Nixys, которая помогает другим компаниям внедрять в их IT‑решения передовые практики DevOps, MLOps и DevSecOps.

Сегодня я приглашаю вас вместе со мной пройти путь «от незнания к best practices» в работе с Terraform. Этот материал подготовлен для серии наших одноименных видеороликов на YouTube, но мы решили дополнить его и предложить вам более детальное описание процесса в этой статье.

Не забывайте следить за нашими обновлениями на YouTube, Habr и подписывайтесь на наш Telegram‑канал DevOps FM — мы всегда рады новым друзьям. Начнём?

Чем мне поможет Terraform? Так ли он мне нужен?

Ответ однозначный — да! Если у вашего провайдера есть возможность работы с этим инструментом, обязательно используйте его.

Почему? В первую очередь, Terraform — это автоматизация. А всё, что можно автоматизировать, надо автоматизировать. Да‑да, тавтология, но, надеюсь, так лучше закрепится мысль. Вы же не задаетесь вопросом: «а зачем конвейерные линии на производстве ставят», верно? Вот и здесь принцип тот же. Да, вам нужен оператор этой линии (в нашем случае толковый DevOps‑инженер с нужными скиллами), но просто вдумайтесь, сколько проблем вы этим решаете!

  • Предоставление инфраструктуры и решение долгих рутинных задач. Наверняка перед вами когда‑то возникала проблема, что нужно обеспечить несколько сред для разработки своего продукта, так как параллельно у вас 20 разрабов сражаются за то, чтобы запустить свой код на единственном сервере (а в большинстве случаев они просто будут ждать пока под них не настроят окружение). Вы же в это время получаете простой в разработке, неконкурентоспособность на рынке и т. д. и т. п. Если это про вас, тогда вам определенно нужен Terraform, ведь он автоматизирует процесс создания и предоставления ресурсов инфраструктуры — таких, как серверы, сети, балансировщики нагрузки и базы данных. И вуаля — вам не нужно тратить кучу времени на новое окружение.

  • Снижение человеческого фактора. У вас было такое, что в процессе работы проекта, возникали ошибки по вине человека? Это может быть что угодно: опечатка в названии сервиса при обращении к нему, или же просто кто‑то мог поторопиться, мискликнуть и случайно удалить не ту машину. Вот 100% что было, все мы люди. Тогда опишите свою инфраструктуру кодом, вы снизите шанс подобного к минимуму. Не до нуля, нет, но на несколько порядков меньше — это факт.

  • Обновление. Ох, когда‑нибудь сталкивались с тем, что на рабочем проекте нужно обновить софт? И ладно если это что‑то несущественное, а вдруг надо прям куб обновлять, да ещё через несколько мажорных версий? В общем, Terraform и тут поможет: потратьте время на описание своей инфраструктуры в коде и вы забудете эту боль. Это не снимет с вас обязанность продумать все нюансы. Но во‑первых, Terraform изначально заставляет вас организовать инфраструктуру особым образом, и с такой организацией легче обновляться, а во‑вторых — запускаете две команды, и вы подняли полный клон своей инфраструктуры — можете там ломать, откатывать, переделывать всё, что вашей душе угодно. И если у вас получилось обновиться, с вероятностью, крайне близкой к 100%, вы без проблем обновите и свой прод.

  • Стандартизация. Если у вас команда из двух и более человек, работающих с инфраструктурой, то вы наверняка сталкивались с тем, что они работают по‑разному. Да есть нормы, но они разнятся от компании к компании, и в регламенты всё не затолкаешь, и по итогу получаете вроде бы одну инфраструктуру, но часть поднята одним способом, часть — другим. Это может быть что угодно — от нестандартизированных имён сервисов до использования разных ОС для однотипных хостов. С Terraform вы это решаете, так или иначе. Даже если у вас нет регламента к написанию кода, всё устроено таким образом, что инженерам всё равно придётся работать по одному стандарту.

  • Очевидность. Нанимали нового человека? Сколько времени ему нужно на то, чтобы влиться в работу? С инфраструктурой, которая поднята руками, всё ещё сложнее — там может быть столько неочевидных вещей, что обнаружить их можно будет только когда что‑то уже сломалось, и инженеру приходится носом землю рыть в поисках неисправности. Если вы описали всё с помощью кода, это означает, что больше нет кучи мест, где может быть реализован тот или иной функционал — всё здесь, в манифестах. Да, они могут быть сложны и запутанны, но, по крайней мере, это не какой‑нибудь пользовательский cron на 101-м сервере ходит в прод и забивает весь пул соединений — если он есть, то описан в коде, и мы знаем где его искать. Есть один универсальный источник — это код: ознакомился с ним, значит ознакомился со всей инфраструктурой.

  • Работа в команде. Здесь всё просто — система контроля версий. Если есть код, мы и работаем с ним, как с кодом. Заливаем его в GitHub, GitLab или где вы привыкли работать — и всё, мы обеспечили необходимый функционал для нормальной работы в команде. Если у вас всё руками поднималось, забудьте про такие плюшки.

В целом, Terraform — это мощный инструмент, который решает множество проблем как IT‑отдела в частности, так и, соответственно, бизнеса в целом.

Теперь повторим основы

Terraform — это инструмент, который позволяет описывать инфраструктуру в виде кода и автоматизировать её развёртывание, модификацию и удаление. Он поддерживает множество провайдеров — таких, как AWS, Google Cloud, Azure, Yandex Cloud и других.

Одним из основных преимуществ Terraform является возможность описать желаемое состояние инфраструктуры в виде кода, и затем он автоматически приводит её в это состояние, а вы не заботитесь о ручном управлении каждым ресурсом.

Чтобы начать эффективно работать с этим инструментом, важно понимать основные концепции — такие, как манифесты, провайдеры и команды. Далее в статье мы рассмотрим их, а также принципы работы с Terraform. Кроме того, я расскажу про best practices, которые помогут вам создавать качественный и надежный код.

Итак, Terraform использует манифесты, чтобы описать состояние инфраструктуры, которую вы хотите создать или управлять. Манифесты Terraform — это файлы с расширением.tf, которые содержат описание ресурсов, их атрибутов и зависимостей. Для описания манифестов используется HashiCorp Configuration Language (HCL). HCL является удобным и понятным языком, который позволяет описывать ресурсы, зависимости между ними и указывать значения атрибутов.

После того, как манифесты написаны, Terraform может использовать их для управления ресурсами в провайдере. Это позволяет вам автоматизировать процесс работы с инфраструктурой, улучшать ее надежность и снижать вероятность возникновения ручных ошибок.

Terraform использует несколько основных элементов для описания инфраструктуры:

  1. Providers: провайдеры — это интерфейсы для взаимодействия с ресурсами, такими, как AWS, Google Cloud, Microsoft Azure и т. д.

  1. Resources: ресурсы — это объекты, которые вы хотите создать, удалить или изменить. Например, виртуальная машина, балансировщик нагрузки, база данных.

  1. Variables: переменные — это значения, которые можно передать в Terraform для конфигурирования вашей инфраструктуры.

  1. Data Sources: источники данных — это инструменты, которые позволяют Terraform получать данные из внешних источников, таких как API, базы данных или другие службы.

  1. Outputs: выводы — это результаты, которые Terraform выдает после выполнения конфигурации, например, IP‑адреса, идентификаторы ресурсов и т. д.

Дополнительные элементы Terraform, которые стоит упомянуть:

  1. Модули. Позволяют повторно использовать код и упрощают управление сложными инфраструктурами.

  1. Варианты окружения. Terraform позволяет управлять различными версиями инфраструктуры в зависимости от окружения.

  1. Выражения. Terraform использует выражения для вычисления значений и проверки условий.

Итак, мы понимаем, что с помощью Terraform можно описать инфраструктуру в коде, этот код содержится в файлах, что логично????, так давайте поговорим с вами о них.

Основные типы файлов, которые вы можете использовать в Terraform, включают в себя:

  1. Файлы конфигурации (.tf файлы) — это основные файлы Terraform, которые содержат описание ресурсов, которые вы хотите создать или управлять.

  1. Файлы переменных (.tfvars файлы) — это файлы, которые содержат значения переменных, использующихся в файлах конфигурации.

Файл состояния (.tfstate файл): это файл, который хранит текущее состояние вашей инфраструктуры. Он обновляется каждый раз, когда вы запускаете apply (конечно, если вы что‑то изменили в коде).

Также, стоит отдельно рассказать про файл состояния ‑.tfstate. Terraform сохраняет информацию о текущем состоянии вашей инфраструктуры в файле состояния. Этот файл содержит информацию о ресурсах, которые уже были созданы в вашей инфраструктуре, и их текущих свойствах.

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

Файл состояния может храниться локально или удаленно, например, в объектном хранилище. Это позволяет вам хранить общую копию файла состояния и управлять им с нескольких хостов.

Важно отметить, что файл состояния является важным аспектом управления вашей инфраструктурой, поэтому необходимо обеспечить его блокировку на время работы с ним.

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

Кроме всего этого, хочу упомянуть, что если вам пришлось вручную залезть в tfstate‑файл, то, как говорит мой коллега: «Вы где‑то что‑то сделали не так в этой жизни» ????

Теперь давайте поговорим с вами о том, на какие best practices стоит обратить внимание:

  1. Не храните всю инфраструктуру в одном месте, разбейте её на части;

  1. Изолируйте несвязанные компоненты друг от друга. Разделяйте логически свою инфраструктуру, сеть, сервисы, мониторинг и т. д.;

  1. Не храните файл состояния локально;

  1. Блокируйте файл состояния при работе;

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

  1. Пользуйтесь переменными — удобнее использовать переменные для управления конфигурацией инфраструктуры.

  1. Пишите документацию, она всегда пригодится.

Чуть подробнее о том, что мы здесь перечислили

Когда вы пишите код своей инфраструктуры, достаточно написать всё в одном файле, к примеру в файле main.tf:

my_project/ 
└── main.tf

В нём будет всё, и объявление провайдеров, и ресурсы, и data sources, и outputs: 

terraform {
  required_providers {
    yandex = {
      source = "yandex-cloud/yandex"
    }
  }
  required_version = ">= 0.13"

  backend "s3" {
    endpoint   = "storage.yandexcloud.net"
    bucket     = "BAKET_NAME"
    region     = "ru-central1"
    key        = "FILE_NAME"
    shared_credentials_file = "storage.key"

    skip_region_validation      = true
    skip_credentials_validation = true
  }
}

provider "yandex" {
  service_account_key_file = "key.json"
  cloud_id                 = "CLOUD_ID"
  folder_id                = "FOLDER_ID"
  zone                     = "ru-central1-a"
}

resource "yandex_vpc_network" "test-vpc" {
  name = "nixys"
}

resource "yandex_vpc_subnet" "test-subnet" {
  v4_cidr_blocks = ["10.2.0.0/16"]
  network_id     = yandex_vpc_network.test-vpc.id
}

resource "yandex_vpc_security_group" "test-sg" {
  name        = "My security group"
  description = "description for my security group"
  network_id  = yandex_vpc_network.test-vpc.id

  labels = {
    my-label = "my-label-value"
  }

  dynamic "ingress" {
    for_each = ["80", "8080"]
    content {
      protocol       = "TCP"
      description    = "rule1 description"
      v4_cidr_blocks = ["0.0.0.0/0"]
      from_port      = ingress.value
      to_port        = ingress.value
    }
  }

  egress {
    protocol       = "ANY"
    description    = "rule2 description"
    v4_cidr_blocks = ["0.0.0.0/0"]
    from_port      = 0
    to_port        = 65535
  }
}

resource "yandex_vpc_address" "test-ip" {
  name = "exampleAddress"

  external_ipv4_address {
    zone_id = "ru-central1-a"
  }
}

resource "yandex_compute_instance" "nixys" {
  name        = "nixys"
  platform_id = "standard-v1"

  resources {
    cores  = 2
    memory = 2
  }

  boot_disk {
    initialize_params {
      image_id = "fd83h3kff4ja27ejq0d9"
    }
  }

  network_interface {
    subnet_id      = yandex_vpc_subnet.test-subnet.id
    nat            = true
    nat_ip_address = yandex_vpc_address.test-ip.external_ipv4_address.0.address
  }

  metadata = {
    ssh-keys  = "debian:${file("/root/.ssh/id_rsa.pub")}"
    user-data = "${file("init.sh")}"
  }
}

output "external_ip" {
  value = yandex_vpc_address.test-ip.external_ipv4_address.0.address
}

output "external_ip-2" {
  value = yandex_compute_instance.nixys.network_interface.0.nat_ip_address
}

Terraform это «прожуёт» и исполнит код. Всё бы ничего, если вы описали один ресурс, но их может быть два, а может и пару десятков. В таком файле будет уже сложнее разбираться и вносить изменения в инфраструктуру.

Хорошо, мы можем под каждый ресурс создавать отдельный файл, например gitlab.tf, mongodb.tf, rebbitmq.tf и т. д.:

my_project/
├── k8s
└── network

Уже лучше, теперь мы знаем в каком файле необходимо изменить значение или дописать параметр. Но возникает ещё одна проблема — Terraform воспринимает все файлы в рабочем каталоге как один, а это значит, что и при запуске plan или apply он пойдёт перепроверять всю вашу инфраструктуру. Это долго и никому не нужно. Нет необходимости перепроверять ресурсы для кластера k8s, если изменения вносились в подсеть. Какой тогда выход? Всё просто, мы создадим отдельный рабочий каталог под каждый ресурс или связанную логически группу ресурсов:

my_project/
├── k8s
│   └── main.tf
└── network
    └── main.tf

Теперь у нас имеется несколько манифестов Terraform, каждый из которых описывает свою часть общей инфраструктуры, но всё же у нас остаётся проблема с тем, что всё свалено в одну кучу. Описание того же кластера k8s достаточно большое, и работать в одном файле совсем не удобно. Решение следующее — мы разбиваем конфигурационные файлы по типу описываемых компонентов, т. е. есть главный файл main.tf, где мы объявим провайдера и пропишем доступы к облаку. Далее, мы логически распределим код в отдельные файлы. Выглядит это следующим образом:

my_project/
├── k8s
│   ├── cluster.tf
│   ├── main.tf
│   ├── node_group.tf
│   ├── service_account.tf
└── network
    ├── external_ip.tf
    ├── main.tf
    ├── security_group.tf
    └── vpc.tf

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

Конечно, можно придумывать ещё много разных усовершенствований, но иногда стоит просто остановиться и работать дальше)

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

Итак, теперь мы примерно понимаем, что к чему, и как нам организовать нашу конфигурацию. Давайте теперь перейдём к делу и начнём создание межрегионального кластера kubernetes с автоскейлингом worker-нод с помощью Terraform в Yandex Cloud. В этом кластере мы поднимем мониторинг посредством kube-prometheus-stack.

Части про установку Terraform и yc (консольной утилиты Yandex Cloud) я снова пропускаю, вы можете самостоятельно ознакомиться с этой информацией по приведённым ссылкам. 

Также я не буду останавливаться на создании сервисного аккаунта и организации доступа к облаку, об этом я рассказывал в демонстрационном ролике – DevOps с Nixys | Знакомство с Terraform - Tutorial для начинающих #1.

Начнём мы, как это водится, с документации. Согласно ей, код по созданию межрегионального кластера будет выглядеть так:

main.tf
locals {
  cloud_id    = "b1gq90dgh25bebiu75o"
  folder_id   = "b1gia87mbaomkfvsleds"
  k8s_version = "1.22"
  sa_name     = "myaccount"
}

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

provider "yandex" {
  folder_id = local.folder_id
}

resource "yandex_kubernetes_cluster" "k8s-regional" {
  network_id = yandex_vpc_network.mynet.id
  master {
    version = local.k8s_version
    regional {
      region = "ru-central1"
      location {
        zone      = yandex_vpc_subnet.mysubnet-a.zone
        subnet_id = yandex_vpc_subnet.mysubnet-a.id
      }
      location {
        zone      = yandex_vpc_subnet.mysubnet-b.zone
        subnet_id = yandex_vpc_subnet.mysubnet-b.id
      }
      location {
        zone      = yandex_vpc_subnet.mysubnet-c.zone
        subnet_id = yandex_vpc_subnet.mysubnet-c.id
      }
    }
    security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
  }
  service_account_id      = yandex_iam_service_account.myaccount.id
  node_service_account_id = yandex_iam_service_account.myaccount.id
  depends_on = [
    yandex_resourcemanager_folder_iam_binding.k8s-clusters-agent,
    yandex_resourcemanager_folder_iam_binding.vpc-public-admin,
    yandex_resourcemanager_folder_iam_binding.images-puller
  ]
  kms_provider {
    key_id = yandex_kms_symmetric_key.kms-key.id
  }
}

resource "yandex_vpc_network" "mynet" {
  name = "mynet"
}

resource "yandex_vpc_subnet" "mysubnet-a" {
  v4_cidr_blocks = ["10.5.0.0/16"]
  zone           = "ru-central1-a"
  network_id     = yandex_vpc_network.mynet.id
}

resource "yandex_vpc_subnet" "mysubnet-b" {
  v4_cidr_blocks = ["10.6.0.0/16"]
  zone           = "ru-central1-b"
  network_id     = yandex_vpc_network.mynet.id
}

resource "yandex_vpc_subnet" "mysubnet-c" {
  v4_cidr_blocks = ["10.7.0.0/16"]
  zone           = "ru-central1-c"
  network_id     = yandex_vpc_network.mynet.id
}

resource "yandex_iam_service_account" "myaccount" {
  name        = local.sa_name
  description = "K8S regional service account"
}

resource "yandex_resourcemanager_folder_iam_binding" "k8s-clusters-agent" {
  # Сервисному аккаунту назначается роль "k8s.clusters.agent".
  folder_id = local.folder_id
  role      = "k8s.clusters.agent"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_resourcemanager_folder_iam_binding" "vpc-public-admin" {
  # Сервисному аккаунту назначается роль "vpc.publicAdmin".
  folder_id = local.folder_id
  role      = "vpc.publicAdmin"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_resourcemanager_folder_iam_binding" "images-puller" {
  # Сервисному аккаунту назначается роль "container-registry.images.puller".
  folder_id = local.folder_id
  role      = "container-registry.images.puller"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_kms_symmetric_key" "kms-key" {
  # Ключ для шифрования важной информации, такой как пароли, OAuth-токены и SSH-ключи.
  name              = "kms-key"
  default_algorithm = "AES_128"
  rotation_period   = "8760h" # 1 год.
}

resource "yandex_kms_symmetric_key_iam_binding" "viewer" {
  symmetric_key_id = yandex_kms_symmetric_key.kms-key.id
  role             = "viewer"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}",
  ]
}

resource "yandex_vpc_security_group" "k8s-main-sg" {
  name        = "k8s-main-sg"
  description = "Правила группы обеспечивают базовую работоспособность кластера. Примените ее к кластеру и группам узлов."
  network_id  = yandex_vpc_network.mynet.id
  ingress {
    protocol          = "TCP"
    description       = "Правило разрешает проверки доступности с диапазона адресов балансировщика нагрузки. Нужно для работы отказоустойчивого кластера и сервисов балансировщика."
    predefined_target = "loadbalancer_healthchecks"
    from_port         = 0
    to_port           = 65535
  }
  ingress {
    protocol          = "ANY"
    description       = "Правило разрешает взаимодействие мастер-узел и узел-узел внутри группы безопасности."
    predefined_target = "self_security_group"
    from_port         = 0
    to_port           = 65535
  }
  ingress {
    protocol          = "ANY"
    description       = "Правило разрешает взаимодействие под-под и сервис-сервис. Укажите подсети вашего кластера и сервисов."
    v4_cidr_blocks    = concat(yandex_vpc_subnet.mysubnet-a.v4_cidr_blocks, yandex_vpc_subnet.mysubnet-b.v4_cidr_blocks, yandex_vpc_subnet.mysubnet-c.v4_cidr_blocks)
    from_port         = 0
    to_port           = 65535
  }
  ingress {
    protocol          = "ICMP"
    description       = "Правило разрешает отладочные ICMP-пакеты из внутренних подсетей."
    v4_cidr_blocks    = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
  }
  ingress {
    protocol          = "TCP"
    description       = "Правило разрешает входящий трафик из интернета на диапазон портов NodePort. Добавьте или измените порты на нужные вам."
    v4_cidr_blocks    = ["0.0.0.0/0"]
    from_port         = 30000
    to_port           = 32767
  }
  egress {
    protocol          = "ANY"
    description       = "Правило разрешает весь исходящий трафик. Узлы могут связаться с Yandex Container Registry, Yandex Object Storage, Docker Hub и т. д."
    v4_cidr_blocks    = ["0.0.0.0/0"]
    from_port         = 0
    to_port           = 65535
  }
}

А чтобы развернуть ещё и группы узлов, понадобится новый ресурс:

main.tf
resource "yandex_kubernetes_node_group" "my_node_group_a" {
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = "worker-a"
  description = "description"
  version     = local.k8s_version
  labels = {
    "key" = "value"
  }
  instance_template {
    platform_id = "standard-v1"
    name        = "worker-a-{instance.short_id}"
    network_interface {
      nat                = true
      subnet_ids         = [yandex_vpc_subnet.mysubnet-a.id]
      security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
    }
    resources {
      memory = 2
      cores  = 2
    }
    boot_disk {
      type = "network-hdd"
      size = 32
    }
    scheduling_policy {
      preemptible = false
    }
  }
  scale_policy {
    auto_scale {
      min     = 1
      max     = 3
      initial = 1
    }
  }
  allocation_policy {
    location {
      zone = "ru-central1-a"
    }
  }
}

Это согласно инструкции Яндекса + документации Terraform.

Если хотим больше групп — давайте сделаем по одной в каждой зоне и в конечном итоге, с остальными ресурсами мы получим такую картину:

main.tf
locals {
  cloud_id    = "b1gq90dgh25bebiu75o"
  folder_id   = "b1gia87mbaomkfvsleds"
  k8s_version = "1.22"
  sa_name     = "myaccount"
}

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

provider "yandex" {
  folder_id = local.folder_id
}

resource "yandex_kubernetes_cluster" "k8s-regional" {
  network_id = yandex_vpc_network.mynet.id
  master {
    version = local.k8s_version
    regional {
      region = "ru-central1"
      location {
        zone      = yandex_vpc_subnet.mysubnet-a.zone
        subnet_id = yandex_vpc_subnet.mysubnet-a.id
      }
      location {
        zone      = yandex_vpc_subnet.mysubnet-b.zone
        subnet_id = yandex_vpc_subnet.mysubnet-b.id
      }
      location {
        zone      = yandex_vpc_subnet.mysubnet-c.zone
        subnet_id = yandex_vpc_subnet.mysubnet-c.id
      }
    }
    security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
  }
  service_account_id      = yandex_iam_service_account.myaccount.id
  node_service_account_id = yandex_iam_service_account.myaccount.id
  depends_on = [
    yandex_resourcemanager_folder_iam_binding.k8s-clusters-agent,
    yandex_resourcemanager_folder_iam_binding.vpc-public-admin,
    yandex_resourcemanager_folder_iam_binding.images-puller
  ]
  kms_provider {
    key_id = yandex_kms_symmetric_key.kms-key.id
  }
}

resource "yandex_vpc_network" "mynet" {
  name = "mynet"
}

resource "yandex_vpc_subnet" "mysubnet-a" {
  v4_cidr_blocks = ["10.5.0.0/16"]
  zone           = "ru-central1-a"
  network_id     = yandex_vpc_network.mynet.id
}

resource "yandex_vpc_subnet" "mysubnet-b" {
  v4_cidr_blocks = ["10.6.0.0/16"]
  zone           = "ru-central1-b"
  network_id     = yandex_vpc_network.mynet.id
}

resource "yandex_vpc_subnet" "mysubnet-c" {
  v4_cidr_blocks = ["10.7.0.0/16"]
  zone           = "ru-central1-c"
  network_id     = yandex_vpc_network.mynet.id
}

resource "yandex_iam_service_account" "myaccount" {
  name        = local.sa_name
  description = "K8S regional service account"
}

resource "yandex_resourcemanager_folder_iam_binding" "k8s-clusters-agent" {
  # Сервисному аккаунту назначается роль "k8s.clusters.agent".
  folder_id = local.folder_id
  role      = "k8s.clusters.agent"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_resourcemanager_folder_iam_binding" "vpc-public-admin" {
  # Сервисному аккаунту назначается роль "vpc.publicAdmin".
  folder_id = local.folder_id
  role      = "vpc.publicAdmin"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_resourcemanager_folder_iam_binding" "images-puller" {
  # Сервисному аккаунту назначается роль "container-registry.images.puller".
  folder_id = local.folder_id
  role      = "container-registry.images.puller"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_kms_symmetric_key" "kms-key" {
  # Ключ для шифрования важной информации, такой как пароли, OAuth-токены и SSH-ключи.
  name              = "kms-key"
  default_algorithm = "AES_128"
  rotation_period   = "8760h" # 1 год.
}

resource "yandex_kms_symmetric_key_iam_binding" "viewer" {
  symmetric_key_id = yandex_kms_symmetric_key.kms-key.id
  role             = "viewer"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}",
  ]
}

resource "yandex_vpc_security_group" "k8s-main-sg" {
  name        = "k8s-main-sg"
  description = "Правила группы обеспечивают базовую работоспособность кластера. Примените ее к кластеру и группам узлов."
  network_id  = yandex_vpc_network.mynet.id
  ingress {
    protocol          = "TCP"
    description       = "Правило разрешает проверки доступности с диапазона адресов балансировщика нагрузки. Нужно для работы отказоустойчивого кластера и сервисов балансировщика."
    predefined_target = "loadbalancer_healthchecks"
    from_port         = 0
    to_port           = 65535
  }
  ingress {
    protocol          = "ANY"
    description       = "Правило разрешает взаимодействие мастер-узел и узел-узел внутри группы безопасности."
    predefined_target = "self_security_group"
    from_port         = 0
    to_port           = 65535
  }
  ingress {
    protocol          = "ANY"
    description       = "Правило разрешает взаимодействие под-под и сервис-сервис. Укажите подсети вашего кластера и сервисов."
    v4_cidr_blocks    = concat(yandex_vpc_subnet.mysubnet-a.v4_cidr_blocks, yandex_vpc_subnet.mysubnet-b.v4_cidr_blocks, yandex_vpc_subnet.mysubnet-c.v4_cidr_blocks)
    from_port         = 0
    to_port           = 65535
  }
  ingress {
    protocol          = "ICMP"
    description       = "Правило разрешает отладочные ICMP-пакеты из внутренних подсетей."
    v4_cidr_blocks    = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
  }
  ingress {
    protocol          = "TCP"
    description       = "Правило разрешает входящий трафик из интернета на диапазон портов NodePort. Добавьте или измените порты на нужные вам."
    v4_cidr_blocks    = ["0.0.0.0/0"]
    from_port         = 30000
    to_port           = 32767
  }
  egress {
    protocol          = "ANY"
    description       = "Правило разрешает весь исходящий трафик. Узлы могут связаться с Yandex Container Registry, Yandex Object Storage, Docker Hub и т. д."
    v4_cidr_blocks    = ["0.0.0.0/0"]
    from_port         = 0
    to_port           = 65535
  }
}

resource "yandex_kubernetes_node_group" "my_node_group_a" {
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = "worker-a"
  description = "description"
  version     = local.k8s_version
  labels = {
    "key" = "value"
  }
  instance_template {
    platform_id = "standard-v1"
    name        = "worker-a-{instance.short_id}"
    network_interface {
      nat                = true
      subnet_ids         = [yandex_vpc_subnet.mysubnet-a.id]
      security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
    }
    resources {
      memory = 2
      cores  = 2
    }
    boot_disk {
      type = "network-hdd"
      size = 32
    }
    scheduling_policy {
      preemptible = false
    }
  }
  scale_policy {
    auto_scale {
      min     = 1
      max     = 3
      initial = 1
    }
  }
  allocation_policy {
    location {
      zone = "ru-central1-a"
    }
  }
}

resource "yandex_kubernetes_node_group" "my_node_group_b" {
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = "worker-b"
  description = "description"
  version     = local.k8s_version
  labels = {
    "key" = "value"
  }
  instance_template {
    platform_id = "standard-v1"
    name        = "worker-b-{instance.short_id}"
    network_interface {
      nat                = true
      subnet_ids         = [yandex_vpc_subnet.mysubnet-b.id]
      security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
    }
    resources {
      memory = 2
      cores  = 2
    }
    boot_disk {
      type = "network-hdd"
      size = 32
    }
    scheduling_policy {
      preemptible = false
    }
  }
  scale_policy {
    auto_scale {
      min     = 1
      max     = 3
      initial = 1
    }
  }
  allocation_policy {
    location {
      zone = "ru-central1-b"
    }
  }
}

resource "yandex_kubernetes_node_group" "my_node_group_c" {
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = "worker-c"
  description = "description"
  version     = local.k8s_version
  labels = {
    "key" = "value"
  }
  instance_template {
    platform_id = "standard-v1"
    name        = "worker-c-{instance.short_id}"
    network_interface {
      nat                = true
      subnet_ids         = [yandex_vpc_subnet.mysubnet-c.id]
      security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
    }
    resources {
      memory = 2
      cores  = 2
    }
    boot_disk {
      type = "network-hdd"
      size = 32
    }
    scheduling_policy {
      preemptible = false
    }
  }
  scale_policy {
    auto_scale {
      min     = 1
      max     = 3
      initial = 1
    }
  }
  allocation_policy {
    location {
      zone = "ru-central1-c"
    }
  }
}

В принципе, если не париться, можно запускать и так. Всё заведётся и будет работать. Мы получим отказоустойчивый кластер k8s с рабочими узлами в разных зонах доступности, да ещё и с автоскейлингом. Но сможем ли мы оставить всё как есть? Конечно же нет, давайте сделаем из этого франкенштейна аккуратные, красивые, а главное функциональные манифесты, с которыми в дальнейшем можно будет жить с чистой совестью.

Погнали)

Работать мы будем в отдельной директории my_project

mkdir ~/my_project

Для того, чтобы создать кластер k8s нам потребуется сеть, подсеть и сервисный аккаунт. Поэтому, в соответствии с упомянутыми ранее рекомендациями, мы начнём создавать инфраструктуру с сети и её компонентов в отдельном рабочем каталоге network:

mkdir ~/my_project/network
cd ~/my_project/network

Далее мы создадим файл main.tf, в нем пропишем провайдера и доступ к облаку:

my_project/network/main.tf
terraform {
  required_providers {
    yandex = {
      source = "yandex-cloud/yandex"
    }
  }
  required_version = ">= 0.13"

  backend "s3" {
    endpoint   = "storage.yandexcloud.net"
    bucket     = "test-tfstate"
    region     = "ru-central1"
    key        = "my-project-network.tfstate"
    shared_credentials_file = "storage.key"

    skip_region_validation      = true
    skip_credentials_validation = true
  }
}

provider "yandex" {
  service_account_key_file = "key.json"
  cloud_id                 = "b1gq90dgh25bebiu75o"
  folder_id                = "b1gia87mbaomkfvsleds"
  zone                     = "ru-central1-a"
}

Всё, что здесь описано, должно быть знакомо по предыдущему ролику, поэтому не будем останавливаться на этом и перейдем к описанию сети и подсети, их мы опишем в файле vpc.tf:

my_project/network/vpc.tf
resource "yandex_vpc_network" "network-main" {
  name = "mynet"
}

resource "yandex_vpc_subnet" "mysubnet-a" {
  v4_cidr_blocks = ["10.5.0.0/16"]
  zone           = "ru-central1-a"
  network_id     = yandex_vpc_network.network-main.id
}

resource "yandex_vpc_subnet" "mysubnet-b" {
  v4_cidr_blocks = ["10.6.0.0/16"]
  zone           = "ru-central1-b"
  network_id     = yandex_vpc_network.network-main.id
}

resource "yandex_vpc_subnet" "mysubnet-c" {
  v4_cidr_blocks = ["10.7.0.0/16"]
  zone           = "ru-central1-c"
  network_id     = yandex_vpc_network.network-main.id
}

Это согласно инструкции Яндекса. Давайте на примере кода в main.tf и vpc.tf проведём ряд усовершенствований. Для начала вынесем имя сети и ещё пару параметров в переменные. Создадим файлы terraform.tfvars и variables.tf. Теперь наш рабочий каталог выглядит следующим образом:

my_project/
└── network
    ├── main.tf
    ├── terraform.tfvars
    ├── variables.tf
    └── vpc.tf

В файле variables.tf укажем, что хотим создать четыре переменные:

my_project/network/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== network ==============
variable "network_name" {
  description = "The name of main network"
  type        = string
}

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

Далее, в файле terraform.tfvars мы присвоим значение этим переменным:

my_project/network/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== network ==============
network_name = "mynet"

И в конце концов перепишем в файлах main.tf и vpc.tf соответствующие параметры:

my_project/network/main.tf
terraform {
  required_providers {
    yandex = {
      source = "yandex-cloud/yandex"
    }
  }
  required_version = ">= 0.13"

  backend "s3" {
    endpoint   = "storage.yandexcloud.net"
    bucket     = "test-tfstate"
    region     = "ru-central1"
    key        = "my-project-network.tfstate"
    shared_credentials_file = "storage.key"

    skip_region_validation      = true
    skip_credentials_validation = true
  }
}

provider "yandex" {
  service_account_key_file = "key.json"
  cloud_id                 = var.cloud_id
  folder_id                = var.folder_id
  zone                     = var.default_zone
}

my_project/network/vpc.tf
resource "yandex_vpc_network" "network-main" {
  name = var.network_name
}

resource "yandex_vpc_subnet" "mysubnet-a" {
  v4_cidr_blocks = ["10.5.0.0/16"]
  zone           = "ru-central1-a"
  network_id     = yandex_vpc_network.network-main.id
}

resource "yandex_vpc_subnet" "mysubnet-b" {
  v4_cidr_blocks = ["10.6.0.0/16"]
  zone           = "ru-central1-b"
  network_id     = yandex_vpc_network.network-main.id
}

resource "yandex_vpc_subnet" "mysubnet-c" {
  v4_cidr_blocks = ["10.7.0.0/16"]
  zone           = "ru-central1-c"
  network_id     = yandex_vpc_network.network-main.id
}

Отлично, с переменными разобрались, теперь перейдём к циклу — зачем нам описывать три подсети, по сути одинаковых ресурса, если можно обойтись одним блоком кода, верно? Добавим в variables.tf описание переменной subnets, только здесь мы обозначим не один параметр, а их набор. У Terraform есть тип map, который нам отлично подходит под задачу (ещё бы, для этого и создавался):

my_project/network/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== network ==============
variable "network_name" {
  description = "The name of main network"
  type        = string
}

#=========== subnet ==============
variable "subnets" {
  description = "Subnets for k8s"

  type = map(list(object(
    {
      name = string,
      zone = string,
      cidr = list(string)
    }))
  )

  validation {
    condition     = alltrue([for i in keys(var.subnets) : alltrue([for j in lookup(var.subnets, i) : contains(["ru-central1-a", "ru-central1-b", "ru-central1-c"], j.zone)])])
    error_message = "Error! Zones not supported!"
  }
}

Тут же, исключительно ради примера, делаю проверку на корректность указываемых значений с помощью блока validation, где в condition пишу условие, а в параметре error_message текст ошибки. Можно делать проверки на всё подряд, но это отдельная и трудоемкая задача. Остановимся на том, что просто упомянули о такой возможности.

Далее, в файле terraform.tfvars мы просто укажем значения:

my_project/network/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== network ==============
network_name = "mynet"

#=========== subnet ==============
subnets = {
  "k8s_masters" = [
    {
      name = "k8s_master_zone_a"
      zone = "ru-central1-a"
      cidr = ["10.0.1.0/28"]
    },
    {
      name = "k8s_master_zone_b"
      zone = "ru-central1-b"
      cidr = ["10.0.2.0/28"]
    },
    {
      name = "k8s_master_zone_c"
      zone = "ru-central1-c"
      cidr = ["10.0.3.0/28"]
    }
  ],
  "k8s_workers" = [
    {
      name = "k8s_worker_zone_a"
      zone = "ru-central1-a"
      cidr = ["10.0.4.0/28"]
    },
    {
      name = "k8s_worker_zone_b"
      zone = "ru-central1-b"
      cidr = ["10.0.5.0/28"]
    },
    {
      name = "k8s_worker_zone_c"
      zone = "ru-central1-c"
      cidr = ["10.0.6.0/28"]
    }
  ],
}

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

А теперь создадим файл local.tf, со следующим содержимым:

my_project/network/locals.tf
locals {
  subnet_array = flatten([for k, v in var.subnets : [for j in v : {
    name = j.name
    zone = j.zone
    cidr = j.cidr
    }
  ]])
}

Выражение перебирает входную переменную var.subnets и с помощью встроенной функции flatten сглаживает полученный вложенный список в единый одномерный список объектов. Каждый объект в результирующем списке имеет три атрибута: name, zone и cidr. При этом, оставляя значение для ключа cidr в списке, что очень важно. Ведь далее в ресурсе нам нужно указывать именно список.

Таким образом, мы можем переписать наш код в vpc.tf:

my_project/network/vpc.tf
resource "yandex_vpc_network" "network-main" {
  name = var.network_name
}

resource "yandex_vpc_subnet" "subnet-main" {
  for_each = {
    for k, v in local.subnet_array : "${v.name}" => v
  }
  network_id = yandex_vpc_network.network-main.id
  v4_cidr_blocks = each.value.cidr
  zone           = each.value.zone
  name           = each.value.name
}

Здесь мы используем цикл for_each, в котором сопоставляем уникальный идентификатор с каждым элементом списка local.subnet_array. В данном случае, уникальным идентификатором является “name”.

Таким образом, сейчас для Terraform это выглядит как 6 ресурсов, и он создаст отдельные подсети с уникальным именем.

Всё, у нас есть 6 подсетей ????

Что ещё. А, ну давайте создадим статические IP-адреса, сколько бы их ни было. Действуем по тому же сценарию, в variables.tf добавляем новую переменную:

my_project/network/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== network ==============
variable "network_name" {
  description = "The name of main network"
  type        = string
}

#=========== subnet ==============
variable "subnets" {
  description = "Subnets for k8s"

  type = map(list(object(
    {
      name = string,
      zone = string,
      cidr = list(string)
    }))
  )

  validation {
    condition     = alltrue([for i in keys(var.subnets) : alltrue([for j in lookup(var.subnets, i) : contains(["ru-central1-a", "ru-central1-b", "ru-central1-c"], j.zone)])])
    error_message = "Error! Zones not supported!"
  }
}

#=========== external_ip ==============
variable "external_static_ips" {
  description = "static ips"

  type = map(list(object(
    {
      name = string,
      zone = string
    }))
  )
}

После этого в terraform.tfvars добавляем значения. Сделаю один, но мы сразу закладываем возможность создания нескольких:

my_project/network/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== network ==============
network_name = "mynet"

#=========== subnet ==============
subnets = {
  "k8s_masters" = [
    {
      name = "k8s_master_zone_a"
      zone = "ru-central1-a"
      cidr = ["10.0.1.0/28"]
    },
    {
      name = "k8s_master_zone_b"
      zone = "ru-central1-b"
      cidr = ["10.0.2.0/28"]
    },
    {
      name = "k8s_master_zone_c"
      zone = "ru-central1-c"
      cidr = ["10.0.3.0/28"]
    }
  ],
  "k8s_workers" = [
    {
      name = "k8s_worker_zone_a"
      zone = "ru-central1-a"
      cidr = ["10.0.4.0/28"]
    },
    {
      name = "k8s_worker_zone_b"
      zone = "ru-central1-b"
      cidr = ["10.0.5.0/28"]
    },
    {
      name = "k8s_worker_zone_c"
      zone = "ru-central1-c"
      cidr = ["10.0.6.0/28"]
    }
  ],
}

#=========== external_ip ==============
external_static_ips = {
  ingress_lb = [
    {
      name = "ingress_lb_zone_ru_central1_a"
      zone = "ru-central1-a"
    }
  ]
}

Создаём локальную переменную external_ips_array в locals.tf:

my_project/network/locals.tf
locals {
  subnet_array = flatten([for k, v in var.subnets : [for j in v : {
    name = j.name
    zone = j.zone
    cidr = j.cidr
    }
  ]])
  external_ips_array = flatten([for k, v in var.external_static_ips : [for j in v : {
    name = j.name
    zone = j.zone
    }
  ]])
}

Ну и, наконец, создадим файл static-ip.tf:

my_project/network/static-ip.tf
resource "yandex_vpc_address" "public_addr" {
  for_each = {
    for v in local.external_ips_array : "${v.name}" => v
  }
  name = each.value.name
  external_ipv4_address {
    zone_id = each.value.zone
  }
}

Всё как и с подсетями.

Что ещё нам потребуется? Конечно же связность подсетей и безопасность, а значит пора заняться описанием security group.

По уже сложившейся традиции, создадим файл security-group.tf, где опишем группы безопасности для мастеров и воркеров:

my_project/network/security-group.tf
resource "yandex_vpc_security_group" "internal" {
  name        = "internal"
  description = "Managed by terraform"
  network_id  = yandex_vpc_network.mynet.id
  labels = {
    firewall = "yc_internal"
  }
  ingress {
    protocol          = "ANY"
    description       = "self"
    predefined_target = "self_security_group"
    from_port         = 0
    to_port           = 65535
  }
  egress {
    protocol          = "ANY"
    description       = "self"
    predefined_target = "self_security_group"
    from_port         = 0
    to_port           = 65535
  }
}

resource "yandex_vpc_security_group" "k8s_master" {
  name        = "k8s-master"
  description = "Managed by terraform"
  network_id  = yandex_vpc_network.mynet.id
  labels = {
    firewall = "k8s-master"
  }
  ingress {
    protocol       = "TCP"
    description    = "access to api k8s"
    v4_cidr_blocks = var.white_ips_for_master
    port           = 443
  }
  ingress {
    protocol          = "TCP"
    description       = "access to api k8s from Yandex lb"
    predefined_target = "loadbalancer_healthchecks"
    from_port         = 0
    to_port           = 65535
  }
}

resource "yandex_vpc_security_group" "k8s_worker" {
  name        = "k8s-worker"
  description = "Managed by terraform"
  network_id  = yandex_vpc_network.mynet.id
  labels = {
    firewall = "k8s-worker"
  }
  ingress {
    protocol       = "ANY"
    description    = "any connections"
    v4_cidr_blocks = ["0.0.0.0/0"]
    from_port      = 0
    to_port        = 65535
  }
  egress {
    protocol       = "ANY"
    description    = "any connections"
    v4_cidr_blocks = ["0.0.0.0/0"]
    from_port      = 0
    to_port        = 65535
  }
}

Лично я считаю такую организацию групп безопасности более верной для кластера:

  •  internal, для связности мастеров и воркеров, здесь разрешено всё в рамках этой самой группы. 

  •  k8s_master исключительно под мастеров где разрешён доступ к 443 порту по переменной, которой мы должны передать вайтлист и служебное правило для балансировщиков.

  •  k8s_worker группа безопасности для воркеров, где мы разрешим вообще всё.

 Создадим переменную для вайтлиста и пропишем значение для неё.

my_project/network/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== network ==============
variable "network_name" {
  description = "The name of main network"
  type        = string
}

#=========== subnet ==============
variable "subnets" {
  description = "Subnets for k8s"

  type = map(list(object(
    {
      name = string,
      zone = string,
      cidr = list(string)
    }))
  )

  validation {
    condition     = alltrue([for i in keys(var.subnets) : alltrue([for j in lookup(var.subnets, i) : contains(["ru-central1-a", "ru-central1-b", "ru-central1-c"], j.zone)])])
    error_message = "Error! Zones not supported!"
  }
}

#=========== external_ip ==============
variable "external_static_ips" {
  description = "static ips"

  type = map(list(object(
    {
      name = string,
      zone = string
    }))
  )
}

#=========== security_group ==============
variable "white_ips_for_master" {
  type = list(string)
}

my_project/network/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== network ==============
network_name = "mynet"

#=========== subnet ==============
subnets = {
  "k8s_masters" = [
    {
      name = "k8s_master_zone_a"
      zone = "ru-central1-a"
      cidr = ["10.0.1.0/28"]
    },
    {
      name = "k8s_master_zone_b"
      zone = "ru-central1-b"
      cidr = ["10.0.2.0/28"]
    },
    {
      name = "k8s_master_zone_c"
      zone = "ru-central1-c"
      cidr = ["10.0.3.0/28"]
    }
  ],
  "k8s_workers" = [
    {
      name = "k8s_worker_zone_a"
      zone = "ru-central1-a"
      cidr = ["10.0.4.0/28"]
    },
    {
      name = "k8s_worker_zone_b"
      zone = "ru-central1-b"
      cidr = ["10.0.5.0/28"]
    },
    {
      name = "k8s_worker_zone_c"
      zone = "ru-central1-c"
      cidr = ["10.0.6.0/28"]
    }
  ],
}

#=========== external_ip ==============
external_static_ips = {
  ingress_lb = [
    {
      name = "ingress_lb_zone_ru_central1_a"
      zone = "ru-central1-a"
    }
  ]
}

#=========== security_group ==============
white_ips_for_master = ["0.0.0.0/0"]    # Add your IP ["$YOUR_IP/32"]

С network закончили, переносим сюда ключи от сервисного аккаунта и от бакета. Рабочий каталог, после всех наших манипуляций, должен выглядеть следующим образом:

my_project/
└── network
    ├── external_ip.tf
    ├── key.json
    ├── locals.tf
    ├── main.tf
    ├── outputs.tf
    ├── security_group.tf
    ├── storage.key
    ├── terraform.tfvars
    ├── variables.tf
    └── vpc.tf

Можно запускать terraform init, затем terraform apply.

Теперь переходим к каталогу k8s

mkdir ~/my_project/k8s
cd ~/my_project/k8s

Всё по аналогии: создадим файлы main.tf, variables.tf и terraform.tfvars, а также перенесём наши ключи. Кроме того, создадим ещё три конфигурационных файла cluster.tf, node_group.tf и service_account.tf:

my_project/
├── k8s
│   ├── cluster.tf
│   ├── key.json
│   ├── main.tf
│   ├── node_group.tf
│   ├── service_account.tf
│   ├── storage.key
│   ├── terraform.tfvars
│   └── variables.tf
└── network
    ├── external_ip.tf
    ├── key.json
    ├── locals.tf
    ├── main.tf
    ├── outputs.tf
    ├── security_group.tf
    ├── storage.key
    ├── terraform.tfvars
    ├── variables.tf
    └── vpc.tf

Переносим ресурсы для групп узлов в node_group.tf, описание кластера в cluster.tf, и всё, что относится к сервисным аккаунтам в service_account.tf, сюда же положим и ключ шифрования.

my_project/k8s/cluster.tf
resource "yandex_kubernetes_cluster" "k8s-regional" {
  network_id = yandex_vpc_network.mynet.id
  master {
    version = local.k8s_version
    regional {
      region = "ru-central1"
      location {
        zone      = yandex_vpc_subnet.mysubnet-a.zone
        subnet_id = yandex_vpc_subnet.mysubnet-a.id
      }
      location {
        zone      = yandex_vpc_subnet.mysubnet-b.zone
        subnet_id = yandex_vpc_subnet.mysubnet-b.id
      }
      location {
        zone      = yandex_vpc_subnet.mysubnet-c.zone
        subnet_id = yandex_vpc_subnet.mysubnet-c.id
      }
    }
    security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
  }
  service_account_id      = yandex_iam_service_account.myaccount.id
  node_service_account_id = yandex_iam_service_account.myaccount.id
  depends_on = [
    yandex_resourcemanager_folder_iam_binding.k8s-clusters-agent,
    yandex_resourcemanager_folder_iam_binding.vpc-public-admin,
    yandex_resourcemanager_folder_iam_binding.images-puller
  ]
  kms_provider {
    key_id = yandex_kms_symmetric_key.kms-key.id
  }
}

my_project/k8s/service_account.tf
resource "yandex_iam_service_account" "myaccount" {
  name        = local.sa_name
  description = "K8S regional service account"
}

resource "yandex_resourcemanager_folder_iam_binding" "k8s-clusters-agent" {
  # Сервисному аккаунту назначается роль "k8s.clusters.agent".
  folder_id = local.folder_id
  role      = "k8s.clusters.agent"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_resourcemanager_folder_iam_binding" "vpc-public-admin" {
  # Сервисному аккаунту назначается роль "vpc.publicAdmin".
  folder_id = local.folder_id
  role      = "vpc.publicAdmin"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_resourcemanager_folder_iam_binding" "images-puller" {
  # Сервисному аккаунту назначается роль "container-registry.images.puller".
  folder_id = local.folder_id
  role      = "container-registry.images.puller"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_kms_symmetric_key" "kms-key" {
  # Ключ для шифрования важной информации, такой как пароли, OAuth-токены и SSH-ключи.
  name              = "kms-key"
  default_algorithm = "AES_128"
  rotation_period   = "8760h" # 1 год.
}

resource "yandex_kms_symmetric_key_iam_binding" "viewer" {
  symmetric_key_id = yandex_kms_symmetric_key.kms-key.id
  role             = "viewer"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}",
  ]
}

my_project/k8s/node_group.tf
resource "yandex_kubernetes_node_group" "my_node_group_a" {
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = "worker-a"
  description = "description"
  version     = local.k8s_version
  labels = {
    "key" = "value"
  }
  instance_template {
    platform_id = "standard-v1"
    name        = "worker-a-{instance.short_id}"
    network_interface {
      nat                = true
      subnet_ids         = [yandex_vpc_subnet.mysubnet-a.id]
      security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
    }
    resources {
      memory = 2
      cores  = 2
    }
    boot_disk {
      type = "network-hdd"
      size = 32
    }
    scheduling_policy {
      preemptible = false
    }
  }
  scale_policy {
    auto_scale {
      min     = 1
      max     = 3
      initial = 1
    }
  }
  allocation_policy {
    location {
      zone = "ru-central1-a"
    }
  }
}

resource "yandex_kubernetes_node_group" "my_node_group_b" {
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = "worker-b"
  description = "description"
  version     = local.k8s_version
  labels = {
    "key" = "value"
  }
  instance_template {
    platform_id = "standard-v1"
    name        = "worker-b-{instance.short_id}"
    network_interface {
      nat                = true
      subnet_ids         = [yandex_vpc_subnet.mysubnet-b.id]
      security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
    }
    resources {
      memory = 2
      cores  = 2
    }
    boot_disk {
      type = "network-hdd"
      size = 32
    }
    scheduling_policy {
      preemptible = false
    }
  }
  scale_policy {
    auto_scale {
      min     = 1
      max     = 3
      initial = 1
    }
  }
  allocation_policy {
    location {
      zone = "ru-central1-b"
    }
  }
}

resource "yandex_kubernetes_node_group" "my_node_group_c" {
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = "worker-c"
  description = "description"
  version     = local.k8s_version
  labels = {
    "key" = "value"
  }
  instance_template {
    platform_id = "standard-v1"
    name        = "worker-c-{instance.short_id}"
    network_interface {
      nat                = true
      subnet_ids         = [yandex_vpc_subnet.mysubnet-c.id]
      security_group_ids = [yandex_vpc_security_group.k8s-main-sg.id]
    }
    resources {
      memory = 2
      cores  = 2
    }
    boot_disk {
      type = "network-hdd"
      size = 32
    }
    scheduling_policy {
      preemptible = false
    }
  }
  scale_policy {
    auto_scale {
      min     = 1
      max     = 3
      initial = 1
    }
  }
  allocation_policy {
    location {
      zone = "ru-central1-c"
    }
  }
}

Так же, как и в network, создадим переменные:

my_project/k8s/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

Присвоим им значения:

my_project/k8s/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

И перепишем main.tf

my_project/k8s/main.tf
terraform {
  required_providers {
    yandex = {
      source = "yandex-cloud/yandex"
    }
  }
  backend "s3" {
    endpoint                = "storage.yandexcloud.net"
    bucket                  = "test-tfstate"
    region                  = "ru-central1"
    key                     = "my-project-k8s.tfstate"
    shared_credentials_file = "storage.key"

    skip_region_validation      = true
    skip_credentials_validation = true
  }
}

provider "yandex" {
  service_account_key_file = "key.json"
  cloud_id                 = var.cloud_id
  folder_id                = var.folder_id
  zone                     = var.default_zone
}

Не забываем сменить имя файла состояния в параметре key.

Итак, давайте переписывать. Начинаем с service_account.tf, у нас имя сервисного аккаунта было задано через locals, мы это переделаем. Нам нужно имя сервисного аккаунта и ключа шифрования задать через переменные, для этого создаём их:

my_project/k8s/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== service_account ==============
variable "service_account_name" {
  description = "Name of service account"
  type = string
  default = null
}

variable "kms_provider_key_name" {
  description = "KMS key name."
  type = string
  default = null
}

И определяем:

my_project/k8s/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== service_account ==============
service_account_name  = "myaccount"
kms_provider_key_name = "kms-key"

Переписываем service_account.tf:

my_project/k8s/service_account.tf
resource "yandex_iam_service_account" "myaccount" {
  name        = var.service_account_name
  description = "K8S regional service account"
}

resource "yandex_resourcemanager_folder_iam_binding" "editor" {
  # Сервисному аккаунту назначается роль "editor".
  folder_id = var.folder_id
  role      = "editor"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_resourcemanager_folder_iam_binding" "images-puller" {
  # Сервисному аккаунту назначается роль "container-registry.images.puller".
  folder_id = var.folder_id
  role      = "container-registry.images.puller"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}"
  ]
}

resource "yandex_kms_symmetric_key" "kms-key" {
  # Ключ для шифрования важной информации, такой как пароли, OAuth-токены и SSH-ключи.
  name              = var.kms_provider_key_name
  default_algorithm = "AES_128"
  rotation_period   = "8760h" # 1 год.
}

resource "yandex_kms_symmetric_key_iam_binding" "viewer" {
  symmetric_key_id = yandex_kms_symmetric_key.kms-key.id
  role             = "viewer"
  members = [
    "serviceAccount:${yandex_iam_service_account.myaccount.id}",
  ]
}

Что ж, вот мы и можем, наконец, приступить к оптимизации кода кластера. Давайте подставим наши значения в cluster, попутно прописав переменные для некоторых из них:

my_project/k8s/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== service_account ==============
variable "service_account_name" {
  description = "Name of service account"
  type = string
  default = null
}

variable "kms_provider_key_name" {
  description = "KMS key name."
  type = string
  default = null
}


#=========== cluster ==============
variable "cluster_name" {
  description = "Name of a specific Kubernetes cluster."
  type        = string
  default     = null
}

variable "network_policy_provider" {
  description = "Network policy provider for the cluster. Possible values: CALICO."
  type = string
  default = "CALICO"
}

variable "master_version" {
  description = "Version of Kubernetes that will be used for master."
  type = string
  default = null
}

variable "master_public_ip" {
  description = "Boolean flag. When true, Kubernetes master will have a visible ipv4 address."
  type = bool
  default = true
}

variable "master_region" {
  description = "Name of region where cluster will be created. Required for regional cluster, not used for zonal cluster."
  type = string
  default = null
}

Как вы можете видеть, у двух переменных, а именно master_public_ip и network_policy_provider, уже задано дефолтное значение. Нам нужно в terraform.tfvars указать остальные:

my_project/k8s/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== service_account ==============
service_account_name  = "myaccount"
kms_provider_key_name = "kms-key"

#=========== cluster ==============
cluster_name   = "cluster"
master_version = "1.22"
master_region  = "ru-central1"

Вот мы и дошли до важнейшего момента - нам необходимо указать id сети, но её мы поднимали другим манифестом Terraform'а, а значит, из этого манифеста Terraform не может увидеть созданные там ресурсы. как же быть?

Для того, чтобы получить какие-либо данные извне, есть data sources. С помощью data мы можем указать терраформ источник, откуда ему следует взять данные. 

Создадим файл data.tf:

my_project/k8s/data.tf
data "yandex_vpc_network" "network" {
  name = "mynet"
}

Источник данных указывается очень просто:  data, и прописываем откуда брать данные, а нужна нам сеть с именем mynet.

Теперь нам достаточно сослаться на этот источник в описании ресурса, обязательно указав что именно нам нужно оттуда забрать - data.yandex_vpc_network.mynet.id:

resource "yandex_kubernetes_cluster" "k8s-regional" {  
network_id = data.yandex_vpc_network.mynet.id
[...]
}

Можем оставить так, но что, если имя сети поменяется? Переписали мы конфигурацию сети, нам же придётся и в каталоге k8s в data.tf поменять параметр, а ведь мы переписываем код для того, чтобы не лазить больше в конфигурационные файлы. Мы можем вынести этот параметр в переменные и менять его там - скажете вы. А что, если вообще не заморачиваться над тем, как назван ресурс? Что, если мы укажем id сети так, что нам нигде не придётся ничего переписывать при любых изменениях в манифесте network? Для этого нужно ответить на один вопрос - а где ещё хранятся данные о созданных ресурсах любого манифеста? Верно, в файле состояния. Вот мы и укажем Terraform'у сходить и прочитать другой tfstate файл.

Создадим другой источник данных и в нем укажем путь и доступы к чужому файлу состояния. 

my_project/k8s/data.tf
data "terraform_remote_state" "network" {
  backend = "s3"
  config = {
    endpoint                = "storage.yandexcloud.net"
    bucket                  = var.network_backet_name
    region                  = "ru-central1"
    key                     = var.network_state_key
    shared_credentials_file = "storage.key"

    skip_region_validation      = true
    skip_credentials_validation = true
  }

Как видите, здесь используются переменные, давайте их создадим. И присвоим значения - их мы возьмём из main.tf в каталоге network. Расположил я их после переменных для main.

my_project/k8s/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== terraform_remote_state ==============
variable "network_state_key" {
  description = "The key of state for the network."
  type        = string
  default     = null
}

variable "network_bucket_name" {
  description = "The name of bucket for the network."
  type        = string
  default     = null
}

#=========== service_account ==============
variable "service_account_name" {
  description = "Name of service account"
  type = string
  default = null
}

variable "kms_provider_key_name" {
  description = "KMS key name."
  type = string
  default = null
}


#=========== cluster ==============
variable "cluster_name" {
  description = "Name of a specific Kubernetes cluster."
  type        = string
  default     = null
}

variable "network_policy_provider" {
  description = "Network policy provider for the cluster. Possible values: CALICO."
  type = string
  default = "CALICO"
}

variable "master_version" {
  description = "Version of Kubernetes that will be used for master."
  type = string
  default = null
}

variable "master_public_ip" {
  description = "Boolean flag. When true, Kubernetes master will have a visible ipv4 address."
  type = bool
  default = true
}

variable "master_region" {
  description = "Name of region where cluster will be created. Required for regional cluster, not used for zonal cluster."
  type = string
  default = null
}

my_project/k8s/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== terraform_remote_state ==============
network_state_key = "my-project-network.tfstate"
network_bucket_name = "test-tfstate"

#=========== service_account ==============
service_account_name  = "myaccount"
kms_provider_key_name = "kms-key"

#=========== cluster ==============
cluster_name   = "cluster"
master_version = "1.22"
master_region  = "ru-central1"

Теперь мы можем читать другой файл состояния и считывать оттуда значения, однако, их там пока нет. Для того, чтобы они появились, нам нужно вернуться в каталог network и оформить outputs. Итак, создаём outputs.tf и описываем то, что хотим вывести:

my_project/network/outputs.tf
output "network_id" {
  value = yandex_vpc_network.mynet.id
}

А нужно нам id сети. Вот и всё. Обязательно запустите terraform apply, чтобы данные записались в файл состояния.

Теперь, для того, чтобы нам сослаться на какой-либо ресурс из network, потребуется постоянно указывать конструкцию data.terraform_remote_state.network.outputs. и имя того output’а который нам нужен.

Например:

resource "yandex_kubernetes_cluster" "k8s-regional" {  
network_id = data.terraform_remote_state.network.outputs.network_id
[...]
}

Давайте используем locals, чтобы упростить себе жизнь. Итак, создаём файл locals.tf тут мы создадим переменную:

my_project/k8s/locals.tf
locals {
  network_output = data.terraform_remote_state.network.outputs
}

Теперь можем всегда указывать просто:

resource "yandex_kubernetes_cluster" "k8s-regional" {  
network_id = local.network_output.network_id
[...]
}

Думаю так гораздо удобнее.

Давайте для групп безопасности тоже выведем id, т.к. они нам также понадобятся:

my_project/network/outputs.tf
output "network_id" {
  value = yandex_vpc_network.mynet.id
}

output "sg_internal" {
  value = yandex_vpc_security_group.internal.id
}

output "sg_k8s_master" {
  value = yandex_vpc_security_group.k8s_master.id
}

output "sg_k8s_worker" {
  value = yandex_vpc_security_group.k8s_worker.id
}

Но также нам нужны зоны и id подсетей. Здесь уже сложнее, ведь мы не можем просто указать эти значения, так же, как и для других, поскольку подсети у нас создаются циклом в network и писать output под каждую подсеть мы не можем. 

Для нашей задачи мы действительно используем output, но для этого напишем небольшое условие:

my_project/network/outputs.tf
output "network_id" {
  value = yandex_vpc_network.mynet.id
}

output "sg_internal" {
  value = yandex_vpc_security_group.internal.id
}

output "sg_k8s_master" {
  value = yandex_vpc_security_group.k8s_master.id
}

output "sg_k8s_worker" {
  value = yandex_vpc_security_group.k8s_worker.id
}

output "k8s_masters_subnet_info" {
  value = [for k, v in var.subnets["k8s_masters"] : zipmap(["subnet_id", "zone"], [yandex_vpc_subnet.subnet-main[v.name].id, yandex_vpc_subnet.subnet-main[v.name].zone])]
}

output "k8s_workers_subnet_info" {
  value = [for k, v in var.subnets["k8s_workers"] : zipmap(["subnet_id", "zone"], [yandex_vpc_subnet.subnet-main[v.name].id, yandex_vpc_subnet.subnet-main[v.name].zone])]
}

Как вы помните, мы создали 6 подсетей, три под мастеров и три под воркеров, вот я и хочу получить их id и zone разными output»ами: первый — под наши требования для кластера, а второй — заранее сделаем для воркеров.

Здесь мы фильтруем значение переменной subnets, чтобы бралась информация только по необходимым подсетям(в данном случае для мастеров). Далее с помощью функции zipmap мы создаём список, где присваиваем ключам subnet_id и zone соответствующие атрибуты из переменной, и так для каждой подсети в k8s_masters.

Аналогично и для подсетей в k8s_workers.

Перезапускаем terraform apply.

Окей, теперь переписываем конфигурацию на переменные, а ID групп безопасности указываем две, internal и k8s_master из output»ов предыдущего манифеста:

my_project/k8s/cluster.tf
resource "yandex_kubernetes_cluster" "k8s-regional" {
  network_id              = local.network_output.network_id
  network_policy_provider = var.network_policy_provider
  service_account_id      = yandex_iam_service_account.myaccount.id
  node_service_account_id = yandex_iam_service_account.myaccount.id
  master {
    version   = var.master_version
    public_ip = var.master_public_ip
    regional {
      region = var.master_region
      dynamic "location" {
        for_each = local.network_output.k8s_masters_subnet_info
        content {
          zone      = location.value["zone"]
          subnet_id = location.value["subnet_id"]
        }
      }
    }
    security_group_ids = [local.network_output.sg_internal, local.network_output.sg_k8s_master]
  }  
  kms_provider {
    key_id = yandex_kms_symmetric_key.kms-key.id
  }
  depends_on = [
    yandex_resourcemanager_folder_iam_binding.editor,
    yandex_resourcemanager_folder_iam_binding.images-puller
  ]
}

Для того, чтобы указать подсети и зоны, мы используем dynamic блок с циклом for_each, указываем, откуда будем брать значения (а берем мы их из нашего нового output»a). Далее мы описываем content, и сюда пишем те параметры, которые нужны этому блоку, а нужны зона и ID, теперь указываем откуда брать значения, а их мы берем из того цикла, который мы передали, т. е. location.value и указываем какой элемент нужен.

Запускаем terraform init и terraform apply в каталоге k8s.

Ну вот, у нас получилось поднять инфраструктуру с помощью двух разных манифестов Terraform. Бежим в Яндекс и проверяем — кластер на месте. Нам осталось решить, как описать группы узлов. Они у нас так же, как и подсети до этого, указаны тремя ресурсами. Давайте исправлять. Как вы наверное уже догадались, делать мы это будем через тот же for_each. Но сначала, создадим переменную:

my_project/k8s/variables.tf
#=========== main ==============
variable "cloud_id" {
  description = "The cloud ID"
  type        = string
}
variable "folder_id" {
  description = "The folder ID"
  type        = string
}
variable "default_zone" {
  description = "The default zone"
  type        = string
  default     = "ru-cenral1-a"
}

#=========== terraform_remote_state ==============
variable "network_state_key" {
  description = "The key of state for the network."
  type        = string
  default     = null
}

variable "network_bucket_name" {
  description = "The name of bucket for the network."
  type        = string
  default     = null
}

#=========== service_account ==============
variable "service_account_name" {
  description = "Name of service account"
  type = string
  default = null
}

variable "kms_provider_key_name" {
  description = "KMS key name."
  type = string
  default = null
}

#=========== cluster ==============
variable "cluster_name" {
  description = "Name of a specific Kubernetes cluster."
  type        = string
  default     = null
}

variable "network_policy_provider" {
  description = "Network policy provider for the cluster. Possible values: CALICO."
  type = string
  default = "CALICO"
}

variable "master_version" {
  description = "Version of Kubernetes that will be used for master."
  type = string
  default = null
}

variable "master_public_ip" {
  description = "Boolean flag. When true, Kubernetes master will have a visible ipv4 address."
  type = bool
  default = true
}

variable "master_region" {
  description = "Name of region where cluster will be created. Required for regional cluster, not used for zonal cluster."
  type = string
  default = null
}

#=========== node_groups ==============
variable "node_groups" {
  description = "Parameters of Kubernetes node groups."
  default = {}
}

И присвоим значение этой переменной в terraform.tfvars:

my_project/k8s/terraform.tfvars
#=========== main ==============
cloud_id  = "b1gq90dgh25bebiu75o"
folder_id = "b1gia87mbaomkfvsleds"

#=========== terraform_remote_state ==============
network_state_key = "my-project-network.tfstate"
network_bucket_name = "test-tfstate"

#=========== service_account ==============
service_account_name  = "myaccount"
kms_provider_key_name = "kms-key"

#=========== cluster ==============
cluster_name   = "cluster"
master_version = "1.22"
master_region  = "ru-central1"

#=========== node_groups ==============
node_groups = {
  node-group-a = {
    platform_id    = "standard-v1",
    name           = "worker-a-{instance.short_id}",
    cores          = 2,
    memory         = 2,
    boot_disk_type = "network-ssd",
    boot_disk_size = 32,
    zone           = "ru-central1-a",
    auto_scale = {
      min     = 1,
      max     = 3,
      initial = 1
    }
  }
  node-group-b = {
    platform_id    = "standard-v1",
    name           = "worker-b-{instance.short_id}",
    cores          = 2,
    memory         = 2,
    boot_disk_type = "network-ssd",
    boot_disk_size = 32,
    zone           = "ru-central1-b",
    fixed_scale = {
      size     = 1
    }
  }
  node-group-c = {
    platform_id    = "standard-v1",
    name           = "worker-c-{instance.short_id}",
    cores          = 2,
    memory         = 2,
    boot_disk_type = "network-ssd",
    boot_disk_size = 32,
    zone           = "ru-central1-c",
    fixed_scale = {
      size     = 1
    }
  }
}

Тут мы указываем всё то, что хотим видеть в параметрах ресурса, главное потом это правильно присвоить. Как вы могли заметить, блоки отличаются, а именно для node‑group‑a указан auto_scale, а для остальных групп fixed_scale. Таким образом, я хочу добавить возможность менять тип групп через переменные.

Также, в yandex_kubernetes_node_group нам потребуется указать id подсетей. Вспоминаем о нашем ранее созданном output»е — k8s_workers_subnet_info, но там передаётся не только id, но и зона. В чем проблема? — спросите вы. Ведь можно переписать output, чтобы он выдавал только id. Но нет, в таком случае у нас пропадёт привязка к зоне, ведь мы просто получим набор id, и понять какой из них принадлежит определённой зоне не получится. Да, они у нас сейчас по порядку, первый id будет относится к зоне ru‑cenral1-a, второй — к b и третий — к c. Но это мы в манифесте network нашей переменной так значения по порядку указали, а вот если их поменять местами, получим путаницу с таким подходом, и поднимется у нас, к примеру, группа с именем node‑group‑a в зоне b. Поэтому output мы не трогаем, а сделаем преобразование данных через locals:

my_project/k8s/locals.tf
locals {
  network_output = data.terraform_remote_state.network.outputs
  worker_subnet_list = zipmap([for subnet in local.network_output.k8s_workers_subnet_info : subnet.zone], [for subnet in local.network_output.k8s_workers_subnet_info : subnet.subnet_id])
}

Создаём переменную worker_subnet_list, тут мы создадим список, где каждой зоне присвоится id.

Возвращаемся к нашему ресурсу в файл node_group.tf:

my_project/k8s/node_group.tf
resource "yandex_kubernetes_node_group" "my_node_groups" {
  for_each    = var.node_groups
  cluster_id  = yandex_kubernetes_cluster.k8s-regional.id
  name        = each.key
  description = lookup(each.value, "description", null)
  version     = lookup(each.value, "version", var.master_version)
  labels = lookup(each.value, "labels", null)
  instance_template {
    platform_id = lookup(each.value, "platform_id", null)
    name        = lookup(each.value, "name", null)
    network_interface {
      nat                = lookup(each.value, "nat", true)
      subnet_ids         = [lookup(local.worker_subnet_list, each.value["zone"])]
      security_group_ids = [local.network_output.sg_internal, local.network_output.sg_k8s_worker]
    }
    resources {
      memory = lookup(each.value, "memory", 2)
      cores  = lookup(each.value, "cores", 2)
    }
    boot_disk {
      type = lookup(each.value, "boot_disk_type", "network-hdd")
      size = lookup(each.value, "boot_disk_size", 64)
    }
    scheduling_policy {
      preemptible = lookup(each.value, "preemptible", false)
    }
  }
  scale_policy {
    dynamic "fixed_scale" {
      for_each = flatten([lookup(each.value, "fixed_scale", can(each.value["auto_scale"]) ? [] : [{ size = 1 }])])

      content {
        size = fixed_scale.value.size
      }
    }
    dynamic "auto_scale" {
      for_each = flatten([lookup(each.value, "auto_scale", [])])
      content {
        min     = auto_scale.value.min
        max     = auto_scale.value.max
        initial = auto_scale.value.initial
      }
    }
  }
  allocation_policy {
    location {
      zone = each.value["zone"]
    }
  }
}

  • Конечно же, меняем имя ресурса в Terraform'е.

  • Объявляем, что хотим использовать for_each и указываем откуда циклу брать значения (а лежат они в переменной node_groups).

  • name, берет значение из each.key, это у нас имя объекта: node‑group‑a, node‑group‑b и node‑group‑c.

  • Далее присваиваем значения остальным параметрам, но делаем мы это по условию, для этого используем функция lookup, синтаксис у неё очень прост, мы указываем где искать, что искать, и что присвоить если ничего не нашла (значение по умолчанию).

  • subnet_ids ставим источник local.worker_subnet_list, указываем что брать, а забирать мы будем id той зоны, которая указана в переменной node_groups, поэтому ставим each.value[«zone»]. Дефолтное значение убираем, т.к. сюда мы по умолчанию ничего не сможем поставить.

  • security_group_ids — группы безопасности мы указываем без всяких условий, просто указываем нужные output»ы.

  • scale_policy — как я уже говорил, нужна возможность выбора, либо фиксированная группа узлов, либо с автоскейлингом — для этого добавляем два соответствующих dynamic блока, в них описываем цикл, но, просто указать источник значений не получится, у нас в переменной вложенный список для этих блоков, чтобы вытащить необходимые значения, нужно этот список вытащить из другого, для этого воспользуемся функцией flatten, которая выравнивает вложенные списки. А далее можем использовать lookup — отличие лишь в том, что для блока fixed_scale на месте дефолтного значения мы ставим условие, которое проверяет наличие параметра auto_scale и если он есть, оставляет этот блок fixed_scale без значения, а если нет, то выставляет один фиксированный узел.

  • В content для этих блоков указываем использовать значения из соответствующего источника: для auto_scale — auto_scale.value.что_берем, для fixed_scale — fixed_scale.value.size

  • allocation policy: без всяких lookup»ов ссылаемся на нужное значение.

Получившаяся конфигурация должна поднимать для нас региональный кластер Kubernetes, с автоскейлингом нод в зоне ru‑central1-a, а в остальных зонах с 1 фиксированный нодой.

Однако, в процессе подготовки тестового стенда я столкнулся с проблемой, что инфраструктура поднималась и работала с этим кодом, а уже на следующий день — нет. Все необходимые узлы создавались, но не имели связи с мастерами. После проверки, я понял, что правила для группы безопасности не отрабатывают так, как это задумано. Пришлось переписать группу internal:

my_project/network/security-group.tf
resource "yandex_vpc_security_group" "internal" {
  name        = "internal"
  description = "Managed by terraform"
  network_id  = yandex_vpc_network.mynet.id
  labels = {
    firewall = "yc_internal"
  }
  ingress {
    protocol          = "ANY"
    description       = "self"
    predefined_target = "self_security_group"
    from_port         = 0
    to_port           = 65535
  }
  ingress {
    protocol       = "ANY"
    description    = "Правило разрешает взаимодействие под-под и сервис-сервис. Укажите подсети вашего кластера и сервисов. P.s. Правило избыточно и добавлено только потому, что политика self_security_group не функционирует как положено между машинами в разных регионах."
    v4_cidr_blocks = flatten([for v in concat(var.subnets["k8s_masters"], var.subnets["k8s_workers"]) : v.cidr])
    from_port      = 0
    to_port        = 65535
  }

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

resource "yandex_vpc_security_group" "k8s_master" {
  name        = "k8s-master"
  description = "Managed by terraform"
  network_id  = yandex_vpc_network.mynet.id
  labels = {
    firewall = "k8s-master"
  }
  ingress {
    protocol       = "TCP"
    description    = "access to api k8s"
    v4_cidr_blocks = var.white_ips_for_master
    port           = 443
  }
  ingress {
    protocol          = "TCP"
    description       = "access to api k8s from Yandex lb"
    predefined_target = "loadbalancer_healthchecks"
    from_port         = 0
    to_port           = 65535
  }
}

resource "yandex_vpc_security_group" "k8s_worker" {
  name        = "k8s-worker"
  description = "Managed by terraform"
  network_id  = yandex_vpc_network.mynet.id
  labels = {
    firewall = "k8s-worker"
  }
  ingress {
    protocol       = "ANY"
    description    = "any connections"
    v4_cidr_blocks = ["0.0.0.0/0"]
    from_port      = 0
    to_port        = 65535
  }
  egress {
    protocol       = "ANY"
    description    = "any connections"
    v4_cidr_blocks = ["0.0.0.0/0"]
    from_port      = 0
    to_port        = 65535
  }
}

Поясню свою мысль. Правило, где мы выставляем predefined_target = «self_security_group», должно разрешать всё в рамках этой самой группы, т. е. хосты, к которым привязана группа internal (а в нашем случае это все мастеры и воркеры), должны свободно общаться друг с другом. Но этого не происходит — по информации от техподдержки, они перерабатывают механизм работы этого правила, и пока оно может «работать неожидаемым образом, когда эндпоинты находятся в разных зонах». Поэтому, мне пришлось добавить другое, которое открывает доступ всем узлам, находящимся в перечисленных в ней подсетях.

Как работает правило: 

  1. Нам нужны cidr наших подсетей, они у нас в переменной subnets. Однако, нам не нужно собирать этот параметр со всех подсетей, мы же можем прописать создание и других, не только для кластера и воркеров, как сейчас, — у нас могут появиться другие подсети, и мы не можем позволить трафику с других хостов, которые не относятся к кластеру, ходить к нему. Поэтому отфильтруем только то, что относится к кластеру, — это мастеры и воркеры:

[(var.subnets["k8s_masters"], var.subnets["k8s_workers"])] 
  1. Но, таким образом, мы получили два списка. Не проблема, вызовем функцию concat, которая объединяет несколько списков в один: 

concat(var.subnets["k8s_masters"], var.subnets["k8s_workers"]) 
  1. Теперь, нужно вытащить только cidr, а у нас в переменной помимо этого ключа ещё name и zone, поэтому напишем условие, что в список мы собираем только значения ключа cidr:

[for v in concat(var.subnets["k8s_masters"], var.subnets["k8s_workers"]) : v.cidr]
  1. Мы получили нужные параметры, но, cidr у нас представлен в переменной, как вложенный список, с помощью flatten мы выравниваем этот список:

flatten([for v in concat(var.subnets["k8s_masters"], var.subnets["k8s_workers"]) : v.cidr])

Вот теперь, можем запускаем terraform apply и любуемся на нашу инфраструктуру. ????

Если есть необходимость в настройке дополнительных параметров, как доступ по ssh к воркер‑нодам, вы это сможете сделать уже самостоятельно, почитав документацию.

Заключение

Можно было ещё много параметров описать, но цель статьи в другом — показать разные методы по написанию кода инфраструктуры и научить способам работы с компонентами Terraform»а. Надеюсь вам это помогло.

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

Спасибо, что дочитали до конца. Постараемся выложить следующую часть, как можно скорее. Благодарю всех за внимание, до скорого!)

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


  1. Ru6aKa
    00.00.0000 00:00
    +1

    При применении Terragrunt часть этих самых best practices просто поменяется или пропадет и появятся новые рекомендации и практики.


    1. Vikontrol Автор
      00.00.0000 00:00

      Абсолютно согласен, но это уже совсем другая история)


      1. Ru6aKa
        00.00.0000 00:00

        Я бы с этой другой истории и начинал бы, потому что terragrunt решает кучу проблем которые не решить никакими best practices в terraform. Да и мало того с terraform не так просто перейти на terragrunt, там логика на другая и на уровне выше и это все можно было бы описать в статье, хотя бы вскользь с какими-то простыми примерами или ремарками.


        1. Vikontrol Автор
          00.00.0000 00:00
          +2

          Всё впереди, и до terragrunt доберёмся)


  1. Jsty
    00.00.0000 00:00
    +4

    У вас для network указан state с

    key = "test_network.tfstate"

    А далее используется

    network_state_key = "my-project-network.tfstate"


    1. Vikontrol Автор
      00.00.0000 00:00

      Благодарю, поправил ????