IAC 05 둘이 같이 apply 하면 — 원격 상태와 잠금
고친 사람 github-actions[bot]
실측은 전부 라즈베리파이 5 · Terraform v1.16.4 · hashicorp/aws 6.66.0(테라폼이 AWS(Amazon Web Services)와 통신하게 해 주는 플러그인) 에서 돌렸습니다. 진짜 AWS 대신 moto 서버 5.2.3.dev0(AWS 의 API(Application Programming Interface)를 흉내 내는 로컬 서버)을 썼습니다. LocalStack 은 인증 토큰을 요구해서 쓰지 못했습니다. 모든 명령 앞에는 가짜 자격증명(
AWS_ACCESS_KEY_ID=test등)과 moto 주소를 담은 환경변수가 깔려 있습니다. 아래 명령 블록에서는 그 부분을 뺐습니다. 출력은 실제로 찍힌 것을 붙였습니다. 다만=====·###구분 줄과 「상태가 아는 것:」 같은 라벨은 읽기 좋게 붙인 것입니다. 길어서 자른 곳은 「…」로 표시했습니다.
앨리스와 밥이 같은 테라폼 코드를 각자 받아서 terraform apply(코드에 적힌 대로 실제 인프라를 만들거나 고치는 명령)를 했습니다. 둘 다 Apply complete! 가 떴습니다. 종료 코드도 둘 다 0 입니다. 경고도 오류도 한 줄 없었습니다. 그런데 AWS 에서 demo-app 인스턴스를 세어 보니 2대였습니다. 코드에는 인스턴스가 한 대만 적혀 있습니다.
더 이상한 일이 뒤따랐습니다. 밥이 「중복이네, 내가 정리할게」 하고 terraform destroy(코드가 만든 것을 전부 지우는 명령)를 했습니다. Destroy complete! 가 떴는데 인스턴스는 여전히 1대 남았습니다. 밥이 지운 것은 밥이 만든 것뿐이었습니다.
이 편은 「둘이 같은 인프라에 apply 하면 무엇이 깨지나」를 다섯 장면으로 따라갑니다. 한 폴더에서 둘이 돌릴 때, 각자 사본에서 돌릴 때, 상태를 S3(Simple Storage Service) 한 곳에 모으고 잠금을 켰을 때, 잠금만 껐을 때, apply 도중 죽었을 때입니다. 끝에서 두 장치가 각각 무엇을 막는지 표 하나로 가릅니다.
1. 먼저, 단어 다섯
| 단어 | 한 줄 |
|---|---|
| 테라폼 | 만들고 싶은 인프라를 코드로 적어 두면 실제 클라우드를 그 모양으로 맞춰 주는 도구 |
| 리소스(resource) | 코드에 적는 인프라 한 덩어리. EC2(Elastic Compute Cloud) 인스턴스 하나, S3 버킷 하나가 각각 리소스 하나입니다 |
| HCL | HashiCorp Configuration Language. 테라폼 코드를 쓰는 언어로, resource "종류" "이름" { … } 처럼 중괄호 블록을 쌓아 씁니다 |
| plan · apply | plan 은 코드와 상태 파일(그리고 상태 파일이 가리키는 실제 자원)을 비교해 무엇을 바꿀지 계획만 보여 줍니다. apply 는 그 계획을 실제로 실행합니다 |
| 상태 파일 | 테라폼이 「내가 만든 것」을 적어 두는 JSON(JavaScript Object Notation) 파일. 기본 이름은 terraform.tfstate 입니다 |
이 편에서 가장 중요한 것은 마지막 줄입니다. 상태 파일은 코드 속 이름과 실제 AWS 자원을 잇는 장부입니다. 「코드의 aws_instance.app 은 AWS 의 i-9d3dd… 다」 같은 대응이 적혀 있습니다. 테라폼은 plan 을 짤 때 상태 파일을 읽습니다.
여기서 이 편 전체가 기대는 전제가 하나 나옵니다. plan 은 상태 파일에 적힌 자원만 실제 AWS 에서 다시 확인하고, 상태 밖 자원은 찾아보지 않습니다. 그래서 상태에 없는 인스턴스는 AWS 에서 돌고 있어도 「아직 안 만든 것」으로 봅니다. 상태가 틀리면 테라폼은 이미 있는 것을 또 만듭니다.
실험에 쓴 코드
두 apply 가 확실히 겹치게 하려고, 인스턴스를 만든 뒤 20초 걸리는 단계를 하나 붙였습니다. 서버 초기 설정 스크립트가 도는 시간이라고 보시면 됩니다.
resource "aws_instance" "app" {
ami = "ami-0f47531f8c49bd1c6"
instance_type = "t4g.small"
tags = {
Name = "demo-app"
}
}
resource "terraform_data" "bootstrap" {
triggers_replace = [aws_instance.app.id]
provisioner "local-exec" {
command = "sleep 20"
}
}
terraform_data는 AWS 에 아무것도 만들지 않는 테라폼 내장 리소스입니다. 여기서는 명령 하나를 돌리는 데 씁니다provisioner "local-exec"는 테라폼을 돌리는 기계에서 셸 명령을 실행합니다. 여기서는sleep 20으로 20초를 끕니다triggers_replace는 「인스턴스가 바뀌면 이 단계도 다시 돌려라」는 표시입니다
그래서 apply 한 번은 인스턴스 생성(약 10초) 뒤 20초를 더 기다립니다. 리소스는 인스턴스와 bootstrap 단계, 이렇게 둘입니다. 「두 사람」은 한 기계에서 첫 apply 를 띄우고 3~6초 뒤 둘째를 띄워 흉내 냈습니다. 인스턴스 수는 count.sh 로 셉니다. Name=demo-app 이고 실행 중인 인스턴스의 수와 ID 를 찍는 짧은 셸 스크립트로, 출력 끝의 「인스턴스(running, …)」 줄이 그 결과입니다.
2. 같은 폴더에서 둘이 돌리면 — 로컬 상태도 잠긴다
상태 파일을 둘이 동시에 쓰면 뭔가 깨질 것 같습니다. 가장 쉬운 경우부터 봅니다. 한 기계, 한 폴더, 터미널 두 개입니다. 둘 다 같은 terraform.tfstate 를 봅니다.
실측 — 로컬 상태, 같은 폴더에서 동시에 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)
( terraform apply -auto-approve -no-color > A.log 2>&1
echo "A exit=$?" >> A.log ) &
sleep 3
terraform apply -auto-approve -no-color > B.log 2>&1
echo "B exit=$?" >> B.log
wait
-auto-approve 는 「정말 실행할까요?」 확인을 건너뛰는 옵션입니다. B 는 무엇을 받았고, 인스턴스는 몇 대일까요.
===== A
Plan: 2 to add, 0 to change, 0 to destroy.
…
aws_instance.app: Creation complete after 10s [id=i-f584a61657e5b430d]
…
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
A exit=0
===== B
Error: Error acquiring the state lock
Error message: resource temporarily unavailable
Lock Info:
ID: da0e7489-ad26-e542-51eb-dd2b4a5818e4
Path: terraform.tfstate
Operation: OperationTypeApply
Who: root@raspberrypi
Version: 1.16.4
Created: 2026-09-30 02:26:54.76107028 +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.
B exit=1
인스턴스(running, Name=demo-app): 1 i-f584a61657e5b430d
Plan: 2 to add, 0 to change, 0 to destroy 는 새로 만들 것 2개, 고칠 것 0개, 지울 것 0개라는 뜻입니다. 2는 인스턴스와 bootstrap 단계, 리소스 두 개입니다. 이후 출력의 2 added·2 destroyed 도 같은 두 개를 셉니다.
아무것도 안 깨졌습니다. B 는 시작하자마자 막혔습니다. 인스턴스는 1대입니다. B 를 막은 것은 상태 잠금(state lock) 입니다. 잠금은 화장실 문고리의 「사용 중」 표시와 같습니다. 먼저 들어간 사람이 표시를 돌려 두면 다음 사람은 문을 열지 못하고 돌아갑니다. 테라폼은 상태를 쓸 수 있는 모든 작업에서 상태를 잠급니다. 잠금을 얻지 못하면 더 진행하지 않습니다.
로컬 상태 파일에도 이 잠금이 있습니다. 공식 문서는 로컬 상태를 「시스템 API 로」 잠근다고 씁니다. 운영체제의 파일 잠금을 쓴다는 뜻입니다. resource temporarily unavailable 이 그 운영체제가 돌려준 오류 문구입니다.
오류 아래 Lock Info 는 잠금을 얻은 쪽이 남긴 잠금 정보입니다. 뒤에서 계속 나오니 칸마다 풀어 둡니다.
| 칸 | 뜻 |
|---|---|
ID |
잠금 하나마다 붙는 고유 번호. 7절에서 잠금을 강제로 풀 때 이 값이 필요합니다 |
Path |
잠근 상태 파일의 경로 |
Operation |
잠금을 얻은 작업 종류. OperationTypeApply 는 apply 입니다 |
Who |
잠금을 얻은 사용자와 기계 이름 |
Version · Created |
잠금을 얻은 테라폼 버전과 잠근 시각 |
Who 가 root@raspberrypi 인 것은 한 기계에서 두 사람을 흉내 냈기 때문입니다. 실제로는 사람마다 다른 이름이 찍힙니다. 이 경우에는 아무것도 깨지지 않았습니다. 같은 파일을 보는 한 둘째는 막힙니다. 문제는 같은 파일이 아닐 때입니다.
3. 진짜로 깨지는 것 — 각자 사본
실제로 두 사람이 일하는 모양은 이렇지 않습니다. 앨리스와 밥은 같은 코드를 git 으로 각자 받아 자기 노트북에서 돌립니다. 상태 파일도 각자 자기 폴더에 하나씩 생깁니다. 이 모양을 폴더 둘로 재현했습니다. alice/ 와 bob/ 에 같은 코드를 두고 각자 terraform init(작업 폴더를 준비하는 첫 명령)을 했습니다.
실측 — 로컬 상태, 각자 사본에서 apply (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)
( cd alice && terraform apply -auto-approve -no-color \
> ../alice.log 2>&1; echo "alice exit=$?" >> ../alice.log ) &
sleep 3
( cd bob && terraform apply -auto-approve -no-color \
> ../bob.log 2>&1; echo "bob exit=$?" >> ../bob.log )
wait
# 각자 상태가 아는 인스턴스 (alice/, bob/ 에서 각각)
terraform state show -no-color aws_instance.app \
| awk '$1=="id"{print $3}'
# 밥이 정리하겠다고 destroy
(cd bob && terraform destroy -auto-approve -no-color)
terraform state show 는 상태 파일에 적힌 리소스 하나의 속성을 보여 줍니다. 여기서는 그중 id 만 뽑아 「이 상태가 아는 인스턴스」를 봅니다. 두 폴더에서 한 번씩 돌렸습니다. 두 사람의 종료 코드와 인스턴스 수를 보십시오.
===== alice
aws_instance.app: Creation complete after 10s [id=i-9d3dd3903e4bc7fe8]
terraform_data.bootstrap: Creation complete after 20s [id=4c8ebff3-748e-b209-72f0-160c3c20bcf4]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
alice exit=0
상태가 아는 것: "i-9d3dd3903e4bc7fe8"
===== bob
aws_instance.app: Creation complete after 10s [id=i-858fa8b01e8a255af]
terraform_data.bootstrap: Creation complete after 20s [id=aa3cf1ad-381c-add0-de6d-e00199779a60]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
bob exit=0
상태가 아는 것: "i-858fa8b01e8a255af"
인스턴스(running, Name=demo-app): 2 i-9d3dd3903e4bc7fe8 i-858fa8b01e8a255af
### 밥이 destroy 하면
Destroy complete! Resources: 2 destroyed.
인스턴스(running, Name=demo-app): 1 i-9d3dd3903e4bc7fe8
서두의 장면이 이것입니다. 둘 다 성공했습니다. 인스턴스는 2대가 됐습니다. 각자의 상태는 자기가 만든 한 대씩만 압니다.
flowchart TD
C["같은 코드<br/>인스턴스 한 대"] --> A["alice/terraform.tfstate<br/>i-9d3dd3903e4bc7fe8 만 기록"]
C --> B["bob/terraform.tfstate<br/>i-858fa8b01e8a255af 만 기록"]
A --> W["AWS<br/>demo-app 인스턴스 2대"]
B --> W
왜 경고가 없었을까요. 잠금은 같은 상태 파일을 두고 다툴 때만 켜집니다. 앨리스와 밥은 서로 다른 파일을 잠갔으니 부딪칠 일이 없었습니다. 밥의 plan 은 자기 상태 파일을 읽고 「인스턴스가 하나도 없다」고 판단했습니다. 1절의 전제대로 plan 은 상태 밖을 찾아보지 않습니다. 앨리스의 인스턴스는 밥의 상태 어디에도 없으니 보일 수가 없었습니다. 가계부를 두 사람이 따로 쓰면서 둘 다 월세를 낸 셈입니다. 각자 가계부는 틀린 데가 없는데 월세는 두 번 나갑니다.
이 실험에는 동시성이 필요 없습니다. 실측은 3초 간격이었지만, 원리상 앨리스가 아침에, 밥이 오후에 apply 해도 결과는 똑같이 2대입니다. 밥의 상태가 앨리스의 apply 를 모르는 한 시간 간격은 상관이 없습니다. 로컬 상태의 진짜 문제는 「동시에 쓴다」가 아니라 상태가 여럿으로 갈라진다는 것입니다. 공식 문서도 팀에서 로컬 파일을 쓰면 각 사용자가 「늘 최신 상태를 가지고 있는지」와 「아무도 동시에 돌리지 않는지」를 스스로 챙겨야 한다고 짚습니다. 이 편은 이 둘을 따로 봅니다. 앞의 것은 원격 상태가 맡습니다. 뒤의 것은 잠금이 맡습니다.
4. 상태를 한 곳에 — S3 백엔드
상태가 갈라지는 것을 막는 방법은 상태 파일을 하나만 두는 것입니다. 모두가 같은 곳에서 읽고 같은 곳에 쓰면 됩니다. 이것이 원격 상태(remote state) 입니다.
상태를 어디에 두고 어떻게 읽고 쓸지를 정하는 테라폼 부품을 백엔드(backend) 라 부릅니다. 아무 설정이 없으면 local 백엔드가 쓰여 폴더 안 terraform.tfstate 에 저장합니다. S3 백엔드를 고르면 상태를 Amazon S3 버킷 안 객체 하나로 저장합니다(S3 자체는 AWS 02-1 S3 하나로 이렇게 많은 일을 한다 — 저장 등급·S3 Tables·S3 Vectors에서 다뤘습니다).
앨리스와 밥의 코드에 이 블록을 똑같이 넣었습니다. moto 로 향하게 하는 설정 여덟 항목(주소·가짜 키·검증 끄기)은 진짜 AWS 에서는 쓰지 않으므로 생략했습니다.
terraform {
backend "s3" {
bucket = "demo-tfstate"
key = "app/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
# … moto 로 향하게 하는 설정 여덟 항목 생략
}
}
bucket·key는 상태를 담을 버킷과 그 안의 객체 경로입니다. 두 사람이 같은 값을 쓰니 같은 상태 파일을 봅니다use_lockfile = true는 잠금을 켭니다. 다음 절에서 자세히 봅니다
버킷 demo-tfstate 는 미리 만들고 버전 관리(객체를 덮어써도 옛 버전을 남겨 두는 S3 기능)를 켜 두었습니다. 공식 문서도 실수로 지우거나 사람이 잘못했을 때 상태를 되살릴 수 있도록 버킷 버전 관리를 켜 두라고 강하게 권합니다. 두 폴더 모두 terraform init 이 Successfully configured the backend "s3"! 를 찍었습니다.
5. 둘째를 막는 것 — 잠금 파일과 412
상태가 하나가 되면 이번에는 2절의 걱정이 돌아옵니다. 한 상태 파일에 둘이 동시에 쓰면 어떻게 되나. 로컬 파일은 운영체제가 잠가 줬지만 S3 객체에는 운영체제의 파일 잠금이 없습니다.
S3 백엔드의 잠금은 켜야 켜지는 기능입니다. use_lockfile 의 기본값은 false 입니다. 켜면 상태 경로 뒤에 .tflock 을 붙인 객체를 잠금 파일로 씁니다. 이 실험에서는 app/terraform.tfstate.tflock 입니다. 예전 방식인 DynamoDB 테이블 잠금은 문서에서 폐기 예정(deprecated)이며 이후 마이너 버전에서 제거될 예정이라고 적혀 있어서 쓰지 않았습니다.
앨리스가 apply 하는 도중에 버킷을 열어 본 뒤 밥이 apply 했습니다.
실측 — S3 백엔드 + 잠금에서 둘이 apply (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)
( cd alice && terraform apply -auto-approve -no-color \
> ../alice.log 2>&1; echo "alice exit=$?" >> ../alice.log ) &
sleep 6
aws s3api list-objects-v2 --bucket demo-tfstate \
--query 'Contents[].[Key,Size]' --output text
aws s3 cp s3://demo-tfstate/app/terraform.tfstate.tflock -
( cd bob && terraform apply -auto-approve -no-color \
> ../bob.log 2>&1; echo "bob exit=$?" >> ../bob.log )
wait
list-objects-v2 는 버킷 안 객체의 이름과 크기를, aws s3 cp … - 는 객체 내용을 화면에 찍습니다. 앨리스가 apply 하는 동안 버킷에는 이런 객체가 생겼습니다.
### 앨리스 apply 도중 버킷 안
app/terraform.tfstate.tflock 219
{"ID":"e9b40489-aa6c-d3ab-6cc2-a1b6b7299f1b","Operation":"OperationTypeApply","Info":"","Who":"root@raspberrypi","Version":"1.16.4","Created":"2026-09-30T02:30:24.931648806Z","Path":"demo-tfstate/app/terraform.tfstate"}
잠금의 실물은 219바이트짜리 JSON 파일 하나입니다. 내용은 2절의 Lock Info 와 같습니다. 이제 밥이 받은 것을 봅니다.
===== alice
aws_instance.app: Creation complete after 10s [id=i-36af6a39452fa7a73]
terraform_data.bootstrap: Creation complete after 20s [id=52b8562e-4f61-b6d0-13d9-b12508b6a949]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
alice exit=0
===== bob
Error: Error acquiring the state lock
Error message: operation error S3: PutObject, https response error
StatusCode: 412, RequestID: …, HostID: …,
api error PreconditionFailed: At least one of the pre-conditions you
specified did not hold
Lock Info:
ID: e9b40489-aa6c-d3ab-6cc2-a1b6b7299f1b
Path: demo-tfstate/app/terraform.tfstate
Operation: OperationTypeApply
Who: root@raspberrypi
Version: 1.16.4
Created: 2026-09-30 02:30:24.931648806 +0000 UTC
Info:
…
bob exit=1
인스턴스(running, Name=demo-app): 1 i-36af6a39452fa7a73
### 끝난 뒤 버킷 안
app/terraform.tfstate 5414
밥은 막혔습니다. 인스턴스는 1대입니다. 밥의 Lock Info 에 찍힌 ID 는 버킷의 잠금 파일에 적힌 앨리스의 ID e9b40489-… 와 같습니다. 앨리스가 끝난 뒤 버킷에는 상태 파일만 남았습니다. 잠금 파일은 사라졌습니다.
412 는 무엇을 거절한 것인가
오류의 핵심은 PutObject 와 StatusCode: 412 두 조각입니다. PutObject 는 S3 에 객체를 올리는 API 입니다. 밥의 테라폼은 잠금 파일을 올리려다 거절당한 것입니다.
이 거절은 S3 의 조건부 쓰기(conditional write) 가 만듭니다. 올릴 때 「이런 조건일 때만 써라」를 붙이는 기능입니다(조건부 요청의 한 갈래입니다). AWS 문서에 따르면 If-None-Match 헤더를 붙인 PutObject 는 같은 이름의 객체가 아직 없을 때만 성공합니다. 이미 있으면 412 Precondition Failed 로 실패합니다. 같은 이름으로 여럿이 동시에 쓰면 먼저 끝난 하나만 성공하고 나머지는 412 를 받습니다.
sequenceDiagram
participant A as 앨리스 테라폼
participant S as S3 버킷
participant B as 밥 테라폼
A->>S: .tflock 업로드 (없을 때만)
S-->>A: 200 성공 — 앨리스가 잠금을 얻음
B->>S: .tflock 업로드 (없을 때만)
S-->>B: 412 — 이미 있음
Note over B: Error acquiring the state lock
A->>S: 새 상태 쓰기
A->>S: .tflock 삭제
「없을 때만 만들어라」 한 번으로 「확인」과 「차지」가 동시에 끝납니다. 확인하고 나서 따로 만들면 그 사이에 남이 끼어들 수 있습니다. 조건부 쓰기는 그 틈을 S3 가 대신 막아 줍니다.
테라폼 공식 문서의 S3 백엔드 쪽에는 조건부 쓰기라는 말이 나오지 않습니다. 잠금 파일에 PutObject 권한이 필요하다는 것까지만 적혀 있습니다. 조건부 쓰기로 동작한다는 것은 위 오류 원문에서 읽은 것입니다. 이 412 는 moto 가 S3 의 조건부 쓰기를 재현한 결과입니다. 진짜 S3 도 같은 방식으로 412 를 내지만 RequestID·HostID 모양은 다릅니다.
앨리스가 끝난 뒤 밥이 plan 을 돌렸습니다.
aws_instance.app: Refreshing state... [id=i-36af6a39452fa7a73]
terraform_data.bootstrap: Refreshing state... [id=52b8562e-4f61-b6d0-13d9-b12508b6a949]
No changes. Your infrastructure matches the configuration.
Refreshing state... [id=…] 는 상태에 적힌 ID 를 AWS 에 다시 물어보는 줄입니다. 1절의 전제가 여기서 보입니다. plan 이 확인한 것은 상태에 적힌 두 리소스뿐이고, 상태에 없는 자원을 찾는 줄은 없습니다. 이번에는 밥의 상태에 앨리스가 만든 인스턴스 i-36af6a39452fa7a73 가 적혀 있습니다. 3절과 달리 둘이 같은 상태 파일 하나를 봅니다. 그래서 「더 만들 것 없음」입니다.
6. 잠금만 끄면 — 원격 상태로는 모자란 이유
그럼 원격 상태만 있으면 되고 잠금은 덤일까요. 확인하려고 원격 상태는 그대로 두고 잠금만 껐습니다. 잠금 오류 끝에 「-lock=false 로 잠금을 끌 수 있다」고 친절하게 적혀 있으니, 급한 누군가가 실제로 할 수 있는 일입니다. 같은 문구가 이 옵션을 권하지 않는다고도 적습니다.
5절에서 만든 인스턴스를 앨리스가 destroy 해 원격 상태가 비어 있을 때, 둘이 -lock=false 로 apply 했습니다.
실측 — 같은 원격 상태, 잠금 끄고 둘이 apply (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)
( cd alice && terraform apply -lock=false -auto-approve \
-no-color > ../alice-nolock.log 2>&1 ) &
sleep 3
( cd bob && terraform apply -lock=false -auto-approve \
-no-color > ../bob-nolock.log 2>&1 )
wait
aws s3 cp s3://demo-tfstate/app/terraform.tfstate - \
| jq -r '.serial as $s | .resources[]
| select(.type=="aws_instance")
| "serial=\($s) id=\(.instances[0].attributes.id)"'
aws s3api list-object-versions --bucket demo-tfstate \
--prefix app/terraform.tfstate \
--query 'Versions[?Key==`app/terraform.tfstate`].[LastModified,Size,IsLatest]' \
--output text
jq 줄은 원격 상태에 적힌 인스턴스 ID 를 뽑습니다. 함께 찍는 serial 은 상태 파일 안의 일련번호로, 상태를 새로 쓸 때마다 오릅니다. list-object-versions 는 버전 관리가 남겨 둔 상태 파일의 옛 버전들을 시각·크기·최신 여부로 보여 줍니다.
===== alice
Plan: 2 to add, 0 to change, 0 to destroy.
aws_instance.app: Creation complete after 11s [id=i-d13330d4a85888a05]
…
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
alice exit=0
===== bob
Plan: 2 to add, 0 to change, 0 to destroy.
aws_instance.app: Creation complete after 10s [id=i-cf886d9a8f83657e8]
…
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
bob exit=0
인스턴스(running, Name=demo-app): 2 i-d13330d4a85888a05 i-cf886d9a8f83657e8
### 원격 상태가 아는 인스턴스
serial=3 id=i-cf886d9a8f83657e8
### 버전 기록
2026-09-30T02:32:50+00:00 5414 True
2026-09-30T02:32:47+00:00 5416 False
2026-09-30T02:31:58+00:00 181 False
2026-09-30T02:31:08+00:00 5414 False
둘 다 빈 상태를 읽고 2 to add 로 계획했습니다. 둘 다 성공했습니다. 인스턴스는 2대입니다. 그런데 원격 상태에는 나중에 쓴 밥의 인스턴스만 남았습니다.
버전 기록은 최신이 위에 옵니다. 아래에서부터(옛 버전부터) 읽으면 무슨 일이 있었는지 보입니다. 맨 아래 02:31:08 의 5414바이트는 5절에서 앨리스가 apply 한 뒤의 상태입니다. 그 위 181바이트짜리가 destroy 뒤의 빈 상태입니다. 02:32:47 에 앨리스가 자기 인스턴스를 적은 상태(5416바이트)를 올렸습니다. 3초 뒤 02:32:50 에 밥이 자기 인스턴스를 적은 상태(5414바이트)를 올려 그것을 덮어썼습니다.
원격 상태의 serial 은 3 이었습니다. 앨리스가 올린 버전도 같은 빈 상태(serial 2)에서 출발했으니 3 이었을 것입니다. 일반 apply 는 쓰기 전에 원격 serial 을 대조하지 않으므로 테라폼은 덮어쓰기를 알리지 않았습니다.
앨리스의 인스턴스 i-d13330d4a85888a05 는 이제 어느 상태에도 없습니다. AWS 에서 돌고 요금은 나가는데 테라폼은 그 존재를 모릅니다. 이런 자원을 고아 자원(orphaned resource) 이라 부릅니다.
한 상태 파일을 둘이 동시에 고치면 나중에 저장한 쪽이 이깁니다. 공유 문서를 둘이 따로 내려받아 고친 뒤 차례로 올리면 앞사람의 수정이 사라지는 것과 같습니다. 원격 상태는 상태를 하나로 만들어 줄 뿐, 둘이 동시에 쓰는 것은 막지 못합니다. 그래서 원격 상태와 잠금은 짝으로 있어야 합니다.
버킷 버전 관리가 있어도 옛 버전을 되살리는 것은 복구가 아닙니다. 02:32:47 버전으로 되돌리면 이번에는 밥의 인스턴스가 상태 밖으로 밀려나 고아가 됩니다. 옛 버전에서 얻을 수 있는 것은 고아의 ID 입니다. 그 인스턴스를 상태에 다시 올리는 것은 03편(이미 있는 것을 코드로 가져오기)에서 다룬 import 가 합니다.
7. apply 도중 죽으면 — force-unlock
잠금 파일은 apply 가 끝날 때 테라폼이 스스로 지웁니다. 그런데 끝나기 전에 죽으면 어떻게 될까요. 노트북 배터리가 나가거나 CI(Continuous Integration, 지속적 통합) 작업이 강제 종료되는 경우입니다.
시작 전에 6절의 두 대는 정리했습니다(밥의 destroy, 고아 인스턴스는 AWS CLI 로 종료). 그래서 원격 상태는 비어 있습니다. 그 상태에서 앨리스의 apply 를 인스턴스 생성이 끝난 직후, 20초 대기 도중에 kill -9(운영체제가 프로세스를 즉시 강제 종료하는 명령)로 죽였습니다. 테라폼이 뒷정리할 기회가 없는 죽음입니다.
실측 — apply 도중 kill -9 뒤의 버킷과 상태 (2026-09-30, 라즈베리파이 5, Terraform 1.16.4 + moto)
cd alice
terraform apply -auto-approve -no-color > ../alice-kill2.log 2>&1 &
TP=$!
for i in $(seq 1 120); do
grep -q 'aws_instance.app: Creation complete' ../alice-kill2.log \
&& break
sleep 0.5
done
sleep 3
pkill -9 -P $TP; kill -9 $TP
aws s3api list-objects-v2 --bucket demo-tfstate \
--query 'Contents[].[Key,Size]' --output text
aws s3 cp s3://demo-tfstate/app/terraform.tfstate.tflock -
aws s3 cp s3://demo-tfstate/app/terraform.tfstate - \
| jq -c '{serial, resources: [.resources[] | "\(.type).\(.name)"]}'
for 줄은 로그에 인스턴스 생성 완료가 찍힐 때까지 기다립니다. pkill -9 -P $TP 는 테라폼이 띄운 자식 프로세스까지 함께 죽입니다. 죽은 뒤 버킷에는 무엇이 남았을까요.
/bin/bash: line 16: 4158633 Killed terraform apply -auto-approve -no-color > ../alice-kill2.log 2>&1
--- alice 로그 (kill 직전까지)
…
aws_instance.app: Creation complete after 10s [id=i-699736b7ac7aa41fc]
terraform_data.bootstrap: Creating...
terraform_data.bootstrap: Provisioning with 'local-exec'...
terraform_data.bootstrap (local-exec): Executing: ["/bin/sh" "-c" "sleep 20"]
### 버킷 안
app/terraform.tfstate 181
app/terraform.tfstate.tflock 219
{"ID":"b2a3c2b2-bf11-520b-325f-31a1458dc293","Operation":"OperationTypeApply","Info":"","Who":"root@raspberrypi","Version":"1.16.4","Created":"2026-09-30T02:34:19.998560919Z","Path":"demo-tfstate/app/terraform.tfstate"}
인스턴스(running, Name=demo-app): 1 i-699736b7ac7aa41fc
### 원격 상태가 아는 자원
{"serial":4,"resources":[]}
두 가지가 남았습니다. 첫째, 잠금 파일이 그대로 있습니다. 잠금을 얻은 프로세스는 죽었는데 잠금 파일은 남아 있습니다. 이제 밥이 apply 하면 5절과 같은 412 잠금 오류를 받습니다(이번 잠금으로는 재지 않았고, 앞선 kill 시도에서 같은 412 를 확인했습니다).
둘째가 더 고약합니다. 인스턴스는 만들어졌는데 원격 상태는 비어 있습니다(resources: [], 181바이트). 테라폼이 새 상태를 올리기 전에 죽었기 때문입니다. 죽는 순간에 따라 상태에 일부가 남을 수도 있는데, 이번에는 그 경우를 재지 않았습니다.
잠금을 강제로 푸는 명령
잠금을 얻은 프로세스가 죽어 남은 잠금을 푸는 명령이 terraform force-unlock 입니다. 공식 문서는 이 명령이 잠금만 풀 뿐 인프라는 건드리지 않는다고 두 번 적습니다. 인자로 잠금 ID 를 받습니다. 먼저 틀린 ID 를 넣어 보고, 다음에 잠금 파일에 적힌 ID 를 넣었습니다. 이어서 다시 apply 했습니다.
# 틀린 ID
terraform force-unlock -force -no-color \
00000000-0000-0000-0000-000000000000
# 잠금 파일의 ID
terraform force-unlock -force -no-color \
b2a3c2b2-bf11-520b-325f-31a1458dc293
# 다시 apply
terraform apply -auto-approve -no-color
-force 는 「정말 풀까요?」 확인 질문만 건너뛰는 옵션입니다. 이 명령들은 위 블록에 이어 alice/ 폴더에서 돌렸습니다. 원격 상태를 함께 쓰므로 밥의 폴더에서 돌렸어도 같은 상태를 읽습니다.
### 틀린 ID 로 force-unlock
Failed to unlock state: lock ID '00000000-0000-0000-0000-000000000000' does not match the existing lock ID 'b2a3c2b2-bf11-520b-325f-31a1458dc293'
Lock Info:
ID: b2a3c2b2-bf11-520b-325f-31a1458dc293
…
exit=1
### 맞는 ID 로 force-unlock
Terraform state has been successfully unlocked!
The state has been unlocked, and Terraform commands should now be able to
obtain a new lock on the remote state.
exit=0
### 다시 apply
Plan: 2 to add, 0 to change, 0 to destroy.
aws_instance.app: Creation complete after 11s [id=i-78fd8965b5dcb1d86]
…(중략)
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
인스턴스(running, Name=demo-app): 2 i-699736b7ac7aa41fc i-78fd8965b5dcb1d86
틀린 ID 는 거절됐습니다. 거절 문구가 지금 걸린 잠금의 ID 를 알려 줍니다. ID 를 요구하는 까닭은 엉뚱한 잠금을 풀지 않도록 하기 위해서입니다. 실제로 쓸 때는 잠금 오류의 Lock Info 에서 ID 를 그대로 복사하면 됩니다.
마지막 줄에서 인스턴스가 2대가 됐습니다. 죽은 apply 가 만든 i-699736b7ac7aa41fc 는 상태에 없습니다. plan 은 상태 밖을 찾아보지 않으니 「인스턴스가 없다」고 보고 한 대를 더 만들었습니다. 3절의 사고가 다른 경로로 다시 일어난 것입니다.
잠금을 푸는 것은 복구의 끝이 아니라 시작입니다. 풀기 전에 이 순서로 확인합니다.
- 잠금을 얻은 프로세스가 정말 죽었는지 확인합니다. 아직 살아 있는데 강제로 풀면 상태에 쓰는 쪽이 둘이 될 수 있다고 공식 문서도 경고합니다
- 죽은 작업이 무엇을 만들다 말았는지 확인합니다. 로그의
Creation complete줄과 AWS 의 실제 자원을 상태와 맞춰 봅니다 - 상태 밖 자원이 있으면 03편에서 다룬
import로 상태에 올리거나 지웁니다 - 그다음에
force-unlock으로 잠금을 풉니다
8. 무엇이 무엇을 막나
| 장면 | 상태는 | 잠금 | 둘째 apply | 인스턴스 |
|---|---|---|---|---|
| 같은 폴더 (2절) | 한 파일 | 운영체제 파일 잠금 | resource temporarily unavailable 로 막힘 |
1대 |
| 각자 사본 (3절) | 두 파일 | 각자 따로 잠금 | 오류 없이 성공 | 2대 |
| S3 + 잠금 (5절) | 한 객체 | .tflock + 조건부 쓰기 |
412 로 막힘 |
1대 |
| S3, 잠금 끔 (6절) | 한 객체 | 없음 | 성공, 앞 상태를 덮어씀 | 2대, 1대는 고아 |
| 도중에 죽음 → force-unlock (7절) | 한 객체, 비어 있음 | 풀림 | 성공 | 2대 |
원격 상태는 상태가 갈라지는 것을 막습니다. 잠금은 한 상태 파일에 둘이 같은 시간에 쓰는 것을 막습니다. 둘 다 못 막는 것이 하나 있습니다. 상태에 적히지 않은 자원입니다. 7절에서 죽은 apply 가 만든 인스턴스는 원격 상태도 잠금도 제대로 있었는데 상태 밖에 남았습니다. 잠금은 「누가 쓰는 중인가」만 알려 줄 뿐 「상태가 실제 인프라와 맞나」는 모릅니다. plan 도 상태 밖을 찾아보지 않으니, 그걸 보는 것은 결국 사람의 확인입니다.
한 장 요약
- 원격 상태(S3 백엔드)는 「각자 사본」을 막습니다. 로컬 상태를 각자 쓰면 둘 다 exit 0 인데 인스턴스가 2대가 됐습니다. 이 사고는 동시에 돌리지 않아도 일어납니다. 상태를 한 객체로 모으자 밥의 plan 은
No changes였습니다 - 잠금(
use_lockfile = true)은 「동시 쓰기」를 막습니다. 둘째는PutObject … StatusCode: 412로 막혔습니다. 잠금을 끄면 나중 쓰기가 앞 상태를 덮어써 고아 인스턴스가 남습니다 - 둘 다 상태 밖 자원은 못 막습니다. plan 은 상태에 적힌 자원만 확인합니다. apply 도중 죽은 뒤
force-unlock만 하고 다시 apply 하면 한 대가 더 생깁니다. 프로세스가 죽었는지, 상태 밖 자원이 있는지 먼저 확인하고import나 삭제로 맞춘 뒤 풉니다
관련 항목
이 편이 다루는 테라폼 부품
상태 파일 · 백엔드 · 원격 상태 · 상태 잠금 · S3 백엔드 · force-unlock · use_lockfile · local-exec · terraform_data
잠금이 기대는 동시성 개념
락 · 경쟁 상태 · 낙관적 잠금 · 조건부 요청 · 조건부 쓰기 · If-None-Match · 412 Precondition Failed · ETag · 분산 락
원격 상태를 담는 저장소
Amazon S3 · S3 버전 관리 · DynamoDB · HCP Terraform · Consul · Azure Blob Storage · Google Cloud Storage
상태와 실제 인프라가 어긋나는 사고
고아 자원 · 구성 드리프트 · terraform import · 마지막 쓰기 승리 · 갱신 손실 · kill -9
코드로 인프라를 다루는 도구와 원칙
코드형 인프라 · 테라폼 · HCL · 멱등성 · 선언적 설정 · CloudFormation · OpenTofu · moto · LocalStack
IaC 시리즈의 다른 편
IAC 01 손으로 만든 서버는 무엇을 잃나 — 명령형 스크립트와 선언형 코드 · IAC 02 plan 이 말해 주는 것 — 제자리 변경·교체·상태 파일 · IAC 03 이미 있는 것을 코드로 가져오기 — import · IAC 04 누가 콘솔에서 손대면 — 드리프트와 테라폼이 못 보는 것