Композиция модулей
В простой конфигурации 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.9/language/modules/develop/composition/