Два блока resource — и Terraform сам понимает, что образ нужен раньше контейнера
fmt и validateПроект Terraform — это каталог. Terraform читает все файлы *.tf в нём и склеивает в одну конфигурацию; порядок файлов и блоков внутри них не важен. Подкаталоги не читаются — для них есть модули (модуль 06). Делить код по файлам можно как угодно, но в командах принято так:
| Файл | Что в нём |
|---|---|
versions.tf | Блок terraform: версии Terraform и провайдеров; настройки провайдеров |
main.tf | Ресурсы; в большом проекте — несколько файлов по смыслу: network.tf, db.tf |
variables.tf | Входные переменные (модуль 04) |
outputs.tf | Выходные значения (модуль 04) |
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
}
}
// тоже комментарий
/* и многострочный
комментарий */
"${path.module}/motd.txt".ports.snake_case: price_web, не priceWeb и не price-web. Дефисы допустимы, но в ссылках их легко спутать с минусом.terraform {
required_version = ">= 1.6"
required_providers {
docker = {
source = "kreuzwerker/docker"
version = "~> 4.6"
}
}
}
provider "docker" {
host = "unix:///var/run/docker.sock"
}
required_version — с какими версиями Terraform этот код работает. Запуск более старой версией закончится понятной ошибкой, а не странным поведением.required_providers — какие провайдеры нужны. Ключ (docker) — локальное имя провайдера в этом проекте, source — его адрес в реестре: kreuzwerker/docker — сокращение полного адреса, в котором впереди стоит ещё имя реестра registry.terraform.io. Провайдеры HashiCorp — hashicorp/…, остальные публикуют компании и сообщество.| Ограничение | Какие версии подходят |
|---|---|
= 4.6.0 | Только 4.6.0 |
>= 4.6 | 4.6 и любые новее, включая 5.x и 6.x |
~> 4.6 | 4.6 и новее, но меньше 5.0: 4.7, 4.12 — да, 5.0 — нет |
~> 4.6.0 | 4.6.x: исправления без новых возможностей |
>= 4.6, < 4.9 | Диапазон через запятую |
Для провайдеров обычно пишут ~> МАЖОР.МИНОР: смена мажорной версии по правилам семантического версионирования может ломать совместимость, и её делают осознанно. Точную выбранную версию всё равно фиксирует .terraform.lock.hcl (модуль 01), а обновляют её командой terraform init -upgrade.
Блок provider "docker" в том же файле — настройки провайдера: куда подключаться. Здесь это сокет Docker на этой машине; у облачного провайдера — ключ доступа, облако, каталог и зона по умолчанию. Если настройки по умолчанию подходят, блок provider можно не писать вовсе — Terraform создаст провайдер с пустыми настройками.
Пользователь, от которого запускается Terraform, должен иметь доступ к /var/run/docker.sock — состоять в группе docker (курс docker-basics). Это право равно root на машине, поэтому учебный Docker держат на своей машине или виртуалке, а не на общем сервере.
# Образ: 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, и с адресом оно не связано.
name, image, блок ports. Какие аргументы есть у ресурса и какие из них обязательны, написано в документации провайдера. Сайт реестра из России может не открываться — та же документация лежит в репозитории провайдера на GitHub, каталог docs/resources.id, image_id — идентификатор скачанного образа. Атрибуты читаются, но не задаются.Выражение docker_image.nginx.image_id — ссылка: «атрибут image_id ресурса docker_image.nginx». Ссылка — ещё и неявная зависимость: контейнер нельзя создать, пока не известен образ, поэтому Terraform сначала создаст образ, а удалять будет в обратном порядке. Писать порядок вручную не нужно и почти никогда не стоит.
Две проверки перед каждым коммитом. 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.
Каталог 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 по умолчанию — частая причина открытых наружу сервисов.
После 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).
$ 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 скачивал бы его снова.
В облаке меняются провайдер и типы ресурсов, а приёмы остаются. Сеть и подсеть в 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 на всё облако.
*.tf в нём; принято делить на versions.tf, main.tf, variables.tf, outputs.tfтип "метка" { } и аргументы имя = значение; строки в двойных кавычках, имена в snake_caserequired_version и required_providers с ~> МАЖОР.МИНОР; точную версию держит lock-файлтип.имя; аргументы задаёте вы, атрибуты появляются после созданияfmt -check и validate — перед каждым коммитом и в CI; validate не обращается к API127.0.0.1, если сервис не должен быть доступен снаружи