Модуль 03 — plan, apply, destroy

Строка «must be replaced» в плане базы данных — повод остановиться, а не нажать yes

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

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

01

Граф зависимостей

Учебный сервис этого модуля — образ, том с данными и контейнер, который его монтирует:

resource "docker_image" "nginx" {
  name         = "nginx:1.28-alpine"
  keep_locally = true
}

# Данные сервиса: удалять случайно нельзя
resource "docker_volume" "data" {
  name = "price-data"

  lifecycle {
    prevent_destroy = true
  }
}

resource "docker_container" "web" {
  name    = "price-web"
  image   = docker_image.nginx.image_id
  restart = "no"

  ports {
    internal = 80
    external = 8080
    ip       = "127.0.0.1"
  }

  volumes {
    volume_name    = docker_volume.data.name
    container_path = "/usr/share/nginx/html"
  }
}

Перед любым действием Terraform строит граф: ресурсы — вершины, ссылки — рёбра. terraform graph выводит его в формате Graphviz (картинку рисует dot -Tsvg из пакета graphviz):

$ terraform graph
digraph G {
  rankdir = "RL";
  node [shape = rect, fontname = "sans-serif"];
  "docker_container.web" [label="docker_container.web"];
  "docker_image.nginx" [label="docker_image.nginx"];
  "docker_volume.data" [label="docker_volume.data"];
  "docker_container.web" -> "docker_image.nginx";
  "docker_container.web" -> "docker_volume.data";
}

Контейнер зависит от образа и тома, образ и том друг от друга не зависят. Поэтому Terraform создаёт образ и том параллельно (по умолчанию до 10 операций одновременно, ключ -parallelism), контейнер — после них, а удаляет в обратном порядке. В облаке, где ресурс создаётся минутами, параллельность экономит много времени.

02

Как читать план

СимволДействиеНа что смотреть
+СоздатьОжидали ли столько новых ресурсов
~Изменить на местеКакие именно атрибуты: стрелка старое -> новое
-/+Удалить и создать зановоПростой и потеря данных; причина — пометка # forces replacement
+/-Создать новый, потом удалить старыйТо же, но без простоя (create_before_destroy)
-УдалитьКаждую строку — особенно базы, тома, бакеты, DNS
<=Прочитать источник данныхМодуль 07

Начинают с последней строки — Plan: N to add, M to change, K to destroy. Если вы поменяли порт, а план собирается удалить пять ресурсов, — что-то не так, и разбираться надо до apply. Из-за таких планов в работе с настоящей инфраструктурой не пишут -auto-approve.

03

План в файл

Обычный apply строит план заново, и между вашим plan и apply мир мог измениться. plan -out сохраняет план в файл, show показывает его, а apply с именем файла выполняет ровно его, без вопроса:

$ terraform plan -out=tfplan | tail -4
Saved the plan to: tfplan

To perform exactly these actions, run the following command to apply:
    terraform apply "tfplan"
$ terraform show tfplan -no-color | grep -E "^  # |Plan:"
  # docker_container.web will be created
  # docker_image.nginx will be created
  # docker_volume.data will be created
Plan: 3 to add, 0 to change, 0 to destroy.
$ terraform apply tfplan | grep -E "Creat|complete"
docker_volume.data: Creating...
docker_image.nginx: Creating...
docker_volume.data: Creation complete after 0s [id=price-data]
docker_image.nginx: Creation complete after 0s [id=sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5nginx:1.28-alpine]
docker_container.web: Creating...
docker_container.web: Creation complete after 0s [id=1dbd8dc4a3c21eaf78eedd6d92c65f74bd1cee9de786e84052c0fc3f121ab93e]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.

Так работают в CI: задание plan сохраняет файл, человек читает план в merge request, задание apply применяет тот же файл (модуль 08). Файл плана — двоичный, в нём есть значения всех атрибутов, включая пароли; в Git его не кладут.

04

Изменение на месте

Меняем политику перезапуска контейнера — Docker умеет менять её у работающего контейнера (docker update), и провайдер это знает:

$ sed -i "s/restart = \"no\"/restart = \"unless-stopped\"/" main.tf
$ terraform plan
docker_volume.data: Refreshing state... [id=price-data]
docker_image.nginx: Refreshing state... [id=sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5nginx:1.28-alpine]
docker_container.web: Refreshing state... [id=1dbd8dc4a3c21eaf78eedd6d92c65f74bd1cee9de786e84052c0fc3f121ab93e]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # docker_container.web will be updated in-place
  ~ resource "docker_container" "web" {
        id                                          = "1dbd8dc4a3c21eaf78eedd6d92c65f74bd1cee9de786e84052c0fc3f121ab93e"
        name                                        = "price-web"
      ~ restart                                     = "no" -> "unless-stopped"
        # (50 unchanged attributes hidden)

        # (2 unchanged blocks hidden)
    }

Plan: 0 to add, 1 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.
$ terraform apply -auto-approve | grep -E "Modif|complete"
docker_container.web: Modifying... [id=1dbd8dc4a3c21eaf78eedd6d92c65f74bd1cee9de786e84052c0fc3f121ab93e]
docker_container.web: Modifications complete after 0s [id=1dbd8dc4a3c21eaf78eedd6d92c65f74bd1cee9de786e84052c0fc3f121ab93e]
Apply complete! Resources: 0 added, 1 changed, 0 destroyed.

Знак ~ и одна строка с изменением; остальные атрибуты скрыты (50 unchanged attributes hidden). Идентификатор контейнера тот же — контейнер не пересоздавался.

05

Пересоздание

Опубликованный порт у существующего контейнера не поменять — только создать новый. Провайдер помечает такие аргументы, и план превращается в -/+ (вывод отфильтрован grep; ключ -no-color убирает цветовые коды, чтобы фильтровать было удобно):

$ sed -i "s/external = 8080/external = 8081/" main.tf
$ terraform plan -out=tfplan -no-color | grep -E "^  # |replacement|forces|Plan:"
-/+ destroy and then create replacement
  # docker_container.web must be replaced
          ~ external = 8080 -> 8081 # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.

Для контейнера без состояния это несколько секунд простоя. Для виртуальной машины — потеря всего, что было на её диске, для управляемой базы — потеря данных. Что пересоздаёт ресурс, написано в документации провайдера и видно в плане по # forces replacement. Частые случаи в облаках: зона доступности, образ загрузочного диска, имя.

06

Устаревший план

План выше сохранён в tfplan. Пока его читали, коллега выполнил apply сам. Применить сохранённый план теперь нельзя:

$ terraform apply -auto-approve | grep -E "complete"
docker_container.web: Destruction complete after 1s
docker_container.web: Creation complete after 0s [id=7bd96b0c47e4ff63003f854bedb36ec60990592b3ff1d601aa99c1a45495139b]
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
$ terraform apply tfplan
╷
│ Error: Saved plan is stale
│ 
│ The given plan file can no longer be applied because the state was changed
│ by another operation after the plan was created.
╵

Terraform помнит, к какому состоянию построен план, и отказывается применять его к другому. Это защищает от гонок, но не заменяет блокировку: двое, запустивших apply одновременно, испортят состояние. Блокировки — в модуле 05.

07

Дрейф и -refresh-only

Перед каждым планом Terraform обновляет (refresh) состояние: спрашивает провайдер, как ресурсы выглядят сейчас (строки Refreshing state…). Удалим контейнер мимо Terraform:

$ docker rm -f price-web
price-web
$ terraform plan -no-color | grep -E "^  # |Plan:"
  # docker_container.web will be created
Plan: 1 to add, 0 to change, 0 to destroy.

Terraform заметил пропажу и планирует создать контейнер снова. Иногда изменение снаружи правильное — например, ресурс удалили вручную намеренно, и вы хотите только, чтобы Terraform «узнал» об этом. Для этого есть -refresh-only: показать, что изменилось снаружи, и предложить записать это в состояние, ничего не трогая в инфраструктуре:

$ terraform plan -refresh-only -no-color | grep -E "^  # |Objects|refresh-only plan"
Note: Objects have changed outside of Terraform
  # docker_container.web has been deleted
This is a refresh-only plan, so Terraform will not take any actions to undo
$ terraform apply -auto-approve | grep complete
docker_container.web: Creation complete after 0s [id=6c73b976c3143f774d9d43c70f65a3ddde16e09ab0aa4fa5c7a96ad8fe88b386]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

apply -refresh-only приняло бы изменения в состояние; обычный apply, как здесь, возвращает инфраструктуру к коду. Регулярный plan по расписанию в CI — простой детектор дрейфа: непустой план ночью означает, что кто-то правил руками.

08

ignore_changes: что меняет кто-то другой

Бывают атрибуты, которыми управляет не Terraform: число машин в группе меняет автомасштабирование, метки ставит система учёта. Изобразим это: политику перезапуска поменяли снаружи, и каждый план собирается её вернуть:

$ docker update --restart=always price-web
price-web
$ terraform plan -no-color | grep -E "^  # |restart |Plan:"
  # docker_container.web will be updated in-place
      ~ restart                                     = "always" -> "unless-stopped"
Plan: 0 to add, 1 to change, 0 to destroy.

Если так и задумано, атрибут исключают из сравнения блоком lifecycle внутри ресурса:

resource "docker_container" "web" {
  # … аргументы как раньше …

  lifecycle {
    ignore_changes = [restart]
  }
}
$ terraform plan -no-color | grep -E "^  # |No changes"
No changes. Your infrastructure matches the configuration.

ignore_changes прячет дрейф, поэтому перечисляют конкретные атрибуты и пишут комментарий «кто ими управляет». ignore_changes = all превращает Terraform в «создал и забыл» — так делают только осознанно.

09

create_before_destroy и уникальные имена

По умолчанию пересоздание идёт как -/+: сначала удалить, потом создать — между ними сервис недоступен. create_before_destroy = true меняет порядок на +/-. Добавим его и снова сменим порт:

  lifecycle {
    ignore_changes        = [restart]
    create_before_destroy = true
  }
$ terraform apply -auto-approve -no-color 2>&1 | grep -E "^  # |replacement|Plan:|Creat|Error"
+/- create replacement and then destroy
  # docker_container.web must be replaced
          ~ external = 8081 -> 8082 # forces replacement
Plan: 1 to add, 0 to change, 1 to destroy.
docker_container.web: Creating...
Error: Unable to create container: Error response from daemon: Conflict. The container name "/price-web" is already in use by container "6c73b976c3143f774d9d43c70f65a3ddde16e09ab0aa4fa5c7a96ad8fe88b386". You have to remove (or rename) that container to be able to reuse that name.
$ terraform apply -auto-approve | grep -E "complete"
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Новый контейнер не создался: старый ещё жив, а имя price-web занято. То же произойдёт с любым ресурсом, имя которого должно быть уникальным, — виртуальной машиной в каталоге облака, бакетом, DNS-записью. С create_before_destroy имя делают изменяемым — например, добавляют случайный суффикс (random_id, модуль 07) — или не задают вовсе, если провайдер умеет генерировать его сам. Старый контейнер ошибка не тронула: Terraform не удаляет старое, пока не создано новое. Возвращаем порт и lifecycle без create_before_destroy.

10

-replace: пересоздать принудительно

Код не менялся, но ресурс «испорчен»: машина зависла, в контейнере что-то правили руками. -replace=АДРЕС пересоздаёт один ресурс:

$ terraform plan -replace=docker_container.web -no-color | grep -E "^  # |Plan:"
  # docker_container.web will be replaced, as requested
Plan: 1 to add, 0 to change, 1 to destroy.

Раньше для этого была команда terraform taint; она устарела — -replace сначала показывает план, а taint молча помечал ресурс в состоянии.

11

prevent_destroy

Том price-data защищён prevent_destroy = true (код в начале модуля). Любой план, в котором он удаляется, — и destroy, и замена из-за правки, — завершается ошибкой до каких-либо действий:

$ terraform destroy -auto-approve 2>&1 | sed -n "/Error/,\$p"
│ Error: Instance cannot be destroyed
│ 
│   on main.tf line 7:
│    7: resource "docker_volume" "data" {
│ 
│ Resource docker_volume.data has lifecycle.prevent_destroy set, but the plan
│ calls for this resource to be destroyed. To avoid this error and continue
│ with the plan, either disable lifecycle.prevent_destroy or reduce the scope
│ of the plan using the -target option.
╵

Чтобы удалить ресурс, защиту снимают в коде — это отдельное изменение, которое пройдёт ревью. После этого destroy удаляет всё, вместе с данными тома:

$ terraform destroy -auto-approve | grep -E "Destr|complete"
docker_container.web: Destroying... [id=6c73b976c3143f774d9d43c70f65a3ddde16e09ab0aa4fa5c7a96ad8fe88b386]
docker_container.web: Destruction complete after 0s
docker_image.nginx: Destroying... [id=sha256:dc73b49f5124cf2ee538dfbdfbd121f0b4ccdcb20fea30f3a81bd477c02e2bb5nginx:1.28-alpine]
docker_volume.data: Destroying... [id=price-data]
docker_image.nginx: Destruction complete after 0s
docker_volume.data: Destruction complete after 2s
Destroy complete! Resources: 3 destroyed.
prevent_destroy не спасёт, если удалить из кода блок ресурса целиком: защиты в коде больше нет, и следующий apply удалит ресурс. Для данных нужна защита и на стороне облака — запрет удаления у базы и диска, резервные копии, — а план с destroy для таких ресурсов читают особенно внимательно.

Ключ -target=АДРЕС ограничивает план одним ресурсом и его зависимостями. Он нужен для починки в аварии, но не для работы: план с -target показывает только часть картины, и Terraform сам предупреждает об этом.

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

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

  • Порядок действий следует из графа зависимостей; независимые ресурсы создаются параллельно
  • План читают с итоговой строки; -/+ и - для данных — повод остановиться
  • plan -out + apply файл — применяется ровно прочитанный план; устаревший план не применится
  • ~ — изменение на месте, -/+ — пересоздание, причина — # forces replacement
  • Refresh перед планом находит дрейф; -refresh-only — принять изменения в состояние, не трогая инфраструктуру
  • ignore_changes — для атрибутов, которыми управляет кто-то другой, перечисляя их явно
  • create_before_destroy убирает простой, но требует изменяемых уникальных имён
  • -replace вместо устаревшего taint; prevent_destroy не защищает от удаления блока из кода
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 02: HCL и первый ресурс Все модули Модуль 04: Переменные, выходы, locals