namespace Terminating 고착과 연쇄 장애

삭제 버튼 하나가 연쇄 장애의 방아쇠였다. myapp-worker 추가 배포 중 namespace를 foreground delete로 처리했더니, Ingress finalizer가 SG DependencyViolation으로 막히며 namespace가 Terminating에 고착됐다. finalizer를 수동 제거해 namespace를 날린 뒤 재배포했지만 이번엔 test.example.com에서 502가 터졌다. Route53은 갱신됐는데 CloudFront가 삭제된 구 ALB를 origin으로 바라보고 있었다. 두 단계에 걸쳐 장애를 따라갔고, CloudTrail로 원인을 확정한 뒤 복구했다.

환경: EKS, ArgoCD, AWS Load Balancer Controller, CloudFront


원인은 namespace 이중 관리였다

myapp-worker 배포 PR에 namespace.yaml이 실수로 함께 포함됐다. 그 결과 myapp namespace가 ArgoCD의 기존 관리 경로(myapp-api)와 신규 경로(myapp-worker) 양쪽에서 동시에 관리되는 상태가 됐다.

충돌을 빠르게 해소하려고 myapp-worker 대시보드에서 myapp namespace를 foreground delete로 처리했다. namespace를 실제 삭제하는 것이 아니라 추적 태그만 없애는 조작이라고 착각했던 것이다. 버튼을 누른 직후 배포 실패 알림이 울렸고, ArgoCD 대시보드가 노란색과 빨간색으로 바뀌었다.

namespace가 사라지면 재배포도 불가능하므로 namespace 삭제가 완료되길 기다렸다. 그런데 myapp namespace는 Terminating에 박혀 움직이지 않았다.


Terminating은 느린 삭제가 아니었다

처음엔 삭제가 느린 것으로 볼 수도 있었지만, 실제로는 무엇이 막고 있는지를 확인해야 했다.

kubectl get ns myapp -o yaml
kubectl get ns myapp -o jsonpath='{.status.phase}{"\n"}{.metadata.deletionTimestamp}{"\n"}{.spec.finalizers}{"\n"}'
Terminating
2026-02-12T08:38:47Z
["kubernetes"]
 
NamespaceContentRemaining=True
NamespaceFinalizersRemaining=True
Some resources are remaining: ingresses.networking.k8s.io has 1 resource instances
Some content in the namespace has finalizers remaining: ingress.k8s.aws/resources in 1 resource instances

NamespaceContentRemaining=True, NamespaceFinalizersRemaining=True — 남은 리소스는 ingresses.networking.k8s.io 1개, 남은 finalizer는 ingress.k8s.aws/resources였다. “삭제가 느린 것”이 아니라 “특정 finalizer가 삭제를 막고 있음”으로 문제 정의를 바꿨다.

어떤 Ingress인지 좁혔다.

kubectl -n myapp get ingress -o yaml
kubectl -n myapp describe ingress myapp-worker-ingress

myapp-worker-ingressdeletionTimestamp가 이미 찍혀 있었지만 finalizer가 잔존했다. 삭제 요청은 들어갔지만 정리 루틴이 끝나지 않은 상태였다. ALB Controller 로그를 열었다.

kubectl -n kube-system logs <controller-pod> --since=4h \
  | rg -i "myapp-worker-ingress|failed|error|finalizer|delete|securityGroup"
Reconciler error ... myapp-worker-ingress ...
error="failed to delete securityGroup: timed out waiting for the condition"

ingress.k8s.aws/resources finalizer는 AWS 리소스 정리가 완료돼야 제거된다. 그 정리의 마지막 단계인 SG 삭제가 타임아웃되어 finalizer가 남아 있었다.


SG 삭제를 막은 것은 DependencyViolation이었다

SG가 왜 삭제되지 않았는지 CloudTrail에서 확인했다.

AWS_PROFILE=myapp-test aws cloudtrail lookup-events --region ap-northeast-2 \
  --lookup-attributes AttributeKey=EventName,AttributeValue=DeleteSecurityGroup \
  --start-time 2026-02-12T08:35:00Z --end-time 2026-02-12T08:50:20Z --max-results 200 \
  | jq -r '.Events[] | .CloudTrailEvent|fromjson
    | select(.requestParameters.groupId=="sg-xxxxxxxxx")
    | [.eventTime,.errorCode,.errorMessage,.userIdentity.sessionContext.sessionIssuer.userName] | @tsv' | sort
2026-02-12T08:48:21Z ~ 2026-02-12T08:49:57Z
Client.DependencyViolation
resource sg-xxxxxxxxx has a dependent object
caller: myapp-test-aws-loadbalancer-controller

08:48~08:49 사이에 Client.DependencyViolation이 반복됐다. sg-xxxxxxxxx는 ALB Controller의 shared backend SG였고, 삭제 시점에 이 SG를 참조하는 객체가 AWS에 남아 있었다. 같은 시간대의 DeleteLoadBalancer, DeleteNetworkInterface, AuthorizeSecurityGroupIngress 이벤트를 교차하면 후보는 두 축이다.

  • ALB 삭제 직후 남은 ELB ENI 연결 상태
  • 다른 SG 규칙에서의 source SG 참조

둘 다 DependencyViolation을 유발할 수 있다. 다만 CloudTrail의 DeleteSecurityGroup 오류는 blocking 객체 타입/ID를 상세히 반환하지 않아, “정확히 어떤 단일 객체 때문에 막혔는지”까지 1:1로 확정할 수는 없었다.

결론은 두 계층으로 나뉜다. 원인은 확정이다 — 의존 객체 존재로 SG 삭제 실패 → finalizer 잔존 → namespace Terminating 고착. 그 의존 객체가 누군지는 확정할 수 없다 — 그 순간의 단일 blocking 객체 ID는 미식별.

sequenceDiagram
    participant U as 운영자
    participant Argo as ArgoCD
    participant K8s as Kubernetes
    participant AWS as AWS (ALB/SG)
    participant CF as CloudFront

    U->>Argo: namespace 삭제 (foreground)
    Argo->>K8s: myapp 리소스 전체 삭제
    K8s->>AWS: Ingress finalizer → SG 삭제 시도
    AWS--xK8s: DependencyViolation (SG 의존 객체)
    Note over K8s: namespace Terminating 고착

    U->>K8s: finalizer 강제 제거
    K8s-->>K8s: namespace 삭제 완료

    U->>Argo: myapp-api Sync (재배포)
    Argo->>AWS: 신규 ALB 생성 (DNS 변경)
    U->>AWS: Route53 alias 갱신
    Note over CF: origin은 여전히 구 ALB
    CF--xU: 502 Bad Gateway

    U->>CF: origin → 신규 ALB로 교체
    CF-->>U: 200 OK

finalizer는 세 조건이 충족될 때만 강제 제거한다

ingress.k8s.aws/resources finalizer가 “AWS 정리 완료”를 조건으로 제거되는 구조였는데, 그 정리 자체가 차단됐다. 컨트롤러가 재시도해도 DependencyViolation은 해소되지 않았다.

finalizer는 리소스를 즉시 삭제하지 않고, 삭제 전에 반드시 끝내야 할 정리 작업을 수행하도록 만드는 메커니즘이다. 객체에 metadata.finalizers 값이 남아 있으면 deletionTimestamp가 찍혀도 API 서버에서 완전히 제거되지 않는다. 이번 ingress.k8s.aws/resources는 AWS Load Balancer Controller가 ALB, Target Group, Security Group 같은 외부 리소스를 정리한 뒤 finalizer를 제거하는 용도다.

강제 제거(수동으로 finalizer 삭제)는 정리 루틴을 우회하기 때문에 외부 리소스가 고아로 남거나 의존성이 끊겨 후속 장애가 발생할 수 있다. 강제 제거가 허용되는 경우는 세 조건이 모두 충족될 때다.

  • 원인 확인 완료: 컨트롤러가 왜 finalizer를 못 떼는지 파악했다
  • 복구 우선: 서비스 영향이 장기 분석보다 크다
  • 사후 정리 계획: 잔여 리소스를 사후에 점검한다

이 세 조건이 충족됐으므로 finalizer를 수동 제거했다.

kubectl -n myapp patch ingress myapp-worker-ingress \
  --type=merge -p '{"metadata":{"finalizers":[]}}'
kubectl get ns myapp
ingress.networking.k8s.io/myapp-worker-ingress patched
Error from server (NotFound): namespaces "myapp" not found

Ingress가 즉시 삭제되었고 namespace도 완전히 사라졌다. 동시에 GitOps 매니페스트 저장소에 두 건의 수정 PR을 반영했다.

  • 중복된 namespace 설정 제거 (merged_at: 2026-02-12T08:33:36Z)
  • worker Kustomization에서 namespace 설정 분리 (merged_at: 2026-02-12T09:02:36Z)

namespace 삭제 후 재배포했는데 502가 터졌다

ArgoCD에서 myapp-api Sync를 완료했지만 test.example.com이 502였다. namespace가 사라지면 ALB DNS가 바뀐다는 건 예상했지만, Route53 alias만 갱신하면 끝날 거라 생각했다.

aws elbv2 describe-load-balancers --profile myapp-test --region ap-northeast-2

신규 ALB 6개의 DNS를 확인해 IaC/services/myapp/test/variables.tf의 alias 대상을 갱신했다. (merged_at: 2026-02-12T09:08:53Z) 그런데 여전히 502였다.

경로를 한 단계 더 내려갔다.

curl -sS -o /dev/null -w 'HTTP %{http_code}\n' https://test.example.com
aws route53 list-resource-record-sets --profile myapp-test --hosted-zone-id ZXXXXXXXXXXXXX
aws cloudfront get-distribution --profile myapp-test --id EXXXXXXXXXXXXX
test.example.com -> HTTP 502
Route53 A(alias): test.example.com -> dXXXXXXXXXXX.cloudfront.net
CloudFront(EXXXXXXXXXXXXX) DefaultOrigin:
  k8s-myapp-worker-85fc07725b-1570616586.ap-northeast-2.elb.amazonaws.com (구 ALB)

Route53은 CloudFront를 정상 참조했지만, CloudFront의 기본 origin이 삭제된 구 ALB를 가리키고 있었다. CloudFront는 origin을 자동으로 갱신하지 않는다. Route53 → CloudFront → ALB 경로 전체를 함께 확인하지 않으면 이 불일치를 잡을 수 없다.


CloudFront origin 교체로 최종 복구

원인이 확정됐으므로 CloudFront distribution을 직접 업데이트했다.

aws cloudfront get-distribution-config --profile myapp-test --id EXXXXXXXXXXXXX
aws cloudfront update-distribution --profile myapp-test --id EXXXXXXXXXXXXX \
  --if-match <ETag> --distribution-config file://...
aws cloudfront wait distribution-deployed --profile myapp-test --id EXXXXXXXXXXXXX
Distribution EXXXXXXXXXXXXX Status: InProgress -> Deployed
DefaultOrigin:
  k8s-myapp-worker-85fc07725b-260927434.ap-northeast-2.elb.amazonaws.com (신규 ALB)

배포 완료 후 헬스체크를 재검증했다.

curl https://test.example.com
curl https://api.test.example.com/readyz
curl https://admin-api.test.example.com/readyz
curl https://cms-api.test.example.com/readyz
test.example.com -> HTTP 200
api.test.example.com/readyz -> HTTP 200
admin-api.test.example.com/readyz -> HTTP 200
cms-api.test.example.com/readyz -> HTTP 200

서비스가 복구됐다. finalizer 강제 제거로 인한 orphan 가능성을 사후 검증했다.

aws elbv2 describe-load-balancers --profile myapp-test --region ap-northeast-2 \
  --query "LoadBalancers[?contains(LoadBalancerName, 'k8s-myapp-')].[LoadBalancerName,DNSName,State.Code]" \
  --output table
aws ec2 describe-security-groups --profile myapp-test --region ap-northeast-2 \
  --filters Name=group-name,Values='k8s-myapp-*','k8s-traffic-testeks-*' \
  --query "SecurityGroups[*].[GroupId,GroupName]" --output table
nslookup k8s-myapp-worker-01f28dfae5-1559597038.ap-northeast-2.elb.amazonaws.com
- k8s-myapp-* ALB: 7개 모두 active
- k8s-myapp-* / k8s-traffic-testeks-* SG: 전부 LB attach 상태
- 구 ALB DNS 6개 전부 NXDOMAIN

현재 시점에서 ALB/SG/ENI orphan 증거는 확인되지 않았다.


이 장애가 남긴 것

Terminating이 수 분 이상 지속되면 느린 삭제가 아니라 finalizer 블로킹이다. kubectl get ns -o yaml로 남은 finalizer와 블로킹 리소스를 먼저 확인해야 한다. 이 구분 없이 기다리기만 하면 시간을 낭비한다.

namespace 삭제는 blast radius가 크다. 버튼 하나로 다수 애플리케이션과 외부 리소스(ALB, SG, ENI)에 동시 영향을 준다. RBAC와 admission policy로 기본 차단하고, 예외적인 삭제만 승인 절차를 거쳐야 이번 같은 사고를 반복하지 않는다. 매니페스트 측면에서는 Namespace 같은 클러스터 스코프 리소스를 애플리케이션 매니페스트에서 제거하고, 전용 경로로만 관리하는 것이 변경 책임과 권한 경계를 명확히 한다.

ALB가 바뀌는 배포에서는 Route53만 갱신하면 안 된다. Route53 → CloudFront → ALB 경로 전체를 함께 검증하고 반영해야 한다. CloudFront origin은 자동으로 갱신되지 않는다. CORS 디버깅에서 x-cache 헤더를 가장 먼저 보는 것처럼, 502 디버깅에서는 요청이 어느 계층에서 막히는지부터 추적해야 한다.

강제 조치 후에는 orphan 리소스를 독립된 단계에서 검증해야 한다. finalizer는 외부 리소스 고아를 막기 위한 장치이므로 강제 제거는 그 보호를 우회하는 것이다. 이번에는 ALB/SG/ENI 상태와 구 ALB DNS NXDOMAIN 확인으로 후속 리스크를 닫았지만, 이 단계를 생략하면 계정에 청구되는 orphan 리소스가 남을 수 있다.


앞으로의 방향

이번 사고의 출발점은 namespace 이중 관리였다. 앱 매니페스트에서 Namespace 같은 클러스터 스코프 리소스를 제거하고 전용 경로에서만 관리하는 규칙을 팀 표준으로 정립하는 것이 첫 번째 과제다.

운영 namespace 삭제는 승인 절차를 강제하는 RBAC/admission policy 설계가 필요하다. 매니페스트 기반 ArgoCD 삭제에서도 foreground delete가 발생할 수 있으므로, namespace 삭제 전 체크리스트(Argo 자동 동기화 상태, finalizer 존재 여부, 외부 의존 리소스, 도메인 경로)를 표준 절차로 문서화해 둘 예정이다.

도메인 경로 정합성 검증은 배포 단위에서 자동화할 필요가 있다. ExternalDNS로 Route53 레코드 동기화를 자동화하더라도, CloudFront distribution origin 변경은 별도의 IaC와 배포 검증 단계로 남겨야 한다. 다음 ALB 교체가 발생하는 배포에서는 Route53과 CloudFront를 함께 갱신하는 파이프라인을 구성하는 것이 목표다.


참고 자료


정보 공백 요약

#필요한 정보이유위치
1namespace Terminating 고착 구간 kubectl 출력 전체 (deletionTimestamp, finalizers, conditions)“단순 지연이 아닌 finalizer 블로킹” 시각적 증거진단 흐름 섹션
2CloudFront origin 교체 전후 502/200 응답 수 비교”origin 교체 직후 정상화” 정량적 근거복구 섹션