Управление состоянием Terraform при помощи moved, removed и import
Основная особенность рефакторинга кода Terraform в том, что при изменении описания ресурсов в коде необходимо также переименовывать или удалять ресурсы в файле состояния инфраструктуры. В противном случае Terraform будет считать, что ресурс со старым именем нужно удалить, а с новым — создать.
Традиционно для решения этой проблемы использовались специальные команды CLI — terraform state mv и terraform state rm. Использование этих команд создаёт ряд практических проблем. Главная в том, что изменения кода и файла состояния не синхронны и на заметное время код и состояние остаются несогласованными. Это особенно проблемно при совместной работе с использованием CI/CD. Второстепенная проблема в том, что ошибку изменения файла состояния можно будет увидеть только при контрольном планировании после того, как файл необратимо изменился. Это увеличивает время рассинхронизации кода и состояния и может потребовать дополнительных телодвижений с импортом или восстановлением из резервной копии в случае ошибки удаления.
Триаду "создание", "изменение", "удаление", завершает операция terraform import для импорта ресурсов, которые ранее не находились под управлением Terraform, и также грозит рассинхронизацией кода и состояния.
Начиная с версии 1.1, Terraform поддерживает операции с файлом состояния на уровне языка описания конфигурации. Первым был блок moved, потом, с версии 1.5, import и в версии 1.7 появился блок removed. Теперь Terraform предоставляет полноценную поддержку декларативного описания изменений файла состояния вместе с изменениями соответствующих описаний ресурсов в коде. Сейчас непосредственная правка состояния требуется только для изменения данных, которые из-за конфиденциальности или ошибок реализации не полноценно поддерживаются провайдерами.
Рассмотрим пример рефакторинга описания ресурсов docker. Понадобится terraform версии >= 1.7
Допустим, мы решили при помощи terraform скачать несколько образов.
В новом каталоге создадим файл main.tf
resource "docker_image" "alpine" {
name = "alpine:3.18"
}
resource "docker_image" "busybox_latest" {
name = "busybox:1.36.1"
}
resource "docker_image" "busybox_prev" {
name = "busybox:1.35.0"
}
В файл versions.tf запишем информацию о провайдере docker
terraform {
required_providers {
docker = {
source = "kreuzwerker/docker"
}
}
}
Применим конфигурацию
# terraform init . . . Terraform has been successfully initialized!
# terraform apply -auto-approve
. . .
# docker_image.alpine will be created
+ resource "docker_image" "alpine" {
+ id = (known after apply)
+ image_id = (known after apply)
+ name = "alpine:3.18"
+ repo_digest = (known after apply)
}
# docker_image.busybox_latest will be created
+ resource "docker_image" "busybox_latest" {
+ id = (known after apply)
+ image_id = (known after apply)
+ name = "busybox:1.36.1"
+ repo_digest = (known after apply)
}
# docker_image.busybox_prev will be created
+ resource "docker_image" "busybox_prev" {
+ id = (known after apply)
+ image_id = (known after apply)
+ name = "busybox:1.35.0"
+ repo_digest = (known after apply)
}
Plan: 3 to add, 0 to change, 0 to destroy.
. . .
Отделим данные образов busybox от кода ресурса и опишем образы при помощи for_each
resource "docker_image" "alpine" {
name = "alpine:3.18"
}
locals {
busybox_images = {
latest = "1.36.1"
prev = "1.35.0"
}
}
resource "docker_image" "busybox" {
for_each = local.busybox_images
name = "busybox:${each.value}"
}
Проверим план
# terraform plan
. . .
# docker_image.busybox["latest"] will be created
+ resource "docker_image" "busybox" {
+ id = (known after apply)
+ image_id = (known after apply)
+ name = "busybox:1.36.1"
+ repo_digest = (known after apply)
}
# docker_image.busybox["prev"] will be created
+ resource "docker_image" "busybox" {
+ id = (known after apply)
+ image_id = (known after apply)
+ name = "busybox:1.35.0"
+ repo_digest = (known after apply)
}
# docker_image.busybox_latest will be destroyed
# (because docker_image.busybox_latest is not in configuration)
- resource "docker_image" "busybox_latest" {
- id = "sha256:3f57d9401f8d42f986df300f0c69192fc41da28ccc8d797829467780db3dd741busybox:1.36.1" -> null
- image_id = "sha256:3f57d9401f8d42f986df300f0c69192fc41da28ccc8d797829467780db3dd741" -> null
- name = "busybox:1.36.1" -> null
- repo_digest = "busybox@sha256:6d9ac9237a84afe1516540f40a0fafdc86859b2141954b4d643af7066d598b74" -> null
}
# docker_image.busybox_prev will be destroyed
# (because docker_image.busybox_prev is not in configuration)
- resource "docker_image" "busybox_prev" {
- id = "sha256:0c00acac9c2794adfa8bb7b13ef38504300b505a043bf68dff7a00068dcc732bbusybox:1.35.0" -> null
- image_id = "sha256:0c00acac9c2794adfa8bb7b13ef38504300b505a043bf68dff7a00068dcc732b" -> null
- name = "busybox:1.35.0" -> null
- repo_digest = "busybox@sha256:02289a9972c5024cd2f083221f6903786e7f4cb4a9a9696f665d20dd6892e5d6" -> null
}
Plan: 2 to add, 0 to change, 2 to destroy.
Terraform планирует удалить ресурсы со старыми именами и создать их же с новыми. При помощи блока moved мы можем объяснить, что имели в виду не пересоздание, а переименование.
Объявление переименования my_resource.old_name в my_resource.new_name выглядит так
moved {
from = my_resource.old_name
to = my_resource.new_name
}
В атрибуты блока from и to передаются не строчные имена ресурсов, а ссылки на сами ресурсы, поэтому брать в кавычки их не нужно.
Старые и новые имена ресурсов удобно брать прямо из плана. Дополним main.tf
moved {
from = docker_image.busybox_latest
to = docker_image.busybox["latest"]
}
moved {
from = docker_image.busybox_prev
to = docker_image.busybox["prev"]
}
План теперь выглядит так:
# terraform plan
. . .
Terraform will perform the following actions:
# docker_image.busybox_latest has moved to docker_image.busybox["latest"]
resource "docker_image" "busybox" {
id = "sha256:3f57d9401f8d42f986df300f0c69192fc41da28ccc8d797829467780db3dd741busybox:1.36.1"
name = "busybox:1.36.1"
# (2 unchanged attributes hidden)
}
# docker_image.busybox_prev has moved to docker_image.busybox["prev"]
resource "docker_image" "busybox" {
id = "sha256:0c00acac9c2794adfa8bb7b13ef38504300b505a043bf68dff7a00068dcc732bbusybox:1.35.0"
name = "busybox:1.35.0"
# (2 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
Изменений по плану нет, отмечены только факты переименования ресурсов.
Теперь заберём под управление Terraform стандартную сеть host. Допишем в main.tf
resource "docker_network" "host" {
name = "host"
}
Terraform планирует создание
# terraform plan
. . .
# docker_network.host will be created
+ resource "docker_network" "host" {
+ driver = (known after apply)
+ id = (known after apply)
+ internal = (known after apply)
+ ipam_driver = "default"
+ name = "host"
+ options = (known after apply)
+ scope = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Нам нужно объяснить, что нужно не создавать ресурс, а работать с уже существующим. Для этого используется блок import.
import {
to = existing_resource.name
id = "resource_id"
}
Здесь existing_resource.name это, как и в moved, ссылка на ресурс, не берётся в кавычки. А id это уже строковое значение ID ресурса, должно быть в кавычках, когда задаётся константой.
Итак, для оформления импорта нужны имя и ID ресурса. Имя возьмём из плана. Правила формирования ID для импорта обычно пишутся в конце документации по ресурсу. Согласно документации по docker_network, мы должны взять полный ID сети. Получим его командой
docker network inspect host --format '{{ .ID }}'
Добавим в main.tf блок import
import {
to = docker_network.host
id = "69c8be307fddac49be9dae58d6636b0216a6f136f609949d2458fbf82bcdf254"
}
Здесь в to, как и в атрибутах moved, ссылка на объект пишется без кавычек, а вот id это строка, поэтому его берём в кавычки.
Проверим план
# terrform plan
. . .
# docker_network.host will be imported
resource "docker_network" "host" {
attachable = false
driver = "host"
id = "69c8be307fddac49be9dae58d6636b0216a6f136f609949d2458fbf82bcdf254"
ingress = false
internal = false
ipam_driver = "default"
ipam_options = {}
ipv6 = false
name = "host"
options = {}
scope = "local"
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Изменений нет, планируется только импорт ресурса в файл состояния.
Теперь уберём образ alpine из-под управления Terraform.
Удалим из конфигурации код
resource "docker_image" "alpine" {
name = "alpine:3.18"
}
Проверим план
# terraform plan
. . .
# docker_image.alpine will be destroyed
# (because docker_image.alpine is not in configuration)
- resource "docker_image" "alpine" {
- id = "sha256:d3782b16ccc94322a5c5a7d004192b5daa2a1ecd61c143074e36dba844408e1calpine:3.18" -> null
- image_id = "sha256:d3782b16ccc94322a5c5a7d004192b5daa2a1ecd61c143074e36dba844408e1c" -> null
- name = "alpine:3.18" -> null
- repo_digest = "alpine@sha256:11e21d7b981a59554b3f822c49f6e9f57b6068bb74f49c4cd5cc4c663c7e5160" -> null
}
. . .
Plan: 1 to import, 0 to add, 0 to change, 1 to destroy.
Terraform планирует удаление. Мы бы хотели оставить сам образ в системе. Реальная потребность в таком решении обычно возникает, когда мы перемещаем ресурс из одного файла состояния в другой.
Проинструктируем Terraform забыть состояние ресурса без удаления объекта. Добавим блок removed, обязательно с destroy = false
removed {
from = docker_image.alpine
lifecycle {
destroy = false
}
}
Здесь во from, как и в блоке moved, ссылка на ресурс, кавычки не используются.
Теперь в плане снова только импорт и информация об удалении образа alpine из состояния
# terraform plan . . . Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. ╷ │ Warning: Some objects will no longer be managed by Terraform │ │ If you apply this plan, Terraform will discard its tracking information for the following objects, but it will not delete them: │ - docker_image.alpine │ │ After applying this plan, Terraform will no longer manage these objects. You will need to import them into Terraform to manage them again. ╵
Итоговая конфигурация рефакторинга выглядит так
locals {
busybox_images = {
latest = "1.36.1"
prev = "1.35.0"
}
}
resource "docker_image" "busybox" {
for_each = local.busybox_images
name = "busybox:${each.value}"
}
moved {
from = docker_image.busybox_latest
to = docker_image.busybox["latest"]
}
moved {
from = docker_image.busybox_prev
to = docker_image.busybox["prev"]
}
resource "docker_network" "host" {
name = "host"
}
import {
to = docker_network.host
id = "69c8be307fddac49be9dae58d6636b0216a6f136f609949d2458fbf82bcdf254"
}
removed {
from = docker_image.alpine
lifecycle {
destroy = false
}
}
Применим изменения
# terrafork apply
. . .
Terraform will perform the following actions:
# docker_image.alpine will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
. resource "docker_image" "alpine" {
id = "sha256:d3782b16ccc94322a5c5a7d004192b5daa2a1ecd61c143074e36dba844408e1calpine:3.18"
name = "alpine:3.18"
# (2 unchanged attributes hidden)
}
# docker_image.busybox_latest has moved to docker_image.busybox["latest"]
resource "docker_image" "busybox" {
id = "sha256:3f57d9401f8d42f986df300f0c69192fc41da28ccc8d797829467780db3dd741busybox:1.36.1"
name = "busybox:1.36.1"
# (2 unchanged attributes hidden)
}
# docker_image.busybox_prev has moved to docker_image.busybox["prev"]
resource "docker_image" "busybox" {
id = "sha256:0c00acac9c2794adfa8bb7b13ef38504300b505a043bf68dff7a00068dcc732bbusybox:1.35.0"
name = "busybox:1.35.0"
# (2 unchanged attributes hidden)
}
# docker_network.host will be imported
resource "docker_network" "host" {
attachable = false
driver = "host"
id = "69c8be307fddac49be9dae58d6636b0216a6f136f609949d2458fbf82bcdf254"
ingress = false
internal = false
ipam_driver = "default"
ipam_options = {}
ipv6 = false
name = "host"
options = {}
scope = "local"
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
После этого блоки рефакторинга можно удалить
locals {
busybox_images = {
latest = "1.36.1"
prev = "1.35.0"
}
}
resource "docker_image" "busybox" {
for_each = local.busybox_images
name = "busybox:${each.value}"
}
resource "docker_network" "host" {
name = "host"
}
Изменений в плане не будет
# terraform plan . . . No changes. Your infrastructure matches the configuration.
Основное преимущество декларативного описания рефакторинга заключается в том, что все изменения планируются относительно состояния, загруженного в память в процессе операции plan, и применяются только операцией apply, причём атомарно, в течение одного сеанса блокировки файла состояния. Таким образом, планирование рефакторинга не нарушает совместную работу.
Основная проблема в том, что moved и removed не поддерживают атрибут for_each, поэтому при массовом изменении ресурсов понадобится добавлять большое количество кода. Вручную или скриптом на основании исходного кода или плана. С import всё получше: там и поддержка for_each и подстановка переменных.
И ещё одно неудобство с removed. Директива не умеет удалять отдельные элементы массива ресурсов. Выглядит это так
$ cat main.tf
resource "random_string" "x" {
# Ранее создали с for_each = toset(["a", "b", "c"])
for_each = toset(["a", "c"])
length = 8
}
removed {
from = random_string.x["b"]
lifecycle {
destroy = false
}
}
$ terraform plan
╷
│ Error: Resource instance keys not allowed
│
│ on main.tf line 8, in removed:
│ 8: from = random_string.x["b"]
│
│ Resource address must be a resource (e.g. "test_instance.foo"), not a resource instance (e.g. "test_instance.foo[1]").
╵
Компенсировать это ограничение можно при помощи вспомогательного блока moved
$ cat main.tf
resource "random_string" "x" {
for_each = toset(["a", "c"])
length = 8
}
moved {
from = random_string.x["b"]
to = random_string.anything_you_want
}
removed {
from = random_string.anything_you_want
lifecycle {
destroy = false
}
}
$ terraform plan
random_string.x["c"]: Refreshing state... [id=sXe$1gI+]
random_string.anything_you_want: Refreshing state... [id=kc<Ng-st]
random_string.x["a"]: Refreshing state... [id=}c4W$k1y]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
Terraform will perform the following actions:
# random_string.anything_you_want will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
# (moved from random_string.x["b"])
. resource "random_string" "anything_you_want" {
id = "kc<Ng-st"
# (11 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.
╷
│ Warning: Some objects will no longer be managed by Terraform
│
│ If you apply this plan, Terraform will discard its tracking information for the following objects, but it will not delete them:
│ - random_string.anything_you_want
│
│ After applying this plan, Terraform will no longer manage these objects. You will need to import them into Terraform to manage them again.
╵
Подробности об описанных блоках конфигурации в официальной документации Terraform: