테스트 테넌트에서 앱 하나를 비활성화했다. 그 앱의 Pod는 예상대로 사라졌다. 그런데 몇 분 뒤, 손대지 않은 다른 앱의 Pod가 Terminating으로 넘어가더니 Init부터 다시 올라왔다. 한 번이 아니라 앱을 끌 때마다 반복됐다.
하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트에서, 테넌트에 줄 앱을 관리자 화면의 토글로 켜고 끄는 기능을 만들던 중이었다(기능 자체는 앱 목록을 정하는 자리를 한 계층 위로 옮겼다에서 따로 다룬다). 개발 환경이라 서비스가 끊겨도 큰일은 없었다. 하지만 운영에서 운영사 운영팀이 앱 하나를 내렸는데 다른 서비스가 몇 분씩 흔들린다면 그건 기능이 아니라 사고다.
범인은 배포 도구가 아니었다. 이건 Pod 단위 사건이 아니라 노드 단위 사건이었다. 이 글에서는 ArgoCD를 의심하며 구조까지 바꿨다가 Karpenter의 노드 정리(consolidation)에 닿기까지의 과정과, 그 뒤에 고친 것과 되돌린 것을 적는다.
ArgoCD가 전체를 다시 훑는다고 생각했다
테넌트 하나의 앱들은 ArgoCD ApplicationSet 하나로 묶여 있었다. 앱 목록이 바뀌면 ApplicationSet이 묶인 Application 전체를 다시 계산한다. 그 과정에서 바뀌지 않은 앱까지 건드리는 게 아닐까. 배포 계층에서 앱 목록을 바꿨고 그 직후 다른 앱이 흔들렸으니, 배포 계층부터 의심했다.
ArgoCD가 끼어드는 폭을 하나씩 줄여 갔다.
| # | 시도 | 결과 |
|---|---|---|
| 1 | ApplicationSet을 --upsert로 앱 목록만 바꿔 갱신 | 다른 앱 Pod 재시작 |
| 2 | kubectl patch로 ApplicationSet 항목만 수정 | 다른 앱 Pod 재시작 |
| 3 | ApplicationSet을 버리고 앱마다 개별 Application으로 분리 | 다른 앱 Pod 재시작 |
| 4 | git에서 앱 디렉터리 삭제 + kubectl delete만 수행 (ArgoCD 미접촉) | 다른 앱 Pod 재시작 |
3번은 꽤 큰 변경이었다. 앱 하나가 Application 하나면 서로 독립이니, 한 앱을 지워도 다른 앱은 건드릴 이유가 없다고 봤다. 그래도 재시작됐다.
4번에서는 ArgoCD를 아예 손에서 놨는데도 같은 증상이 나왔다.
flowchart TD
S["증상: 다른 앱 Pod 재시작"] --> H1["가설: ApplicationSet이 전체를 다시 훑는다"]
H1 --> T1["시도: 앱마다 개별 Application으로 분리"]
T1 --> R1["결과: 여전히 재시작"]
R1 --> T2["시도: ArgoCD 없이 kubectl로 직접 삭제"]
T2 --> R2["결과: 여전히 재시작"]
R2 --> K["ArgoCD는 원인이 아니다. 배포 계층 아래를 봐야 한다"]
K --> E["노드 이벤트 확인"]
E --> F["Karpenter가 노드를 회수하고 있었다"]ArgoCD를 손에서 놓고도 증상이 그대로라면, ArgoCD는 그 시점에 후보에서 지워야 한다. 배포 계층을 어떻게 바꿔도 재현된다는 건 원인이 그 아래, 클러스터나 노드 쪽에 있다는 뜻이다.
노드 이벤트에 답이 있었다
ArgoCD 화면을 내려놓고 클러스터 이벤트를 시간순으로 뽑았다.
kubectl get events --sort-by=.metadata.creationTimestamp
이런 줄이 이어졌다.
DisruptionTerminating Disrupting Node: Underutilized
FailedDraining Failed to drain node, 4 pods are waiting to be evicted
DisruptionBlocked Pdb prevents pod evictions
Shutdown Shutdown manager detected shutdown event
NodeNotReady Node ... status is now: NodeNotReady
DeletingNode Deleting node ... because it does not exist in the cloud provider
Disrupting Node: Underutilized. Karpenter가 노드를 “덜 쓰인다”고 판단해 비우고 있었다. NodePool 설정은 이랬다.
spec:
disruption:
consolidationPolicy: WhenEmptyOrUnderutilized # 빈 노드뿐 아니라 덜 쓰이는 노드도 정리
consolidateAfter: 5m # Pod 변동이 멈추고 5분 뒤 판단
참고로 처음에는 습관대로 kubectl get provisioner를 쳤다가 그런 리소스가 없다는 답을 받았다. Karpenter v1부터는 Provisioner 대신 NodePool과 NodeClaim을 쓴다.
내가 지운 건 Pod였고, Karpenter가 지운 건 노드였다
Karpenter는 Pod가 들어갈 자리가 없으면 노드를 새로 띄우고 노드가 남으면 지운다. 노드를 줄이는 쪽이 노드 정리다. WhenEmptyOrUnderutilized는 완전히 빈 노드뿐 아니라 덜 쓰이는 노드도 정리 대상으로 본다. 그런데 Kubernetes에는 Pod를 살려 둔 채 다른 노드로 옮기는 방법이 없다. 노드를 비우면 그 위의 Pod는 종료되고, 다른 노드에서 처음부터 다시 뜬다.
사건을 순서대로 놓으면 이렇다.
sequenceDiagram
participant O as 나
participant N as 노드 X
participant K as Karpenter
O->>N: 앱 A 비활성화 (Pod 제거)
Note over N: 노드 X 사용률 하락<br/>앱 B, C의 Pod는 남아 있음
K->>K: consolidateAfter(5m) 경과
K->>N: "노드 X는 덜 쓰인다" → drain 시작
N-->>N: 앱 B, C의 Pod 종료
Note over N: 앱 B, C가 다른 노드에서 재기동내 변경은 다른 앱을 직접 건드리지 않았다. 앱 A가 빠지면서 노드 X에 빈자리가 생겼고, Karpenter가 그 빈자리를 정리하는 과정에서 같은 노드에 있던 앱 B와 C가 함께 옮겨졌다. “어떤 앱이 재시작되는가”가 매번 달랐던 것도 이걸로 설명된다. 재시작되는 앱은 내가 끈 앱과 같은 노드에 우연히 있던 앱이었다.
ArgoCD를 아무리 쪼개도 소용없었던 이유도 같다. 배포 쪽을 어떻게 나누든, 노드가 통째로 비워지면 그 위의 Pod는 함께 내려간다.
옮겨진 Pod가 금방 돌아오지도 않았다. 이 프로젝트의 앱은 전부 Spring Boot였고, Pod 하나가 새로 만들어져 다시 트래픽을 받을 수 있는 상태(Ready)가 되기까지 2분 47초쯤 걸렸다. 이와 별개로 다른 날 노드 배치 시점을 0초로 두고 시각을 찍어 본 기록이 있는데, 이미지 pull은 16초에 끝났고 JVM은 47초에 시작했으며 Spring 컨텍스트는 65초에 올라왔다. Tomcat 기동과 Ready 판정은 그다음이다. 노드 하나가 정리될 때마다 그 위의 앱들은 이만큼씩 흔들린다.
정리는 끄지 않고, 마지막 Pod만 지켰다
정리 정책을 WhenEmpty로 바꾸거나 consolidateAfter를 30분쯤으로 늘리는 선택지도 적어 두었다. 다만 노드 정리는 비용을 줄이려는 정상 동작이라 사건 직후에는 정리 설정을 건드리지 않았다. 보장해야 하는 건 “노드가 움직이지 않는다”가 아니라 “노드가 어떻게 움직이든 서비스는 끊기지 않는다”였다.
Pod가 쫓겨날 때 지킬 선을 정하는 건 PodDisruptionBudget(PDB)이다. 테넌트 템플릿의 PDB는 이렇게 되어 있었다.
# saas-app/tenants/default/common/pod-disruption-budget.yaml
spec:
maxUnavailable: 1 # 한 번에 하나까지는 내려가도 된다
maxUnavailable: 1은 replicas가 넉넉할 때는 괜찮다. 문제는 replicas가 1인 앱이었다.
| replicas | maxUnavailable | 최소 가용 Pod | drain 시 eviction |
|---|---|---|---|
| 4 | 1 | 3 | 1개 가능 |
| 2 | 1 | 1 | 1개 가능 |
| 1 | 1 | 0 | 유일한 Pod도 가능 → 서비스 중단 |
replicas 1에서 maxUnavailable: 1은 “0개가 되어도 된다”와 같은 말이다. 그래서 기준을 “몇 개까지 죽어도 되나”에서 “몇 개는 반드시 살아 있어야 하나”로 바꿨다.
# ❌ 전: replicas가 1이면 0개가 될 수 있다
spec:
maxUnavailable: 1
# ✓ 후: 최소 한 개는 살아 있어야 한다
spec:
minAvailable: 1
검증은 가장 불리한 조건에서 했다. 테스트 네임스페이스의 모든 Rollout replicas를 1로 내리고 HPA minReplicas도 1로 맞췄다. ArgoCD auto-sync는 꺼서 손으로 바꾼 값이 되돌아가지 않게 한 뒤 노드 회수를 유도했다.
| PDB 설정 | replicas | Karpenter drain 시 | 결과 |
|---|---|---|---|
| maxUnavailable: 1 | 1 | 유일한 Pod eviction 가능 | Pod 0개, 서비스 중단 |
| minAvailable: 1 | 1 | PDB 위반으로 eviction 차단 | Pod 1개 유지 |
템플릿 파일은 고쳤으니 새 테넌트에는 반영된다. 이미 떠 있는 테넌트에는 아래 명령으로 일괄 적용했다.
# 네임스페이스의 모든 PDB에서 maxUnavailable을 빼고 minAvailable: 1을 넣는다
for PDB in $(kubectl get pdb -n <tenant-id> -o name); do
kubectl patch $PDB -n <tenant-id> --type json \
-p '[{"op":"remove","path":"/spec/maxUnavailable"},{"op":"add","path":"/spec/minAvailable","value":1}]'
done
JSON Patch로 remove와 add를 같이 보낸 건 PDB에 maxUnavailable과 minAvailable을 동시에 둘 수 없기 때문이다. merge patch로 minAvailable만 넣으면 검증에서 거절된다.
PDB 한 줄을 고치기까지 며칠이 걸렸고, 그 며칠은 거의 다 ArgoCD 쪽을 파는 데 썼다.
틀린 근거로 바꾼 구조는 되돌렸다
남은 일이 하나 있었다. ArgoCD가 원인이라고 생각해서 ApplicationSet을 앱별 개별 Application으로 쪼개 둔 상태였다. 그렇게 쪼갠 근거는 “ApplicationSet이 전체를 다시 훑어서 다른 앱을 건드린다”는 가설 하나였고, 이제 그 가설이 틀렸다는 걸 알았다.
그대로 두면 몇 달 뒤 누군가 “테넌트 앱은 왜 ApplicationSet이 아니라 Application 수십 개로 관리하지?”라고 묻게 되고, 그 답은 “예전에 잘못 짚었는데 안 되돌려서”가 된다. ApplicationSet은 테넌트 앱들을 한 곳에서 관리하는 원래 구조였고 문제도 없었다. 앱별 Application을 다시 ApplicationSet 하나로 합쳤다.
가설을 부정하는 증거(4번 시도)가 나온 시점에 멈췄다면 3번의 구조 변경도, 그걸 되돌리는 일도 없었을 것이다. 이 부분은 디버깅에서 가장 비싼 건 틀린 가설을 붙들고 있는 시간이다에서 따로 다룬다.
아직 남은 문제
PDB 한 줄로 마지막 Pod는 지켰지만 이걸로 끝은 아니었다.
replicas 1 +
minAvailable: 1은 노드를 비울 수 없게 만든다. 허용 중단이 0이 되니 그 Pod가 있는 노드는 drain이 막힌다. 정리뿐 아니라 노드 교체 작업도 같이 막힌다. 이 부작용은 CPU는 노는데 노드는 꽉 찬 클러스터에서 다룬다.replicas가 많은 앱에서는 오히려 느슨해졌다. replicas 4에서
maxUnavailable: 1은 한 번에 1개만 허용했지만minAvailable: 1은 3개까지 허용한다. 같은 앱의 Pod가 한 노드에 몰려 있으면 노드 하나가 정리될 때 여러 개가 같이 내려갈 수 있다.PDB에도 시간 제한이 있을 수 있다. 당시 이벤트에는
Pdb prevents pod evictions뒤에TerminationGracePeriodExpiring이 이어졌다. Karpenter v1의 NodePool에terminationGracePeriod가 있으면 그 시간이 지난 뒤에는 PDB와 관계없이 Pod를 내보내고 노드를 지운다.
같은 정리 동작은 다른 얼굴로 한 번 더 나타났다. 새 테넌트를 처음 배포하면 60개 Pod가 안정되기까지 40분이 걸렸는데, 그중 대부분이 배포 도중 끼어든 노드 정리로 인한 반복 재기동이었다. 정리가 끼어드는 타이밍을 늦춘 과정과, 그 뒤 실험에서 2번의 “몰림”이 정리 한 번의 충격을 키운다는 걸 확인한 이야기는 테넌트 배포 40분을 7분으로 줄이고도 노드 정리는 끄지 않았다에서 이어진다.