Модуль 05 — Состояние

Потерять terraform.tfstate — значит остаться с работающей инфраструктурой, которой Terraform больше не узнаёт

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

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

01

Что лежит в terraform.tfstate

Учебный сервис — снова nginx в контейнере:

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

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

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

После apply в каталоге появился terraform.tfstate — JSON. Посмотрим на него через jq (sudo apt install jq):

$ ls
main.tf
terraform.tfstate
versions.tf
$ jq "{version, terraform_version, serial, resources: [.resources[] | .type + \".\" + .name]}" terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.16.5",
  "serial": 3,
  "resources": [
    "docker_container.web",
    "docker_image.nginx"
  ]
}
$ jq ".resources[] | select(.type == \"docker_container\") | .instances[0].attributes | {id, name, ip_address: .network_data[0].ip_address}" terraform.tfstate
{
  "id": "269bdf5ee1df0990e19e6e5c7f0f34f5ba20adede033444e631bf73b0d14155d",
  "name": "price-web",
  "ip_address": "172.17.0.2"
}
Что случилосьПоследствие
Состояние удалено или потеряноTerraform считает, что ничего не создано, и пытается создать всё заново — дубли или конфликты имён
Двое работают со своими копиямиКаждый план врёт: один не видит изменений другого, удаляет «лишнее»
Состояние в GitПароли в истории навсегда; конфликты слияния в JSON
Правка JSON рукамиИспорченный файл; для правок есть команды и блоки ниже

Поэтому в .gitignore проекта всегда *.tfstate и *.tfstate.*, а для работы больше чем одного человека состояние переносят на сервер (раздел 06).

02

Переименование: moved

Переименуем в коде docker_container.web в docker_container.frontend — имя стало точнее. План:

$ terraform plan -no-color | grep -E "^  # |Plan:"
  # docker_container.frontend will be created
  # docker_container.web will be destroyed
  # (because docker_container.web is not in configuration)
Plan: 1 to add, 0 to change, 1 to destroy.

Для Terraform адрес и есть ресурс: web из кода пропал — его надо удалить, frontend появился — его надо создать. Для контейнера это несколько секунд простоя, для базы — потеря данных. Правильно — сказать Terraform, что ресурс переехал, блоком moved рядом с ресурсом:

moved {
  from = docker_container.web
  to   = docker_container.frontend
}
$ terraform plan -no-color | grep -E "^  # |moved|Plan:"
  # docker_container.web has moved to docker_container.frontend
Plan: 0 to add, 0 to change, 0 to destroy.
$ terraform apply -auto-approve -no-color | grep -E "Apply complete"
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list
docker_container.frontend
docker_image.nginx

Ноль изменений: при apply Terraform переписал адрес в состоянии. Блок moved оставляют в коде, пока все копии состояния (у коллег, в других окружениях) не прошли через apply, — потом удаляют. Старый способ — команда terraform state mv: она делает то же самое сразу в состоянии, но мимо ревью и только в одном окружении.

03

import: забрать созданное вручную

Кэш Redis запустили руками задолго до Terraform. Удалять и создавать заново не хочется — нужно, чтобы Terraform начал им управлять. Провайдер docker импортирует контейнер по его ID — какой идентификатор нужен для импорта, у каждого типа ресурса написано в документации провайдера:

$ docker run -d --name price-cache --restart unless-stopped redis:8-alpine >/dev/null && docker ps --format "{{.Names}}"
price-cache
price-web
$ docker inspect -f "{{.Id}}" price-cache
5e0f48ee645600f5b2c74b276f789b715f1023f48a3dc588c6cc56d0558b2887

Блок import говорит: «объект с таким ID — это ресурс docker_container.cache»:

import {
  to = docker_container.cache
  id = "…" # полный ID контейнера из docker inspect
}

Код самого ресурса можно не писать руками: plan -generate-config-out прочитает объект и запишет его описание в файл:

$ terraform plan -generate-config-out=generated.tf -no-color | grep -E "^  # |Plan:|Warning|Error"
  # docker_container.cache must be replaced
  # (imported from "5e0f48ee645600f5b2c74b276f789b715f1023f48a3dc588c6cc56d0558b2887")
  # Warning: this will destroy the imported resource
Plan: 1 to import, 1 to add, 0 to change, 1 to destroy.
$ cat generated.tf
# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "5e0f48ee645600f5b2c74b276f789b715f1023f48a3dc588c6cc56d0558b2887"
resource "docker_container" "cache" {
  attach                                      = null
  cgroup_parent                               = null
  cgroupns_mode                               = null
  command                                     = ["redis-server"]
  container_read_refresh_timeout_milliseconds = null
  cpu_period                                  = null
  cpu_quota                                   = null
  cpu_set                                     = null
  cpu_shares                                  = 0
  cpus                                        = null
…
$ rm generated.tf

Сгенерированный код — черновик: в нём все атрибуты подряд, многие со значением null, и план с ним всё равно может требовать пересоздания. Его чистят до нужного минимума и переносят в main.tf:

resource "docker_container" "cache" {
  name    = "price-cache"
  image   = "redis:8-alpine"
  restart = "unless-stopped"
  env     = [] # иначе провайдер считает env неизвестным и пересоздаёт контейнер
}
$ terraform plan -no-color | grep -E "^  # |import|Plan:"
docker_container.cache: Preparing import... [id=5e0f48ee645600f5b2c74b276f789b715f1023f48a3dc588c6cc56d0558b2887]
  # docker_container.cache will be updated in-place
  # (imported from "5e0f48ee645600f5b2c74b276f789b715f1023f48a3dc588c6cc56d0558b2887")
Plan: 1 to import, 0 to add, 1 to change, 0 to destroy.
$ terraform apply -auto-approve -no-color | grep -E "Import|Apply complete"
docker_container.cache: Importing... [id=5e0f48ee645600f5b2c74b276f789b715f1023f48a3dc588c6cc56d0558b2887]
docker_container.cache: Import complete [id=5e0f48ee645600f5b2c74b276f789b715f1023f48a3dc588c6cc56d0558b2887]
Apply complete! Resources: 1 imported, 0 added, 1 changed, 0 destroyed.

В плане 1 to import и изменение на месте — провайдер дописывает в состояние значения, которые при импорте не прочитал; контейнер не пересоздаётся. Если план с импортом требует must be replaced, его не применяют, а ищут в плане атрибут с # forces replacement и дописывают в код. Блок import после применения можно удалить. Команда terraform import делает то же без плана — блок лучше: его видно в ревью, и план показывает результат заранее.

04

removed: отпустить ресурс

Обратная задача: кэшем теперь управляет другая команда, а удалять контейнер нельзя. Если просто стереть блок resource, следующий apply удалит контейнер. Блок removed с destroy = false убирает ресурс только из состояния:

removed {
  from = docker_container.cache

  lifecycle {
    destroy = false
  }
}
$ terraform apply -auto-approve -no-color | grep -E "^ # |no longer|Apply complete"
 # docker_container.cache will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
Warning: Some objects will no longer be managed by Terraform
After applying this plan, Terraform will no longer manage these objects. You
Apply complete! Resources: 0 added, 0 changed, 0 destroyed.
$ terraform state list
docker_container.frontend
docker_image.nginx
$ docker ps --format "{{.Names}}"
price-cache
price-web
$ docker rm -f price-cache >/dev/null

В состоянии кэша больше нет, а контейнер работает. Старый способ — terraform state rm АДРЕС, с теми же оговорками, что у state mv. Блоки moved, import и removed есть и в OpenTofu.

КомандаЧто делает
terraform state listАдреса ресурсов в состоянии
terraform state show АДРЕСАтрибуты одного ресурса
terraform state pullСостояние целиком в stdout — для резервной копии и jq
terraform state mv / rmПравка без плана — когда блоки не подходят, например в аварии
05

Удалённое состояние: backend

Backend — где Terraform хранит состояние. По умолчанию local: файл в каталоге. Удалённые backend'ы хранят его на сервере, а главное — блокируют на время операции, чтобы два apply не писали одновременно.

BackendГдеБлокировка
s3S3-совместимое хранилище: Yandex Object Storage, VK Cloud, Selectel, MinIOФайл-блокировка рядом с состоянием (use_lockfile, Terraform 1.10+) или таблица DynamoDB/YDB
pgБаза PostgreSQLAdvisory-блокировки PostgreSQL
httpHTTP-сервис, например GitLab: у каждого проекта есть хранилище состоянийЕсть
localФайл в каталогеТолько на одной машине

В курсе — pg: PostgreSQL на своей машине работает без облака и честно показывает блокировки. Для учебного стенда — контейнер postgres:17 с портом 127.0.0.1:15432 и базой tfstate. Настройка — в блоке terraform; строку подключения с паролем в код не пишем, backend читает её из переменной PG_CONN_STR:

terraform {
  backend "pg" {
    # строка подключения — в переменной окружения PG_CONN_STR, не в коде
  }
}

Значения внутри блока backend не могут ссылаться на переменные — backend настраивается раньше, чем вычисляется остальной код. Что не задано в блоке, передают переменными окружения или ключами terraform init -backend-config=…. В OpenTofu переменные в блоке backend разрешены.

06

Перенос состояния

Добавили backend.tf — смена backend'а требует init. Ключ -migrate-state копирует существующее состояние в новое место, -force-copy отвечает «да» на вопрос о копировании:

$ export PG_CONN_STR="postgres://postgres:state-pass@127.0.0.1:15432/tfstate?sslmode=disable"
$ terraform init -migrate-state -force-copy -no-color | grep -E "backend|Successfully|copy"
Initializing the backend...
Successfully configured the backend "pg"! Terraform will automatically
use this backend unless the backend configuration changes.
If you ever set or change modules or backend configuration for Terraform,
$ docker exec tfstate-pg psql -U postgres -d tfstate -c "select id, name, length(data) from terraform_remote_state.states"
 id |  name   | length 
----+---------+--------
  1 | default |   5262
(1 row)

$ terraform plan -no-color | grep -E "No changes"
No changes. Your infrastructure matches the configuration.
$ ls -l terraform.tfstate
-rw-rw-r-- 1 padmin padmin 0 окт 24 20:16 terraform.tfstate

Состояние — строка в таблице terraform_remote_state.states, которую backend создал сам; name — имя рабочего пространства (workspace, модуль 08). Код и реальность сошлись — No changes. Локальный terraform.tfstate остался пустым файлом; его можно удалить.

07

Блокировка

Добавим в код медленный ресурс — time_sleep из провайдера hashicorp/time «создаётся» 20 секунд. Запустим apply в фоне, как будто это коллега, и сразу plan:

resource "time_sleep" "slow" {
  create_duration = "20s"
}
$ terraform apply -auto-approve -no-color > apply.log 2>&1 &
$ terraform plan -lock-timeout=0s -no-color 2>&1 | sed -n "/Error/,\$p"
Error: Error acquiring the state lock

Error message: Workspace is already locked: default
Lock Info:
  ID:        35ff71e5-fda3-d664-0db3-10f0c3891291
  Path:      
  Operation: OperationTypePlan
  Who:       padmin@mypc
  Version:   1.16.5
  Created:   2026-10-24 17:16:21.840928832 +0000 UTC
  Info:      


Terraform acquires a state lock to protect the state from being written
by multiple users at the same time. Please resolve the issue above and try
again. For most commands, you can disable locking with the "-lock=false"
flag, but this is not recommended.
$ tail -1 apply.log
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Второй запуск не получил блокировку и остановился, ничего не прочитав и не записав. В Lock Info — кто и когда её взял. -lock-timeout=0s — не ждать; в CI ставят, наоборот, -lock-timeout=5m, чтобы задания вставали в очередь. Если процесс упал и блокировка осталась, её снимают terraform force-unlock ID — только убедившись, что никто действительно не работает. -lock=false не используют никогда.

08

Состояние в облаке: Yandex Object Storage

В облаке состояние обычно хранят в бакете S3. Для Yandex Object Storage (пример, в курсе не запускается):

terraform {
  backend "s3" {
    bucket = "price-tfstate"
    key    = "prod/terraform.tfstate"
    region = "ru-central1"

    endpoints = {
      s3 = "https://storage.yandexcloud.net"
    }

    use_lockfile = true # блокировка файлом рядом с состоянием

    skip_region_validation      = true
    skip_credentials_validation = true
    skip_requesting_account_id  = true
    skip_s3_checksum            = true
  }
}

Последнее — резервная копия состояния командой terraform state pull и удаление стенда:

$ terraform state pull | jq ".serial, (.resources | length)"
2
3
$ terraform destroy -auto-approve -no-color | grep "Destroy complete"
Destroy complete! Resources: 3 destroyed.

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

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

  • Состояние связывает адреса из кода с объектами в API; без него Terraform не узнаёт свою инфраструктуру
  • В состоянии пароли открытым текстом: *.tfstate — в .gitignore, хранилище — как для секретов
  • Переименование — блоком moved, иначе план удалит и создаст ресурс заново
  • Созданное вручную — блоком import с ID из документации провайдера; -generate-config-out даёт черновик кода
  • Отпустить ресурс, не удаляя, — блок removed с destroy = false
  • Команде — удалённый backend с блокировками: pg, s3, http; перенос — init -migrate-state
  • В блоке backend нельзя ссылаться на переменные; секреты — через переменные окружения
  • Блокировку не отключают; зависшую снимают force-unlock, убедившись, что никто не работает
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Модуль 04: Переменные, выходы, locals Все модули Модуль 06: Модули