Spec-Zone.ru › OpenTofu 1.12

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

В простой конфигурации 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
}

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

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

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

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

Например, представим ситуацию, в которой модуль 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.12/language/modules/develop/composition/

Spec-Zone.ru

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