Terraform을 혼자 쓸 때는 로컬 terraform.tfstate 파일로도 버틴다. 그런데 팀원이 둘만 넘어가도 state 파일이 서로 꼬이고, apply가 겹치면서 리소스가 중복 생성되는 사고가 바로 터진다. 저도 처음 팀에 Terraform을 들일 때 이 단계에서 제일 많이 깨졌다. 그래서 state를 원격에 두고 잠금을 거는 구성은 사실상 필수에 가깝다.
결론부터 말하자면, state 저장은 S3 백엔드로 하고 잠금은 Terraform 1.10부터 추가된 use_lockfile로 거는 방식을 권한다. 예전처럼 DynamoDB 테이블을 따로 만들 필요가 없다. DynamoDB 기반 락은 공식 문서에서 deprecated로 표시됐고 향후 마이너 버전에서 제거 예정이다. 기존 AWS 리소스는 import 블록으로 코드화한다.
아래에서 state와 백엔드의 정의부터 S3 구성, 잠금 방식 선택, 그리고 기존 리소스 import까지 순서대로 살펴본다.
1. 개요 - state와 백엔드란
Terraform의 state는 코드(HCL)로 선언한 리소스와 실제 클라우드에 존재하는 리소스를 연결해주는 매핑 파일이다. 기본값은 작업 디렉터리의 terraform.tfstate 파일이다. 이 파일이 있어야 Terraform이 "이 aws_instance는 i-0abc에 해당한다"는 걸 안다.
백엔드(backend)는 이 state를 어디에 저장하고 어떻게 잠글지를 정하는 설정이다. 기본 백엔드는 로컬 파일이고, 원격 백엔드로 바꾸면 state를 S3 같은 공유 저장소에 둘 수 있다.
원격 백엔드가 필요한 이유는 두 가지다.
- 공유: 팀원 모두가 같은 state를 본다. 로컬에 흩어진 tfstate끼리 충돌하지 않는다.
- 잠금(locking): 두 사람이 동시에 apply할 때 한쪽만 들어가게 막는다. 이게 없으면 state가 깨진다.
2. S3 백엔드 구성
먼저 state를 담을 S3 버킷을 만든다. 버전 관리를 켜두는 게 중요하다. state가 깨졌을 때 이전 버전으로 되돌릴 수 있는 유일한 안전망이다.
aws s3api create-bucket \ --bucket my-tfstate-ap-northeast-2 \ --region ap-northeast-2 \ --create-bucket-configuration LocationConstraint=ap-northeast-2 aws s3api put-bucket-versioning \ --bucket my-tfstate-ap-northeast-2 \ --versioning-configuration Status=Enabled
그다음 backend 블록을 선언한다. terraform 블록 안에 들어가고, 여기에는 변수나 보간(interpolation)을 쓸 수 없다. 값은 리터럴로 적어야 한다.
terraform {
backend "s3" {
bucket = "my-tfstate-ap-northeast-2"
key = "prod/network/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
use_lockfile = true
}
}
각 인자의 뜻은 다음과 같다.
bucket: state를 담을 S3 버킷 이름. 필수.key: 버킷 안에서의 state 경로. 프로젝트나 환경별로 다르게 줘서 state를 분리한다. 필수.region: 버킷이 있는 리전. 필수.encrypt: 서버 측 암호화(SSE)를 켠다. state에는 평문 비밀번호가 들어갈 수 있으니 항상true로 둔다.use_lockfile: S3 자체 잠금을 켠다. 아래에서 설명한다.
KMS 키로 암호화하려면 kms_key_id에 키 ARN을 넣는다. 이때 해당 역할에 kms:Encrypt, kms:Decrypt, kms:GenerateDataKey 권한이 있어야 한다.
설정을 적용하려면 terraform init을 돌린다. 로컬 state가 이미 있다면 S3로 옮길지 물어본다.
$ terraform init Initializing the backend... Do you want to copy existing state to the new backend? Pre-existing state was found while migrating the previous "local" backend to the newly configured "s3" backend. ... Enter "yes" to copy and "no" to start with an empty state. Enter a value: yes Successfully configured the backend "s3"! Terraform will automatically use this backend unless the backend configuration changes.
여기서 yes를 치면 로컬 tfstate가 S3로 복사된다. 처음 구성할 때 제가 자주 걸리는 지점이 이거다. 한번 마이그레이션하고 나면 로컬 terraform.tfstate는 더 이상 쓰지 않으니 .gitignore에 넣고 커밋 이력에서도 지워두는 게 좋다.
3. 상태 잠금 - use_lockfile 대 DynamoDB
잠금은 apply가 동시에 돌지 못하게 막는 장치다. 한 사람이 apply하는 동안 다른 apply가 들어오면 Error acquiring the state lock으로 거절된다.
use_lockfile (권장)
Terraform 1.10부터 S3 백엔드가 자체 잠금을 지원한다. use_lockfile = true만 켜면 state 파일 옆에 <key>.tflock 파일을 만들어 잠금을 표현한다. S3의 조건부 쓰기(conditional write)를 이용하므로 별도 테이블이 필요 없다. 새로 구성한다면 이쪽을 쓴다.
dynamodb_table (deprecated)
예전 방식은 LockID라는 파티션 키를 가진 DynamoDB 테이블을 만들어 거기에 잠금 레코드를 쓰는 것이었다. 공식 문서는 이제 dynamodb_table 인자를 deprecated로 명시했고 향후 마이너 버전에서 제거한다고 적혀 있다. 신규 프로젝트에서 굳이 테이블을 만들 이유가 없다.
DynamoDB에서 넘어가기
이미 DynamoDB 락으로 운영 중이라면 두 인자를 한동안 같이 둘 수 있다. Terraform은 마이그레이션을 위해 S3 잠금과 DynamoDB 잠금을 동시에 켜는 걸 허용한다. 먼저 두 인자를 모두 둔 채로 init하고, 팀 전원이 1.10 이상으로 올라온 걸 확인한 뒤 dynamodb_table을 떼고 테이블을 지우면 된다.
terraform {
backend "s3" {
bucket = "my-tfstate-ap-northeast-2"
key = "prod/network/terraform.tfstate"
region = "ap-northeast-2"
encrypt = true
use_lockfile = true # 새 방식
dynamodb_table = "tf-locks" # 전환 기간 동안만 유지
}
}
잠금이 비정상 종료 등으로 남아버렸다면 terraform force-unlock <LOCK_ID>로 강제 해제한다. 에러 메시지에 Lock ID가 찍히니 그 값을 쓴다. 다른 사람이 실제로 apply 중은 아닌지 반드시 먼저 확인한다.
4. 기존 AWS 리소스를 import로 코드화
콘솔에서 손으로 만든 리소스가 많을수록 코드화가 숙제로 남는다. Terraform은 import로 이미 존재하는 리소스를 state에 등록해 코드가 관리하게 만든다. 리소스를 새로 만드는 게 아니라 기존 것을 가져오는 것이다.
import 블록 방식 (권장)
Terraform 1.5부터 import 블록과 설정 자동 생성이 들어왔다. 블록에 to(코드상 주소)와 id(AWS 리소스 ID)를 적는다.
import {
to = aws_security_group.web
id = "sg-0abc123def456"
}
그다음 -generate-config-out으로 plan을 돌리면 해당 리소스의 HCL을 파일로 뽑아준다. 존재하지 않는 새 파일 경로를 줘야 한다. 기존 파일을 지정하면 에러가 난다.
$ terraform plan -generate-config-out=generated.tf aws_security_group.web: Preparing import... [id=sg-0abc123def456] aws_security_group.web: Refreshing state... [id=sg-0abc123def456] Terraform will perform the following actions: # aws_security_group.web will be imported # (config generated by Terraform) Plan: 1 to import, 0 to add, 0 to change, 0 to destroy. Terraform has generated configuration for the following resources: - aws_security_group.web in generated.tf. Please review ...
생성된 generated.tf는 완성품이 아니라 초안이다. 공식 문서도 경고하는데, 스키마가 복잡한 리소스는 서로 양립할 수 없는 인자가 같이 나올 수 있다. 예를 들어 ipv6_address_count와 ipv6_addresses가 둘 다 찍히면 하나는 손으로 지워야 apply가 된다. 생성 코드는 꼭 눈으로 훑고 정리한 뒤 쓴다.
정리가 끝나면 terraform apply로 import를 확정한다. 이때 plan이 "0 to add, 0 to change"로 나와야 정상이다. 변경이 잡힌다면 코드가 실제 리소스와 아직 다르다는 뜻이니 차이를 맞춘다. import가 끝나면 import 블록은 지워도 된다.
terraform import CLI 방식
단건이거나 예전 방식이 익숙하다면 CLI도 된다. 다만 CLI는 state에만 등록하고 설정 코드는 만들어주지 않는다. 먼저 리소스 블록을 손으로 적어둔 다음 import한다.
$ terraform import aws_security_group.web sg-0abc123def456 aws_security_group.web: Importing from ID "sg-0abc123def456"... aws_security_group.web: Import prepared! Prepared aws_security_group for import aws_security_group.web: Refreshing state... [id=sg-0abc123def456] Import successful!
import ID 형식은 리소스마다 다르다. 어떤 값을 넣어야 하는지는 각 리소스의 프로바이더 문서 맨 아래 Import 절에 적혀 있다. 모든 리소스가 import를 지원하지는 않는다는 점도 기억해둔다.
5. 주의사항
- S3 버킷 버전 관리는 반드시 켠다. state 손상 시 복구 수단이 이것뿐이다.
backend블록에는 변수를 못 쓴다. 환경별로 값을 바꾸려면terraform init -backend-config=prod.s3.tfbackend처럼 부분 설정 파일을 분리한다.- state 파일에는 DB 비밀번호 같은 민감값이 평문으로 들어갈 수 있다.
encrypt = true와 버킷 정책으로 접근을 좁게 막는다. - 생성된 import 설정은 초안이다. apply 전에 plan이 깨끗한지(변경 0건) 확인하는 습관을 들인다.
- 잠금 해제(
force-unlock)는 실제 실행 중이 아님을 확인하고 쓴다. 남의 apply를 끊으면 state가 깨진다.
정리하면, 신규 구성은 S3 백엔드 + use_lockfile로 가고 DynamoDB는 전환 대상이 있을 때만 잠깐 병행한다. 기존 리소스는 import 블록으로 코드를 뽑아 검토 후 apply한다. 버전은 저장소 상태에 따라 다를 수 있으니 terraform version으로 1.10 이상인지 먼저 확인한다.