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

Это обеспечивает большую гибкость и позволяет легко начать работу, просто поместив несколько ресурсов в файл и запустив terraform apply.

Но по мере роста вашей среды потребуется более строгий подход к структурированию кода.

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

Первое, что вам понадобится при работе с Terraform, — это управление файлом состояния.

Управление состоянием

Состояние Terraform — это место, где Terraform хранит информацию о ресурсах, которые вы создали с его помощью.

Если вы один разработчик, то можете просто хранить файл terraform.tfstate локально на своей машине (не поднимая его в Git, так как он потенциально может содержать конфиденциальную информацию), но при работе в команде необходимы две вещи:

  1. Способ безопасного обмена этим файлом состояния

  2. Способ предотвратить одновременное изменение файла состояния несколькими разработчиками.

К счастью, бэкенд типа s3 решает все эти проблемы - https://www.terraform.io/docs/language/settings/backends/s3.html.

Он хранит файл terraform.tfstate в S3-бакете и использует DynamoDB для получения эксклюзивной блокировки перед выполнением действий, которым нужен файл состояния.

Однако при этом возникают новые проблемы: как изначально создать S3-бакет и таблицу DynamoDB?

Существует множество решений, это можно сделать с помощью Cloudformation или вручную.

Но мы хотели сохранить все это в Terraform, поэтому у нас есть модуль, который создает S3-бакет и таблицу DynamoDB.

Он инстанцируется один раз в каждой среде в папке bootstrap, чтобы дать нам специфические для среды S3-бакеты и таблицы DynamoDB для "основного" состояния Terraform, а состояние Terraform для этих двух бутстрап-ресурсов хранится в Git.

Эти 2 начальных ресурса никогда не изменяются после первоначального создания, поэтому нам не нужна блокировка для защиты этого состояния от одновременного изменения, и оно не содержит никаких конфиденциальных данных, поэтому мы с удовольствием коммитим его в Git.

Как компоновать код Terraform

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

Начать можно отсюда:

terraform/
  dev/
    bootstrap/
      bootstrap.tf
      terraform.tfstate
  staging/
    bootstrap/
      bootstrap.tf
      terraform.tfstate
  prod/
    bootstrap/
      bootstrap.tf
      terraform.tfstate

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

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

Можно было бы просто иметь один огромный файл terraform.tf и поместить в него все определения.

Но при этом вы получите множество конфликтов мерджинга и обнаружите, что практически невозможно будет найти, где определены конкретные ресурсы.

Поэтому разумный макет выглядит следующим образом:

/terraform
   dev/
     application_a.tf
        -> resources / modules for app A
     application_b.tf
        -> resources / module for app B
     vpc.tf
        -> VPC / Subnets / NAT Gateways etc.
     eks.tf
        -> K8s Cluster

Настройте его под свой конкретный набор технологий, идея вам ясна.

Переменные

Следующее, что вам понадобится, это способ определения переменных. Модули для приложения A, вероятно, должны брать имя приложения A, чтобы они могли тегировать ресурсы / добавлять идентификаторы, поэтому поместите блок locals в application_a.tf.

locals {
    app_a_name = "A"
}

А затем в определении модуля сослаться на него

module app_a_resources {
    app_name = local.app_a_name
}

Обратите внимание, что каждый файл в каталоге dev/ будет иметь доступ к этому блоку locals, поэтому вы не можете просто вызвать переменную app_name.

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

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

Проблемы с этим подходом

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

  1. Поскольку вся ваша среда представляет собой единое состояние Terraform, операции terraform, такие как plan и apply, будут занимать все больше времени, так как они обрабатывают каждый ресурс в среде.

  2. По мере роста вашего репозитория terraform структура будет становиться все менее последовательной.

Люди будут называть файлы e_resources.tf, а не application_e.tf.

Люди будут забывать/не находить локальное определение для maintainence_window = "mon12:13" и создавать новые локальные определения, такие как maint_window, или просто вводить строку в качестве переменной.

Люди будут создавать модули Terraform с незначительными вариациями имен переменных, app_name будет app в одних местах, subnet_id будет subnet в других.

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

module a_resources {
   app = local.app_name
}

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

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

Нет проблем, вы создаете eu_maintainence_window и во всех определениях модуля eu пишете

module eu_a_resources {
   maintainence_window = local.eu_maintainence_window
}

Однажды кто-то скопирует определение maintainence_window при создании модуля EU и забудет изменить его, в результате чего база данных перезагрузится в пиковое время для ваших клиентов из EU.

В идеале вы должны переименовать maintainence_window в us_maintainence_window везде, но в реальности из-за нехватки времени такой рефакторинг обычно не доводится до конца.

3. Список .tf-файлов в одной директории становится довольно большим, и вам приходится часто использовать grep, чтобы найти то, что вы ищете.

4. Однажды кто-то быстро определяет дополнительный ресурс для app_c в dev вне модуля app_c_resources и прямо в файле dev/app_c.tf, чтобы быстро протестировать что-то.

Они забывают перенести ресурс в модуль, и когда app_c запускается, в нем отсутствует ресурс или изменения не были скопированы из dev/app_c.tf в prod/app_c.tf.

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

Однако постоянно увеличивающийся размер вашего состояния Terraform будет замедлять работу.

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

И наконец, если вы случайно повреждаете состояние, то потенциально может пострадать большое количество ресурсов (убедитесь, что у вас включено версионирование S3!).

Для решения этих проблем мы начали использовать Terragrunt, который отлично себя зарекомендовал.

Terragrunt

Долгое время я пытался понять, в чем смысл Terragrunt и что именно он делает.

Лучше всего это можно объяснить так: Terragrunt накладывает ограничения на то, как вы можете организовать свой код Terraform, и заставляет вас использовать иерархии структуры каталогов и общие файлы определения переменных для организации вашего кода.

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

Не думаю, что я смог бы по-настоящему оценить Terragrunt, не пройдя через все вышеперечисленное и не увидев проблемы, связанные с возможностями свободного стиля.

Логическая организация вашей инфраструктуры

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

Например, на верхнем уровне у вас может быть среда (например, dev), затем регион (например, us-east-1), приложение (например, app-a), и, наконец, части инфраструктуры, необходимые приложению (например, база данных, кэш, s3-бакет).

Таким образом, у вас будет примерно следующая структура папок

dev/
  -> us-east-1/
    -> applications/
      -> app-a
        -> database/
          -> terragrunt.hcl
        -> cache/
          -> terragrunt.hcl
        -> s3-bucket/
          -> terragrunt.hcl

Ваша структура каталогов отражает то, как организована ваша инфраструктура.

Файлы terragrunt.hcl - это то, что читает Terragrunt, чтобы понять, какой модуль Terraform применить, подробнее об этом позже.

А что, если мы добавим еще одно приложение, которому просто нужна IAM роль?

dev/
  -> us-east-1/
    -> applications/
      -> app-a
        -> database/
          -> terragrunt.hcl
        -> cache/
          -> terragrunt.hcl
        -> s3-bucket/
          -> terragrunt.hcl
      -> app-b/
        -> iam-role/
          -> terragrunt.hcl

Это замечательно, но как насчет ресурсов, совместно используемых в регионе, например в кластере EKS?

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

dev/
  -> us-east-1/
    -> eks-cluster/
      -> terragrunt.hcl
    -> applications/
      -> app-a
        -> database/
          -> terragrunt.hcl
        -> cache/
          -> terragrunt.hcl
        -> s3-bucket/
          -> terragrunt.hcl
      -> app-b/
        -> iam-role
          -> terragrunt.hcl

Итак, почему мы поместили app-a и app-b в папку с названием applications?

Разве они не могли быть просто в папке us-east-1?

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

Файлы значений

Давайте поговорим об общих файлах значений, еще одной важной особенности Terragrunt.

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

Некоторые из этих входных переменных, вероятно, будут одинаковыми для всех ресурсов, развернутых в среде, например, environment_name для таких вещей, как тегирование.

Вместо того чтобы писать environment_name=dev в каждом отдельном файле terragrunt.hcl, давайте определим все эти переменные уровня окружения в файле environment.yaml

dev/
  -> environment.yaml
  -> us-east-1/
    -> eks-cluster/
      -> terragrunt.hcl
  -> applications/
      -> app-a/
        -> database/
          -> terragrunt.hcl
        -> cache/
          -> terragrunt.hcl
        -> s3-bucket/
          -> terragrunt.hcl
      -> app-b/
        -> iam-role/
          -> terragrunt.hcl

И environment.yaml будет выглядеть следующим образом:

environment_name: dev

Далее у нас есть некоторые настройки на уровне региона, например, в us-east-1 нам нужно, чтобы окно maintenance_window для инстансов RDS было в период mon05:00-07:00 - тихое время для наших клиентов из США.

dev/
  -> environment.yaml
  -> us-east-1/
    -> region.yaml
  -> eks-cluster/
      -> terragrunt.hcl
  -> applications/
      -> app-a/
        -> database/
          -> terragrunt.hcl
        -> cache/
          -> terragrunt.hcl
        -> s3-bucket/
          -> terragrunt.hcl
      -> app-b/
        -> iam-role/
          -> terragrunt.hcl

И region.yaml будет выглядеть следующим образом:

maintainence_window: mon05:00-07:00

Теперь, если бы мы расширились в ЕС и тихий час для наших клиентов из ЕС был tue21:00-22:00, то можно было бы сделать что-то вроде этого:

dev/
  -> environment.yaml
  -> us-east-1/
    -> region.yaml
<SNIP>
  
  -> eu-west-1/
    -> region.yaml
  -> applications/
      -> app-c/
        -> database/
          -> terragrunt.hcl

И eu-west-1/region.yaml будет выглядеть следующим образом:

maintainence_window: tue21:00-22:00

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

Последняя общая переменная, которую мы создадим, будет для каждого приложения, имя приложения, вероятно, появится где-то в ресурсах для него.

dev/
  -> environment.yaml
  -> us-east-1/
    -> region.yaml
    -> eks-cluster/
      -> terragrunt.hcl
  -> applications/
      -> app-a/
        -> app.yaml
        -> database/
          -> terragrunt.hcl
        -> cache/
          -> terragrunt.hcl
        -> s3-bucket/
          -> terragrunt.hcl
      -> app-b/
        -> app.yaml
        -> iam-role/
          -> terragrunt.hcl

И каждый app.yaml будет выглядеть примерно так:

app_name: <NAME>

Итак, не все входные переменные будут общими, есть и специфические для отдельных модулей.

Их можно задавать непосредственно в модуле.

Давайте посмотрим, что на самом деле представляет собой terragrunt.hcl:

terraform {
  source = "Link to Terraform Module on Github"
}
include {
  path = find_in_parent_folders()
}
inputs = {
  module_specific_variable = "amazing"

Это довольно простой файл.

Вам понадобится ссылка на модуль, который вы хотите применить, он работает только со ссылками на Github, поэтому здесь нет ссылок на реестр модулей Terraform.

Пока игнорируйте include, он связан с поиском общих файлов переменных, мы к этому еще вернемся.

inputs - это место, где вы можете передавать любые входные данные модулям.

Если переменная input имеет то же имя, что и переменная, определенная в одном из наших общих файлов переменных .yaml, то она будет автоматически принята.

Если переменная не из общего файла определения, вы можете ввести ее здесь вручную.

Есть еще несколько моментов, которые следует упомянуть о входных переменных, пока мы здесь.

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

Вероятно, вы также встретите ситуации, когда вы назвали переменную дженерик как-то вроде environment_name, однако некоторые модули ожидают, что она будет называться environment или env_name.

Это хорошая особенность Terragrunt, потому что быстро станет ясно, где ваши модули не соответствуют друг другу, и вы сможете со временем привести все в соответствие.

В краткосрочной перспективе вы можете изменить имена переменных дженериков на более конкретные имена модулей.

locals {
  env_vars = yamldecode(
    file("${find_in_parent_folders("environment.yaml")}"),
  )
}
inputs {
  env_name = local.env_vars['environment_name']
}

Зависимости

Кроме того, вполне вероятно, что входные данные одного модуля будут являться выходными данными другого.

Например, допустим, наш модуль eks-cluster выводит worker-sg-id - идентификатор группы безопасности, используемой воркерами K8s.

Затем наш модуль базы данных принимает входной параметр sg_to_allow_access_from - ID группы безопасности, для которой он будет создавать правило входа.

Вы можете использовать выход одного модуля в качестве входа для другого следующим образом:

dependency "k8s-worker-sg" {
  config_path = "../../eks-cluster"
}
inputs = {
  sg_to_allow_access_from = dependency.k8s-worker-sg.outputs.worker-sg-id
}

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

Файл конфигурации Terragrunt

Но мы все еще не можем запустить terragrunt, так как для того, чтобы связать все воедино, необходима какая-то конфигурация.

Ранее мы видели, что файл состояния Terraform охватывает все ресурсы в нашем окружении.

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

В данной установке Terragrunt мы можем создавать по одному файлу состояния на "листовой узел" дерева каталогов, поэтому, по сути, там, где есть файл terragrunt.hcl, определяющий применяемый модуль, мы создаем новый файл состояния.

Это делает работу Terraform очень быстрой и уменьшает последствия повреждения файла состояния.

Нам нужно создать файл terragrunt.hcl в dev/us-east-1/terragrunt.hcl, и вместо определения модуля Terraform для применения, он определяет всю нашу конфигурацию Terragrunt, которую все остальные файлы terragrunt.hcl импортируют с помощью оператора include, который мы видели ранее.

include {
  path = find_in_parent_folders()
}

find_in_parent_folders - это встроенная функция Terragrunt, которая возвращает первый файл terragrunt.hcl, найденный в родительских папках.

Итак, давайте запустим наш файл dev/us-east-1/terragrunt.hcl и определим нашу конфигурацию состояния

remote_state {
  backend = "s3"
  config = {
    bucket         = "S3 BUCKET NAME"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "AWS REGION"
    encrypt        = true
    dynamodb_table = "DYNAMO DB TABLE"
  }
}

Это использует S3 и DynamoDB для хранения состояния / получения эксклюзивных блокировок на файл состояния, как мы видели раньше без Terragrunt.

Но строка key = ... означает, что внутри S3-бакета будет структура каталогов, имитирующая структуру папок Terragrunt с файлами состояния.

Также, помните, как в исходном чистом Terraform мы должны были выполнить хитроумные манипуляции для бутстрапа S3-бакета и таблицы DynamoDB?

Terragrunt автоматически их создаст, если они не существуют, решив тем самым эту проблему.

Однако существует один недостаток: все модули Terraform, которые вы применяете, должны определять следующие параметры

terraform {
  backend "s3" {}
}

в модуле, чтобы Terragrunt мог заполнить детали при запуске.

В 2017 году на Github появилась тема с обсуждением этого - https://github.com/gruntwork-io/terragrunt/issues/230.

Теперь нам нужно сказать Terragrunt, где найти все те общие файлы переменных, которые мы определили.

inputs = merge(
  yamldecode(
    file("${find_in_parent_folders("environment.yaml", find_in_parent_folders("environment.yaml"))}"),
  ),
  yamldecode(
    file("${find_in_parent_folders("region.yaml", find_in_parent_folders("environment.yaml"))}"),
  ),
  yamldecode(
    file("${find_in_parent_folders("app.yaml", find_in_parent_folders("environment.yaml"))}"),
  ),
)

Как и раньше, find_in_parent_folders заставляет Terragrunt искать в дереве модулей, где они определены, первое упоминание файла.

Второй параметр для file - это значение по умолчанию, которое будет использоваться, если он не сможет найти файл, в данном случае мы возвращаемся к environment.yaml, который всегда существует, и это означает, что если у нас есть ситуация, когда модуль вложен не слишком глубоко, чтобы все файлы app.yaml, region.yaml и environment.yaml были в его родительских каталогах, он не разделяется.

merge - стандартная функция Terraform - https://www.terraform.io/docs/language/functions/merge.html

Это означает, что любые переменные, определенные ниже по дереву, переопределяют переменные, расположенные выше.

Еще один последний параметр конфигурации, и все готово.

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

generate "aws_provider" {
  path      = "aws_provider.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "aws" {
  region = "us-east-1"
}
EOF
}

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

Теперь мы можем запускать команды Terragrunt и создавать нашу инфраструктуру.

Есть два варианта запуска команд Terragrunt:

  1. В нескольких папках, что позволяет нам создавать несколько модулей одновременно.

  1. В одном модуле для более быстрого/целевого применения.

run-all команды

Если вы хотите запускать команды сразу для нескольких модулей, это можно сделать с помощью команды run-all.

Например, если вы запустите команду terragrunt run-all plan в каталоге dev, она запустит terraform plan в каждом подкаталоге и представит вам план.

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

Применение одного модуля

Если вы хотите применить изменения только к одному модулю Terraform, вы можете доставить cd в эту директорию и запустить terragrunt <COMMAND>; изменения будут применены только к текущей рабочей директории.

Кэширование модулей

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

Например, если у вас есть ref = github.com/module?ref=my-branch и вы внесли новые изменения в my-branch после запуска terragrunt apply, он не заметит, что источник изменился, и получит новые изменения.

Вам нужно очистить кэш Terragrunt с помощью find . -type d -name ".terragrunt-cache" -prune -exec rm -rf {} ;

Резюме

Теперь у нас есть полностью рабочая установка Terragrunt, чтобы подытожить то, что мы получили:

  • Небольшие файлы состояний для каждого модуля, что позволяет быстрее выполнять команды terraform и уменьшает последствия потери/повреждения одного файла состояния

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

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

  • Возможность объявить, что один модуль зависит от другого, создавая модули в соответствующем порядке и передавая выходные переменные между ними

  • Невозможность создавать произвольные специальные ресурсы вне модуля Terraform с версией, что снижает вероятность того, что ресурсы/изменения не будут распространены в другие среды.

  • Возможность автоматически повторять команды Terraform при возникновении определенных ошибок, что уменьшает влияние нестабильных / в конечном итоге несовместимых API.

Это не работает идеально, так как есть некоторые ограничения/проблемы:

  • Каждый используемый модуль Terraform должен определять пустой блок backend, что означает, придется модифицировать каждый имеющийся у вас модуль, и нельзя использовать модули сообщества.

  • Вы не можете использовать ссылки на реестр модулей Terraform, теряя возможность указывать версии, которые могут быть слабо заблокированы, и, вероятно, вам придется предоставить учетные данные для аутентификации на Git, чтобы Terragrunt получал модули с Github.

  • Вам и всем остальным членам команды необходимо изучить Terragrunt.

  • Теперь у вас есть еще один инструмент, который необходимо постоянно обновлять наряду с Terraform.

  • Если вы не были особенно внимательны к тому, чтобы ваши входные переменные модулей назывались единообразно, то вы не получите многих преимуществ общих файлов переменных без некоторого рефакторинга.

Пример Repo

В этом посте много блоков кода, вы можете увидеть их все вместе в примере репо здесь - https://github.com/AaronKalair/example-terragrunt-repo.


Материал подготовлен в рамках курса «Infrastructure as a code». Если вам интересно узнать подробнее о формате обучения и программе, познакомиться с преподавателем курса — приглашаем на день открытых дверей онлайн. Регистрация здесь.

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


  1. MrAlone
    09.12.2021 14:31

    Одна из самых больших проблем Террагрунта - невозможность вызова модуля несколько раз.