Управление состоянием Terraform при помощи moved, removed и import

terraform

Основная особенность рефакторинга кода 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 и подстановка переменных.

Подробности об описанных блоках конфигурации в официальной документации Terraform: