Управление состоянием 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: