테넌트 하나를 지우는 오프보딩 파이프라인이 12분을 돌다가 실패했다. 재시도해도 똑같이 12분 뒤에 실패했다. 로그에는 “ArgoCD Application이 아직 남아 있다”는 대기 메시지만 반복됐다. 따라 내려가 보니 끝에는 Pod 하나가 있었다. 그날 낮 이미지 업데이트 때 지워졌어야 할 Pod가 Terminating에 멈춘 채 다음 날 아침까지 풀리지 않았다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서 겪은 일이다. 이 증상은 처음이 아니었다. 한 달 전 비슷한 좀비 컨테이너를 보고 원인을 “다른 가용 영역의 EFS에 고정 IP로 붙는 구조”로 짚었고, 그 가설로 개선 작업을 만들어 dev 검증까지 마쳤다. 재발했을 때도 나는 같은 가설로 설명을 끝내려 했다.
개선 작업까지 한 가설이라도 재발하면 가설부터 다시 검증해야 한다. 이번에는 “다른 노드는 멀쩡한가”라는 비교 한 번이 원인을 통째로 바꿨다. 진짜 원인은 스토리지도 네트워크도 아니었다. Istio 사이드카가 NFS 트래픽을 가로채고 있었다. 이 글은 첫 가설을 버리기까지와, 사이드카가 Pod 종료를 막은 경로를 따라간다.
삭제 버튼 하나가 Pod 하나에 막히기까지
오프보딩은 테넌트의 ArgoCD Application들을 지우고, 모두 사라질 때까지 기다린 뒤 ArgoCD Project, 테넌트 매니페스트 디렉터리, Terraform 리소스를 차례로 정리한다. 기다리는 단계에 상한이 있다. offboarding-v4.sh는 Application이 남아 있으면 최대 600초를 기다리고 exit 1로 끝난다.
Application이 안 지워진 이유는 finalizer가 위로 줄줄이 걸려 있었기 때문이다.
Pod 종료 안 됨 (Terminating)
→ ReplicaSet, Rollout의 foregroundDeletion finalizer 대기
→ ArgoCD Application의 resources-finalizer 대기
→ offboarding-v4.sh 600초 대기 초과, exit 1
→ CodeBuild 실패. 뒤의 Project 삭제, 디렉터리 정리, Terraform destroy는 실행되지 않음
빌드 이력은 이랬다(시각은 KST).
| 시각 | 테넌트 | 결과 | 비고 |
|---|---|---|---|
| 09-01 17:48 | tenant-b 오프보딩 | 실패 (12분) | 앱 1개가 10분 넘게 잔존, 같은 증상 |
| 09-01 17:48 | tenant-a 오프보딩 | 성공 | |
| 09-02 17:41 | tenant-b 오프보딩 | 성공 (2.5분) | |
| 09-02 17:42 | tenant-a 오프보딩 | 실패 (12분) | mo-back, mo-ecommerce-back 2개 잔존 |
| 09-02 17:56 | tenant-a 재시도 | 실패 (12분) | mo-ecommerce-back 1개 잔존 |
두 테넌트의 오프보딩을 30초 간격으로 연달아 눌렀기 때문에 처음에는 동시 실행을 의심했다. 하지만 막힌 건 스크립트가 아니라 Pod였다. 남아 있던 mo-ecommerce-back Pod는 그날 12시 40분대에 ArgoCD가 이미지를 RELEASE-17에서 18로 동기화하면서 지운 구 Pod였다. 오프보딩보다 다섯 시간 먼저 멈춰 있었던 것이다.
노드에서 본 모습은 전형적인 좀비 컨테이너였다. java 프로세스는 defunct, NFS 커널 스레드는 D state(uninterruptible sleep)로 20시간째 멈춰 있었다. containerd의 StopContainer는 계속 DeadlineExceeded로 실패했고, kubectl exec은 setns 오류로 튕겼다.
첫 가설은 다른 가용 영역의 고정 IP였다
이 앱들은 공유 스토리지로 EFS를 쓴다. 다만 Kubernetes 볼륨이 아니라 컨테이너 안의 start.sh가 직접 NFS를 마운트한다. 그래서 컨테이너는 privileged로 돌고, EFS 주소는 hostAliases로 마운트 타깃 IP를 고정해 둔다. 앱 그룹별로 PRIMARY, SECONDARY 두 IP를 반씩 나눠 쓰기 때문에, 노드가 어느 존에 뜨느냐에 따라 절반은 다른 가용 영역의 마운트 타깃에 붙는다.
한 달 전인 08-06에 dev와 운영에서 EFS 통신이 동시에 멈추는 일이 있었고, 그때 mo-back 컨테이너가 하루 넘게 FailedKillPod를 반복했다. 같은 시기 HTTP 요청이 90초씩 지연되는 현상도 있었다. hard 마운트에 cross-AZ 고정 IP라는 구조가 눈에 띄었고, 나는 이걸 원인으로 봤다.
그래서 존별 마운트로 바꾸는 개선 작업을 만들었다.
앱 yaml 18개에서
hostAliases를 빼고, 컨테이너 command를 래퍼로 감쌌다. 래퍼는 노드 IP의 세 번째 옥텟을 경계값(ZONE_BOUNDARY: "31")과 비교해 자기 존의 마운트 타깃 IP를/etc/hosts에 적고start.sh를 실행한다.이 과정에서
/etc/hosts에 적은 줄이 랜덤하게 사라지는 문제를 만났고,holdApplicationUntilProxyStarts: true로 컨테이너 기동 순서를 고정해 풀었다. 이 경쟁 조건은 kubelet이 /etc/hosts를 다시 쓰는 문제를 다룬 글에서 따로 정리했다.dev에서 8번 중 8번 의도한 존의 IP로 마운트되는 것을 확인했다.
운영 반영에는 유관 부서 협의가 필요해서 08-10에 일단 revert했다. 협의만 끝나면 다시 적용할 수 있는 상태로 두었다. 이 작업이 확률을 줄일 뿐 hard 마운트 구조가 남는 한 재발할 수 있다는 한계는 그때도 알고 있었다.
재발했을 때 가설은 더 그럴듯해 보였다
09-02 장애를 처음 분석할 때는 증거가 하나같이 첫 가설 쪽을 가리켰다.
멈춘 Pod의 노드는 2a 존인데,
hostAliases는 2c 존 마운트 타깃을 가리키고 있었다.템플릿에서 SECONDARY IP를 쓰는 앱이 정확히
mo-back과mo-ecommerce-back이었다. 삭제가 늦거나 멈춘 앱도 정확히 이 둘이었다.노드 dmesg에는 그날 10:53부터
nfs: server fs-0123456789abcdef0... not responding이 8,800건 넘게 쌓여 있었다.
나는 “revert한 존별 마운트 개선이 미적용이라 재발했다”고 결론을 냈고, 단기 대책으로 재적용을, 근본 대책으로 EFS CSI 전환을 적었다. 멈춘 노드는 교체할 생각이었다.
그런데 결론을 적고 나서 한 가지가 걸렸다. 존별 마운트는 테스트까지 성공했지만, 그게 근본 원인을 건드린 작업이었는지는 확인한 적이 없었다. 다른 존의 EFS를 고정 IP로 붙인 게 정말 원인인지 팩트체크부터 다시 하기로 했다.
다른 노드 7대는 이틀간 NFS 오류가 0건이었다
비교 대상을 넓히자 가설이 바로 무너졌다.
09-02 하루 동안 tenant-a 앱 18개가 2a, 2c 노드에 섞여 돌았다. 그중 8개가 SECONDARY, 10개가 PRIMARY IP를 썼으니 cross-AZ 마운트가 여러 노드에 있었다. 그런데 좀비 노드를 뺀 나머지 7대는 이틀 동안 not responding을 한 번도 기록하지 않았다.
좀비 노드 자신도 Pod를 죽이기 전에는 10:53~11:05의 12분짜리 끊김 한 번뿐이었고, 그것도
OK로 복구됐다. 오류가 끊임없이 쌓이기 시작한 건 정확히 kill이 시작된 12:44 이후였다.EFS 자체 지표도 정상이었다. elastic 모드에 IO 한도 사용률은 0.1% 미만이었다.
가설대로라면 cross-AZ로 붙은 다른 노드에도 같은 로그가 있어야 했다. 오류가 Pod 종료 시점에 맞춰 시작됐다면 원인은 네트워크가 아니라 종료 과정에 있다. 멈춘 java 스레드의 커널 스택이 그걸 보여줬다.
do_exit → filp_close → nfs4_file_flush → nfs_wb_all → folio_wait_writeback
SIGKILL은 이미 전달됐다. 프로세스는 죽는 중이었고, 종료 경로에서 열린 파일을 닫다가 EFS에 남은 더티 페이지를 flush하려고 기다리고 있었다. 프로세스가 멈춘 곳은 앱 코드가 아니라 커널의 종료 경로였다. 그렇다면 질문도 “왜 EFS가 응답하지 않았나”에서 “왜 이 Pod의 flush만 EFS에 닿지 못했나”로 바뀐다.
NFS 트래픽이 사이드카를 지나가고 있었다
Pod의 네트워크 네임스페이스에 들어가 NAT 규칙을 봤다. ISTIO_OUTPUT 체인이 ISTIO_REDIRECT로 점프하고 있었고, 그 대상에 NFS 포트 2049도 포함돼 있었다. istio-init이 받은 인자가 이랬기 때문이다.
# istio-init 인자: 모든 아웃바운드를 가로채고, 제외 포트는 없음
-i "*" -x ""
사이드카 주입 설정의 excludeOutboundPorts가 비어 있었다. 앱 컨테이너가 커널 NFS 클라이언트로 맺은 TCP 연결도 다른 아웃바운드와 똑같이 envoy(15001)로 넘어가고 있었다. 마운트 옵션은 이랬다.
hard,timeo=600,retrans=2,proto=tcp,addr=10.0.1.10
평소에는 이게 문제가 되지 않는다. envoy가 살아 있으면 2049 트래픽을 그대로 EFS에 전달한다. 문제는 Pod가 종료될 때다. 종료 순서를 시각대로 맞춰 보니 이랬다.
| 컨테이너 | preStop | 일어난 일 |
|---|---|---|
| istio-proxy | 없음 | 삭제 즉시 SIGTERM, 15초 drain 뒤 12:44:09 종료 |
| 앱 | sleep 10 | 12:44:04 SIGTERM, 12:44:06 SIGKILL, 종료 경로에서 flush 시작 |
앱이 마지막 flush를 시작한 순간 envoy는 drain 중이었고, 3초 뒤에는 아예 없었다. 그다음부터 커널 NFS 클라이언트가 재접속하려고 보내는 SYN은 여전히 iptables 규칙에 걸려 15001로 꺾였다. 15001에서 듣는 프로세스가 없으니 돌아오는 건 RST다. hard 마운트는 포기하지 않는다. 연결 상태는 “bound, not connected”였고, 시간당 400건씩 not responding이 쌓였다. dmesg의 8,800건은 EFS 장애의 흔적이 아니라 이 무한 재시도의 흔적이었다.
sequenceDiagram
participant APP as 앱 컨테이너
participant ENVOY as 사이드카(envoy)
participant NFS as EFS
Note over APP,NFS: 평소: 2049 트래픽도 iptables로 envoy를 거쳐 나감
APP->>ENVOY: NFS 요청
ENVOY->>NFS: 전달
Note over APP,NFS: Pod 종료 시작
ENVOY->>ENVOY: preStop 없이 SIGTERM, 15초 drain 뒤 종료
APP->>APP: preStop sleep 10 뒤 SIGKILL, 종료 경로에서 더티 페이지 flush
APP-xENVOY: 재접속 SYN이 15001로 꺾여 RST
Note over APP: hard 마운트라 무한 재시도<br/>D state로 프로세스가 끝나지 못함사이드카가 앱보다 먼저 죽으면, 사이드카를 거치던 앱의 마지막 통신은 갈 곳을 잃는다. HTTP였다면 에러 하나로 끝났겠지만 hard 마운트의 NFS는 에러를 내지 않고 영원히 기다린다.
08-06 사건도 같은 틀로 다시 읽을 수 있다. 그때는 실제로 EFS 통신이 멈춘 것이 방아쇠였고, 이번에는 사이드카의 종료 순서가 방아쇠였다. 프로세스가 종료 경로의 flush에서 무한히 기다리는 메커니즘은 같다. 존별 마운트 개선은 첫 번째 방아쇠의 확률을 줄일 수는 있어도 두 번째 방아쇠는 건드리지 못한다. 입구가 둘인 메커니즘에서 첫 가설은 08-06 쪽 입구만 보고 있었다.
규칙 하나를 지우자 10초 만에 풀렸다
D state 스레드는 커널 안에서 기다리고 있어서 사용자 공간에서 죽일 방법이 없다. 처음에는 노드 교체가 유일한 방법이라고 봤다. 그런데 원인이 맞다면 다른 길이 있다. 스레드가 기다리는 flush를 끝나게 해주면 된다. 좀비 Pod의 네트워크 네임스페이스에서 REDIRECT 규칙만 지우면, 다음 재시도의 SYN은 envoy가 아니라 EFS로 직접 간다.
규칙은 Pod마다 독립된 네트워크 네임스페이스에 있으므로 지워도 같은 노드의 다른 Pod에는 영향이 없다. 좀비 Pod는 이미 서비스에서 빠졌고 Rollout이 새 Pod를 띄워 둔 상태라 트래픽 영향도 없다. 위험은 하나, 엉뚱한 Pod의 네임스페이스에 들어가는 것이다. 정상 Pod의 REDIRECT를 지우면 그 Pod의 아웃바운드가 envoy를 우회해 mTLS 통신이 깨진다. 그래서 지우기 전에 세 가지를 확인했다.
# 1) 좀비 Pod의 UID
kubectl get pod <pod> -n tenant-a -o jsonpath='{.metadata.uid}'
# 2) 노드에서: D state 스레드를 찾고, 그 프로세스의 cgroup에 Pod UID가 있는지 대조
# cgroup 경로에서는 UID의 하이픈이 언더스코어로 바뀌어 있다
ps -eLo pid,tid,stat,wchan:30,comm | awk '$3 ~ /D/'
grep -c "<pod-uid, 하이픈을 언더스코어로>" /proc/<PID>/cgroup # 1이어야 한다
# 3) 네임스페이스가 호스트와 다른지, REDIRECT 규칙이 정확히 1개인지 눈으로 확인
nsenter --net=/proc/<PID>/task/<TID>/ns/net iptables -t nat -S ISTIO_OUTPUT
# 세 가지가 모두 맞을 때만 지운다
nsenter --net=/proc/<PID>/task/<TID>/ns/net iptables -t nat -D ISTIO_OUTPUT -j ISTIO_REDIRECT
경로에 함정이 하나 있었다. 좀비가 된 리더 프로세스의 /proc/<PID>/ns/는 읽히지 않는다. 아직 살아 있는 D state 스레드의 /proc/<PID>/task/<TID>/ns/net을 써야 nsenter가 들어간다.
dev에서 실행한 결과는 이랬다.
규칙을 지운 직후 dmesg에
nfs: server ... OK가 찍혔다. 커널 NFS 클라이언트가 EFS에 직접 재접속해 flush를 끝냈다.10초 뒤 java 프로세스가 사라졌다. D state 스레드 0개, defunct 0개, NFS 매니저 커널 스레드도 사라졌다.
kubelet이 볼륨 정리와 샌드박스 정리를 정상으로 끝냈고, 이후
not responding은 다시 나오지 않았다.Pod, ReplicaSet, Rollout이 사라지고, 전날부터 deleting이던 Application도 finalizer가 풀려 삭제됐다.
kubectl delete --force도, 노드 교체도 필요 없었다.
REDIRECT 규칙 하나를 지워서 풀렸다는 사실이 원인의 최종 증명이었다. EFS나 가용 영역이 문제였다면 규칙을 지워도 아무것도 바뀌지 않았어야 한다. 남은 오프보딩 단계는 다시 실행하자 대기 루프를 바로 통과했다.
근본 조치는 세 갈래로 제안했다
다시 안 생기게 하는 근본 조치로 세 가지를 정리해 제안했다. 적용은 하지 않았고, 원인과 조치 방향을 운영사에 전달하는 데까지 했다.
| 방안 | 내용 | 무엇이 해결되나 |
|---|---|---|
| NFS 포트를 사이드카에서 제외 | 앱 템플릿에 traffic.sidecar.istio.io/excludeOutboundPorts: "2049" (또는 EFS 대역을 excludeOutboundIPRanges로) | envoy 생사와 상관없이 EFS에 직결. 이번 유형의 종료 경쟁이 사라진다 |
| 사이드카 종료 순서 보장 | Istio의 네이티브 사이드카 방식 사용 | kubelet이 앱이 끝난 뒤에 사이드카를 내린다 |
| 스토리지 연결 방식 전환 | 컨테이너 안에서 직접 마운트하지 않고 Kubernetes 스토리지 드라이버(CSI) 사용 | 마운트를 kubelet이 관리. privileged와 고정 IP도 함께 사라진다 |
# 첫 번째 방안: Pod 템플릿의 어노테이션 한 줄
spec:
template:
metadata:
annotations:
# 2049(NFS)는 istio-init이 REDIRECT 대상에서 뺀다
traffic.sidecar.istio.io/excludeOutboundPorts: "2049"
비용이 가장 낮은 건 첫 번째다. 앱 yaml에 한 줄이고 운영사 정책과 엮이지 않는다. CSI 전환이 가장 근본적이지만 EFS가 운영사 쪽 정책과 얽혀 있어 도입 장벽이 높았다. 네이티브 사이드카는 클러스터 전체의 주입 동작을 바꾸는 일이라 앱 단위로 시험하기 어렵다.
어느 방안이든 기존 Pod를 다시 만들어야 적용된다. iptables 규칙은 Pod가 생길 때 istio-init이 한 번 깔고 끝나기 때문이다. 어노테이션을 넣으면 Pod 템플릿이 바뀌어 일반 배포처럼 롤링 교체가 일어난다. 그 롤링에서 종료되는 구 Pod는 아직 옛 규칙을 갖고 있어서, 바로 그 종료가 같은 경쟁에 한 번 더 노출된다. 그래서 EFS 쓰기가 적은 시간에, 앱 단위로 나눠서, 구 Pod가 Terminating에서 빠지는지 확인하며 진행하자는 절차까지 함께 적었다.
이 세 가지는 사이드카 경쟁을 없앨 뿐, 08-06처럼 EFS 통신이 정말로 멈추는 경우까지 막지는 못한다. 그 경우의 대응(soft 마운트 등)은 EFS 끊김을 다룬 글에서 따로 다룬다.
아직 남은 문제
근본 조치를 적용하기 전까지는 운영 환경도 같은 조건을 전제로 두고 대비해야 했다. 확인 시점에 운영에는 좀비가 없었고, 그날 아침 Terminating이던 운영 Pod도 6분 안에 정상 종료됐다. 운영에서 같은 일이 생기면 위의 세 가지 확인과 규칙 삭제 절차를 그대로 쓸 수 있도록 정리해 두었다.
오프보딩 스크립트에는 종료가 멈춘 Pod를 처리하는 안전장치가 필요하다. 기다리는 동안 일정 시간 이상 Terminating인 Pod를 API에서 강제로 지우고, 잔존 Application 이름과 Pod가 떠 있는 노드를 로그로 남기는 방식이다. 강제 삭제는 API 객체만 지울 뿐 노드의 좀비는 그대로 남기 때문에, 로그에 노드를 남기는 쪽이 오히려 중요하다. 기존 로그는 잔존 Application의 개수만 찍어서 어떤 앱이 막혔는지 알 수 없었다.