Spec-Zone.ru › OpenTofu 1.10

Композиция модулей

В простой конфигурации OpenTofu, состоящей только из одного корневого модуля, мы создаём плоский набор ресурсов и используем синтаксис выражений OpenTofu, чтобы описать связи между этими ресурсами:

Блок кода
resource "aws_vpc" "example" {
  cidr_block = "10.1.0.0/16"
}

resource "aws_subnet" "example" {
  vpc_id = aws_vpc.example.id

  availability_zone = "us-west-2b"
  cidr_block        = cidrsubnet(aws_vpc.example.cidr_block, 4, 1)
}

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

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

Блок кода
module "network" {
  source = "./modules/aws-network"

  base_cidr_block = "10.0.0.0/8"
}

module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = module.network.vpc_id
  subnet_ids = module.network.subnet_ids
}

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

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

Инверсия зависимостей​

В приведённом выше примере мы видели модуль consul_cluster, который, предположительно, описывает кластер серверов HashiCorp Consul, работающих в сети VPC AWS, и поэтому принимает в качестве аргументов идентификаторы самой VPC и подсетей в этой VPC.

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

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

Блок кода
data "aws_vpc" "main" {
  tags = {
    Environment = "production"
  }
}

data "aws_subnet_ids" "main" {
  vpc_id = data.aws_vpc.main.id
}

module "consul_cluster" {
  source = "./modules/aws-consul-cluster"

  vpc_id     = data.aws_vpc.main.id
  subnet_ids = data.aws_subnet_ids.main.ids
}

Условное создание объектов​

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

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

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

Например, представим, что модуль OpenTofu развёртывает вычислительные экземпляры на основе образа диска, причём в некоторых средах доступен специализированный образ диска, а в других используется общий базовый образ. Вместо того чтобы поручать модулю обработку обоих сценариев, мы можем объявить входную переменную для объекта, представляющего образ диска. На примере AWS EC2 можно объявить общий подтип схем ресурсов и источников данных aws_ami:

Блок кода
variable "ami" {
  type = object({
    # Declare an object using only the subset of attributes the module
    # needs. OpenTofu will allow any object that has at least these
    # attributes.
    id           = string
    architecture = string
  })
}

Теперь вызывающий этот модуль код может напрямую указать, представляет ли объект AMI, создаваемый непосредственно в конфигурации, или AMI, получаемый из другого источника:

Блок кода
# In situations where the AMI will be directly managed:

resource "aws_ami_copy" "example" {
  name              = "local-copy-of-ami"
  source_ami_id     = "ami-abc123"
  source_ami_region = "eu-west-1"
}

module "example" {
  source = "./modules/example"

  ami = aws_ami_copy.example
}
Блок кода
# Or, in situations where the AMI already exists:

data "aws_ami" "example" {
  owner = "9999933333"

  tags = {
    application = "example-app"
    environment = "dev"
  }
}

module "example" {
  source = "./modules/example"

  ami = data.aws_ami.example
}

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

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

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

Предположения и гарантии​

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

  • Предположение: условие, которое должно выполняться, чтобы конфигурацию определённого ресурса можно было использовать. Например, конфигурация aws_instance может предполагать, что указанный AMI всегда будет настроен для архитектуры процессора x86_64.
  • Гарантия: свойство или поведение объекта, на которые должна иметь возможность полагаться остальная часть конфигурации. Например, конфигурация aws_instance может гарантировать, что экземпляр EC2 будет работать в сети, которая назначает ему частную DNS-запись.

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

В следующем примере создаётся предварительное условие, проверяющее, зашифрован ли корневой том экземпляра EC2.

Блок кода
output "api_base_url" {
  value = "https://${aws_instance.example.private_dns}:8433/"

  # The EC2 instance must have an encrypted root volume.
  precondition {
    condition     = data.aws_ebs_volume.example.encrypted
    error_message = "The server's root volume is not encrypted."
  }
}

Мультиоблачные абстракции​

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

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

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

Блок кода
module "webserver" {
  source = "./modules/webserver"
}

locals {
  fixed_recordsets = [
    {
      name = "www"
      type = "CNAME"
      ttl  = 3600
      records = [
        "webserver01",
        "webserver02",
        "webserver03",
      ]
    },
  ]
  server_recordsets = [
    for i, addr in module.webserver.public_ip_addrs : {
      name    = format("webserver%02d", i)
      type    = "A"
      records = [addr]
    }
  ]
}

module "dns_records" {
  source = "./modules/route53-dns-records"

  route53_zone_id = var.route53_zone_id
  recordsets      = concat(local.fixed_recordsets, local.server_recordsets)
}

В приведённом выше примере мы создали лёгкую абстракцию в виде объекта «набора записей». Он содержит атрибуты, описывающие общую концепцию набора DNS-записей, которую можно сопоставить с любым поставщиком DNS.

Затем мы создаём экземпляр одной конкретной реализации этой абстракции в виде модуля — в данном случае развёртываем наши наборы записей в Amazon Route53.

Если позднее мы захотим перейти на другого поставщика DNS, нам потребуется лишь заменить модуль dns_records новой реализацией для этого поставщика, а вся конфигурация, которая создаёт определения наборов записей, останется без изменений.

Создавать такие лёгкие абстракции можно, определяя типы объектов OpenTofu, представляющие соответствующие концепции, и затем используя эти типы объектов для входных переменных модулей. В этом случае во всех наших реализациях «DNS-записей» будет объявлена следующая переменная:

Блок кода
variable "recordsets" {
  type = list(object({
    name    = string
    type    = string
    ttl     = number
    records = list(string)
  }))
}

DNS — простой пример, но у разных поставщиков есть множество других общих элементов, которые можно использовать. Более сложный пример — Kubernetes: сейчас существует множество поставщиков, предлагающих размещённые кластеры Kubernetes, и ещё больше способов самостоятельно запускать Kubernetes.

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

Блок кода
output "hostname" {
  value = azurerm_kubernetes_cluster.main.fqdn
}

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

Блок кода
module "k8s_cluster" {
  source = "modules/azurerm-k8s-cluster"

  # (Azure-specific configuration arguments)
}

module "monitoring_tools" {
  source = "modules/monitoring_tools"

  cluster_hostname = module.k8s_cluster.hostname
}

Модули только для работы с данными​

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

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

Этот подход часто используют, когда система разделена на несколько конфигураций подсистем, но некоторые элементы инфраструктуры, например общая IP-сеть, используются всеми подсистемами. В такой ситуации можно написать общий модуль join-network-aws, который сможет вызывать любая конфигурация, которой нужны сведения об общей сети при развёртывании в AWS:

Блок кода
module "network" {
  source = "./modules/join-network-aws"

  environment = "production"
}

module "k8s_cluster" {
  source = "./modules/aws-k8s-cluster"

  subnet_ids = module.network.aws_subnet_ids
}

Сам модуль network может получать эти данные несколькими способами: напрямую запрашивать API AWS с помощью источников данных aws_vpc и aws_subnet_ids, считывать сохранённые сведения из кластера Consul с помощью consul_keys или напрямую считывать выходные значения из состояния конфигурации, управляющей сетью, с помощью terraform_remote_state.

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

Copyright (c) The OpenTofu Authors
Copyright (c) 2014 HashiCorp, Inc.
Mozilla Public License, version 2.0
https://opentofu.org/docs/v1.10/language/modules/develop/composition/

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API