Published on

Terraform apply 409 충돌 - state 잠금·drift 해결

Authors

Terraform을 운영 환경에 붙이면 언젠가 한 번은 terraform apply 도중 409 Conflict 류의 에러를 만납니다. 겉으로는 “충돌” 한 줄이지만, 실제 원인은 크게 네 갈래로 갈립니다.

  • 원격 state 잠금(lock) 경합: 이미 다른 실행이 state를 잡고 있음
  • CI에서 동시 실행: 같은 workspace/state에 두 파이프라인이 동시에 apply
  • 클라우드 API의 조건부 업데이트 충돌: 리소스 버전(etag)·동시 수정으로 인한 409
  • drift(수동 변경)로 인해 Terraform이 기대하는 상태와 실제가 달라져 충돌

이 글은 409 를 “잠금 문제인지, drift인지, API 충돌인지” 빠르게 분류하고, 안전하게 복구하는 루틴을 제공합니다.

409 Conflict가 의미하는 것: Terraform vs Provider

409 Conflict 는 Terraform 자체 에러라기보다, Terraform이 호출한 클라우드 API(Provider) 가 “현재 상태에서는 이 요청을 적용할 수 없다”라고 응답할 때 흔히 나타납니다. 다만 Terraform 쪽에서도 원격 state 백엔드가 락을 잡는 과정에서 유사한 메시지를 내기도 합니다.

그래서 첫 단계는 로그에서 어디서 409가 났는지 를 분리하는 것입니다.

  • state 백엔드(예: S3+DynamoDB, Terraform Cloud) 관련 메시지: lock 경합 가능성 큼
  • 특정 리소스(예: aws_iam_role, azurerm_*, google_*) 업데이트 요청에서 409: API 충돌 또는 drift 가능성

빠른 진단을 위한 로그 레벨

아래처럼 Terraform 로그를 올리면, 409가 “락 단계”에서 났는지 “리소스 API 호출”에서 났는지 구분이 쉬워집니다.

export TF_LOG=INFO
export TF_LOG_PATH=./tf.log
terraform apply

tf.log 에서 Locking state / Acquiring state lock 같은 문구 근처인지, 특정 리소스의 Update 호출 근처인지 확인하세요.

1) 원격 state 잠금(lock) 경합 해결

대표 증상

  • 누군가 이미 apply 중이거나, CI가 동시에 돌고 있음
  • 이전 실행이 비정상 종료되어 lock이 남아 있음

백엔드별로 메시지는 다르지만, 핵심은 “state lock을 획득하지 못했다”입니다.

안전한 순서: “누가 잡고 있는지”부터 확인

  1. 같은 repo/워크스페이스에서 실행 중인 파이프라인이 있는지 확인
  2. Terraform Cloud/Enterprise면 Runs 화면에서 실행 중인지 확인
  3. S3+DynamoDB면 DynamoDB lock 레코드를 확인

force-unlock 는 최후의 수단

terraform force-unlock 은 정말로 해당 lock을 잡은 프로세스가 더 이상 존재하지 않을 때만 써야 합니다. 살아있는 apply를 강제로 풀면, 두 apply가 같은 state를 업데이트하면서 state 손상으로 이어질 수 있습니다.

# lock ID는 에러 메시지에 함께 출력되는 경우가 많습니다.
terraform force-unlock LOCK_ID

S3+DynamoDB 백엔드에서 자주 하는 실수

  • DynamoDB TTL을 걸어 “언젠가 풀리겠지”로 운영하는 경우
  • 네트워크 끊김으로 apply가 죽었는데 lock은 남는 경우

운영 팁:

  • CI에서 concurrency(GitHub Actions) 또는 배포 락(예: 환경별 뮤텍스)을 걸어 동시 apply 자체를 차단 하세요.

2) 동시 실행(Concurrent apply)로 인한 409 줄이기

state lock이 정상이라도, 클라우드 자원은 서로 다른 모듈/스택이 같은 리소스를 만지는 순간 409가 납니다. 예를 들면:

  • 네트워크 스택과 앱 스택이 같은 IAM 정책/보안그룹을 수정
  • 다른 workspace가 같은 실자원(동일 name)으로 생성/수정

해결 전략: “소유권”을 분명히

  • 리소스 단위로 “어느 스택이 관리하는지”를 명확히 분리
  • 공용 리소스는 별도 스택으로 떼고, 나머지는 data 로 참조

예: 공용 VPC는 network 스택이 소유하고, 앱 스택은 VPC ID를 입력으로 받습니다.

# app stack
variable "vpc_id" {
  type = string
}

resource "aws_security_group" "app" {
  vpc_id = var.vpc_id
  name   = "app-sg"
}

3) Drift로 인한 apply 충돌: 상태 불일치가 만드는 409

drift는 “Terraform state” 와 “실제 인프라”가 달라진 상태입니다. 운영 중 수동 변경(콘솔 수정), 자동화 도구(클라우드 정책/컨트롤러), 혹은 다른 Terraform 프로젝트가 개입하면 drift가 생깁니다.

drift가 있으면 Terraform이 업데이트를 시도할 때 API가 다음과 같이 반응할 수 있습니다.

  • “이미 다른 버전으로 바뀌었으니 네 업데이트는 충돌” (etag/버전 불일치)
  • “해당 속성은 현재 상태에서 변경 불가” (상태 전이 제약)

drift 탐지: plan 을 “refresh 포함”으로 다시 보기

Terraform 1.1+ 기준으로는 기본적으로 refresh를 수행하지만, 상황에 따라 명시적으로 확인하는 습관이 좋습니다.

terraform plan -refresh=true

또는 변경분을 파일로 저장해서 팀 리뷰를 거치면, “수동 변경이 섞였는지” 더 잘 보입니다.

terraform plan -out=tfplan
terraform show -no-color tfplan | sed -n '1,200p'

drift 복구의 3가지 선택지

  1. 실자원을 Terraform 기대값으로 되돌린다
  • 운영 정책상 “수동 변경 금지”라면 정답
  1. Terraform 코드를 실자원에 맞춘다
  • 이미 운영에서 정착된 수동 변경이 “정당한 요구사항”이면 코드로 흡수
  1. state를 실자원에 동기화한다(import/state rm)
  • 코드와 리소스 소유권을 재정리할 때 유용

import 로 state에 편입

이미 존재하는 리소스를 Terraform이 새로 만들려다 충돌할 때(예: name 중복으로 409) import 가 필요합니다.

terraform import aws_security_group.app sg-0123456789abcdef0
terraform plan

state rm 로 “더 이상 Terraform이 관리하지 않게”

공용 리소스를 다른 스택으로 넘기거나, 관리 주체를 변경할 때 사용합니다.

terraform state rm aws_iam_policy.shared

주의: state rm 은 실자원을 삭제하지 않습니다. 대신 Terraform이 더 이상 추적하지 않으므로, 이후 drift 감시도 사라집니다.

drift가 반복되어 apply 가 무한 루프에 빠지는 케이스는 별도 패턴이 있습니다. 아래 글의 “상태 꼬임” 섹션이 특히 도움이 됩니다.

4) Provider API의 조건부 업데이트 충돌(ETag/버전) 대응

AWS, Azure, GCP 모두 일부 리소스는 “동시 수정”을 막기 위해 버전/etag 기반 업데이트를 합니다. Terraform이 읽어온 시점과 업데이트 시점 사이에 누군가 변경하면 409가 납니다.

전형적인 시나리오

  • CI가 길게 돌고 있는 동안, 운영자가 콘솔에서 같은 리소스를 수정
  • Auto-scaler, 정책 엔진, 컨트롤러가 주기적으로 태그/규칙을 갱신

해결책

  • 해당 리소스를 누가 건드리는지(사람/자동화)부터 제거하거나 단일화
  • 불가피하게 외부 시스템이 만지는 필드는 ignore_changes 로 경계 설정

예: 태그는 외부 정책이 붙이는 경우가 있어 충돌을 줄이기 위해 일부 키를 무시합니다.

resource "aws_instance" "app" {
  ami           = var.ami
  instance_type = "t3.micro"

  tags = {
    Name        = "app"
    ManagedBy   = "terraform"
    CostCenter  = var.cost_center
  }

  lifecycle {
    ignore_changes = [
      tags["CostCenter"],
    ]
  }
}

주의: ignore_changes 는 충돌을 “숨기는” 도구이기도 합니다. 정말로 Terraform이 관리할 필요가 없는 필드에만 제한적으로 쓰세요.

5) 운영에서 자주 쓰는 “안전한 복구 플레이북”

409가 떴을 때, 무작정 재시도부터 하면 상태가 더 꼬일 수 있습니다. 아래 순서를 권합니다.

1단계: 실행 중인 apply가 있는지 확인

  • CI 파이프라인 동시 실행 여부 확인
  • Terraform Cloud runs 확인
  • 백엔드 lock 확인

2단계: plan 으로 현재 의도 재확인

terraform plan -refresh=true
  • 변경이 “내가 기대한 것”인지
  • 갑자기 대량 변경이 생겼는지(= drift 가능성)

3단계: 충돌 리소스를 최소 단위로 좁히기

대규모 apply에서 한 리소스 때문에 전체가 막히면, 타겟 적용으로 원인 리소스만 먼저 확인할 수 있습니다.

terraform apply -target=aws_security_group.app

주의: -target 은 임시 디버깅 용도입니다. 장기적으로는 의존성 그래프를 깨뜨릴 수 있어, 최종적으로는 전체 apply로 수렴시키는 것을 목표로 하세요.

4단계: drift/소유권 문제면 import 또는 분리

  • 이미 존재하는데 새로 만들려는 경우: import
  • 다른 스택이 관리해야 하는 경우: state rmdata 로 참조

5단계: 마지막으로 재시도(재현 가능한 상태에서)

원인이 제거된 뒤에만 재시도하세요. 단순 재시도는 “일시적 409(잠깐 다른 업데이트 중)”에는 통하지만, drift/소유권 문제에는 독입니다.

6) CI/CD에서 409를 예방하는 체크리스트

동시 실행 차단(가장 효과 큼)

GitHub Actions라면 환경별 동시성 키를 걸어 같은 state에 대한 apply를 직렬화합니다.

concurrency:
  group: terraform-prod
  cancel-in-progress: false

OIDC로 AWS에 배포하는 파이프라인에서 권한 문제와 함께 409가 섞여 보이는 경우도 많습니다(실제로는 AccessDenied가 먼저 원인인 경우). CI 권한/AssumeRole 이슈는 아래 글을 함께 참고하세요.

작업 단위 분리

  • 네트워크/공용 IAM/클러스터 같은 기반 스택과 애플리케이션 스택을 분리
  • 공용 리소스는 한 곳에서만 수정

Drift 유발 요인 제거

  • 콘솔 수동 변경을 금지하거나, 변경 시 PR로 코드 반영 프로세스 마련
  • 외부 컨트롤러가 만지는 필드를 명확히 문서화

7) 결론: 409는 “재시도”가 아니라 “분류”가 핵심

terraform apply409 Conflict 는 대부분 아래 중 하나입니다.

  • state lock 경합(동시 실행/비정상 종료)
  • 리소스 소유권 충돌(여러 스택이 같은 자원을 수정)
  • drift(수동 변경/외부 자동화)
  • provider의 조건부 업데이트 충돌(etag/버전)

해결의 핵심은 “락을 풀어야 하나, drift를 흡수해야 하나, 소유권을 재정의해야 하나”를 빠르게 분류하고, import/state rm/ignore_changes 를 정확한 목적에만 쓰는 것입니다.

특히 EKS 같은 복합 리소스는 drift가 누적되면 apply가 반복적으로 실패하거나 무한 루프에 빠지기 쉬우니, 관련 패턴은 아래 글도 함께 보시면 문제를 더 빨리 끊을 수 있습니다.