Потерять terraform.tfstate — значит остаться с работающей инфраструктурой, которой Terraform больше не узнаёт
Учебный сервис — снова 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"
}
serial растёт с каждой записью состояния: так Terraform отличает более новую копию от старой.id объекта на стороне API. Это главное содержимое: «docker_container.web — это контейнер с вот этим ID». По этой связи Terraform при следующем плане спрашивает провайдер про конкретный объект, а не ищет «похожий».| Что случилось | Последствие |
|---|---|
| Состояние удалено или потеряно | Terraform считает, что ничего не создано, и пытается создать всё заново — дубли или конфликты имён |
| Двое работают со своими копиями | Каждый план врёт: один не видит изменений другого, удаляет «лишнее» |
| Состояние в Git | Пароли в истории навсегда; конфликты слияния в JSON |
| Правка JSON руками | Испорченный файл; для правок есть команды и блоки ниже |
Поэтому в .gitignore проекта всегда *.tfstate и *.tfstate.*, а для работы больше чем одного человека состояние переносят на сервер (раздел 06).
Переименуем в коде 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: она делает то же самое сразу в состоянии, но мимо ревью и только в одном окружении.
Кэш 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 делает то же без плана — блок лучше: его видно в ревью, и план показывает результат заранее.
Обратная задача: кэшем теперь управляет другая команда, а удалять контейнер нельзя. Если просто стереть блок 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 | Правка без плана — когда блоки не подходят, например в аварии |
Backend — где Terraform хранит состояние. По умолчанию local: файл в каталоге. Удалённые backend'ы хранят его на сервере, а главное — блокируют на время операции, чтобы два apply не писали одновременно.
| Backend | Где | Блокировка |
|---|---|---|
s3 | S3-совместимое хранилище: Yandex Object Storage, VK Cloud, Selectel, MinIO | Файл-блокировка рядом с состоянием (use_lockfile, Terraform 1.10+) или таблица DynamoDB/YDB |
pg | База PostgreSQL | Advisory-блокировки PostgreSQL |
http | HTTP-сервис, например 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 разрешены.
Добавили 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 остался пустым файлом; его можно удалить.
Добавим в код медленный ресурс — 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 не используют никогда.
В облаке состояние обычно хранят в бакете 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
}
}
AWS_ACCESS_KEY_ID и AWS_SECRET_ACCESS_KEY, не в коде. Аккаунту — права только на этот бакет.dynamodb_table). В Terraform 1.11 этот способ объявлен устаревшим в пользу use_lockfile; работает ли use_lockfile с вашим хранилищем, проверяют так же, как в разделе 07, — двумя одновременными запусками.Последнее — резервная копия состояния командой 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.
*.tfstate — в .gitignore, хранилище — как для секретовmoved, иначе план удалит и создаст ресурс зановоimport с ID из документации провайдера; -generate-config-out даёт черновик кодаremoved с destroy = falsepg, s3, http; перенос — init -migrate-stateforce-unlock, убедившись, что никто не работает