새 테넌트 하나를 온보딩하면 앱 19개, Pod 60개가 한꺼번에 뜬다. 이 60개가 전부 Running으로 안정될 때까지 길게는 40분이 걸렸다. 지켜보고 있으면 Pod가 Running이 됐다가 Terminating으로 넘어가고, 다른 노드에서 처음부터 다시 뜨기를 되풀이했다. 하나의 서비스를 여러 고객사가 각자 쓰도록 바꾸는 멀티 테넌트 SaaS 전환 프로젝트였고, 고객사가 하나 붙을 때마다 이 40분을 기다려야 했다.

원인은 Karpenter의 노드 정리(consolidation)였다. 정리를 완전히 막자 같은 배포가 7분에 끝났다. 여기서 정리를 꺼 버리면 이야기는 끝나지만, 정리는 비용을 줄이려고 켜 둔 기능이다. 그래서 “정리가 왜 아팠는가”를 한 번 더 물었고, 40분을 만든 건 정리 자체가 아니라 정리가 끼어드는 타이밍이었다. 타이밍을 고친 뒤의 실험에서는 정리 한 번에 같은 앱의 Pod가 몇 개씩 쓸려 가느냐가 따로 드러났다. 측정은 전부 트래픽이 없는 dev 클러스터에서 했다.

의심 가는 게 세 개였다

처음에는 40분이 세 가지가 겹친 결과라고 봤다.

  1. 이중 배포. 새 테넌트는 매니페스트의 시드 태그로 먼저 뜨고, 곧바로 Image Updater가 최신 태그를 찾아 Blue-Green으로 한 번 더 배포한다. 사실상 두 번 배포하는 셈이다.

  2. 느린 기동. 앱이 Spring Boot라 Pod가 생성되고 Ready가 되기까지 약 2분 47초가 걸렸다. 노드 배치 시점을 0으로 두고 다른 날 따로 잰 기록에서는 이미지 pull이 16초, JVM 시작이 47초, Spring 컨텍스트 기동이 65초 시점에 끝났고, Tomcat 기동과 Ready 판정은 그 뒤였다.

  3. Karpenter의 노드 정리. 덜 쓰이는 노드를 비우고 지우는 동작이다. 노드를 지우려면 그 위의 Pod를 옮겨야 하고, 옮긴다는 건 Pod를 종료하고 다른 노드에서 새로 띄운다는 뜻이다.

2번은 혼자서는 문제가 아니다. 2분 47초짜리 Pod가 한 번 뜨고 끝나면 된다. 기동이 느린 앱에서는 Pod 이동 한 번 한 번이 비싸고, 3번이 2번을 여러 번 일으키면 시간이 곱으로 늘어난다.

팀에서 먼저 손댄 것도 3번이었다. 당시 NodePool은 consolidateAfter: 1m이었는데, 부하테스트 때 노드 비용을 줄이려고 낮춰 둔 값이다. 팀원이 이 값을 늘려 봤지만 효과가 없었다고 했다. 방향은 맞는데 효과가 없으니 정말 정리가 원인인지부터 확신할 수 없었다.

정리를 완전히 막고 같은 배포를 다시 했다

추측을 더 쌓기보다 의심 가는 기능을 끄고 재 보기로 했다. NodePool의 disruption budget을 0으로 두면 Karpenter는 어떤 노드도 자발적으로 지우지 못한다.

# 한 번에 정리할 수 있는 노드 수를 0으로: 정리를 완전히 차단
kubectl patch nodepool <name> --type merge \
  -p '{"spec":{"disruption":{"budgets":[{"nodes":"0"}]}}}'

이 상태로 새 테넌트를 하나 배포했다.

5시 34분  첫 Pod 생성 시작
5시 38분  시드 태그(1.0.1) 60개 배포 완료
5시 41분  Image Updater가 최신 태그로 Blue-Green까지 마치고 전부 Running
조건소요 시간
기존 설정 (consolidateAfter: 1m)약 40분
정리 완전 차단 (budgets.nodes: "0")약 7분

이중 배포도 그대로였고 2분 47초 기동도 그대로였다. 정리만 막았는데 7분이 됐다. 40분 중 33분이 노드 정리로 인한 반복 재기동이었다. 나머지 두 의심은 사실상 무죄였다.

다만 nodes: "0"은 완전히 빈 노드도 지우지 못하니 운영에 쓸 수 없다. 원인을 가리는 데 쓰는 도구다.

consolidateAfter는 정리 주기가 아니라 대기 타이머다

팀원이 값을 늘렸는데 왜 효과가 없었을까. consolidateAfter의 정의를 다시 읽었다. 노드에 Pod가 추가되거나 빠지면 타이머가 다시 시작되고, 변동 없이 이 시간이 지나야 그 노드가 정리 대상이 된다.

1분이면 거의 모든 노드가 배포 도중에 대상이 된다. Pod가 다 뜨기도 전에 정리가 끼어들고, 쫓겨난 Pod가 다른 노드에서 다시 뜨면 노드 배치가 또 바뀐다.

flowchart TD
    A["배포 시작: Pod 60개 스케줄"] --> B["노드 증설"]
    B --> C["일부 Pod Running"]
    C --> D["1분 뒤 Karpenter:<br/>'이 노드는 덜 쓰인다'"]
    D --> E["노드 비우기, Pod 종료"]
    E --> F["Pod 재기동 (2분 47초)"]
    F --> G["노드 배치가 다시 바뀜"]
    G --> D

반대로 이 값을 한 번의 배포 시간보다 충분히 길게 잡으면, 배포하는 동안에는 Pod가 오갈 때마다 타이머가 리셋되고 정리는 배포가 끝난 뒤에야 시작된다. 배포가 7분 남짓이니 10분으로 잡았다. 팀원이 처음 늘린 값은 배포 시간보다 짧았던 것으로 보는데, 이건 재현하지 않은 추정이다.

# Karpenter NodePool (Terraform)
disruption = {
  consolidationPolicy = "WhenEmptyOrUnderutilized"  # 덜 쓰이는 노드도 정리. 그대로 유지
  consolidateAfter    = "10m"                       # 1m에서 변경. 배포 중에는 타이머가 계속 리셋된다
}

정책은 WhenEmptyOrUnderutilized로 그대로 뒀다. 바꾼 건 기다리는 시간 하나다.

배포가 끝나자 Pod가 다시 움직이기 시작했다

10분으로 두고 다시 지켜보니 배포 중의 반복 재기동은 사라졌다. 그런데 배포가 끝나고 10분쯤 지나자 Karpenter가 저활용 노드를 정리하며 Pod를 옮기기 시작했다. 한 번으로 끝나지도 않고 몇 분 간격으로 몇 차례 더 돌았다.

disrupting node(s) reason:underutilized decision:delete pod-count:9
disrupting node(s) reason:underutilized decision:delete pod-count:8

이게 정상인지 헷갈렸다. 한 번 정리하고 끝나야 하는 게 아닌가. 이렇게 Pod가 옮겨 다니는 걸 줄일 수는 없을까. 노드를 더 촘촘히 쓰면 정리할 노드 자체가 줄어들 거라고 생각했다.

배치 규칙을 풀었더니 8개가 노드 2개에 몰렸다

앱 매니페스트에는 같은 앱의 Pod를 가용 영역과 노드에 강제로 흩어 놓는 규칙이 있었다.

# 앱 매니페스트 (Pod 배치)
topologySpreadConstraints:
  - maxSkew: 1
    topologyKey: topology.k8s.aws/zone-id
    whenUnsatisfiable: DoNotSchedule   # 규칙을 못 지키면 스케줄하지 않는다
    labelSelector:
      matchLabels:
        app: ${TENANT_ID}-${APP}
  - maxSkew: 1
    topologyKey: kubernetes.io/hostname
    whenUnsatisfiable: DoNotSchedule
    labelSelector:
      matchLabels:
        app: ${TENANT_ID}-${APP}

이걸 ScheduleAnyway로 풀면 규칙은 선호 사항이 되고, 한 노드에 같은 앱 Pod가 여러 개 올라갈 수 있다. 노드를 아낄 수 있을 것 같아 전 앱을 바꿔 테스트했다.

결과는 예상대로였다. 노드를 확실히 덜 썼고, mo-back Pod 8개가 노드 2개, 그것도 한쪽 가용 영역에 4개씩 몰렸다. 문제는 그다음이었다. Karpenter가 그 2개 노드를 저활용으로 판정해 정리하는 순간 mo-back 8개가 한꺼번에 쫓겨났다. 8개가 동시에 2분 47초씩 재기동하는 동안 서비스가 끊겼다.

Readiness probe failed: HTTP probe failed with statuscode: 503
FailedGetContainerResourceMetric: pods might be unready

503과 500이 떴고 HPA는 메트릭조차 읽지 못했다. Karpenter 로그에는 이런 줄도 있었다.

pod(s) have a preferred TopologySpreadConstraint which can prevent consolidation

ScheduleAnyway로 바꾸면 가용성을 잃는 데서 그치지 않고, 선호 규칙이 된 분산 조건이 Karpenter의 정리 계산까지 흐린다. 이 로그를 꼼꼼히 읽은 건 한참 뒤였다.

flowchart LR
    subgraph BAD["몰림 (ScheduleAnyway)"]
        direction TB
        x1["노드 1: mo-back ×4"]
        x2["노드 2: mo-back ×4"]
        x3["노드 1, 2 정리<br/>8개 전부 재기동, 서비스 중단"]
        x1 --> x3
        x2 --> x3
    end
    subgraph GOOD["분산 (DoNotSchedule)"]
        direction TB
        y1["노드 1~8: mo-back ×1씩"]
        y2["노드 하나 정리<br/>1개만 재기동, 나머지 7개가 서비스"]
        y1 --> y2
    end

40분과는 별개로, 정리가 서비스를 끊느냐는 같은 앱의 Pod가 소수 노드에 몰려 있느냐에 달려 있었다. Pod가 노드마다 하나씩 흩어져 있으면 노드 하나가 지워져도 앱마다 Pod 하나씩만 옮겨진다. 나머지가 트래픽을 받는 동안 옮겨진 하나가 다시 뜨니 충격이 쪼개진다.

그래서 DoNotSchedule로 되돌렸다. 운영 중에 Pod가 옮겨 다니는 것 자체는 어느 쪽이든 일어난다. 몰려 있으면 한 번에 무더기로 튕기고, 흩어져 있으면 하나씩 튕긴다. 노드 몇 개를 아끼자고 앞의 것을 감수할 이유가 없었다.

마지막 Pod 하나는 PDB가 지킨다. 앱마다 minAvailable: 1을 두어 노드가 비워질 때도 최소 한 개는 살아 있게 했다. 이 설정은 노드 정리가 건드리지도 않은 앱을 재시작시킨 일에서 나왔고, 앱 하나를 껐는데 옆 앱이 재시작됐다에서 따로 다룬다.

최종 설정

영역설정목적
Pod 배치 (가용 영역, 노드)whenUnsatisfiable: DoNotSchedule같은 앱 Pod를 강제로 분산. 몰림 방지
Karpenter 정책WhenEmptyOrUnderutilized정리 기능 유지(비용 절감)
Karpenter 대기consolidateAfter: 10m배포 중에는 타이머가 계속 리셋돼 정리가 시작되지 않음
Karpenter 상한미설정(기본 10%)실측상 대부분 한 개씩 삭제됨
PDBminAvailable: 1마지막 Pod 보호
startupProbeinitialDelay 60 / period 10 / failureThreshold 30Ready 판정이 늦어지는 꼬리 제거
HPA scaleUp 안정화45초 (트래픽 민감 앱 1개만 10초)기동 직후 CPU 급등을 실제 부하로 오인하는 것 방지

표의 마지막 두 줄은 배포 시간을 다듬은 부수 설정이다. 배포 중에 Pod 수가 4, 6, 4로 출렁였는데, JVM이 Ready 직후 30~60초 동안 JIT 워밍업으로 CPU를 많이 쓰고 HPA가 이걸 실제 부하로 보고 Pod를 늘렸다가 줄인 것이었다. scaleUp 안정화 창을 45초로 두면 그보다 짧은 급등은 무시된다.

검토하고 채택하지 않은 설정들

정리 때문에 아팠으니 정리를 약하게 만드는 후보가 여럿 나왔다. 분산만 해 두면 정리가 무해하다는 걸 확인한 뒤에는 대부분 필요 없어졌다.

후보채택하지 않은 이유
WhenEmpty로 바꿔 Pod 이동 자체를 막기분산만 해 두면 정리가 무해함을 확인. 끄면 비용 절감(노드 37 → 28)을 잃음
budgets: nodes: "1"로 한 번에 하나만기본 10%에서도 실측상 대부분 하나씩 삭제됨. 정리 속도만 늦춰 부하 급증 뒤 비용이 늘어남
새벽 시간에만 정리 허용낮에 정리해도 서비스가 유지됨을 확인
배치 규칙을 ScheduleAnyway로 완화몰림을 만들어 가용성을 잃고, Karpenter의 정리 계산도 방해함
여유 노드를 미리 띄워 두기비용 사유로 비활성

nodes: "1"은 동시 삭제가 실제로 문제가 되면 그때 조이기로 했다. 지금 조이면 막는 것보다 잃는 게 크다.

2분 간격으로 본 세 번째 테넌트 온보딩

최종 설정이 정말 괜찮은지는 숫자로 봐야 했다. 테넌트 2개가 돌고 있는 dev 클러스터에 세 번째 테넌트를 온보딩하며 약 2분 간격으로 Pod와 노드 수를 기록했다.

경과새 테넌트 Pod (Running/전체)클러스터 노드 수일어난 일
0분0 / 031배포 전
2분14 / 6029배포 시작
4분37 / 6034노드 증설
6분60 / 60371차 배포 완료
11분83 / 10739최신 이미지로 Blue-Green 시작(Pod 일시 증가)
13분90 / 9238Blue-Green 진행, 노드 정리 시작
15분60 / 6035Blue-Green 완료
22분60 / 6434노드 1개 정리, Terminating 4개
24분60 / 6033노드 1개 정리
28분60 / 6033변화 없음(수렴)
31 → 29 → 34 → 37 → 39(최대) → 38 → 35 → 34 → 33 → 33 (수렴)
            증설 ──┘  └── Blue-Green 정점   └──── 하나씩 회수 ────┘

시드 태그 60개는 6분에 전부 Running이 됐다. 이번에는 Image Updater의 두 번째 배포가 11분에 시작해 15분에 끝났다. 두 번의 배포 사이에 노드 정리가 끼어들어 Pod를 다시 띄우는 일은 없었다.

이 표에서 확인한 것은 세 가지다.

  1. Running이 한 번도 60 아래로 내려가지 않았다. 22분과 24분에 노드가 정리되며 Terminating이 4개까지 보였지만 Running은 그대로였다. 정리 중에도 서비스가 유지된다.

  2. 노드는 한 번에 하나, 많아야 둘씩 지워졌다. 상한이 10%여도 Karpenter는 “이 노드를 지워도 Pod가 다른 곳에 들어가는가”를 매번 계산하고 조심스럽게 지운다. budget은 목표치가 아니라 상한이다.

  3. 정리는 멈췄다. 39에서 33까지 여러 번에 걸쳐 줄고 24분 이후로는 변화가 없었다.

13분에는 Blue-Green 도중에 노드 정리가 시작됐다. consolidateAfter 타이머는 노드마다 따로 돌기 때문에, 새 테넌트의 Pod가 오가지 않은 노드라면 배포 중에도 대상이 될 수 있다. 그래도 Running은 줄지 않았다.

처음 보면 문제처럼 보이는 정상 동작도 정리해 뒀다. 운영팀이 같은 화면을 보고 다시 장애로 오해하지 않도록 넘긴 표다.

보이는 현상실제
같은 앱 Pod가 더 떴다가 사라진다Blue-Green에서 구버전과 신버전이 잠시 공존
안정된 뒤 Pod가 또 움직인다노드 정리로 인한 재배치. 소수만 이동
노드가 여러 번에 걸쳐 줄어든다하나씩 회수하는 정상 과정. 곧 멈춤

아직 남은 문제

  • Pod가 옮겨질 때 그 앱의 처리 용량은 잠깐 줄어든다. 분산과 PDB로 중단은 막지만, 옮겨진 Pod가 2분 47초 동안 다시 뜨는 사이에는 Pod 하나만큼 용량이 빈다. 트래픽이 높은 순간과 겹치면 체감될 수 있다.

  • PDB minAvailable: 1은 replicas가 1인 앱에서 노드를 못 비우게 만든다. 허용 중단이 0이 되어 그 노드는 정리도, 교체도 막힌다. replicas를 1로 운영하는 환경이라면 maxUnavailable: 1이 맞다.

  • 측정 범위가 좁다. dev 클러스터, 테넌트 3개, 트래픽 없는 상태에서만 쟀다. consolidateAfter: 10m이 모든 규모에서 충분한지, 운영 트래픽이 있을 때 영향이 어떤지는 아직 모른다. 테넌트가 늘면 NodePool의 CPU·메모리 한도와 동시 온보딩도 다시 봐야 한다.

  • 진짜 뿌리는 2분 47초다. Pod 이동이 비싼 건 기동이 느려서다. 기동이 몇 초로 줄면 정리가 종일 돌아도 아무도 모를 것이고, 이런 노드 정책 튜닝 자체가 필요 없어진다. 이미지와 앱 영역의 일이라 숙제로 남겼다.