광고
~ / posts / developer
smith@lab:~/posts$ cat developer/terraform-s3-state-lock-lockfile-timeout-migration-guide.md

테라폼 상태 잠금 설정 고르기, S3 use_lockfile 이전과 lock-timeout

테라폼 S3 백엔드를 쓰는 팀이 잠금 때문에 정해야 할 값은 세 가지예요. 잠금을 무엇으로 걸지, 잠금을 못 잡았을 때 몇 분 기다릴지, 남은 잠금을 언제 강제로 풀지. 결론부터 말하면 새로 만드는 백엔드는 테라폼 1.11 이상에서 use_lockfile = true 만 켜고, DynamoDB 를 쓰던 곳은 둘을 함께 켠 기간을 거쳐 옮기며, CI 는 -lock-timeout 을 "가장 긴 apply × 앞에 설 수 있는 작업 수 + 30초" 정도로 주고, 사람이 치는 명령은 기본값 0 으로 두는 게 무난해요.

이 글은 동작 원리보다 값을 고르는 기준과 운영에서 볼 순서를 다뤄요. 근거는 Terraform S3 백엔드 문서, 테라폼 main 브랜치의 S3 백엔드 client.go와 statemgr/locker.go, Amazon S3 조건부 쓰기 문서이고, 시간·확률·비용은 조건을 밝힌 직접 계산이에요(2026-11-07 기준). plan 옵션과 상태 분리 기준은 테라폼 plan 옵션 고르는 법에서 따로 다뤘어요.

광고

잠금 방식 판단표, use_lockfile·DynamoDB·이중 잠금 중 무엇을 켤까

S3 문서는 잠금이 선택 기능이고 DynamoDB 잠금은 지원 중단(deprecated)이라 향후 마이너 버전에서 빠진다고 적어요. 변경 기록을 보면 S3 자체 잠금은 1.10에 들어왔고 1.11에서 정식 기능이 되면서 DynamoDB 관련 인자가 지원 중단으로 바뀌었어요. 판단은 "지금 어디서 출발하느냐"로 갈려요.

지금 상황설정이유
새 백엔드, 모든 실행 환경이 1.11 이상use_lockfile = true 만테이블·테이블 권한이 필요 없어요
DynamoDB 잠금을 쓰는 중, 옛 버전 러너가 남음dynamodb_table 유지 + 러너 먼저 올리기1.9 이하 러너는 use_lockfile 을 몰라요
모든 러너가 1.11 이상, 저장소·파이프라인이 여러 개use_lockfile + dynamodb_table 함께반영이 덜 된 곳이 있어도 DynamoDB 에서 서로 막혀요
이중 잠금으로 몇 주 돌았고 DynamoDB 만 쓰는 설정이 없음dynamodb_table 제거잠금 한 곳, 권한 한 곳으로 줄어요
잠금 설정이 아예 없음오늘 use_lockfile = true 추가지금은 동시 apply 를 아무것도 막지 않아요
S3 호환 저장소(MinIO 등 자체 운영)조건부 쓰기 지원부터 시험If-None-Match 를 무시하는 저장소면 잠금이 겉으로만 돼요

S3 잠금은 .tflock 객체를 If-None-Match: * 조건으로 올리는 방식이라, 저장소가 이 헤더를 지키지 않으면 두 작업이 모두 잠금에 성공해요. 도입 전에 AWS CLI 의 put-object --if-none-match 옵션으로 같은 키에 두 번 올려 보고, 두 번째가 412(Precondition Failed)로 실패하는지 확인해요. 두 번째도 성공하면 그 저장소에서는 S3 잠금을 믿으면 안 돼요.

echo '{}' > /tmp/probe.json
aws s3api put-object --bucket mycorp-tfstate --key probe.tflock \
  --body /tmp/probe.json --if-none-match '*'      # 첫 번째: 성공해야 함
aws s3api put-object --bucket mycorp-tfstate --key probe.tflock \
  --body /tmp/probe.json --if-none-match '*'      # 두 번째: 412 가 나와야 함
aws s3api delete-object --bucket mycorp-tfstate --key probe.tflock

버킷 버전 관리를 켠 버킷에서도 잠금은 문제없어요. 조건부 쓰기 문서에 따르면 현재 버전이 삭제 마커면 "객체 없음"으로 보고 쓰기를 허용해요. 대신 잠금을 한 번 걸고 풀 때마다 .tflock 의 옛 버전과 삭제 마커가 하나씩 쌓여요. 하루 200번이면 1년에 7만 3천 개지만 잠금 JSON 이 200바이트 남짓이라 합쳐도 약 18MB 예요.

DynamoDB 에서 use_lockfile 로 옮기는 3단계

1단계 dynamodb_table 만 쓰는 상태에서 러너를 1.11 이상으로 올리고, 2단계 use_lockfile 과 dynamodb_table 을 함께 켜 모든 파이프라인에 반영한 뒤, 3단계 dynamodb_table 을 빼고 테이블을 지우는 이전 순서와 한 번에 건너뛰면 동시 apply 가 가능해지는 이유
2단계를 건너뛰지 않는 게 핵심이에요

소스의 Lock 함수를 보면 이중 잠금에서는 .tflock 을 먼저 잡고 DynamoDB 를 나중에 잡으며, DynamoDB 쪽이 실패하면 방금 만든 .tflock 을 지우고 실패해요. 그래서 2단계 동안 잠금은 "둘 다 잡아야 성공"이에요. 단계별로 확인할 것을 정리하면 이래요.

  1. 러너 버전 확인: CI 이미지, 개발자 노트북, 스케줄 잡, 다른 저장소의 원격 상태 소비자까지 terraform version 을 모아요. required_version = ">= 1.11" 을 먼저 넣어 두면 옛 버전 러너가 init 단계에서 바로 실패해서 빠뜨린 곳을 찾기 쉬워요.
  2. 권한 먼저, 설정 나중: .tflock 에 s3:GetObject·s3:PutObject·s3:DeleteObject 를 준 정책을 먼저 배포하고, 그다음 use_lockfile = true 를 넣어요. 순서가 바뀌면 첫 실행부터 잠금 단계에서 AccessDenied 가 나요.
  3. terraform init -reconfigure: 백엔드 인자가 바뀌면 다시 초기화해야 해요. 버킷과 키는 그대로라 상태를 옮길 필요는 없어요.
  4. 2단계 유지 기간: 저장소·파이프라인 목록이 전부 새 설정으로 바뀐 것을 확인할 때까지예요. 2단계에서는 새 설정도 DynamoDB 에 쓰기 때문에 테이블 쓰기 지표로는 남은 곳을 구분할 수 없어요. 대신 저장소 전체에서 dynamodb_table 이 있고 use_lockfile 이 없는 백엔드 블록을 찾아요.
  5. 3단계 뒤 테이블 삭제: dynamodb_table 을 뺀 뒤 일정 기간 테이블 쓰기가 0 인지 보고 지워요. 테이블에는 잠금 항목 말고도 상태의 MD5 를 담은 -md5 항목이 남아 있지만, S3 잠금만 쓰면 더 읽지 않아요.

이중 잠금 해제는 S3 와 DynamoDB 를 둘 다 시도하고, 한쪽만 실패해도 오류를 돌려줘요. 2단계에서 .tflock Delete 권한이 빠졌다면 DynamoDB 잠금은 풀리는데 .tflock 만 남는 상황이 생기니, 이전 첫날 실행 로그에 failed to unlock S3 가 없는지 봐요.

-lock-timeout 값 고르는 법, 재시도 간격으로 계산하기

-lock-timeout 의 기본값은 0초라 잠금을 못 잡으면 바로 실패해요. 값을 주면 LockWithContext 가 첫 대기 1초에서 시작해 실패할 때마다 두 배로 늘리고 16초에서 멈춰요. 첫 시도부터 따지면 0·1·3·7·15·31·47·63초… 에 다시 시도해요. 재시도는 상대 잠금 정보가 담긴 잠금 오류일 때만 하고, 권한 오류는 기다리지 않고 끝나요.

-lock-timeout 값별 잠금 시도 횟수 (첫 시도 포함)
010203040506030초 · 시도 횟수 5회530초1분 · 시도 횟수 7회71분2분 · 시도 횟수 11회112분5분 · 시도 횟수 22회225분10분 · 시도 횟수 41회4110분15분 · 시도 횟수 60회6015분

31초 이후로는 16초마다 한 번씩이라 시간에 거의 비례해서 늘어요.

단위: 회 · 자료: locker.go 의 1초 시작·2배·최대 16초 백오프로 직접 계산 (2026-11-07)
표로 보기
구분시도 횟수
30초5
1분7
2분11
5분22
10분41
15분60

이 간격 때문에 생기는 숫자가 하나 더 있어요. 앞 작업이 잠금을 푼 순간 바로 잡는 게 아니라 다음 재시도 때 잡아요. 대기가 31초를 넘긴 뒤라면 풀린 시점부터 최대 16초, 평균 8초쯤 더 기다려요. 로컬 백엔드(테라폼 1.9.8, 재시도 함수는 S3 와 같은 코드)로 40초 걸리는 apply 를 돌리고 3초 뒤 plan -lock-timeout=120s 를 띄웠더니, apply 는 40.4초에 끝났고 plan 은 50.3초에 끝났어요. 3초에 시작한 plan 의 재시도 시각이 3+47=50초라 계산과 맞아요.

그래서 값은 이렇게 잡아요.

실행 주체권장 값이유
사람이 직접 치는 plan·apply0(기본)누가 잡고 있는지 바로 보고 판단하는 게 나아요
PR 마다 도는 plan2~5분같은 상태의 apply 하나가 끝날 정도만 기다려요
main 머지 후 apply가장 긴 apply × 앞에 설 수 있는 작업 수 + 30초줄 선 작업이 다 끝나고 재시도 간격까지 넣은 값
야간 드리프트 점검10~15분급하지 않으니 여유 있게, 대신 CI 잡 시간 한도 안에서

예를 들어 가장 긴 apply 가 6분이고 동시에 두 작업이 앞에 설 수 있으면 6×2 = 12분에 30초를 더해 12분 30초예요. 이때 CI 잡의 시간 한도는 잠금 대기 + 자기 apply 시간보다 커야 해요. 잡 한도가 먼저 와서 러너가 강제 종료되면, 기다리던 작업은 잠금을 못 잡은 채 끝나니 괜찮지만 이미 잠금을 쥐고 apply 하던 작업이 잘리면 잠금이 남아요.

줄이 자주 길어지면 CI 에서 직렬화하는 게 깔끔해요. GitHub Actions 라면 concurrency 그룹을 상태 키 단위로 걸어 같은 상태를 쓰는 잡이 동시에 시작하지 않게 해요.

잠금 충돌 확률, 상태를 나눌 때인지 숫자로 보기

같은 상태 파일에 잠금을 거는 작업(plan 포함)이 시간당 λ번 무작위로 시작하고 한 번에 d분 걸린다면, 내 작업이 도는 동안 다른 작업이 들어와 막힐 확률은 대략 1 − e^(−λ × d ÷ 60)이에요.

작업 길이별 잠금 충돌 확률 (예시 계산)
시간당 2번시간당 6번시간당 20번
02550751002분 · 시간당 2번 6.4%2분 · 시간당 6번 18.1%2분 · 시간당 20번 48.7%2분5분 · 시간당 2번 15.4%5분 · 시간당 6번 39.3%5분 · 시간당 20번 81.1%5분10분 · 시간당 2번 28.3%10분 · 시간당 6번 63.2%10분 · 시간당 20번 96.4%10분20분 · 시간당 2번 48.7%20분 · 시간당 6번 86.5%20분 · 시간당 20번 99.9%20분

plan 도 잠금을 잡으니 PR 이 많은 저장소는 시간당 횟수가 생각보다 커요.

단위: % · 자료: 1 − e^(−시간당 횟수 × 작업 분 ÷ 60)으로 직접 계산 (포아송 도착 가정)
표로 보기
구분시간당 2번시간당 6번시간당 20번
2분6.418.148.7
5분15.439.381.1
10분28.363.296.4
20분48.786.599.9

시간당 6번, 한 번에 10분이면 셋 중 둘(63.2%)은 누군가를 기다려요. 이 정도면 타임아웃을 늘려 버티기보다 상태 파일을 쪼갤 신호예요. 잠금 단위가 상태 파일 하나라서, 워크스페이스나 키가 다르면 서로 막지 않아요. 네트워크처럼 거의 안 바뀌는 것과 하루에도 여러 번 바뀌는 앱 리소스를 나누면 λ 가 각각 줄어요. 작업 길이(d) 자체를 줄이는 방법은 테라폼 parallelism 값 고르는 법에서 다뤘어요.

비용과 한계, DynamoDB 를 빼도 돈은 거의 안 바뀌어요

AWS 가격 API 의 서울 리전(ap-northeast-2) 값(2026-11-04 게시)으로 실행 1만 번을 계산해 봤어요. S3 는 PUT 1,000건당 0.0045달러, GET 1만 건당 0.0035달러, DynamoDB 온디맨드는 쓰기 요청 단위 100만 개당 0.68달러, 읽기 100만 개당 0.1355달러예요. 실제 요금표는 Amazon S3 요금 페이지에서 볼 수 있어요.

실행 1만 번당 상태·잠금 요청 비용 (서울 리전 예시)
S3 잠금(use_lockfile)S3 잠금(use_lockfile) · 9.7센트9.7센트DynamoDB 잠금DynamoDB 잠금 · 7.2센트7.2센트

둘 다 1달러에 한참 못 미쳐요. 고를 때 기준은 비용이 아니라 관리할 리소스 수예요.

단위: 센트 · 자료: AWS Price List API 서울 리전 단가로 직접 계산. 실행당 상태 GET 1·PUT 1 은 공통이고, S3 잠금은 PUT 1·GET 1, DynamoDB 잠금은 쓰기 3·읽기 2 단위를 더한 최소치로 가정
표로 보기
구분비용
S3 잠금(use_lockfile)9.7
DynamoDB 잠금7.2

S3 잠금 쪽이 조금 더 나오는 건 S3 PUT 단가가 DynamoDB 쓰기 단가보다 비싸서인데, 차이는 실행 1만 번에 3센트도 안 돼요. apply 가 상태를 여러 번 쓰거나 잠금 재시도가 늘어도 자릿수는 그대로예요. DynamoDB 를 빼서 얻는 건 돈이 아니라 테이블 하나, IAM 문장 하나, 계정마다 반복하던 부트스트랩 코드가 사라지는 거예요.

반대로 S3 잠금이 해 주지 않는 일도 알아 둬요.

광고
광고

Error acquiring the state lock 확인 순서

잠금 오류가 나면 Lock Info 가 찍혔는지, Who 가 나 자신인지, 그 작업이 살아 있는지, 상태를 쓰다 말았는지 차례로 물어 권한 확인·Delete 권한 확인·기다리기·force-unlock 후 plan 으로 보내는 판단 순서도
위에서부터 물어 처음 '예'가 나온 칸으로 가요

1. 오류에 Lock Info 가 있는지 봐요. ID·Path·Operation·Who·Created 가 찍혔다면 진짜 잠금 충돌이에요. 없이 AccessDenied 만 있다면 .tflock 경로에 Put 권한이 없거나, kms_key_id 를 쓰는 백엔드에서 KMS 권한이 빠진 경우예요. 소스를 보면 잠금 파일도 상태와 같은 KMS 키로 암호화해 올려서, 문서대로 kms:Encrypt·kms:Decrypt·kms:GenerateDataKey 가 필요해요.

2. Who 가 매번 나 자신이면 해제 실패예요. apply 는 성공했는데 다음 실행부터 같은 러너 이름의 잠금에 걸린다면 .tflock 의 s3:DeleteObject 권한을 의심해요. 상태 파일 ARN 만 허용하는 정책을 그대로 쓰면 이렇게 돼요. 해제는 .tflock 을 GetObject 로 읽어 ID 를 비교한 뒤 DeleteObject 로 지우는 순서라 Get 권한도 같이 필요해요.

3. Created 와 Who 로 작업이 살아 있는지 확인해요. Created 는 UTC 라서 한국 시각으로 볼 때는 9시간을 더해요. 헷갈리면 타임스탬프 변환기로 바꿔 보고, .tflock 내용 전체는 aws s3 cp s3://버킷/키.tflock - 로 받아 JSON 포매터로 펼쳐 봐요. CI 러너 이름이면 그 잡의 상태를, 사람 이름이면 당사자에게 물어요.

4. 살아 있으면 기다려요. -lock-timeout 을 주고 다시 돌리거나 CI 큐에 맡겨요. "오래 걸리는 것 같다"는 이유로 푸는 게 가장 흔한 사고예요.

5. 죽은 게 확실하면 terraform force-unlock <ID> 를 쓰고 plan 부터 돌려요. force-unlock 문서대로 인프라는 건드리지 않고 잠금만 지워요. Operation 이 Apply 였다면 상태를 쓰다 죽었을 수 있으니, 바로 apply 하지 말고 plan 결과로 상태와 실제가 맞는지 먼저 봐요. 버킷 버전 관리가 켜져 있으면 상태 파일의 직전 버전을 비교할 수 있어요.

6. lock ID ... does not match 가 나면 손을 멈춰요. 내가 가진 ID 와 저장된 잠금의 ID 가 다르다는 뜻이라, 내가 쥔 잠금을 누가 force-unlock 하고 다른 작업이 새로 잡은 경우가 많아요. 지금 상태를 두 작업이 다 썼을 수 있으니 양쪽 로그를 맞춰 봐요.

죽은 잠금을 미리 찾아 두고 싶다면 하루 한 번 .tflock 목록을 훑는 점검을 걸 수 있어요. aws s3api list-objects-v2 --bucket mycorp-tfstate --query "Contents[?ends_with(Key, '.tflock')].[Key,LastModified]" 로 뽑아서, 가장 긴 apply 시간보다 오래된 것만 알리면 돼요.

도입 전에 따질 것

PR plan 용 읽기 전용 권한과 -lock=false

PR plan 역할을 읽기 전용으로 두고 싶어도, plan 도 잠금을 잡기 때문에 .tflock 에 Put·Delete 가 없으면 plan 이 잠금 단계에서 실패해요. 선택지는 둘이에요.

방법장점대가
plan 역할에 .tflock Get·Put·Delete 만 추가상태 파일과 리소스는 계속 읽기 전용plan 이 apply 와 줄을 서서 PR 피드백이 느려질 수 있어요
plan 에 -lock=false잠금 권한이 아예 필요 없음apply 와 동시에 돌면 쓰는 중인 상태를 읽어 결과가 어긋날 수 있어요

PR plan 은 결과를 그대로 적용하지 않는 미리보기라면 -lock=false 도 받아들일 만해요. 다만 plan 결과를 저장해 apply 로 넘기는 파이프라인이라면 잠금을 켜요. apply 에 -lock=false 를 쓰는 건 어떤 경우에도 권하지 않아요.

광고
광고

자주 묻는 질문

use_lockfile 을 안 켜면 S3 백엔드는 잠금을 안 거나요?

네. use_lockfile 의 기본값은 false 이고, dynamodb_table 도 없으면 S3 백엔드의 Lock 은 아무것도 하지 않고 성공을 돌려줘요. 오류나 경고가 없으니 설정 파일을 직접 확인해야 해요.

DynamoDB 잠금은 언제까지 쓸 수 있나요?

문서는 지원 중단 상태이고 향후 마이너 버전에서 제거된다고만 적고 있어요. 제거 버전이 정해지면 변경 기록에 나오니, 그 전에 이중 잠금 단계를 거쳐 옮겨 두는 게 안전해요.

-lock-timeout 을 아주 길게 주면 문제가 있나요?

잠금이 죽은 작업의 것이면 그만큼 헛되이 기다려요. S3 잠금은 만료가 없어서 시간이 지나도 풀리지 않아요. CI 잡 시간 한도 안에서, 가장 긴 apply 와 줄 선 작업 수로 계산한 값만 주세요.

force-unlock 에 넣을 ID 는 어디서 보나요?

잠금 오류 메시지의 Lock Info 아래 ID 줄이에요. .tflock 객체 본문의 ID 값과 같아요. 확인 질문 없이 풀려면 -force 를 붙이지만, 사람이 칠 때는 빼는 편이 안전해요.

워크스페이스를 나누면 잠금도 나뉘나요?

네. 잠금 파일은 상태 경로 뒤에 .tflock 을 붙인 객체라 워크스페이스나 키가 다르면 서로 막지 않아요. 잠금 대기가 잦다면 상태를 나누는 게 근본적인 해결이에요.

정리

더 깊이 읽기

이 글은 실무에서 판단할 때 필요한 것 위주로 정리했어요. 같은 주제의 내부 동작 원리는 devboi 기술 블로그의 테라폼 상태 잠금 원리 — S3 use_lockfile·조건부 쓰기에서 도식과 코드로 단계별로 풀었어요.

참고한 자료

#테라폼 상태 잠금#use_lockfile 설정#DynamoDB 잠금 이전#lock-timeout 값#force-unlock 언제#Error acquiring the state lock#tflock 권한#Terraform S3 backend
← 이전 글비전 LLM 직접 서빙 해상도 고르기, max_pixels별 GPU 비용과 KV 캐시다음 글 →프로메테우스 히스토그램 버킷 정하는 법, p99 오차 계산과 네이티브 전환
광고

더 둘러보기

💻 개발자 다른 도구

🔥 많이 찾는 도구

🔮 재미로 한 번 더

📝 읽어 볼 만한 글

글 전체 보기 →