Модуль 02 — HCL и первый ресурс

Два блока resource — и Terraform сам понимает, что образ нужен раньше контейнера

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

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

01

Файлы проекта

Проект Terraform — это каталог. Terraform читает все файлы *.tf в нём и склеивает в одну конфигурацию; порядок файлов и блоков внутри них не важен. Подкаталоги не читаются — для них есть модули (модуль 06). Делить код по файлам можно как угодно, но в командах принято так:

ФайлЧто в нём
versions.tfБлок terraform: версии Terraform и провайдеров; настройки провайдеров
main.tfРесурсы; в большом проекте — несколько файлов по смыслу: network.tf, db.tf
variables.tfВходные переменные (модуль 04)
outputs.tfВыходные значения (модуль 04)
02

Синтаксис HCL

HCL (HashiCorp Configuration Language) состоит из блоков и аргументов. Блок — тип, ноль или несколько меток в кавычках и тело в фигурных скобках. Аргумент — имя = значение.

resource "docker_container" "web" {   # тип блока, две метки
  name    = "price-web"               # строка
  memory  = 256                       # число
  start   = true                      # логическое значение
  env     = ["TZ=Europe/Moscow"]      # список
  log_opts = {                        # map: ключ = значение
    max-size = "10m"
  }

  ports {                             # вложенный блок
    internal = 80
  }
}

// тоже комментарий
/* и многострочный
   комментарий */
03

Блок terraform: какие версии нужны

terraform {
  required_version = ">= 1.6"

  required_providers {
    docker = {
      source  = "kreuzwerker/docker"
      version = "~> 4.6"
    }
  }
}

provider "docker" {
  host = "unix:///var/run/docker.sock"
}
ОграничениеКакие версии подходят
= 4.6.0Только 4.6.0
>= 4.64.6 и любые новее, включая 5.x и 6.x
~> 4.64.6 и новее, но меньше 5.0: 4.7, 4.12 — да, 5.0 — нет
~> 4.6.04.6.x: исправления без новых возможностей
>= 4.6, < 4.9Диапазон через запятую

Для провайдеров обычно пишут ~> МАЖОР.МИНОР: смена мажорной версии по правилам семантического версионирования может ломать совместимость, и её делают осознанно. Точную выбранную версию всё равно фиксирует .terraform.lock.hcl (модуль 01), а обновляют её командой terraform init -upgrade.

04

Провайдер и его настройки

Блок provider "docker" в том же файле — настройки провайдера: куда подключаться. Здесь это сокет Docker на этой машине; у облачного провайдера — ключ доступа, облако, каталог и зона по умолчанию. Если настройки по умолчанию подходят, блок provider можно не писать вовсе — Terraform создаст провайдер с пустыми настройками.

Пользователь, от которого запускается Terraform, должен иметь доступ к /var/run/docker.sock — состоять в группе docker (курс docker-basics). Это право равно root на машине, поэтому учебный Docker держат на своей машине или виртуалке, а не на общем сервере.

05

Ресурсы, аргументы, атрибуты

# Образ: Terraform скачает его, если на машине такого нет
resource "docker_image" "nginx" {
  name         = "nginx:1.28-alpine"
  keep_locally = true # при destroy не удалять образ с машины
}

# Контейнер ссылается на образ — Terraform создаст их по порядку
resource "docker_container" "web" {
  name  = "price-web"
  image = docker_image.nginx.image_id

  ports {
    internal = 80
    external = 8080
    ip       = "127.0.0.1" # только с этой машины
  }
}

У блока resource две метки: тип (docker_container — начинается с имени провайдера) и локальное имя (web). Вместе они дают адрес ресурса — docker_container.web; он уникален в проекте и встречается в планах, сообщениях и командах. Имя самого контейнера в Docker задаёт аргумент name, и с адресом оно не связано.

Выражение docker_image.nginx.image_id — ссылка: «атрибут image_id ресурса docker_image.nginx». Ссылка — ещё и неявная зависимость: контейнер нельзя создать, пока не известен образ, поэтому Terraform сначала создаст образ, а удалять будет в обратном порядке. Писать порядок вручную не нужно и почти никогда не стоит.

06

fmt и validate

Две проверки перед каждым коммитом. terraform fmt приводит код к единому стилю: отступы по два пробела, выравнивание знаков = в группе аргументов. Ключ -check только проверяет и возвращает ошибку, если нужно форматирование, -diff показывает, что изменилось бы. В каталоге bad/ — файл, набранный «как получилось»:

$ terraform fmt -check -diff
main.tf
--- old/main.tf
+++ new/main.tf
@@ -1,19 +1,19 @@
 terraform {
   required_providers {
     docker = {
-      source = "kreuzwerker/docker"
+      source  = "kreuzwerker/docker"
       version = "~> 4.6"
     }
   }
 }
 
 resource "docker_image" "nginx" {
-name = "nginx:1.28-alpine"
-    keep_locally=true
+  name         = "nginx:1.28-alpine"
+  keep_locally = true
 }
 
 resource "docker_container" "web" {
-  name  = "price-web"
-  image = docker_image.ngnix.image_id
+  name           = "price-web"
+  image          = docker_image.ngnix.image_id
   restart_policy = "always"
 }
$ terraform fmt
main.tf
$ terraform fmt -check && echo "формат в порядке"
формат в порядке

В CI ставят terraform fmt -check -recursive: неотформатированный код не проходит в основную ветку, и в ревью не спорят о пробелах. terraform validate проверяет смысл: существуют ли ресурсы, на которые ссылаемся, есть ли такие аргументы у ресурса, подходят ли типы. Ему нужны схемы провайдеров, поэтому сначала init; -backend=false — не подключать хранилище состояния, для проверки оно не нужно:

$ terraform init -backend=false | grep -E "Installed|initialized"
- Installed kreuzwerker/docker v4.6.0 (unauthenticated)
Terraform has been successfully initialized!
$ terraform validate
╷
│ Error: Reference to undeclared resource
│ 
│   on main.tf line 17, in resource "docker_container" "web":
│   17:   image          = docker_image.ngnix.image_id
│ 
│ A managed resource "docker_image" "ngnix" has not been declared in the root
│ module.
╵

Опечатка в ссылке найдена до всякого обращения к Docker: ngnix вместо nginx. Исправляем и проверяем снова:

$ sed -i "s/docker_image.ngnix/docker_image.nginx/" main.tf
$ terraform validate
╷
│ Error: Unsupported argument
│ 
│   on main.tf line 18, in resource "docker_container" "web":
│   18:   restart_policy = "always"
│ 
│ An argument named "restart_policy" is not expected here.
╵

У контейнера нет аргумента restart_policy — он называется restart. validate не обращается к API и не знает, свободен ли порт или существует ли образ в реестре — такие ошибки покажут plan и apply.

07

Создаём контейнер

Каталог web/ с двумя файлами выше. Подготовка и проверка:

$ ls
main.tf
versions.tf
$ terraform init | grep -E "^- |initialized"
- 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!
$ terraform validate
Success! The configuration is valid.

План:

$ terraform plan

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # docker_container.web will be created
  + resource "docker_container" "web" {
      + attach                                      = false
      + bridge                                      = (known after apply)
      + command                                     = (known after apply)
      + container_logs                              = (known after apply)
      + container_read_refresh_timeout_milliseconds = 15000
      + entrypoint                                  = (known after apply)
      + env                                         = (known after apply)
      + exit_code                                   = (known after apply)
      + hostname                                    = (known after apply)
      + id                                          = (known after apply)
      + image                                       = (known after apply)
      + init                                        = (known after apply)
      + ipc_mode                                    = (known after apply)
      + log_driver                                  = (known after apply)
      + logs                                        = false
      + memory_reservation                          = 0
      + must_run                                    = true
      + name                                        = "price-web"
      + network_data                                = (known after apply)
      + network_mode                                = "bridge"
      + platform                                    = (known after apply)
      + read_only                                   = false
      + remove_volumes                              = true
      + restart                                     = "no"
      + rm                                          = false
      + runtime                                     = (known after apply)
      + security_opts                               = (known after apply)
      + shm_size                                    = (known after apply)
      + start                                       = true
      + stdin_open                                  = false
      + stop_signal                                 = (known after apply)
      + stop_timeout                                = (known after apply)
      + tty                                         = false
      + wait                                        = false
      + wait_timeout                                = 60

      + healthcheck (known after apply)

      + labels (known after apply)

      + ports {
          + external = 8080
          + internal = 80
          + ip       = "127.0.0.1"
          + protocol = "tcp"
        }
    }

  # docker_image.nginx will be created
  + resource "docker_image" "nginx" {
      + id           = (known after apply)
      + image_id     = (known after apply)
      + keep_locally = true
      + name         = "nginx:1.28-alpine"
      + repo_digest  = (known after apply)
    }

Plan: 2 to add, 0 to change, 0 to destroy.

─────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't
guarantee to take exactly these actions if you run "terraform apply" now.

Мы задали четыре аргумента, а в плане их десятки: остальные — значения по умолчанию провайдера (restart = "no", network_mode = "bridge") и атрибуты, которые станут известны после создания. Блоки в плане идут по алфавиту адресов, а не в порядке создания — порядок определяют зависимости. Применяем:

$ terraform apply -auto-approve | grep -E "Creat|complete"
docker_image.nginx: Creating...
docker_image.nginx: Creation complete after 0s [id=sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5nginx:1.28-alpine]
docker_container.web: Creating...
docker_container.web: Creation complete after 1s [id=1e06e9feb2bbe9b4e5836de7c8012a8cc44d15fddfadaee7b2470bdbd28c87ad]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
$ docker ps --format "{{.Names}}  {{.Image}}  {{.Ports}}"
price-web  dc73b49f5124  127.0.0.1:8080->80/tcp
$ curl -s --retry 5 --retry-connrefused http://127.0.0.1:8080/ | grep -o "<title>.*</title>"
<title>Welcome to nginx!</title>

Сначала образ, потом контейнер — как и следовало из ссылки. В docker ps вместо имени образа идентификатор: мы передали в image именно его. Порт опубликован только на 127.0.0.1: Docker открывает опубликованные порты в обход правил файрвола хоста, и 0.0.0.0 по умолчанию — частая причина открытых наружу сервисов.

08

Что Terraform знает о ресурсе

После apply Terraform записал созданное в состояние. terraform state list — адреса ресурсов, terraform state show — все атрибуты одного ресурса так, как их вернул провайдер:

$ terraform state list
docker_container.web
docker_image.nginx
$ terraform state show docker_container.web
# docker_container.web:
resource "docker_container" "web" {
    attach                                      = false
    bridge                                      = null
    command                                     = [
        "nginx",
        "-g",
        "daemon off;",
    ]
    container_read_refresh_timeout_milliseconds = 15000
    cpu_set                                     = null
    cpu_shares                                  = 0
    domainname                                  = null
    entrypoint                                  = [
        "/docker-entrypoint.sh",
    ]
    env                                         = []
    hostname                                    = "1e06e9feb2bb"
    id                                          = "1e06e9feb2bbe9b4e5836de7c8012a8cc44d15fddfadaee7b2470bdbd28c87ad"
    image                                       = "sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5"
    init                                        = false
    ipc_mode                                    = "private"
    log_driver                                  = "json-file"
    logs                                        = false
…

Именно эти значения Terraform сравнивает с реальностью при следующем plan. Идентификатор контейнера id — связь между адресом docker_container.web и конкретным контейнером в Docker; потеряй её — и Terraform попробует создать контейнер заново (модуль 05).

09

destroy и keep_locally

$ terraform destroy -auto-approve | grep -E "Destr|complete"
docker_container.web: Destroying... [id=1e06e9feb2bbe9b4e5836de7c8012a8cc44d15fddfadaee7b2470bdbd28c87ad]
docker_container.web: Destruction complete after 0s
docker_image.nginx: Destroying... [id=sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5nginx:1.28-alpine]
docker_image.nginx: Destruction complete after 0s
Destroy complete! Resources: 2 destroyed.
$ docker ps -a --filter name=price-web --format "{{.Names}}" | wc -l
0
$ docker image ls nginx:1.28-alpine --format "{{.Repository}}:{{.Tag}}"
nginx:1.28-alpine

Контейнер удалён, а образ остался на машине, хотя ресурс docker_image.nginx тоже «уничтожен»: так работает keep_locally = true. «Удалить ресурс» для Terraform — забыть его в состоянии и попросить провайдер удалить объект; что именно сделает провайдер, описано в его документации. Без keep_locally каждый destroy удалял бы образ, и следующий apply скачивал бы его снова.

10

То же самое в облаке

В облаке меняются провайдер и типы ресурсов, а приёмы остаются. Сеть и подсеть в Yandex Cloud (код для примера, в курсе не запускается — нужен платёжный аккаунт):

terraform {
  required_providers {
    yandex = {
      source  = "yandex-cloud/yandex"
      version = "~> 0.238"
    }
  }
}

provider "yandex" {
  service_account_key_file = "sa-key.json" # ключ сервисного аккаунта — не в Git
  folder_id                = "b1g..."      # идентификатор каталога облака
  zone                     = "ru-central1-a"
}

resource "yandex_vpc_network" "main" {
  name = "price-net"
}

resource "yandex_vpc_subnet" "a" {
  name           = "price-a"
  zone           = "ru-central1-a"
  network_id     = yandex_vpc_network.main.id # ссылка — подсеть после сети
  v4_cidr_blocks = ["10.10.0.0/24"]
}

Ключ сервисного аккаунта даёт права на облако — его не кладут в репозиторий, а передают через переменную окружения или файл вне каталога проекта (модули 04 и 08). Сервисному аккаунту выдают только нужные роли, а не admin на всё облако.

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

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

  • Проект — каталог: Terraform читает все *.tf в нём; принято делить на versions.tf, main.tf, variables.tf, outputs.tf
  • HCL: блоки тип "метка" { } и аргументы имя = значение; строки в двойных кавычках, имена в snake_case
  • required_version и required_providers с ~> МАЖОР.МИНОР; точную версию держит lock-файл
  • Адрес ресурса — тип.имя; аргументы задаёте вы, атрибуты появляются после создания
  • Ссылка на атрибут — неявная зависимость: Terraform сам определяет порядок создания и удаления
  • fmt -check и validate — перед каждым коммитом и в CI; validate не обращается к API
  • Порты контейнеров — на 127.0.0.1, если сервис не должен быть доступен снаружи
  • Ключи облака — не в коде и не в Git
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 01: Инфраструктура как код Все модули Модуль 03: plan, apply, destroy