Композиция модулей
В простой конфигурации 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, работающих в сети AWS VPC, и поэтому требует в качестве аргументов идентификаторы самой 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.11/language/modules/develop/composition/