Terraform S3 native lock, 안에서 무슨 일이 벌어지나
Terraform 1.10에서 use_lockfile = true 옵션이 추가됐고, 1.11에 와서는 experimental 딱지가 떨어졌다. DynamoDB 테이블 없이 S3만으로 state lock을 거는 기능이다.
처음 봤을 때 솔직히 좀 의심스러웠다. lock 메커니즘이라는 게 결국 "동시에 한 명만 쓰게" 만드는 건데, S3는 object storage고 트랜잭션 같은 게 없는데 어떻게? 그리고 DynamoDB는 강한 일관성(strong consistency)을 보장하는 KV store니까 lock 용도로 충분히 합리적이었다. 굳이 S3로 갈아탈 이유가 있나 싶었다.
근데 마이그레이션을 준비하면서 내부를 까보니, 생각보다 깔끔하게 잘 만들어둔 구조였다. 단순히 "DynamoDB를 안 써도 된다"는 비용/운영 측면 외에도 동작 원리를 이해하면 평소에 만나는 lock 관련 이슈들을 더 잘 다룰 수 있다.
conditional write가 모든 것의 출발점
핵심은 2024년 말에 S3에 추가된 conditional write 기능이다. PutObject 요청에 If-None-Match: * 헤더를 붙이면, S3가 "그 키에 객체가 이미 있으면 412 PreconditionFailed로 거절한다." 한 줄로 요약하면 그게 전부다.
이게 왜 중요하냐면, S3는 자체적으로 strong consistency를 제공한다(2020년 12월부터). 같은 키에 대한 read-after-write, list-after-write가 모두 강한 일관성으로 동작한다. 그 위에 conditional write를 얹으면 사실상 분산 락의 기본 요소가 만들어진다. "객체를 만들 수 있었다 = 락을 잡았다", "412가 떨어졌다 = 누군가 이미 잡고 있다." 분산 락 알고리즘에서 흔히 보던 그 패턴 그대로다.
Terraform의 S3 backend가 lock을 걸 때 보내는 요청은 대략 이렇게 생겼다.
PUT /terraform.tfstate.tflock HTTP/1.1
Host: my-tfstate-bucket.s3.us-east-1.amazonaws.com
If-None-Match: *
Content-Type: application/json
{
"ID": "e3a2-...-9c1d",
"Operation": "OperationTypeApply",
"Who": "wgil@macbook.local",
"Version": "1.11.2",
"Created": "2026-06-24T12:08:31.421Z",
"Path": "terraform.tfstate"
}
.tflock이라는 별도 객체로 들어간다. state 파일 자체(terraform.tfstate)는 그대로 두고 옆에 자물쇠 객체 하나가 추가되는 셈이다. 이 객체 본문(JSON)은 사람이 봐도 그대로 읽힌다. 예전에 DynamoDB의 LockID 컬럼 안에 들어가던 정보와 거의 동일한 스키마다.
plan/apply 한 사이클에서 락이 잡히고 풀리는 흐름
terraform apply를 쳤을 때 무슨 일이 일어나는지 시간 순으로 따라가 보자.
먼저 backend init 단계에서 S3 client가 만들어지고, plan/apply가 필요한 경우 operation lock을 시도한다. PutObject with If-None-Match: *. 200이 떨어지면 락 획득. 412가 떨어지면 backoff 후 재시도(기본 10초 retry). 일정 시간 안에 못 잡으면 그 유명한 Error: Error acquiring the state lock 에러를 뱉고 종료한다.
락을 잡았으면 state를 GetObject로 읽어온다. 이때 응답에 ETag가 같이 온다. Terraform은 이 ETag를 메모리에 기억해둔다. plan/apply가 끝나고 새 state를 쓸 때 PutObject 요청에 If-Match: <ETag> 헤더를 붙인다. 즉 "내가 읽은 그 버전에서 변하지 않았을 때만 덮어써라"라는 조건부 쓰기다. 이 부분이 좀 재밌는데, operation lock과는 별도로 ETag 기반 optimistic locking이 한 겹 더 있다. 누가 락 우회로 강제로 write해버린 경우(예: 다른 사람이 S3 콘솔에서 직접 업로드)를 한 번 더 막아준다.
쓰기가 끝나면 .tflock 객체를 DeleteObject로 지운다. 끝.
[acquire] PUT .tflock + If-None-Match:* → 200 OK
[read] GET terraform.tfstate → ETag: "abc123..."
[apply가 변경사항 적용]
[write] PUT terraform.tfstate + If-Match:"abc123..." → 200 OK
[release] DELETE .tflock → 204 No Content
flow만 보면 평범한데 흥미로운 건 각 단계가 거의 모두 S3 단일 API 호출로 완결된다는 점이다. DynamoDB로 lock 잡고 → S3로 state 읽고 → S3로 state 쓰고 → DynamoDB로 lock 풀던 옛날 구조보다 의존성이 하나 줄었다.
race condition은 어떻게 막히는가
두 사람이 동시에 apply를 친다고 해보자. A와 B가 같은 millisecond에 PutObject .tflock을 쏜다.
S3 내부에서는 keymap 단위의 last-writer-wins가 아니라 conditional write에 한해 직렬화가 일어난다. 사실 정확히 말하면 S3가 어떻게 직렬화하는지는 공개되지 않았지만, 표준 분산 시스템 관점에서 보면 두 요청 중 하나만 200을 받고 다른 하나는 412를 받는다는 보장만 있으면 충분하다. 그게 strong consistency + atomic conditional write의 정의다.
A가 200을 받고 락을 잡았다고 치자. B는 412를 받고 재시도 루프로 들어간다. A가 apply를 끝내고 .tflock을 지우면, 그제서야 B의 PutObject가 성공한다.
여기서 한 가지 미묘한 케이스가 있다. A가 apply 중간에 죽으면(SIGKILL, panic, OOM, 네트워크 단절)? .tflock은 그대로 남는다. B는 영원히 락을 못 잡는다. 이때 사용하는 게 terraform force-unlock <LOCK_ID> 명령이다. 내부적으로는 .tflock 객체를 그냥 DeleteObject 한다.
근데 잠깐, force-unlock을 누가 칠 수 있는지에 대한 권한 모델이 좀 까다롭다. DynamoDB lock이면 DynamoDB IAM 권한이 따로 있어서 분리 운영이 가능했는데, S3 native lock은 결국 S3 객체 하나라서 state 파일과 같은 권한 도메인 안에 있다. state를 읽을 수 있는 사람은 락도 풀 수 있다는 얘기. 우리 팀에서는 이 부분 때문에 별도 bucket policy condition으로 .tflock 객체 DELETE를 특정 역할에만 허용하는 식으로 한 겹 더 잠갔다. 이게 정답인지는 모르겠는데, 일단 그렇게 두고 운영 중이다.
DynamoDB lock과 마이그레이션 시 주의점
use_lockfile = true만 켜면 끝일 것 같지만, 실제로는 한동안 DynamoDB 옵션을 같이 두고 운영하는 dual lock 기간이 필요하다. HashiCorp 공식 문서에서도 권장하는 방법이다.
terraform {
backend "s3" {
bucket = "my-tfstate"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # 일단 유지
use_lockfile = true # 추가
}
}
이 상태에서 Terraform은 락을 잡을 때 두 곳 모두에서 락을 시도한다. 한쪽이라도 실패하면 락 획득 실패로 처리된다. 즉 더 빡빡한 락이 걸린다. 단점은 한쪽이 stale lock 상태가 되면(예: DynamoDB lock은 풀렸는데 .tflock이 남아있는 경우) 더 자주 force-unlock을 하게 된다. 우리 팀에서는 약 2주 정도 dual lock으로 돌려서 PR pipeline, manual apply, drift detection cron 같은 모든 경로가 S3 lock에서 문제없는지 확인한 뒤 dynamodb_table 라인을 지웠다.
또 하나, CI 환경에서 의외로 까다로운 부분이 있다. PR마다 plan을 도는 GitHub Actions를 운영한다면, plan 단계에서도 락을 잡는다. 동일 stack에 대해 PR이 동시에 두 개 열리면 둘 중 하나는 락 대기에 빠진다. 옛날에는 plan-only 작업은 -lock=false로 우회하는 경우가 많았는데, S3 native lock으로 옮기면서 이 옵션의 동작은 동일하다(애초에 락을 안 잡는 거니까). 다만 우리는 이번 기회에 plan을 워크스페이스 단위로 직렬화하는 쪽으로 바꿨다. 락 풀리길 기다리는 시간이 아까워서.
그래서 갈아탈 만한가
비용 측면에서 DynamoDB on-demand로 운영하면 한 달에 몇 달러 수준이라 절감 자체는 미미하다. 진짜 이점은 운영 단순화다. backend 리소스가 줄어들면 신규 환경 셋업할 때 작업이 하나 빠진다. Terraform으로 Terraform backend를 부트스트랩하는 그 chicken-and-egg 상황에서도 한 단계 짧아진다.
단점도 있다. force-unlock 권한 분리가 까다롭다는 점, 그리고 1.10 이전 버전의 Terraform/OpenTofu가 섞여 돌아가는 조직에서는 일관성이 깨질 수 있다는 점. OpenTofu도 1.9에서 동일 기능이 들어왔으니 일단 그쪽 사용자도 같이 가능은 한데, 팀 내 모든 사람이 최소 버전 이상을 쓰는지 확인이 필요하다.
아직 production main stack은 dual lock 상태로 두고 있고, 새로 만드는 stack부터는 S3 native만 쓰는 중이다. 한 달쯤 더 돌려보고 메인도 전환하려고 한다.