Модуль 06 — Модули

Второй сервис — шесть строк кода вместо копии сорока

Прогресс курса Модуль 6 из 9

Что вы освоите в этом модуле

01

Модуль — это каталог

Любой каталог с файлами *.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
02

Дочерний модуль webapp

Входы — обычные переменные, с типами, описаниями и проверками:

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 с настройками пишут только в корневом модуле: дочерние модули получают провайдер от него.

03

Два экземпляра в корневом модуле

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
04

Откуда брать модули

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). Чужой модуль — это чужой код, который будет управлять вашей инфраструктурой: его читают перед подключением и закрепляют на теге или коммите.

05

Модуль из Git с версией

Общие модули команда держит в отдельном репозитории и выпускает версиями-тегами. На учебном стенде вместо 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.
06

Хороший модуль и лишний модуль

Модуль-обёртка над одним ресурсом, который просто передаёт все его аргументы, ничего не даёт: тот же ресурс, но с лишним слоем, который надо поддерживать. Модуль появляется, когда один и тот же набор ресурсов нужен второй раз, или когда за набором стоит договорённость команды — например, «у каждого сервиса есть контейнер, проверка и запись в мониторинге». Вложенность модулей держат в два уровня: корень и модули; модули из модулей быстро превращают план в головоломку.

Ключевые выводы модуля

Запомните главное

  • Модуль — каталог с *.tf: входы — variable, выходы — output; корневой модуль подключает дочерние блоком module
  • Адрес ресурса в модуле — module.ИМЯ.тип.имя; снаружи видно только то, что отдано через output
  • В каждом модуле — свой required_providers; блок provider — только в корне
  • Новый или изменённый source — снова terraform init
  • Из России модули — из Git (GitFlic, GitLab, GitHub), не из реестра; зеркало Яндекса — только провайдеры
  • Версию закрепляют тегом ?ref=v1.0.0; обновление — правка ref с ревью, а не ref=main
  • Модуль нужен для повторяющегося набора ресурсов, а не как обёртка над одним ресурсом
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 05: Состояние Все модули Модуль 07: count, for_each, источники данных