# 인프라 철거가 안전장치에 막힐 때 - force_delete, allowEmpty, finalizer의 순서

인프라를 만들 때 넣어 둔 안전장치는 철거할 때 그대로 장애물이 됩니다. 코드에서 리소스를 지웠는데 terraform apply가 실패하거나, Git에서 매니페스트를 전부 지웠는데 클러스터에는 리소스가 남아 있는 식입니다. 안전장치마다 값을 읽는 위치가 다르고 그 차이가 철거 순서를 정합니다.

이 글에서는 Terraform 삭제 플래그, Argo CD의 빈 앱 보호, Kubernetes finalizer가 각각 어디를 보는지 정리합니다. 이어서 이 차이 때문에 철거를 여러 번의 apply로 나눠야 하는 이유와 단계별 확인 방법을 다룹니다.

# 1. 안전장치는 각자 다른 곳을 본다

안전장치 값을 읽는 곳 철거 때 생기는 일
Terraform provider 인자 (force_delete, force_destroy) state 설정과 삭제를 같은 apply에 넣으면 플래그가 적용되지 않음
Terraform lifecycle.prevent_destroy 설정 파일 리소스 블록을 지우면 보호도 함께 사라짐
Argo CD 자동 동기화의 빈 앱 보호 렌더링된 매니페스트 매니페스트를 전부 지우면 동기화 자체를 건너뜀
Kubernetes finalizer 컨트롤러의 처리 컨트롤러가 먼저 사라지면 삭제가 끝나지 않음

같은 "삭제 보호"인데 방향이 반대인 경우도 있습니다. prevent_destroy는 블록을 지우면 무력해지고 force_delete는 state에 기록돼 있어야 효력이 생깁니다.

# 2. Terraform - 삭제 플래그는 state에 먼저 들어가야 한다

# 2-1. Delete는 설정을 보지 않는다

리소스 블록을 지우고 apply하면 provider의 Delete 함수가 호출됩니다. 이때 provider가 받는 입력은 설정이 아니라 직전 state입니다.

The request contains Terraform prior state data. The response is only for returning diagnostics.

Update는 prior state와 설정, plan을 모두 받습니다. 반면 Delete에는 prior state만 전달됩니다. 블록이 설정에서 이미 사라졌으니 읽을 설정도 없습니다.

# 2-2. ECR 저장소가 "not empty"로 실패하는 구조

ECR API는 이미지가 남은 저장소를 강제 옵션 없이 지우지 않습니다.

RepositoryNotEmptyException: The specified repository contains images. To delete a repository that contains images, you must force the deletion with the force parameter.

AWS provider(v6.x)의 Delete 함수는 이 옵션을 d.Get으로 읽습니다. 삭제 시점에 이 값은 state에서 옵니다.

Force: d.Get(names.AttrForceDelete).(bool),
// ...
} else if errs.IsA[*types.RepositoryNotEmptyException](err) {
    return sdkdiag.AppendErrorf(diags, "ECR Repository (%s) not empty, consider using force_delete: %s", d.Id(), err)

그래서 force_delete = true 추가와 블록 삭제를 한 번에 apply하면 state에는 여전히 false가 남아 있어 실패합니다. aws_ecr_repository 문서에는 이 주의 사항이 없고 aws_s3_bucketforce_destroy 문서에만 명시돼 있습니다.

Once this parameter is set to true, there must be a successful terraform apply run before a destroy is required to update this value in the resource state.

RDS deletion_protection은 AWS 쪽 속성이라 true인 동안 API가 삭제를 거부합니다. 이유는 다르지만 false로 바꾸는 apply를 먼저 해야 한다는 결론은 같습니다.

# 2-3. 두 단계로 나눈 apply

# 1단계: 삭제 플래그만 바꿔서 apply
resource "aws_ecr_repository" "legacy" {
  name         = "legacy-app"
  force_delete = true
}
terraform apply
terraform state show aws_ecr_repository.legacy | grep force_delete
#   force_delete = true

2단계에서 블록을 지우고 apply합니다. 이때 plan에는 destroy만 있어야 합니다. 1단계 plan에 다른 변경이 섞여 있으면 삭제 전에 원치 않는 수정이 먼저 반영됩니다.

리소스는 남기고 관리만 넘길 때는 Terraform 1.7부터 쓸 수 있는 removed 블록에 lifecycle { destroy = false }를 둡니다. terraform state rm과 결과는 같지만 plan에 드러나서 리뷰를 거칩니다.

# 2-4. prevent_destroy는 반대로 동작한다

This rule doesn't prevent Terraform from destroying a resource if you remove its configuration.

prevent_destroy는 state에 기록되지 않습니다. 블록이 남아 있는 상태에서 교체나 삭제가 계획될 때만 막고 블록을 통째로 지우는 PR은 막지 못합니다. 운영 DB는 클라우드 쪽 삭제 보호를 함께 켜 두어야 실수로 지워지지 않습니다.

# 3. Argo CD - 매니페스트를 다 지웠는데 클러스터에 남는 이유

# 3-1. 빈 앱 보호

prune: true인 앱에서 경로를 잘못 바꾸면 렌더링 결과가 0개가 되고 그대로 동기화하면 모든 리소스가 지워집니다. Argo CD는 이 경우를 기본으로 막습니다.

By default (and as a safety mechanism), automated sync with prune have a protection from any automation/human errors when there are no target resources.

이 보호는 의도적인 철거도 똑같이 막습니다. 컨트롤러 소스(v3.x)를 보면 조건이 문서보다 구체적입니다.

if app.Spec.SyncPolicy.Automated.GetPrune() && !app.Spec.SyncPolicy.Automated.GetAllowEmpty() {
    // ... 모든 리소스가 prune 대상이면
    message := fmt.Sprintf("Skipping sync attempt to %s: auto-sync will wipe out all resources", desiredRevisions)
  • prune: true일 때만 동작합니다. prune은 기본값이 꺼져 있습니다.
  • 리소스 전부가 prune 대상일 때만 걸립니다.
  • prune만 건너뛰는 게 아니라 자동 동기화 시도 전체를 건너뛰고 앱 condition에 SyncError를 남깁니다.

UI만 봐서는 놓치기 쉬우니 condition을 직접 확인합니다.

kubectl get application <app> -n argocd \
  -o jsonpath='{range .status.conditions[*]}{.type}: {.message}{"\n"}{end}'

철거가 목적이면 해당 앱에만 allowEmpty: true를 켭니다. 이 설정을 공통 템플릿에 넣으면 경로 오타를 막아 주던 보호가 모든 앱에서 사라집니다.

spec:
  syncPolicy:
    automated:
      prune: true
      allowEmpty: true

# 3-2. Application을 지울 때는 finalizer가 결정한다

매니페스트를 비우지 않고 Application을 지우면, 하위 리소스를 함께 지울지는 finalizer가 정합니다.

To perform a non-cascade delete, make sure the finalizer is unset and then delete the app

방식 하위 리소스 적합한 경우
매니페스트 비우기 + allowEmpty 동기화 중 prune 철거 과정을 Git 이력으로 남길 때
resources-finalizer.argocd.argoproj.io가 있는 앱 삭제 연쇄 삭제 앱 단위로 한 번에 정리할 때
finalizer 없이 앱 삭제 남음 리소스를 다른 앱으로 인계할 때

앱 오브 앱스에서 부모가 자식 Application을 prune할 때도 같습니다. 자식에 finalizer가 없으면 Application 객체만 사라지고 워크로드는 남습니다.

# 4. 컨트롤러가 먼저 사라지면 finalizer가 풀리지 않는다

finalizer는 컨트롤러가 정리 작업을 끝낸 뒤 스스로 떼어 내는 표시입니다. 처리할 컨트롤러가 없으면 객체는 삭제 대기에 머뭅니다.

The target object remains in a terminating state while the control plane, or other components, take the actions defined by the finalizers.

# 4-1. Karpenter 노드

NodeClaim의 owner reference는 NodePool입니다. 그래서 NodePool을 지우면 노드가 차례로 정리됩니다.

Karpenter will gracefully terminate nodes through cascading deletion when the owning NodePool is deleted.

Karpenter를 먼저 제거하면 노드의 finalizer가 삭제를 막습니다. 이 finalizer를 수동으로 떼면 노드 객체만 사라집니다.

The instance will continue running in EC2, even though there is no longer a node object for it.

공식 가이드의 삭제 절차도 노드 삭제, Karpenter 제거, 클러스터 삭제 순서입니다. NodePool을 GitOps로 관리한다면 이 단계에서 3장의 빈 앱 보호가 걸립니다.

# 4-2. 로드 밸런서

AWS Load Balancer Controller는 자신이 만든 LB에 연결된 Ingress와 Service에 finalizer를 붙입니다. 소스 기준 이름은 ingress.k8s.aws/resources, service.k8s.aws/resources입니다. EKS 문서는 클러스터 삭제 전에 이 리소스들을 지우라고 안내합니다.

If you don't delete the ingress resources, the application load balancer remains even if you deleted the cluster.

LB가 남으면 VPC 삭제에서 막힙니다. LB가 만든 네트워크 인터페이스가 VPC에 남기 때문입니다. 컨트롤러를 이미 지웠다면 finalizer를 떼기보다 컨트롤러를 다시 올려 정리하게 하는 쪽이 안전합니다.

# 삭제 대기 중인 Ingress/Service
kubectl get ingress,svc -A -o json \
  | jq -r '.items[] | select(.metadata.deletionTimestamp) | "\(.kind) \(.metadata.namespace)/\(.metadata.name)"'

# VPC에 남은 ENI (0개여야 VPC 삭제 가능)
aws ec2 describe-network-interfaces --filters Name=vpc-id,Values=<vpc-id> \
  --query 'NetworkInterfaces[].[NetworkInterfaceId,InterfaceType,Description]' --output table

# 5. 철거 순서

단계 작업 넘어가기 전 확인
1 Ingress, LoadBalancer Service 삭제 VPC의 LB 0개
2 NodePool 삭제 (GitOps면 allowEmpty) NodeClaim 0개
3 컨트롤러 앱 삭제 삭제 대기 객체 없음
4 Terraform: force_delete, deletion_protection = false 적용 terraform state show
5 Terraform: 클러스터·노드 그룹·저장소 블록 삭제 (넘길 리소스는 removed) plan이 의도한 destroy만 포함
6 VPC 계층 삭제 ENI 0개

단계마다 PR을 나누면 실패한 지점이 바로 보이고 되돌릴 범위도 작아집니다.

# 6. 트러블슈팅

증상 원인 해결
설정에 force_delete = true가 있는데 not empty, consider using force_delete 플래그 추가와 블록 삭제를 같은 apply에 넣음 블록을 되살려 플래그만 apply한 뒤 삭제
매니페스트를 다 지웠는데 리소스가 남고 앱에 SyncError 빈 앱 보호 해당 앱에만 allowEmpty: true
노드나 Ingress가 Terminating에서 멈춤 컨트롤러가 먼저 제거됨 컨트롤러를 다시 올려 finalizer 처리
VPC 삭제가 의존성 오류로 실패 LB, NAT 등이 만든 ENI가 남음 ENI Description으로 원래 리소스를 찾아 삭제

# 7. 마무리

  • Terraform provider의 삭제 플래그는 state에서 읽습니다. 플래그 apply와 블록 삭제 apply를 나눕니다.
  • prevent_destroy설정에만 있어서 블록을 지우면 사라집니다. 중요한 리소스는 클라우드 쪽 삭제 보호를 함께 씁니다.
  • Argo CD의 빈 앱 보호와 finalizer는 철거 순서를 강제합니다. LB, 노드, 컨트롤러, Terraform, 네트워크 순으로 내려가면 막히지 않습니다.

# 참고