Published on

Terraform EKS 상태 꼬임으로 apply 무한 반복 끊기

Authors

서로 다른 팀이 같은 EKS를 건드리거나(콘솔/CLI 핫픽스), 모듈 업그레이드로 리소스 스키마가 바뀌거나, EKS 애드온/노드그룹이 AWS 쪽에서 자동으로 값을 보정하면서 Terraform의 기대값과 실제값이 계속 어긋나는 순간이 있습니다. 이때 흔히 겪는 증상이 terraform apply가 성공했는데 다음 실행에서 또 같은 diff가 뜨는 무한 반복입니다.

이 글은 “왜 루프가 생기는지”를 패턴으로 나누고, 가장 안전한 순서로 원인을 좁혀가며 state를 정상화하는 방법을 다룹니다. 특히 EKS는 관리형 서비스 특성상 AWS가 값을 자동 조정하는 영역이 있어, 단순히 apply를 더 돌린다고 해결되지 않는 경우가 많습니다.

> 참고: 네트워크/클러스터 통신 계열 이슈가 함께 섞여 “apply는 돌아가는데 kubernetes provider가 타임아웃” 같은 형태로 보일 수도 있습니다. 이 경우 아래 글들도 같이 확인하면 원인 분리가 빨라집니다. > - Kubernetes apiserver i/o timeout 원인과 해결 > - EKS TLS handshake timeout 해결 - IRSA·VPC·CoreDNS

apply 무한 반복(루프)의 전형적인 징후

다음 중 하나라도 해당하면 “state 꼬임/드리프트 루프”를 의심합니다.

  • terraform apply가 성공했는데 다음 plan에서 동일한 변경이 재등장
  • 매번 ~ update in-place가 뜨며 특정 필드가 A↔B로 토글
  • known after apply가 반복되며, 실제로는 값이 변하지 않는데도 diff가 지속
  • kubernetes_* 리소스가 계속 재적용되거나, helm_release가 매번 변경으로 인식
  • EKS Addon(예: coredns, vpc-cni)이나 Node Group의 특정 옵션이 계속 바뀌었다가 돌아옴

핵심은 “Terraform이 보는 값(상태/state, 데이터 소스 결과)”과 “AWS/EKS가 유지하는 값”이 같지 않은데 그 차이가 자동으로 계속 재발한다는 점입니다.

원인 패턴 6가지(실무에서 가장 자주 터지는 것)

1) AWS가 자동 보정하는 필드(Managed drift)

EKS 애드온, 노드그룹, 보안그룹 규칙 등에는 AWS가 내부 정책/버전 정책으로 값을 자동 정규화하는 경우가 있습니다.

  • 예: 애드온 configuration_values의 JSON 정렬/공백/키 순서 차이
  • 예: 특정 필드가 API 응답에서 기본값으로 채워져 Terraform과 비교 시 매번 diff

이 경우는 정확히 같은 의미인데 표현이 달라서 루프가 생기기도 하고, 실제로는 AWS가 강제로 되돌려서 토글이 생기기도 합니다.

2) kubernetes / helm provider의 “서버 측 디폴트”와 충돌

Kubernetes 리소스는 서버가 기본값을 채우는 필드가 많습니다. Terraform이 그 필드를 “내가 설정한 적 없음”으로 간주했다가, 다음 refresh에서 “값이 생겼네?”로 diff를 만들 수 있습니다.

  • metadata.annotations에 컨트롤러가 주입하는 값
  • spec의 기본값(예: revisionHistoryLimit, strategy 세부 필드)

3) provider 버전/모듈 업그레이드로 스키마가 바뀜

예전에는 computed였던 필드가 configurable로 바뀌거나, 반대로 ForceNew가 붙는 등 스키마가 바뀌면 기존 state와의 비교가 틀어질 수 있습니다.

  • AWS provider 업그레이드 후 EKS 관련 리소스 diff 폭증
  • terraform-aws-eks 모듈 메이저 업그레이드 후 node group / addon 리소스 주소 변경

4) 리소스 주소(address) 변경으로 “새로 만들고 지우기”가 반복

모듈 내부 리소스가 count에서 for_each로 바뀌거나 키가 바뀌면 주소가 달라져, Terraform은 기존 리소스를 “다른 것”으로 인식합니다.

  • module.eks.aws_eks_addon.this[0]module.eks.aws_eks_addon.this["coredns"]

이걸 제대로 moved 처리(또는 state mv)하지 않으면, 매번 recreate가 발생하거나 꼬여서 apply가 끝없이 반복될 수 있습니다.

5) 콘솔/CLI 핫픽스(Out-of-band change)

운영 중 급하게 콘솔에서 보안그룹 규칙을 열었다가, Terraform이 다음 apply에서 다시 닫고, 또 사람이 다시 열고… 이런 식으로 “인간-테라폼 줄다리기”가 루프를 만듭니다.

6) EKS 클러스터/노드 상태 문제로 인해 provider가 불완전한 refresh

EKS API는 되는데 Kubernetes API가 불안정하면, kubernetes_* 리소스 refresh가 실패하거나 부분 성공하면서 state가 애매하게 남을 수 있습니다.

  • apiserver i/o timeout
  • TLS handshake timeout
  • CNI 문제로 노드 NotReady → 애드온/데몬셋이 정상화되지 않아 의도한 상태로 수렴하지 않음

이 경우는 우선 클러스터 상태부터 안정화해야 합니다. (예: CNI/노드 상태) 필요하면 EKS kubelet NotReady - CNI plugin not initialized 해결도 함께 점검하세요.

해결 전략: “루프를 끊는” 안전한 순서

아래 순서대로 진행하면, 불필요한 파괴를 피하면서 원인을 빠르게 좁힐 수 있습니다.

  1. 반드시 백업: state 백업 + 계획 출력 저장
  2. plan을 “어떤 필드가 반복되는지” 기준으로 최소 단위로 쪼개 관찰
  3. out-of-band 변경 여부 확인(CloudTrail/콘솔 이력/팀 커뮤니케이션)
  4. 스키마/주소 변경이면 moved 또는 state mv로 정리
  5. AWS가 보정하는 필드면 ignore_changes 또는 입력값 정규화(JSON 등)
  6. Kubernetes/Helm이면 서버 주입 필드 제거/ignore, 또는 manifest/chart 값 정규화
  7. 마지막 수단으로 state rm + import로 재정렬

이제 각 단계를 실전 명령과 코드로 설명합니다.

1) state 백업과 재현 가능한 plan 확보

state 백업

원격 backend(S3 등)라도 로컬로 백업 파일을 떠두는 게 안전합니다.

terraform state pull > state-backup-$(date +%Y%m%d-%H%M%S).json

반복 diff를 파일로 고정

매번 같은 diff인지 확인하려면 plan을 파일로 저장하고, show로 비교합니다.

terraform plan -out tfplan
terraform show -no-color tfplan > plan.txt

여기서 항상 바뀌는 리소스 주소항상 바뀌는 필드를 체크리스트로 뽑습니다.

2) 리소스 주소 변경(모듈 업그레이드)이라면 moved/state mv로 먼저 정리

모듈/코드 리팩토링 후 루프가 시작됐다면, 1순위로 의심할 건 “주소 변경”입니다.

Terraform 1.1+ moved 블록 사용(권장)

moved {
  from = module.eks.aws_eks_addon.this[0]
  to   = module.eks.aws_eks_addon.this["coredns"]
}

이렇게 하면 state가 새 주소로 안전하게 이동되어, Terraform이 더 이상 “새 리소스”로 착각하지 않습니다.

이미 꼬였다면 terraform state mv

terraform state mv \
  'module.eks.aws_eks_addon.this[0]' \
  'module.eks.aws_eks_addon.this["coredns"]'

주의점:

  • state mv는 즉시 state를 바꾸므로, 반드시 백업 후 진행
  • 주소 문자열(따옴표/이스케이프)을 정확히 써야 함

3) AWS가 보정하는 필드/표현 차이: 입력값 정규화 + ignore_changes

(A) JSON 문자열은 정규화해서 넣기

EKS Addon의 configuration_values는 JSON 문자열로 넣는 경우가 많고, 이게 문자열 비교로 diff를 유발합니다. jsonencode()로 정규화하면 불필요한 차이를 줄일 수 있습니다.

resource "aws_eks_addon" "vpc_cni" {
  cluster_name  = aws_eks_cluster.this.name
  addon_name    = "vpc-cni"
  addon_version = "v1.16.0-eksbuild.1"

  configuration_values = jsonencode({
    env = {
      ENABLE_PREFIX_DELEGATION = "true"
      WARM_PREFIX_TARGET       = "1"
    }
  })
}

(B) 정말로 AWS가 강제 보정하는 필드는 ignore_changes

의미적으로 “AWS가 관리하는 영역”이라면 Terraform이 집착하지 않게 만드는 게 낫습니다.

resource "aws_eks_addon" "coredns" {
  cluster_name  = aws_eks_cluster.this.name
  addon_name    = "coredns"
  addon_version = "v1.11.1-eksbuild.4"

  lifecycle {
    ignore_changes = [
      configuration_values,
    ]
  }
}

권장 기준:

  • 운영 정책상 반드시 고정해야 하는 값이면 ignore하지 말고, 정규화/원인 제거
  • AWS가 자동으로 바꾸는 게 정상 동작이고, Terraform이 그걸 계속 되돌리려 한다면 ignore 고려

4) Kubernetes/Helm 리소스가 매번 바뀔 때: “서버 주입 필드”를 다루는 법

kubernetes provider: 불필요한 필드 선언을 줄이기

서버가 주입하는 annotation/label을 코드에 억지로 고정하면 루프가 생깁니다. 가능하면 필수 필드만 선언합니다.

resource "kubernetes_config_map" "example" {
  metadata {
    name      = "example"
    namespace = "default"
  }

  data = {
    "app.conf" = "key=value\n"
  }
}

Helm: values 정규화 + set 블록 일관성

values에 YAML 문자열을 직접 쓰면 공백/정렬 차이로 drift가 생길 수 있습니다. 가능하면 yamlencode()를 사용합니다.

resource "helm_release" "metrics_server" {
  name       = "metrics-server"
  repository = "https://kubernetes-sigs.github.io/metrics-server/"
  chart      = "metrics-server"
  namespace  = "kube-system"

  values = [
    yamlencode({
      args = ["--kubelet-insecure-tls"]
    })
  ]
}

또한 set {}values를 혼용하면 최종 렌더링 결과가 애매해져 diff가 반복되는 경우가 있으니, 한 방식으로 통일하는 편이 좋습니다.

5) Out-of-band 변경을 끊기: 팀 프로세스와 락

기술적으로는 state를 맞춰도, 사람이 콘솔에서 다시 바꾸면 루프는 재발합니다.

  • 변경은 Terraform으로만 한다는 규칙
  • 급한 핫픽스는 사후에 반드시 코드 반영(PR)
  • 원격 backend에 state lock(DynamoDB)을 켜서 동시 apply 방지

S3+DynamoDB 락 예시:

terraform {
  backend "s3" {
    bucket         = "my-tfstate"
    key            = "eks/prod/terraform.tfstate"
    region         = "ap-northeast-2"
    dynamodb_table = "terraform-lock"
    encrypt        = true
  }
}

6) 최후의 정리: state rm + import로 “현실”에 state를 다시 붙이기

위 조치로도 해결이 안 되면, 특정 리소스만 state에서 제거 후 실제 리소스를 import하여 재동기화합니다. 이 방법은 강력하지만, import 대상/주소를 정확히 해야 합니다.

예: EKS Addon import

  1. state에서 제거(리소스는 삭제되지 않음)
terraform state rm 'aws_eks_addon.coredns'
  1. import (ID 포맷은 보통 cluster_name:addon_name)
terraform import aws_eks_addon.coredns my-cluster:coredns
  1. plan으로 수렴 확인
terraform plan

주의:

  • state rm 직후 apply를 하면 Terraform이 “없으니 새로 만들자”고 할 수 있으니, 반드시 import까지 한 세트로 진행
  • 모듈 내부 리소스면 주소가 module.eks.aws_eks_addon.coredns 같은 형태일 수 있음

루프를 빠르게 진단하는 체크리스트(10분 컷)

  • terraform plan에서 매번 바뀌는 필드가 정확히 무엇인지 텍스트로 추출했는가?
  • 최근에 provider/모듈 버전이 바뀌었는가? (특히 terraform-aws-eks)
  • 리소스 주소가 바뀐 흔적이 있는가? (countfor_each, key 변경)
  • JSON/YAML 문자열 비교로 인한 diff인가? (jsonencode, yamlencode로 정규화)
  • AWS/EKS가 관리하는 필드인가? (필요 시 ignore_changes)
  • 콘솔/CLI에서 누군가 바꾸고 있지 않은가? (CloudTrail/운영 기록)
  • Kubernetes API가 불안정해서 refresh가 깨지지 않는가? (timeout, TLS)
  • 필요 최소 리소스만 -target으로 좁혀서 원인 리소스를 특정했는가?

-target은 남용하면 의존성 그래프가 깨질 수 있지만, 원인 리소스 식별용으로는 유용합니다.

terraform plan -target=aws_eks_addon.coredns

마무리: “정상 수렴”의 기준을 먼저 정하자

Terraform apply 무한 반복은 단순한 버그가 아니라, 대개 관리 주체가 둘 이상이거나(사람/자동화/AWS), 표현(문자열)과 의미(구성)가 섞여 비교되면서 생깁니다. 해결의 핵심은 다음 두 가지입니다.

  1. Terraform이 책임져야 하는 값과 AWS/EKS가 책임지는 값을 분리한다(필요 시 ignore_changes).
  2. state를 “코드의 주소 체계”에 맞게 정리한다(moved, state mv, import).

이 두 축을 잡고 위 순서대로 처리하면, 대부분의 EKS 상태 꼬임 루프는 리소스 파괴 없이 끊을 수 있습니다.