Второй сервис — шесть строк кода вместо копии сорока
Любой каталог с файлами *.tf — уже модуль. Тот, в котором запускают terraform, называется корневым; модули, которые он подключает блоком module, — дочерними. Дочерний модуль — как функция: на вход переменные, внутри ресурсы, на выход значения output. Учебный проект — два веб-сервиса (цены и корзина) на одном модуле webapp:
$ find . -name "*.tf" | sort
./main.tf
./modules/webapp/main.tf
./modules/webapp/outputs.tf
./modules/webapp/variables.tf
./modules/webapp/versions.tf
./outputs.tf
./versions.tf
Входы — обычные переменные, с типами, описаниями и проверками:
variable "name" {
type = string
description = "Имя контейнера"
}
variable "title" {
type = string
description = "Заголовок стартовой страницы"
}
variable "port" {
type = number
description = "Порт на 127.0.0.1"
validation {
condition = var.port >= 1024 && var.port <= 65535
error_message = "Порт — от 1024 до 65535."
}
}
variable "network" {
type = string
description = "Имя сети Docker, к которой подключить контейнер"
}
variable "image" {
type = string
description = "Образ nginx"
default = "nginx:1.28-alpine"
}
Ресурсы модуля. Если ресурс такого типа в модуле один, его принято называть this — имя модуля и так говорит, что это. Блок upload кладёт в контейнер файл стартовой страницы:
resource "docker_image" "this" {
name = var.image
keep_locally = true
}
resource "docker_container" "this" {
name = var.name
image = docker_image.this.image_id
networks_advanced {
name = var.network
}
ports {
internal = 80
external = var.port
ip = "127.0.0.1"
}
upload {
file = "/usr/share/nginx/html/index.html"
content = "<h1>${var.title}</h1>\n"
}
}
Выходы — всё, что понадобится снаружи. Ресурсы модуля из корня напрямую не видны: только то, что модуль отдал через output.
output "name" {
value = docker_container.this.name
}
output "url" {
description = "Адрес сервиса"
value = "http://127.0.0.1:${var.port}/"
}
И собственный versions.tf с required_providers. Без него Terraform решит, что docker в модуле — это hashicorp/docker: короткое имя без адреса раскрывается в пространство имён HashiCorp. Такого провайдера нет:
$ mv modules/webapp/versions.tf /tmp/webapp-versions.tf
$ terraform init -no-color 2>&1 | sed -n "/Error/,\$p"
Error: Failed to query available provider packages
Could not retrieve the list of available versions for provider
hashicorp/docker: provider registry.terraform.io/hashicorp/docker was not
found in any of the search locations
- provider mirror at https://terraform-mirror.yandexcloud.net/
$ mv /tmp/webapp-versions.tf modules/webapp/versions.tf && rm -rf .terraform .terraform.lock.hcl
Правило: в каждом модуле — свой required_providers с адресами и нижними границами версий. А вот блок provider с настройками пишут только в корневом модуле: дочерние модули получают провайдер от него.
resource "docker_network" "app" {
name = "price-net"
}
module "price" {
source = "./modules/webapp"
name = "price-web"
title = "Сервис цен"
port = 8080
network = docker_network.app.name
}
module "cart" {
source = "./modules/webapp"
name = "cart-web"
title = "Корзина"
port = 8081
network = docker_network.app.name
}
У блока module одна метка — имя экземпляра, аргумент source — откуда взять код, остальные аргументы — значения входных переменных. После добавления или смены модуля нужен init: он находит модули и запоминает их в .terraform/modules:
$ terraform init -no-color | grep -E "Initializing modules|^- |initialized"
Initializing modules...
- price in modules/webapp
- cart in modules/webapp
- Finding kreuzwerker/docker versions matching "~> 4.6"...
- Installing kreuzwerker/docker v4.6.0...
- Installed kreuzwerker/docker v4.6.0 (unauthenticated)
Terraform has been successfully initialized!
Адреса ресурсов теперь с префиксом экземпляра: module.price.docker_container.this. Один и тот же this в двух экземплярах — два разных ресурса:
$ terraform plan -no-color | grep -E "^ # |Plan:"
# docker_network.app will be created
# module.cart.docker_container.this will be created
# module.cart.docker_image.this will be created
# module.price.docker_container.this will be created
# module.price.docker_image.this will be created
Plan: 5 to add, 0 to change, 0 to destroy.
Выходы модуля читаются как module.ИМЯ.ВЫХОД — корневой outputs.tf собирает адреса обоих сервисов:
output "urls" {
value = {
price = module.price.url
cart = module.cart.url
}
}
$ terraform apply -auto-approve -no-color | sed -n "/Apply complete/,\$p"
Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
Outputs:
urls = {
"cart" = "http://127.0.0.1:8081/"
"price" = "http://127.0.0.1:8080/"
}
$ curl -s --retry 5 --retry-connrefused http://127.0.0.1:8080/; curl -s http://127.0.0.1:8081/
<h1>Корзина</h1>
$ terraform state list
docker_network.app
module.cart.docker_container.this
module.cart.docker_image.this
module.price.docker_container.this
module.price.docker_image.this
Проверка входов работает и внутри модуля: ошибка показывает и строку вызова, и правило в коде модуля.
$ sed -i "0,/port = 8080/s//port = 80/" main.tf
$ terraform plan -no-color 2>&1 | sed -n "/Error/,\$p"
Error: Invalid value for variable
on main.tf line 10, in module "price":
10: port = 80
├────────────────
│ var.port is 80
Порт — от 1024 до 65535.
This was checked by the validation rule at
modules/webapp/variables.tf:15,3-13.
$ sed -i "0,/port = 80$/s//port = 8080/" main.tf
| source | Откуда | Версия |
|---|---|---|
"./modules/webapp" | Каталог в этом же репозитории | Та же, что у кода |
"git::https://gitflic.ru/project/ГРУППА/tf-modules.git//webapp?ref=v1.0.0" | Git-репозиторий, подкаталог после // | Тег или коммит в ref |
"github.com/ОРГАНИЗАЦИЯ/РЕПОЗИТОРИЙ" | GitHub (сокращённая запись) | ?ref=… |
"АВТОР/ИМЯ/ПРОВАЙДЕР" + version | Реестр модулей | Аргумент version |
Из России реестр registry.terraform.io закрыт, а зеркало Яндекса отдаёт только провайдеры, не модули. Поэтому модули берут из Git: свои — из внутреннего GitLab или GitFlic, чужие — из исходного репозитория на GitHub (модули для Yandex Cloud сам Яндекс публикует в организации terraform-yc-modules). Чужой модуль — это чужой код, который будет управлять вашей инфраструктурой: его читают перед подключением и закрепляют на теге или коммите.
Общие модули команда держит в отдельном репозитории и выпускает версиями-тегами. На учебном стенде вместо GitFlic — локальный «голый» репозиторий /tmp/tf-modules.git; для Terraform разница только в адресе. Кладём туда модуль и ставим тег v1.0.0:
$ git init -q -b main /tmp/tf-modules-src && cp -r modules/webapp /tmp/tf-modules-src/ && cd /tmp/tf-modules-src
$ git add . && git -c user.name=nevabit -c user.email=dev@nevabit.ru commit -qm "webapp: модуль nginx" && git tag v1.0.0
$ git clone -q --bare . /tmp/tf-modules.git && git -C /tmp/tf-modules.git tag
v1.0.0
Экземпляр cart переключаем на репозиторий, price пока оставляем на локальном каталоге:
$ grep -n "source =" main.tf
6: source = "./modules/webapp"
15: source = "git::file:///tmp/tf-modules.git//webapp?ref=v1.0.0"
$ terraform init -no-color | grep -E "Downloading|^- |initialized"
Downloading git::file:///tmp/tf-modules.git?ref=v1.0.0 for cart...
- cart in .terraform/modules/cart/webapp
- Reusing previous version of kreuzwerker/docker from the dependency lock file
- Using previously-installed kreuzwerker/docker v4.6.0
Terraform has been successfully initialized!
$ terraform plan -no-color | grep -E "^ # |No changes|Plan:"
No changes. Your infrastructure matches the configuration.
init скачал модуль в .terraform/modules/cart. Код тот же — план пуст: Terraform сравнивает ресурсы, а не то, откуда пришёл код. Теперь автор модуля выпускает v1.1.0 — контейнеры перезапускаются после сбоя:
$ git diff
diff --git a/webapp/main.tf b/webapp/main.tf
index b5411ed..3d520ed 100644
--- a/webapp/main.tf
+++ b/webapp/main.tf
@@ -4,8 +4,9 @@ resource "docker_image" "this" {
}
resource "docker_container" "this" {
- name = var.name
- image = docker_image.this.image_id
+ name = var.name
+ image = docker_image.this.image_id
+ restart = "unless-stopped"
networks_advanced {
name = var.network
$ git commit -qam "webapp: перезапуск unless-stopped" && git tag v1.1.0 && git push -q /tmp/tf-modules.git main --tags
$ terraform plan -no-color | grep -E "^ # |No changes|Plan:"
No changes. Your infrastructure matches the configuration.
$ sed -i "s/ref=v1.0.0/ref=v1.1.0/" main.tf
$ terraform plan -no-color 2>&1 | grep -E "Error|init"
Error: Module source has changed
"terraform init" to install all modules required by this configuration.
$ terraform init -no-color | grep -E "Downloading"
Downloading git::file:///tmp/tf-modules.git?ref=v1.1.0 for cart...
$ terraform plan -no-color | grep -E "^ # |restart |Plan:"
# module.cart.docker_container.this will be updated in-place
~ restart = "no" -> "unless-stopped"
Plan: 0 to add, 1 to change, 0 to destroy.
Порядок важен. Новый тег в репозитории ничего не меняет у тех, кто закреплён на v1.0.0, — план пуст. Смена ref в коде — изменение, которое проходит ревью; Terraform требует init, чтобы скачать новую версию, и только потом показывает, что она изменит. Так обновление общего модуля доходит до каждого окружения осознанно, а не «само».
ref=main вместо тега — значит, каждый init в CI может привезти новый код модуля без всякого ревью у вас. Закрепляйте теги, а для чужих модулей — ещё надёжнее, полный хеш коммита: тег в чужом репозитории можно перевесить.$ terraform destroy -auto-approve -no-color | grep "Destroy complete"
Destroy complete! Resources: 5 destroyed.
default.variables.tf, main.tf, outputs.tf, versions.tf и README.md с примером вызова; описание у каждой переменной.provider и без жёстко заданных окружений внутри.Модуль-обёртка над одним ресурсом, который просто передаёт все его аргументы, ничего не даёт: тот же ресурс, но с лишним слоем, который надо поддерживать. Модуль появляется, когда один и тот же набор ресурсов нужен второй раз, или когда за набором стоит договорённость команды — например, «у каждого сервиса есть контейнер, проверка и запись в мониторинге». Вложенность модулей держат в два уровня: корень и модули; модули из модулей быстро превращают план в головоломку.
*.tf: входы — variable, выходы — output; корневой модуль подключает дочерние блоком modulemodule.ИМЯ.тип.имя; снаружи видно только то, что отдано через outputrequired_providers; блок provider — только в корнеsource — снова terraform init?ref=v1.0.0; обновление — правка ref с ревью, а не ref=main