“HPA를 없애면 비용이 줄지 않을까?” 비용 절감 회의에서 나온 안은 두 가지였다. 하나는 HPA 기능 자체를 빼는 것, 다른 하나는 Pod의 resources 설정을 빼는 것이다. 출발점은 CPU가 놀고 있다는 사실이었다. 하나의 서비스를 고객사별 멀티 테넌트 SaaS로 옮기는 프로젝트의 운영 클러스터에는 m7i.xlarge 21대가 떠 있었고, 나중에 kubecost로 확인한 두 테넌트의 CPU 효율은 둘 다 1% 안팎이었다.
회의가 끝나고 숫자를 직접 뽑아 보니 두 안 모두 청구서를 거의 바꾸지 못했다. 노드 수를 정하는 건 사용률이 아니라 requests의 합이다.
HPA를 없애도 고정 노드는 그대로 켜져 있다
21대 중 18대는 관리형 노드그룹(tenant-a 10대, tenant-b 8대)이고 크기가 고정이다. cluster-autoscaler가 없으니 Pod가 줄어도 이 18대는 줄지 않는다. Pod 수에 따라 움직이는 건 Karpenter 노드 3대뿐이다. kubecost로 뽑은 최근 7일 비용에서는 아무도 쓰지 않는 용량(idle)이 약 48%였다.
HPA를 빼면 HPA가 만든 초과 replicas는 사라지지만 고정 노드 18대는 남는다. Karpenter 노드 3대는 HPA가 min으로 내려오면 Karpenter가 알아서 회수한다. 추가 절감은 0이고, 트래픽이 몰릴 때 대응할 수단만 사라진다.
resources를 빼면 스케줄러가 Pod를 적은 노드에 몰아 넣을 수는 있지만, 고정 노드그룹은 어차피 줄지 않는다. HPA는 request가 없으면 사용률을 계산할 수 없고, QoS가 BestEffort로 떨어져 메모리가 부족할 때 운영 Pod가 먼저 축출된다.
노드는 예약으로 차 있었다
kubectl describe node의 Allocated resources에는 그 노드에 올라간 Pod들의 CPU request 합이 노드 예산의 몇 %인지 나온다. kubectl top node의 실제 사용량과 나란히 놓으면 이렇다.
노드 CPU 예약(request 합) 실제 CPU 사용(top)
node-1 3510m (94%) 260m (6%)
node-2 3310m (84%) 286m (7%)
node-3 3410m (86%) 185m (4%)
node-4 2910m (74%) 302m (7%)
스케줄러는 Pod를 배치할 때 실제 사용량을 보지 않고 resources.requests만 본다. 노드마다 3.9코어 남짓한 예산 중 2.9~3.5코어가 예약으로 이미 팔려 있었고, 실제로 쓰이는 건 0.2~0.3코어였다. 두 테넌트를 합치면 CPU request는 46코어, kubectl top pod로 본 실사용 합은 0.5코어 남짓이었다.
처음에는 CPU만 보고 계산했다. mo-back을 뺀 모든 모듈의 CPU request를 150m로 줄이면 92개 Pod에 약 14코어니, 4코어짜리 m7i.xlarge 4대면 된다. 그런데 CPU request를 줄이는 순간 노드 수를 정하는 자원이 메모리로 바뀐다. 메모리 request 합을 노드 한 대에서 앱이 쓸 수 있는 몫(약 13.5Gi)으로 나누면 tenant-a는 최소 5대, tenant-b는 최소 4대다. 배포 중 구·신 ReplicaSet이 공존하는 순간, topology spread, HPA가 늘린 Pod를 생각해 그룹마다 1대씩 얹은 6대와 5대를 제안했다. 산출대로라면 21대가 11~12대로, 월 비용의 40~45%가 줄어든다.
requests를 깎기 전에 걸린 것들
비용 회의가 있던 그날 오전, 운영 클러스터에서 Pod가 갑자기 늘었다. HPA 이벤트 사유는 전부 memory container resource utilization above target이었다. 대부분의 모듈이 request 768Mi인데 JVM 앱의 안정 상태 메모리가 650~750Mi라, 기동만 해도 사용률이 85~97%였다. JVM 메모리는 트래픽이 아니라 힙 베이스라인을 따라가서 replicas를 늘려도 Pod당 메모리가 내려가지 않는다. c2c-park-back은 2개에서 6개로 늘고도 86%였고, CPU는 전 모듈 0~7%였다. 이 메모리 지표는 내가 안전판으로 넣은 것이었는데, Pod를 한 방향으로만 늘리는 장치가 되어 있었다. 그날은 메모리 request를 올려 사용률을 80% 아래로 내렸다.
여기에 requests 축소를 대입하면 답이 나온다. HPA의 메모리 사용률은 실사용 ÷ request라, JVM처럼 메모리가 부하와 상관없이 평평한 앱에서 request를 깎으면 사용률만 올라 HPA가 Pod를 늘리고 노드를 다시 부른다. 그래서 조정안은 메모리 지표 제거(또는 90% 이상으로 상향)를 전제로 했다.
2주 뒤 개발 클러스터에서 메모리까지 줄이려 하자 JVM 힙이 걸렸다. Java 컨테이너에 -Xmx가 없으면 JVM은 MaxRAMPercentage 기본값 25%를 적용해 컨테이너 메모리 limit의 1/4을 힙 상한으로 잡는다. limit만 2Gi에서 1280Mi로 낮추면 힙도 512M에서 320M으로 같이 내려간다. 그래서 힙 비율을 먼저 고정하고 limit을 낮추는 안을 만들었다. 앱 이미지의 시작 커맨드를 건드리지 않으려고 JAVA_TOOL_OPTIONS를 썼다.
env:
# 힙 비율을 고정한다. 1280Mi × 40% = 512M, 지금(2Gi × 25%)과 같은 힙
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=40.0 -XX:InitialRAMPercentage=20.0"
resources:
limits:
cpu: 2 # 스케줄링과 무관하니 유지. 낮추면 Java 8 JIT 스레드가 줄어 기동이 느려진다
memory: 1280Mi # 2Gi → 1280Mi
requests:
cpu: 250m # 500m → 250m, 기동 burst는 limit으로 흡수
memory: 900Mi # 1600Mi → 900Mi
PDB도 있었다. 예전에 Karpenter가 노드를 정리하면서 같은 노드의 다른 앱 Pod까지 재기동된 일(앱 하나를 껐는데 옆 앱이 재시작됐다) 뒤로 PDB를 minAvailable: 1로 바꿔 두었다. replicas가 1인 개발 환경에서는 이게 허용 중단 0이 되어 drain도, Karpenter의 노드 정리도 tenant-b Pod가 있는 노드에서 멈춘다. replicas가 2 이상인 운영 환경에서는 minAvailable: 1이 맞고, replicas 1인 개발 환경에는 maxUnavailable: 1(또는 PDB 미적용)이 맞다.
개발 환경 tenant-b의 1.2일치 Prometheus 데이터로 만든 조정안은 앱과 사이드카의 CPU request를 8.0코어에서 3.8코어로, 메모리 request를 18.2Gi에서 11.6Gi로 줄인다. 다만 같은 노드풀 request의 약 75%가 함께 올라간 tenant-c 몫이라, tenant-b만 줄여서는 노드가 크게 줄지 않는다.