IT/IaC

Terraform state lock 삽질 노트 — DynamoDB 걷어내고 use_lockfile로 옮긴 이야기

gfrog 2026. 8. 12. 21:26

지난주 금요일 저녁, 슬랙에서 얼굴이 화끈거리는 알림이 하나 왔다. "누가 지금 prod 계정에 terraform apply 돌리고 있어요?" 확인해 보니 아무도 돌리고 있지 않았다. 근데 state lock은 걸려 있었다. terraform-state-lock DynamoDB 테이블에 유령 락 하나가 남아 있었던 거다. 이런 걸 마주칠 때마다 매번 force-unlock으로 때웠는데, 이번엔 좀 다르게 해결해보고 싶었다. 마침 팀 내부에서 "DynamoDB 이거 언제까지 붙이고 있을 거냐"는 얘기가 몇 주째 돌고 있었고, Terraform 1.11에서 S3 네이티브 락킹이 GA로 승격된 지도 꽤 됐다.

그동안 DynamoDB가 왜 있었나

우리 팀 backend 설정은 5년 넘게 이 모양이었다.

terraform {
  backend "s3" {
    bucket         = "acme-tf-state-prod"
    key            = "network/vpc/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}

dynamodb_table 하나 붙어있는 게 전부인데, 이게 있어야 여러 명이 동시에 apply 돌릴 때 서로 안 밟는다. LockID라는 파티션 키에 락 정보를 넣고, S3 오브젝트 하나당 락을 하나씩 잡는 구조다. 문제는 이게 종종 유령 락을 남긴다는 거였다. CI 러너가 SIGKILL로 죽거나, 로컬에서 apply 돌리다가 노트북 뚜껑을 그냥 덮어버리면 락이 안 풀린다. 그럴 때마다 terraform force-unlock <ID> 치는 것도 귀찮고, 무엇보다 그 락 ID를 찾으려면 DynamoDB 콘솔 뒤져야 해서 새벽에 오탈자 낼 가능성이 컸다.

S3 native locking이 뭘 하나

원리는 생각보다 심심하다. S3의 conditional write (조건부 쓰기)를 이용해서 <state-key>.tflock 오브젝트를 만든다. 이미 그 오브젝트가 존재하면 쓰기가 실패한다. 이 실패를 락 충돌로 해석한다. DynamoDB 없이도 원자적 락이 되는 이유다.

바뀐 backend 설정은 이렇다.

terraform {
  required_version = ">= 1.11.0"
  backend "s3" {
    bucket       = "acme-tf-state-prod"
    key          = "network/vpc/terraform.tfstate"
    region       = "ap-northeast-2"
    use_lockfile = true
    encrypt      = true
  }
}

dynamodb_table 라인이 사라졌다. 대신 use_lockfile = true 한 줄. Terraform 1.10에서 experimental로 들어왔고 1.11부터 GA다. 우리는 CI 이미지 pin을 1.9.x에 걸어놨었기 때문에 이번에 1.11.4로 올렸다.

마이그레이션 시나리오 — 이대로 그냥 갈아치우면 큰일 난다

여기서 처음 삽질했다. 그냥 dynamodb_table 라인 지우고 use_lockfile 추가한 다음에 terraform init 돌리면 되겠거니 했다. 근데 개별 모듈이 30개 넘게 있고, CI에서 도는 워크플로우가 8개, 로컬에서 여전히 apply 돌리는 팀원이 3명. 모든 환경이 동시에 1.11로 올라가지 않는 이상, 누군가는 여전히 DynamoDB만 보고 락 잡고, 누군가는 tflock 파일만 보고 락 잡는 상황이 생긴다. 즉 두 명이 동시에 apply 돌려도 서로를 못 보는 상태.

HashiCorp 문서에도 마이그레이션 기간 동안은 두 개를 같이 쓰라고 권장한다. 그래서 우리는 이 순서로 갔다.

먼저 backend 설정에 두 개 다 넣는다.

terraform {
  backend "s3" {
    bucket         = "acme-tf-state-prod"
    key            = "network/vpc/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-state-lock"  # 아직 유지
    use_lockfile   = true                     # 새로 추가
    encrypt        = true
  }
}

이 상태로 terraform init -reconfigure. 이 상태의 Terraform은 락을 잡을 때 DynamoDB에도 걸고 tflock 파일도 쓴다. 두 개 다 성공해야 락 획득으로 친다. 반대로 언락도 두 곳 다 지운다. 즉 이전 버전(1.9.x)에서 접근해도 DynamoDB 락 때문에 안전하고, 새 버전(1.11.x)에서 접근해도 tflock을 보기 때문에 안전하다.

그 다음 진짜 삽질

이 상태에서 CI 러너를 하나씩 1.11.4로 올렸다. 로컬 팀원들에게도 tfenv로 버전 업하라고 슬랙에 공지했다. 여기까진 무난했다.

문제는 IAM이었다. 새 tflock 오브젝트를 쓰려면 CI 러너의 IAM role에 s3:PutObject, s3:GetObject, s3:DeleteObject 권한이 <bucket>/*.tflock 리소스에 대해 필요하다. 우리는 기존에 state key 패턴만 허용하는 세밀한 IAM 정책이 걸려 있었다.

{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:PutObject"],
  "Resource": "arn:aws:s3:::acme-tf-state-prod/*/terraform.tfstate"
}

이 정책은 tflock 오브젝트를 못 만든다. 락 잡는 시점에 AccessDenied가 뜨고, Terraform은 그걸 그냥 "Error acquiring the state lock"으로 리포트한다. 에러 메시지만 봐서는 락이 걸려있는 건지 권한이 없는 건지 헷갈린다. 나는 처음에 유령 락인 줄 알고 force-unlock 쳤다가, "락이 없는데요" 하는 응답에 잠깐 멍했다.

CloudTrail을 뒤져서야 알았다. PutObject 이벤트에 errorCode: AccessDenied가 찍혀 있었다. IAM 정책 리소스를 arn:aws:s3:::acme-tf-state-prod/* 로 넓히거나, tflock 확장자를 명시적으로 추가하는 방식으로 고쳤다.

{
  "Effect": "Allow",
  "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"],
  "Resource": [
    "arn:aws:s3:::acme-tf-state-prod/*/terraform.tfstate",
    "arn:aws:s3:::acme-tf-state-prod/*/terraform.tfstate.tflock"
  ]
}

이거 팀원 한 명이 "conditional write 쓴다며, 그럼 s3:PutObject에 뭐 조건 안 걸어도 되나?" 물었는데, 사실 안 걸어도 된다. S3 API 레벨에서 If-None-Match 헤더로 처리되는 거고, IAM 정책의 condition으로 강제할 필요는 없다. 다만 편집증적으로 안전을 강화하고 싶으면 s3:x-amz-copy-source-if-none-match 같은 조건을 걸어볼 수는 있는데, 우리는 안 걸었다.

DynamoDB 걷어내기

전 조직이 1.11로 올라오고 CI가 다 새 락 방식으로 락을 잡는 걸 며칠 관찰한 다음, backend에서 dynamodb_table 라인을 지웠다. 그 다음 terraform init -reconfigure. 이때 Terraform이 "이거 DynamoDB 안 쓸 건데 진짜? 옮길래?" 하고 물어본다. yes 하면 마이그레이션 끝.

마지막으로 DynamoDB 테이블 자체를 삭제. 이건 급할 것 없어서 2주 정도 방치했다가 지웠다. 혹시 어디선가 옛날 backend 참조하는 자동화 스크립트가 살아있을 가능성이 있어서.

남은 의문들

솔직히 이 방식이 완벽하다는 확신은 아직 없다. 몇 가지가 마음에 걸린다.

첫째, S3 conditional write의 SLA가 DynamoDB의 conditional write보다 낮을 가능성. 이론상 S3는 read-after-write consistency가 강화됐지만, 그래도 리전 장애 시 동작이 어떻게 될지 아직 자세히 가증 안 했다.

둘째, tflock 파일이 남는 케이스. Terraform 프로세스가 SIGKILL로 죽으면 tflock이 그대로 남는다. DynamoDB 시절과 동일하게 유령 락 문제가 재발할 수 있다. 다만 이 경우 S3 콘솔에서 오브젝트 하나 지우면 되니까, DynamoDB 뒤지는 것보다는 확실히 편하다.

셋째, S3 오브젝트 락과 버저닝이 켜져 있는 버킷에서의 동작. 우리는 state 버킷에 버저닝을 켜놨는데, tflock 오브젝트도 같이 버저닝 된다. 삭제해도 delete marker가 남는다. 스토리지 요금이 크진 않지만 lifecycle rule 하나 걸어두는 걸 추천한다. tflock 파일에 대해 1일 지나면 hard delete 하는 걼로.

resource "aws_s3_bucket_lifecycle_configuration" "tf_state" {
  bucket = aws_s3_bucket.tf_state.id
  rule {
    id     = "expire-tflock-versions"
    status = "Enabled"
    filter { prefix = "" }
    noncurrent_version_expiration { noncurrent_days = 1 }
    expiration { expired_object_delete_marker = true }
  }
}

그래서 결론은

DynamoDB 걷어내고 나서 두 주쯤 지났는데 유령 락 사례는 아직 한 번도 없었다. 이게 방식이 근본적으로 나아진 건지, 아니면 그냥 표본이 부족한 건지는 시간을 좀 더 두고 봐야 알 것 같다. 다만 인프라 표면적이 하나 줄었다는 것 자체가 좋다. 이제 DevOps 온보딩 문서에서 "왜 여기 DynamoDB 테이블이 있어요?" 질문에 답을 안 해도 된다.

혹시 이미 옮기신 팀 있으면, 이 방식으로 두 명 이상이 동시에 apply 돌렸을 때 어떻게 되는지 경험 공유 좀 부탁드립니다. 우리는 아직 그 시나리오를 능동적으로 재현해보지 않았다.