AWS

EKS + Karpenter 무중단 업그레이드 — 노드 잔존 문제부터 검증까지

Somaz 2026. 7. 16. 00:00
728x90
반응형

Overview

prod EKS 클러스터(`prod-app-v1`, eu-central-1, Karpenter ON_DEMAND ARM c7g NodePool)에서 `kubectl rollout restart` 한 번에 노드가 한 대 늘었다가 영영 줄지 않는 문제를 만났다. 단순한 오토스케일러 이슈처럼 보였지만, 추적해보니 Deployment 배포 설정 + memory request 과대 + Karpenter consolidation 정책이 맞물려 만든 구조적 결과였다.

 

이 글은 그 문제를 무중단으로 끝내기까지의 전 과정을 정리한다. 단순히 노드를 줄이는 게 목표가 아니라, 롤링·재시작·consolidation 어느 경로에서도 502 없이, Ready 파드가 0이 되지 않게 만드는 것이 목표였다.

 

 

다루는 순서는 이렇다.

  1. 문제 진단 — 왜 노드가 안 줄어드는가
  2. 세션은 정말 끊기는가 — 앱 코드 분석
  3. Layer 1: Graceful Drain (preStop) — 종료 측
  4. Readiness Gate (ALB target-health) — 기동 측
  5. RollingUpdate 전략 — 규모별 조합
  6. Karpenter consolidation 전환 + PDB
  7. 앱별 적용 (app / admin-api / core-api / admin-web)
  8. 도메인 / API 동작 분석
  9. 검증 — 무중단·노드 회수·도메인 확인

마지막 검증 섹션이 이 작업의 핵심이다. "적용했다"가 아니라 "무중단이 실제로 지켜지는지"를 눈으로 확인하는 단계다.

 

 

 

 

 


 

1. 문제 — 재시작 후 노드가 줄지 않는다

`kubectl rollout restart deployment/app` 을 하면 노드가 한 대 더 떴다가, 롤링이 끝나도 회수되지 않고 잔존했다. 원인은 두 설정이 맞물린 결과다.

 

 

(1) app NodePool은 `WhenEmpty` 정책이다. NodePool이 `consolidationPolicy: WhenEmpty` 로 잡혀 있었다. 의도된 설계로, `ON_DEMAND` + `WhenEmpty` 조합으로 라이브 트래픽을 받는 파드를 Karpenter가 파드들을 최소 노드 수에 몰아 담는 것(bin-pack)하려고 함부로 축출하지 않도록 한 것이다. 단점은 노드에 파드가 단 하나라도 남으면 절대 회수하지 않는다는 점이다.

 

 

(2) Deployment가 `maxSurge: 1` 이다. `rollout restart` 는 기존 파드를 죽이기 전에 새 파드를 먼저 띄운다(surge). 그런데 app 파드의 memory request가 2Gi로 과대해서 기존 노드에 자리가 빠듯했고, surge가 파드 하나가 새 노드를 깨운다.

합쳐지면 롤링 종료 후 파드들이 기존 노드와 새 노드에 걸쳐서(straddle) 배치된다. 두 노드 모두 완전히 비어 있지 않으니 WhenEmpty 로는 영원히 회수되지 않는다.

  • surge는 롤링 업데이트 중에 "원래 원하는 파드 수보다 잠깐 더 띄우는 것" 을 뜻한다. 직역하면 "급증/넘침"인데, 여기선 기존 파드를 죽이기 전에 새 파드를 먼저 추가로 띄워서 일시적으로 파드 수가 정원을 초과하는 것이다.

 

 

 

재시작 직후 실측한 노드 상태다.

노드 인스턴스 mem request 사용률 비-시스템 파드
node-a c7g.xlarge 92% (6248/6933Mi) core-api, app×2, admin-web
node-b c7g.large 35% (1128/3209Mi) admin-api 1개만

 

 

`c7g.large` 가 admin-api 파드 단 하나 때문에 비워지지 않아 잔존했다. xlarge는 이미 92%라 admin-api를 옮길 자리도 없어서, `WhenEmptyOrUnderutilized` 로 바꿔도 합쳐지지 않는 실제 용량 부족 상태에 가까웠다.

 

워크로드 총량(app 2×2Gi + core-api + admin-api + admin-web ≈ 7.4Gi)이 c7g.xlarge 한 대(6.6Gi)에 안 들어가는 셈이다.

근본 원인은 app 파드의 memory request 2Gi 과대였다. 실측 idle 사용량은 파드당 ~101Mi로, 약 20배 과대 잡혀 있었다.

 

 

 

 

 

 

 


 

 

 

 

2. 세션은 정말 끊기는가(앱 분석)

"재시작하면 기존 유저 세션이 끊긴다"는 우려가 있었다. 그러나 게임 서버(Node/NestJS) 소스를 확인한 결과 사실상 끊기지 않는다.

항목 결과 의미
전송 방식 Stateless HTTP (Fastify), WebSocket/TCP 없음 끊길 long-lived 소켓이 애초에 없음
세션 auth 세션(accountId:sessionId), 상태는 Redis 파드 죽어도 세션 STATE 생존
SIGTERM 핸들러 없음 (`enableShutdownHooks()` 미호출) SIGTERM 시 in-flight 요청 즉시 드롭
CMD ["node", ...] exec form, base node:22-slim node가 PID 1로 SIGTERM 직접 수신

 

 

WebSocket이 아니라 stateless HTTP라, 파드를 죽여도 끊기는 건 그 찰나에 처리 중이던 HTTP 요청 몇 개뿐이다. 클라이언트는 다음 요청에서 살아있는 파드로 가 Redis 세션을 읽어 그대로 이어간다.

 

 

→ 진짜 원인은 세션 끊김이 아니라, 죽어가는 파드로 ALB가 아직 트래픽을 보내서 생기는 502다. 그렇다면 해법도 명확하다. "파드를 죽이기 전에 ALB에서 먼저 빼면 된다."

 

 

 

 

 

 

 


 

 

 

 

 

3. Layer 1 — Graceful Drain (preStop, 종료 측)

502 방지는 앱 코드 변경 없이 preStop sleep 하나로 해결된다. 앱이 SIGTERM을 처리하지 못해도 된다. 왜 단순한 sleep 이 통하는지는, 파드가 죽을 때 쿠버네티스와 ALB가 서로 상태를 모른 채 동시에 움직인다는 사실에서 출발한다.

 

 

왜 preStop 없이는 502가 나는가(비동기 경쟁)

파드를 지우면(롤링·스케일다운·재시작·노드 드레인 등) 두 가지 일이 병렬로, 비동기로 일어난다. 둘은 서로를 기다리지 않는다.

  1. kubelet → 컨테이너에 SIGTERM — 즉시 보낸다. 앱이 SIGTERM을 안 다루면 그 순간 프로세스가 죽고 이미 처리 중인(in-flight) 요청이 드롭된다.
  2. EndpointSlice에서 파드 제거 → AWS LB Controller가 ALB 타겟 그룹에서 그 IP를 deregister  이건 컨트롤 플레인을 거쳐 ALB API까지 전파돼야 해서 수 초가 걸린다. 전파가 끝나기 전까지 ALB는 그 파드 IP로 계속 트래픽을 보낸다.

 

문제는 1이 2보다 빠르다는 것이다. 앱은 이미 죽었는데 ALB는 아직 그 IP로 새 요청을 꽂는다 → 502 (Bad Gateway) / connection refused. 세션이 끊겨서가 아니라, "이미 죽은 파드로 ALB가 트래픽을 보내는 시간차" 가 문제의 원인이다.

[preStop 없음]
t0  파드 삭제
t0  ├─ SIGTERM ─────────────▶ 앱 즉시 종료  (in-flight 드롭)
t0  └─ endpoint 제거 시작 … ALB deregister 전파 (수 초 소요)
t0~tN   이 구간에 ALB가 죽은 IP로 보낸 요청 = 502  ◀── 문제

 

 

해결의 핵심은 단 하나다. "ALB가 그 IP를 다 뺄 때까지 파드를 죽이지 않고 살려 두는 것."  그걸 되게 하는게 preStop이다.

 

 

 

 

적용 — preStop sleep

terminationGracePeriodSeconds: 60
lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 35"]

 

 

 

파드 종료 라이프사이클 — 정확한 순서

쿠버네티스가 파드를 지울 때의 실제 순서를 알아야 위 값이 왜 그렇게 잡혔는지 보인다.

  1. 파드에 deletionTimestamp 가 찍히고 상태가 Terminating 이 된다.
  2. (병렬) EndpointSlice에서 파드가 빠지고 → ALB 타겟이 draining 으로 전환되기 시작한다.
  3. preStop 훅이 먼저 실행된다. 여기서 sleep 35 가 도는 동안 컨테이너는 아직 살아서 요청을 정상 처리한다. (SIGTERM은 아직 안 옴)
  4. preStop이 끝나야 비로소 SIGTERM 이 컨테이너에 전달된다.
  5. terminationGracePeriodSeconds 카운트다운이 끝나면 SIGKILL 로 강제 종료된다.

 

 

 

여기서 가장 헷갈리는 포인트 하나. terminationGracePeriodSeconds 는 preStop 시간을 포함한다.

 

즉 grace 60s · preStop 35s 면, SIGTERM 이후 앱이 스스로 정리할 시간은 60 - 35 = 25s 다. preStop이 grace보다 길면 preStop이 중간에 잘리고 곧장 SIGKILL이 떨어진다.

[preStop 있음 — grace 60 / preStop 35 / dereg 30]
t0       파드 Terminating
t0       ├─ endpoint 제거 → ALB deregister 시작 (draining)
t0       └─ preStop sleep 35s 시작  (파드 살아있음, 요청 정상 처리)
t0+30s      └ ALB deregistration_delay(30s) 경과 → 새 트래픽 0 
t0+35s   preStop 종료 → SIGTERM 전달  (이 시점엔 이미 트래픽 0이라 안전)
t0+35s~  앱 종료 (남은 25s 안에). 안 끝나면 t0+60s에 SIGKILL

 

 

 

 

왜 'sleep'만으로 충분한가(앱 코드가 필요 없는 이유)

preStop sleep의 역할은 "파드를 죽이는 시점을 ALB 디레지스터 완료 이후로 미루는 것" 뿐이다.

 

sleep이 도는 35초 동안 ALB는 그 IP를 타겟에서 빼고, 그 사이 새 요청은 살아있는 다른 파드로 라우팅된다. SIGTERM이 도착할 때쯤이면 이미 그 파드로 오는 트래픽이 0이라, 앱이 SIGTERM을 우아하게 처리하든 즉사하든 잃을 in-flight(이미 처리중인) 요청이 없다.

 

그래서 앱 코드(`enableShutdownHooks` 등) 없이도 502(Bad Gateway)가 사라진다.

 

 

 

 

deregistration_delay(connection draining)란

ALB ingress에 단 `deregistration_delay.timeout_seconds=30` 은 타겟을 뺄 때의 draining 시간이다.

 

타겟이 draining 으로 바뀌면 ALB는 새 요청은 즉시 끊고, 이미 처리 중인(in-flight) 요청은 최대 30초까지 기다렸다가 타겟을 완전히 제거한다. 기본값은 300초인데, 짧은 HTTP 요청만 받는 우리 앱엔 과해서 30초로 줄여 종료를 빠르게 했다.

 

 

 

지켜야 할 세 가지 시간 조건

값을 잡을 때 반드시 지켜야 하는 대소 관계다. 하나라도 어기면 무중단이 깨진다.

조건 이유 어기면
preStop sleep(35s) > ALB dereg(30s) 디레지스터가 끝날 때까지 파드를 살려 둬야 함 sleep이 짧으면 dereg 전에 파드가 죽어 다시 502
grace(60s) > preStop(35s) preStop이 잘리지 않고 끝까지 돌아야 함 preStop이 중간에 SIGKILL로 잘림
grace − preStop(25s) ≥ 앱 종료시간 SIGTERM 후 앱이 정리할 여유 정리 중 SIGKILL → 비정상 종료

 

여기에 전제 조건 하나. ingress가 `target-type: ip` 여야 한다. instance 타입이면 노드 단위 등록이라 파드 IP 드레이닝 개념 자체가 안 맞는다.

 

 

 

함정 — preStop은 만능이 아니다

  • best-effort(최선을 다하지만 보장은 못 한)다. 노드가 갑자기 죽거나(하드 크래시) SIGKILL 경로로 들어오면 preStop은 실행되지 않는다. 정상적인 voluntary disruption(롤링·드레인·consolidation)에서만 보장된다.
  • exec 는 셸 `/sleep` 바이너리가 이미지에 있어야 한다. distroless나 scratch 기반 이미지엔 `/bin/sh` 가 없어 ["/bin/sh","-c","sleep 35"] 가 실패한다. 그런 경우엔 sleep 정적 바이너리를 넣거나, `httpGet/sleep` 타입 preStop(쿠버 1.29+의 네이티브 sleep 액션)을 쓴다.
  • preStop 시간만큼 종료가 느려진다. 모든 파드 종료에 최소 35초가 붙으므로, 대량 스케일다운 땐 그만큼 길어진다. 그래도 502를 막는 비용으로는 싸다.

 

 

 

Layer 2 (선택, 후속) — 앱 레벨 graceful close

preStop은 "트래픽을 끊는" 쪽을 책임지지, "이미 들어와 처리 중인 요청을 끝까지 완료"하는 건 앱의 몫이다.

 

게임 서버 `main.ts` 에 `app.enableShutdownHooks()` + `Fastify graceful close(fastify.close())` 를 넣으면 SIGTERM 수신 시 새 연결을 거부하고 진행 중 요청을 마무리한 뒤 종료한다.

 

별도 PR/이미지 빌드가 필요하다. 다만 Layer 1(preStop)만으로 체감 문제의 80%는 해결되므로, Layer 2는 마지막 한 방울(드레인 도중 떨어지는 극소수 요청)을 위한 후속 과제로 둔다.

 

SIGTERM (15) — 정중한 종료 요청
"이제 그만 끝내줘"라고 부탁하는 시그널이야. 프로세스가 이걸 받으면 자기가 직접 처리할 수 있다.

  • 처리 중이던 요청 마무리
  • 열어둔 커넥션/파일 정리
  • 버퍼 flush, 임시 파일 삭제
  • 그 다음 스스로 종료

즉 프로세스가 잡아서(catch) 무시하거나, 미루거나, 정리 후 종료 같은 선택을 할 수 있어. 그래서 "graceful shutdown(우아한 종료)"이 가능한 거다.

 

 

SIGKILL (9) — 강제 즉시 종료
"묻지도 따지지도 않고 죽인다"야. 커널이 프로세스를 그냥 끊어버린다.

  • 프로세스가 잡을 수도, 무시할 수도, 미룰 수도 없음
  • 정리할 틈 없이 즉사
  • 처리 중이던 요청은 유실, 커넥션은 그냥 끊김, 임시 파일은 남을 수 있음

그래서 SIGKILL은 "SIGTERM 보냈는데도 안 죽을 때 쓰는 최후의 수단" 이다.

 

 

 

 

 

 

 


 

 

 

 

 

 

4. Readiness Gate — ALB target-health (기동 측)

preStop이 파드가 나가는 쪽(종료) 을 막는다면, readiness gate는 파드가 들어오는 쪽(기동) 을 막는다. 둘을 합쳐야 502가 양쪽에서 모두 사라진다.

 

 

502는 왜 생기나

새 파드가 readinessProbe 를 통과해 K8s에서 Ready가 되면, 곧바로 Service endpoint에 추가되고 롤링이 다음 단계(기존 파드 종료)로 넘어간다.

 

그런데 `target-type: ip` ALB는 그 파드의 IP를 타겟 그룹에 새로 등록해야 하고, 등록 직후 타겟은 initial 상태로 아직 ALB 헬스 체크를 통과하지 못한 상태다.

 

K8s는 Ready라고 보지만 ALB는 아직 트래픽을 보낼 준비가 안 된 창이 존재한다. 이 창에서 롤링이 기존 파드를 내려버리면 순간적으로 받을 수 있는 타겟이 부족해져 502가 난다. 스케일아웃·롤링 양쪽에서 발생한다.

target-type은 ALB(AWS Load Balancer Controller) 기준으로 ipinstance 두 가지다.

 

 

instance 타입
타겟 그룹에 노드(EC2 인스턴스) + NodePort를 등록하는 방식이야. 트래픽 경로가 한 단계 더 거친다.

ALB → 노드의 NodePort → kube-proxy → 파드

ALB는 파드 IP를 모르고 노드만 알아. 그래서 파드가 어느 노드에 떠도 NodePort로 들어오면 kube-proxy가 알아서 파드로 라우팅한다.

 

 

ip 타입
타겟 그룹에 파드 IP를 직접 등록하는 방식이야. 한 단계를 건너뛴다.

ALB → 파드 IP (직접)

VPC CNI 환경에서 파드가 VPC IP를 직접 받으니까 가능한 거고, kube-proxy/NodePort 홉이 없어서 지연도 낮고 트래픽 흐름이 깔끔하다.

 

 

 

 

Readiness Gate 동작

AWS Load Balancer Controller는 pod readiness gate를 제공한다. 네임스페이스에 라벨 하나를 달면 컨트롤러의 mutating webhook이 새 파드에 target-health readiness gate를 주입한다.

 

그러면 파드는 ALB 타겟이 실제 healthy가 될 때까지 NotReady로 유지되고, NotReady인 동안은 롤링이 다음 단계로 넘어가지 않는다 → 기존 파드가 먼저 안 내려간다 → 502 없음.

 

ArgoCD로 네임스페이스를 관리하므로, Application의 `syncPolicy.managedNamespaceMetadata` 로 라벨을 박았다.

        # AWS LB Controller의 pod-mutator webhook이 ALB target-health readiness gate를
        # 주입하도록 ArgoCD가 생성하는 네임스페이스에 라벨을 단다 (target-type: ip).
        # 새 파드를 ALB 타겟이 healthy가 될 때까지 NotReady로 잡아 둠 -> 롤링/스케일아웃 시 502 0건.
        # 이 네임스페이스를 공유하는 모든 앱이 동일 라벨을 받는다.
        managedNamespaceMetadata:
          labels:
            elbv2.k8s.aws/pod-readiness-gate-inject: enabled

 

 

라벨이 네임스페이스 단위라, 같은 plane의 4개 앱(app / admin-api / core-api / admin-web)이 한 번에 readiness gate를 적용받는다. 주의할 점이 셋 있다.

  • gate 주입은 webhook이 파드 생성 시점에 mutate(webhook이 파드 정의를 손대는 행위를)하는 방식이라, 라벨을 단 이후 새로 뜨는 파드부터 적용된다. 기존 파드는 재기동해야 gate가 붙는다.
  • `target-type: ip` 일 때만 의미가 있다(instance 타입은 노드 단위라 무관). 우리 ingress는 전부 ip라 조건 충족이다.
  • readinessProbe(앱 헬스)와는 별개의 추가 조건이다. 파드 Ready = readinessProbe 통과 AND ALB 타겟 healthy.

 

 

 

preStop과 readiness gate의 대칭

국면 막아야 할 것 막는 장치
파드 종료 (scale-in / 롤링 down) 죽는 파드로 가는 새 요청 preStop drain (sleep 35s)
파드 기동 (scale-out / 롤링 up) 아직 ALB 등록 안 된 새 파드로의 조기 전환 readiness gate

 

둘은 ALB 등록/해제의 반대편 끝을 각각 막는다. preStop만 있으면 들어오는 쪽이, readiness gate만 있으면 나가는 쪽이 뚫린다. 양쪽 다 있어야 롤링 전 구간이 무중단이 된다.

 

 

 

 

 


 

 

 

 

5. RollingUpdate 전략(규모별 조합)

preStop·readiness gate가 "한 파드를 안전하게 죽이고 살리는 법"이라면, strategy는 "여러 파드를 어떤 순서·속도로 교체할지"를 정한다. 두 파라미터가 서로 다른 축을 통제한다.

  • maxSurge = desired보다 몇 개 더 띄울지 → 노드 증가를 결정한다.
  • maxUnavailable = 동시에 몇 개 내려도 되는지 → 롤링 속도 + 용량 저하를 결정한다.
replica 권장 조합 무중단 롤링 중 용량 트레이드오프
2~3 maxSurge:1 + maxUnavailable:1 (절대값) ~100% 노드 잠깐 +1
10~20 maxSurge:0 + maxUnavailable:20~25% ~75% 노드 0 증가

둘 다 "서비스가 0개 되는 순간이 없다"는 의미의 무중단이고, 차이는 무엇을 트레이드하느냐이다.

  • 작은 규모(maxSurge:1) — new를 먼저 띄우고 old를 내리므로 용량이 거의 안 빠진다. 대신 파드가 잠깐 늘어 노드가 +1 될 수 있다.
  • 큰 규모(maxSurge:0) — old를 먼저 내리고 new를 만든다. surge 0이라 Karpenter가 새 노드를 0개 띄우고(기존 빈 슬롯으로 교체), straddle 문제 자체가 없다. 25%가 빠지지만 0은 아니다.

 

 

replica 20 + maxUnavailable:25% 면 5개씩 약 4배치로 수 분이면 끝난다. 절대값 1로 두면 20번 순차 교체라 15~25분이 걸린다. 반대로 replica 2에서 25%는 올림 1개 = 50% 손실이라 아프므로, 작은 replica는 절대값 1을 유지한다.

maxSurge 는 절대값 1로 둔다(퍼센트 금지).
maxSurge: 25% 면 replica 20일 때 surge 5개 → 노드 여러 대가 한꺼번에 떠서 우리가 피하려던 sprawl이 재현된다.
퍼센트로 키울 노드는 maxUnavailable 하나뿐이다.

 

 

 

 

 

 


 

 

 

 

 

 

 

6. Karpenter consolidation 전환 + PDB

수동 drain은 1회성 임시방편이고, 근본 예방책은 NodePool의 consolidation 정책 전환이다. 문제의 뿌리가 WhenEmpty(완전히 빈 노드만 회수)이기 때문이다.

 

원래 WhenEmpty 를 택했던 우려가 지금은 모두 무너졌다는 것이 전환의 근거다.

당시 우려 현재
SPOT reclaim 위험 app pool은 ON_DEMAND → reclaim 없음
consolidation이 라이브 세션 축출 stateless HTTP + Redis 세션 → 파드에 끊길 세션 없음
축출 시 요청 끊김 graceful drain(preStop) → in-flight 안전
축출 시 0개 되는 순간 PDB minAvailable:1 + replica 2 → 항상 1개 Ready

 

 

적용 내용은 두 가지다.

  1. NodePool → WhenEmptyOrUnderutilized + consolidateAfter: 5m — 저활용 노드를 능동적으로 합쳐 잉여 노드를 자동 회수한다. 5m 은 짧은 트래픽 출렁임에 과민 반응(churn)하지 않게 하는 여유다.
  2. app에 PDB minAvailable: 1 — consolidation이 한 번에 app 파드를 다 못 내리게 막아 항상 1개 이상 Ready를 보장한다.
 
podDisruptionBudget:
  enabled: true
  minAvailable: 1

 

 

여기서 안전장치의 역할 분리가 분명해진다.

  • graceful drain(preStop) → evict되는 그 파드의 in-flight(이미 처리중인) 요청을 끝까지 처리한다 (끊김 방지).
  • readiness gate → 교체로 새로 뜬 파드가 ALB 타겟이 healthy가 되기 전엔 트래픽을 안 받게 한다 (조기 전환 방지).
  • PDB + replica 2 → evict 중에도 살아있는 파드가 0이 되지 않게 보장한다 (가용성 유지).

 

→ 셋이 다 있어야 consolidation이 진짜 무중단이 된다. preStop만으론 새로 뜨는 파드의 등록 갭을, readiness gate만으론 죽는 파드의 in-flight를, PDB만으론 evict 파드의 요청을 각각 못 지킨다.

 

여기에 memory request 2Gi → 512Mi 적정화가 더해지면 bin-packing 자체가 개선돼 애초에 straddle이 생길 여지도 줄어든다.

 

 

 

 

 


 

 

 

 

 

7. 앱별 적용 — app / admin-api / core-api / admin-web

같은 plane의 admin-api / core-api / admin-web에도 동일한 ALB 502 위험이 잠재한다. 셋 다 replica 1, ingress는 app-service-alb 공유, deregistration_delay 미설정 → ALB 기본 300초였다

 

readiness gate는 네임스페이스 라벨 하나로 4개 앱에 동시 적용되므로 앱별 설정이 따로 필요 없다.

스택 전송 방식 앱 자체 SIGTERM drain preStop
app Fastify NestJS stateless HTTP 없음 ✅ grace 60 / sleep 35
admin-api Express NestJS stateless HTTP 없음 (app과 동일) ✅ grace 60 / sleep 35
core-api .NET ASP.NET Core stateless HTTP REST 기본 ~30s drain ✅ grace 75 / sleep 35
admin-web React/Refine SPA (vite preview) static, 짧은 요청   🟡 grace 60 / sleep 35
  • admin-api — 기본 Express 어댑터, WebSocket 게이트웨이 없음, SIGTERM 핸들러도 없어 app과 정확히 같은 상황이다. preStop 없으면 in-flight 드롭 + 502 → app과 동일 설정 적용.
  • core-api — 순수 HTTP REST(Hub/WebSocket/TcpListener 없음). ASP.NET Core는 SIGTERM 시 기본으로 in-flight를 ~30초까지 drain한다(Node에 없는 이점).
    • 단 앱 drain은 이미 들어온 요청만 처리하고 ALB가 새 요청을 죽는 파드로 보내는 건 못 막으므로 preStop이 여전히 필요하다. grace는 preStop(35s) + 앱 drain(~30s) = 75초.
  • admin-web — 정적 SPA(vite preview) 서빙. 요청이 짧고 상태가 없어 우선순위는 낮지만, replica 1 swap 시 502를 막기 위해 동일 preStop을 적용했다.

 

 

공통 적용 내역: 3개 ingress 모두 `deregistration_delay.timeout_seconds=30` 추가(기본 300s → 30s, app과 통일), `terminationGracePeriodSeconds` + `preStop sleep 35` 추가(core-api 만 grace 75),

 

네임스페이스에 readiness gate 라벨 부여. replica 1 서비스는 PDB 미적용(걸면 단일 파드를 못 내려 consolidation 자체가 막힌다)  전환용 주석만 남기고, replica ≥ 2로 올릴 때 활성화한다.

 

 

 

 

 

 


 

 

 

 

9. 검증

적용보다 중요한 게 검증이다. "무중단이 지켜지는가, 노드는 회수되는가, 도메인은 의도대로 동작하는가" 세 축으로 확인했다.

 

 

9-1. 무중단 검증 — 롤링/재시작 중 502 없는지

부하를 흘리며 `rollout restart` 를 돌려 끊김 여부를 본다.

# 0.2초 간격으로 app 헬스 체크, non-200만 출력
while true; do
  code=$(curl -s -o /dev/null -w "%{http_code}" https://app.example.com/health)
  [ "$code" != "200" ] && echo "$(date +%T) -> $code"
  sleep 0.2
done

 

 

 

다른 터미널에서 동시에 재시작한다.

kubectl rollout restart deployment/app -n app
kubectl rollout status  deployment/app -n app

 

 

기대 결과

  • preStop·readiness gate 적용 전에는 롤링 시점에 502 가 몇 줄 찍혔다.
  • 적용 후에는 non-200이 한 줄도 안 찍히면 성공이다. 종료 측은 preStop sleep(35s) > ALB deregistration(30s)으로, 기동 측은 readiness gate로 각각 막혀 502가 사라진다.
  • 종료 흐름 자체도 이벤트로 확인한다.
# 파드가 Terminating으로 들어가는 순간 ~ 사라질 때까지 추적
kubectl get pod -n app -w
# preStop hook 실행 여부는 describe의 Lifecycle/Events에서 확인
kubectl describe pod <terminating-pod> -n app | grep -A3 -i lifecycle

 

 

 

9-2. Readiness Gate 검증 — 새 파드가 ALB healthy 전까지 잡히는지

readiness gate가 주입됐고, 새 파드가 ALB 타겟 healthy 전에는 NotReady로 유지되는지 본다.

# READINESS GATES 열이 채워져 있어야 함 (예: 1/1)
kubectl get pod -n app -o wide

# 파드 컨디션에 target-health gate가 보이는지
kubectl describe pod <new-pod> -n app | grep -A5 -i "Readiness Gates"
# -> Readiness Gates:
#      target-health.elbv2.k8s.aws/...  True   (healthy 되면 True)

 

 

기대 결과

  • 스케일아웃/롤링으로 새 파드가 뜨는 순간, readinessProbe는 통과해도 ALB 타겟이 initial 인 동안 파드는 0/1 NotReady 로 잡혀 있어야 한다.
  • 타겟이 healthy로 바뀌면 비로소 Ready(1/1)가 되고, 그제서야 롤링이 다음 단계로 넘어간다.
  • 이 동안 9-1의 헬스 루프는 계속 200을 유지해야 한다. gate 컨디션이 아예 안 보이면 webhook 주입이 안 된 것이니, 네임스페이스 라벨과 target-type: ip 를 다시 확인한다.

 

 

 

9-3. 노드 회수 검증(양쪽 노드에 걸쳐 배치되면서 생긴 잉여 노드가 사라지는지)

consolidation 전환 + memory request 적정화가 실제로 노드를 줄이는지 본다.

# 재시작 직후 노드 수 / mem request 사용률
kubectl get nodes -l nodegroup-workload=app
kubectl top pod -n app                       # 실측 사용량(적정화 근거: ~101Mi)
kubectl describe node <node> | grep -A5 "Allocated resources"

# consolidation 동작은 Karpenter 컨트롤러 로그/이벤트로 확인
kubectl get nodeclaim
kubectl logs -n karpenter deploy/karpenter | grep -i consolidat

 

 

기대 결과

  • 재시작 직후 잠깐 노드가 +1 되더라도, `consolidateAfter: 5m` 경과 후 사용량이 낮은 노드가 자동 회수되어 원래 대수로 복귀한다.
  • 회수되는 노드의 app 파드는 PDB(minAvailable:1)에 의해 한 개씩 evict되며, 그 사이 헬스 체크 루프(9-1)는 계속 200을 유지해야 한다.
  • 즉 노드는 줄고 무중단은 유지되는 두 조건이 동시에 만족돼야 합격이다.

 

 

 

9-4. 도메인 / API 검증 — curl 실측

각 도메인의 `/health`, `/`, `/api-docs` 응답을 직접 찍는다.

for h in app admin-api admin-web core-api; do
  for p in /health / /api-docs; do
    code=$(curl -s -o /dev/null -w "%{http_code}" https://$h.example.com$p)
    echo "$h$p -> $code"
  done
done

 

 

 

실측 결과와 해석이다.

도메인 /health / (root) /api-docs 해석
admin-api 200 404 404 NestJS API — 루트 라우트 없음, Swagger는 prod 게이트(!IS_PRODUCTION)로 비활성. 의도된 동작
admin-web 200 200 200 React SPA — 모든 경로에 index.html 반환(catch-all). 200이라도 진짜 Swagger 아님
core-api 200 404 404 .NET REST — Swagger 미구현, 루트 라우트 없음. 정상
app 200 502 유일한 실제 장애 — /api-docs 타겟 미응답 추정, 별도 조사 대상

 

핵심은 404·"비어 보이는" 응답 대부분이 인프라 장애가 아니라 앱의 의도된 동작이라는 점이다.

 

admin-api는 보안상 prod에서 Swagger를 닫았고(NODE_ENV: prod),

core-api는 애초에 Swagger를 구현하지 않았으며,

admin-web은 SPA catch-all이라 어떤 경로든 화면을 돌려준다.

 

검증을 통해 진짜 고쳐야 할 것(app /api-docs 502) 하나만 남기고 나머지는 정상으로 분류할 수 있었다.

 

 

 

 

 

 

 


 

 

 

 

마무리

이번 무중단 업그레이드에서 얻은 가장 큰 교훈은, 노드가 안 줄어든 게 어떤 하나의 버그 때문이 아니었다는 점이다. 설정 여러 개가 맞물리면서 생긴 일이었다.

 

WhenEmpty 정책, 2Gi로 너무 크게 잡은 memory request, maxSurge:1 이 셋은 하나씩 떼어놓고 보면 다 말이 되는 설정이다. 그런데 셋이 한꺼번에 겹치니까, 노는 노드가 계속 남아서 사라지지 않는 상황이 벌어졌다.

 

해결의 핵심은 간단하다. 파드가 ALB에 붙고 떨어지는 양쪽 순간을 다 막아주는 것이다. 파드가 빠질 때는 preStop drain으로 막고, 새로 들어올 때는 readiness gate로 막아서 502가 안 나게 한다.

 

여기에 세 가지를 더 보탰다. strategy로 파드를 어떤 속도와 순서로 바꿀지 정하고, PDB로 살아 있는 파드가 너무 적어지지 않게 받쳐주고, memory request를 적당히 낮춰서 노드에 파드가 빡빡하게 몰리는 문제를 풀었다. 이 다섯 가지가 다 갖춰지고 나서야 비로소 NodePool을 WhenEmptyOrUnderutilized로 바꿀 수 있었다. 그래야 무중단을 지키면서도 노는 노드를 알아서 회수한다.

 

그리고 가장 강조하고 싶은 건 검증이다. 설정을 바꾼 것과, 무중단이 진짜로 지켜지는 것은 완전히 다른 얘기다. 바꿨다고 끝이 아니라, 직접 확인해야 한다. 헬스 체크를 계속 돌려서 502가 한 건도 안 나는지 보고, readiness gate가 새 파드를 준비될 때까지 제대로 붙잡아 두는지 보고, 노드 수가 원래대로 돌아오는지 지켜보고, 도메인별로 응답이 정상인지 curl로 하나씩 찍어봤다.

 

이 과정을 거쳐야 "설정을 적용했다"가 "실제로 잘 돈다"로 바뀐다. 이렇게 확인한 덕분에, 진짜 문제와 정상 동작을 구분할 수 있었다. 그 결과 남은 숙제는 app /api-docs에서 나는 502 하나로 줄었다.

 

앞으로 할 일은 네 가지다. 첫째, app /api-docs 502를 따로 파본다.

 

둘째, 앱 자체에서도 연결을 깔끔하게 끊는 처리(graceful close, Layer 2)를 넣는다.

셋째, 지금 적용한 drain과 readiness gate를 나머지 환경(dev/qa/review)에도 똑같이 깐다.

넷째, HPA로 파드를 10~20개까지 늘릴 때는 strategy를 maxSurge:0 + maxUnavailable:20~25%로 바꾼다. 규모가 커지면 정답도 달라진다. 이번 작업이 가장 분명하게 남긴 메시지다.

 

 

 

 

 

 

 


Reference

 

 

 

 

 

Somaz | DevOps Engineer | Kubernetes & Cloud Infrastructure Specialist

728x90
반응형