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

Дай мне частную виртуальную сеть с базой данных и кластером kubernetes. Кластер должен иметь некоторое количество узлов, и все они должны использовать определенный тип CPU. База данных должна быть расположена именно здесь, и она должна иметь возможность хранить определенное количество гигабайт…

Цель этой статьи — рассказать о том, как мы в Bulder Bank подходим к проблеме написания понятного кода Terraform.

Исходные данные со вкусом YAML

Большинство разработчиков записывают исходные данные в формате HCL в файлах .tfvars. Я предпочитаю YAML, потому что его легче читать. Когда вы определяете исходные данные для своих корневых модулей Terraform с помощью YAML, легче интерпретировать требуемое состояние:

# ./my-project/live/prod/config.yaml

networks:
  - name: network-a
    region: europe-west1
  - name: network-b
    region: europe-north1  

databases:
  - name: database-number1
    type: cloudsql
    network: network-a
    region: europe-west1-a
    disk_size: 20gb
  - name: database-number2
    type: postgresql
    network: network-b
    region: europe-north1-b
    disk_size: 40gb

clusters:
  - name: prod-blue
    region: europe-west1-a
    network: network-a
    min_nodes: 3
    max_nodes: 6
  - name: prod-green
    region: europe-west1-b
    network: network-a
    min_nodes: 3
    max_nodes: 6

Эквивалентный код в HCL выглядит довольно шумным:

networks = [
  {
    name = "network-a"
    location = "europe-west1"
  },
  {
    name = "network-b"
    location = "europe-west1"
  }
]

databases = [
  {
    name = "database-number1"
    type = "cloudsql"
    network = "network-a"
    location = "europe-west1-a"
    disk_size = "20gb"
  },
  {
    name = "database-number2"
    type = "postgresql"
    network = "network-b"
    region = "europe-west1-b"
    disk_size = "40gb"
  }
]

# etc..

Функционал Terraform позволяет преобразовывать YAML в HCL, для этого есть специальная встроенная функция (yamldecode()). 

YAML-конфигурацию можно сделать доступной для Terraform с помощью следующего алгоритма:

# ./live/*/locals.tf
locals {
  config = yamldecode(file("./config.yaml"))
}

Так вы сделаете все содержимое config.yaml доступным в .tf файлах через объект local.config.

Ориентация на конфигурацию

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

При ориентации на конфигурацию вы минимизируете количество файлов, с которыми инженеры будут работать в долгосрочной перспективе. Инженер обращается к старому коду Terraform только потому, что хочет понять (или изменить) требуемое состояние. Если вы структурируете исходные данные, как показано выше, большинство просмотров кода можно ограничить файлом config.yaml. Любой, кто понимает YAML, интуитивно разберется, как добавлять/удалять базы данных, кластеры и сети. Такие простые задачи, как увеличение размера базы данных, также становятся интуитивно понятными.

Структура каталогов

Общая сложность многих Terraform-проектов заключается в том, что они плохо структурированы. Я видел репозитории с десятками файлов .tf, где читатель даже не знает, с чего начать.

В Bulder Bank мы используем фиксированную структуру каталогов для всех наших проектов Terraform. Предположим, мы хотим развернуть ресурсы в двух средах — dev и prod:

└── my-project
    ├── live
    │   ├── dev
    │   │   ├── config.yaml
    │   │   ├── modules.tf
    │   │   ├── providers.tf
    │   │   ├── locals.tf
    │   │   └── terraform.tf
    │   └── prod
    │       ├── config.yaml
    │       ├── modules.tf
    │       ├── providers.tf
    │       ├── locals.tf
    │       └── terraform.tf
    └── modules
        ├── kubernetes
        │   └── main.tf
        ├── network
        │   └── main.tf
        └── database
            └── main.tf

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

Использование модулей

Ничего особенного в модулях Terraform, которые мы помещаем в каталог modules/, нет. Они написаны на языке HCL и обычно используют простые исходные данные, такие как строки, числа или списки. Если они применяются в нескольких корневых модулях, мы храним их в собственных репозиториях GitHub для повторного использования.

Мы не используем блоки resource в корневом модуле для проектов, где есть несколько окружений: например, prod / dev / staging. Мы используем только блоки module и data в modules.tf. У такого подхода есть несколько преимуществ:

  • Файлы modules.tf всегда идентичны в разных окружениях, их легко копировать и переносить. 

  • Код Terraform становится легче модифицировать в разных окружениях.

  • Команды terraform statecommands становятся проще в обработке.

Для проектов с одним окружением нет необходимости в этом стилевом ограничении.

Файлы modules.tf для нашего примера конфигурации YAML будут выглядеть примерно так:

# ./my-project/live/*/modules.tf

module "network" {
  for_each = { for x in local.config.networks : x.name => x }
  source   = "../../modules/network"

  name      = each.value.name
  region    = each.value.region
}

module "database" {
  for_each = { for x in local.config.databases : x.name => x }
  source   = "../../modules/database"

  name      = each.value.name
  type      = each.value.type
  region    = each.value.region
  disk_size = each.value.disk_size
  network   = module.network[each.value.network].name
}

module "kubernetes" {
  for_each = { for x in local.config.clusters : x.name => x }
  source   = "../../modules/kubernetes"

  name      = each.value.name
  region    = each.value.region
  min_nodes = each.value.min_nodes
  max_nodes = each.value.max_nodes
  network   = module.network[each.value.network].name
}

Значения по умолчанию

Модули Terraform часто содержат большое количество исходных данных. Включение всех этих значений в конфигурационные файлы YAML может негативно сказаться на их читабельности. Когда мы пишем свои собственные модули, мы можем использовать значения по умолчанию в каталоге modules/, чтобы убрать ненужные усложнения из наших YAML-файлов. Проблема, с которой можно столкнуться при использовании YAML, — как поддерживать необязательные значения в config.yaml. Существует изящный прием для отсылки к значениям модуля по умолчанию — он доступен с версии Terraform v1.1.0. 

Предположим, что переменная max_nodes в модуле kubernetes по умолчанию равна 6. Мы хотим иметь возможность переписать это значение по умолчанию в config.yaml, но мы также хотим, чтобы Terraform использовал значение по умолчанию 6, если переменная не указана в config.yaml:

# ./my-project/live/prod/config.yaml

clusters:
  - name: prod-blue
    region: europe-west1-a
    network: network-a
    min_nodes: 3
  - name: prod-green
    region: europe-west1-b
    network: network-a
    min_nodes: 3
    max_nodes: 12
# ./my-project/modules/kubernetes/main.tf

variable "max_nodes" {
  default = 6
  nullable = false
}
# ./my-project/live/*/modules.tf

module "kubernetes" {
  for_each = { for x in local.config.clusters : x.name => x }
  source   = "../../modules/kubernetes"

  name      = each.value.name
  region    = each.value.region
  min_nodes = each.value.min_nodes
  max_nodes = lookup(each.value, "max_nodes", null)
  network   = module.network[each.value.network].name
}

Опция nullable = false в main.tf означает, что если модуль получит нулевое значение из modules.tf, он вернется к значению по умолчанию 6. max_nodes для prod-blue будет 6, а для prod-green — 12.

В случаях, когда вы не управляете модулем Terraform, источником которого является modules.tf, вы не сможете определить значение поля nullable (по умолчанию оно равно true). Если вы хотите, чтобы значение было одновременно настраиваемым и необязательным в config.yaml, вы можете просто предоставить значение по умолчанию в команде lookup() в modules.tf Чем больше вы это делаете, тем сложнее инженеру разобраться, как настроен тот или иной модуль. Обычно я помещаю значения по умолчанию в функцию lookup() только тогда, когда модуль не имеет соответствующего значения по умолчанию, и вряд ли я буду заботиться о заданном значении в долгосрочной перспективе.

Заключение

Я работаю с Terraform уже несколько в различных организациях. Одна из особенностей, которую я заметил, заключается в том, что практики сильно различаются. Похоже, нет стандартизированного подхода к организации проектов Terraform. Код Terraform должен быть написан на долгосрочную перспективу; написание понятного кода равносильно тому, что вы оказываете коллегам (и себе в будущем) большую услугу.

От редакции

Если вы хотите узнать больше об инструменте, то приглашаем вас на курс по Terraform. У курса нет аналогов на русском языке. Мы покажем конкретные практические приёмы работы, сферы применения, кейсы и живые задачи. Все практические задания будут выполняться в Yandex Cloud.

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