클러스터 용량 — CPU request 과할당
prod 클러스터는 CPU request 로 꽉 차 있는데 실사용은 10~29% 입니다. 그 결과 롤아웃이 새 파드를 띄울 자리를 못 찾고, 실제로 서비스가 내려갔습니다 (#3267).
:::danger 총 여유가 늘어도 롤아웃은 여전히 막힐 수 있다
파드는 한 노드 안에 들어가야 합니다. 그래서 실질 상한은 총합이 아니라 최대 단일 노드 여유입니다. 2026-08-09 실측:
| 합계 여유 | 최대 단일 노드 | 최대 파드 request | |
|---|---|---|---|
| 라이트사이징 전 | 1097m | 395m | 400m (trino-worker) |
| 프록시 1건 조정 후 | 1357m | 296m | 400m |
총 여유는 260m 늘었는데 최대 단일 노드 여유는 99m 줄었습니다. 파드가 재배치되며 파편화가 달라진 것입니다. 즉 사이드카를 조금씩 줄이는 방식은 총량은 늘리지만 큰 파드의 자리를 보장하지 못합니다. :::
무슨 일이 났나
rollout restart 는 maxSurge > 0 일 때만 새 파드를 먼저 띄우고, 그 동안
순간적으로 두 배를 씁니다. maxSurge: 0 이면 반대로 옛 파드를 먼저 내립니다 —
아래 표가 그 차이를 정리합니다.
2026-08-09 #3088 자격증명 회전 중:
dagster-daemon중단 —0/8 nodes available: 7 Insufficient cpu로 Pending, 옛 파드는 이미 종료. 파이프라인 스케줄이 멈췄습니다.trino-worker롤아웃 정체 — 옛 파드가 계속 서비스해 중단은 없었습니다.
두 결과가 갈린 이유는 maxSurge 입니다:
| 설정 | 여유가 없을 때 | replicas=1 일 때 |
|---|---|---|
maxSurge: 0 | 옛 파드를 먼저 내린다 → 공백이 곧 장애 | 항상 순간 중단 |
maxSurge: 1 (기본 25%) | 새 파드가 안 뜨고 롤아웃만 정체 — 서비스는 유지 | 무중단 |
dagster-daemon 은 maxSurge: 0 입니다. 싱글턴 스케줄러라 2 replicas 가 불가능해
이 설정 자체는 옳지만, 여유가 없으면 그 공백이 무기한이 됩니다.
실측 (2026-08-09)
노드
노드 8개 · gendpool 5 · syspool2 2 · codexecpool 1(taint). request 8899% / 실사용 1029%.
★ gendpool 과 syspool2 둘 다 taint 가 없고 gend 파드에 nodeSelector 도 없습니다 —
gend 워크로드는 두 풀 어디에나 뜹니다. 여유를 잴 때 gendpool 만 보면 과소평가합니다.
:::caution gendpool 은 버스터블 SKU 다
| 노드풀 | SKU |
|---|---|
gendpool | Standard_B4as_v2 (B 시리즈 = 버스터블) |
syspool2 | Standard_D4as_v4 |
codexecpool | Standard_B2as_v2 |
B 시리즈는 베이스라인을 넘는 지속 사용이 VM 레벨에서 스로틀됩니다. 지금은
사용률이 10~29% 라 안 물리지만, "사용률이 낮으니 과감히 오버커밋해도 된다" 는
논리에는 kubectl top 에 보이지 않는 천장이 있습니다. 크레딧 잔량은 Azure Monitor
에서 봐야 합니다.
:::
무엇이 request 를 먹고 있나
kubectl get resourcequota -A 기준:
| 네임스페이스 | requests.cpu | 소유 |
|---|---|---|
llmops | 12,420m | genonai/gitops — 이 리포 아님 |
gend | 6,955m | 이 리포 |
그리고 서비스 메시 사이드카가 8 CPU 를 예약합니다:
| 개수 | 예약 | |
|---|---|---|
llmops istio-proxy | 58 | 5,530m |
gend linkerd-proxy | 28 | 1,340m (전환 도중: 26개 × 50m + 2개 × 20m) |
linkerd / linkerd-viz | 10 | 1,000m |
:::tip *-init 컨테이너는 예약에 포함되지 않는다
istio-init · linkerd-init 은 일회성 init 컨테이너입니다. 파드의 실효 request 는
max(앱 컨테이너 합 + 네이티브 사이드카, 가장 큰 init) 이라 보통 앱 합에 묻힙니다.
이걸 그냥 더하면 llmops 를 18 CPU 로 2배 넘게 과대평가하게 됩니다(실제로 한 번 틀렸습니다).
★ 손으로 재구현하지 말고 kubectl describe node 의 Allocated resources 를
쓰십시오 — 스케줄러 자신의 회계입니다. 직접 계산하면 네이티브 사이드카·QoS 처리에서
어긋납니다(실측: 재구현 3404m vs describe 3814m).
:::
linkerd 프록시 — 요청이 실사용의 80배였다
Prometheus 7일, gend ns 482 파드:
| 값 | |
|---|---|
| 중앙 파드 중앙값 | 0.6m |
| 최악 파드 p95 | 13.2m |
| 선언된 request | 50m (26개 매니페스트 전부) |
→ 20m 으로 낮췄습니다. 최악 p95 위이고 중앙값의 33배입니다. request 는 스케줄링 예약일 뿐 상한이 아니므로 프록시는 필요하면 그대로 더 씁니다.
28개 전부 반영되면 28 × 20m = 560m 이 되어 현재 예약 1,340m 대비
780m 이 확보됩니다. (28개 전부가 50m 이었다면 840m 이지만, 측정 시점에
이미 2개가 20m 으로 전환돼 있었습니다.)
★ 어노테이션이 없는 워크로드는 linkerd 기본값 100m 을 받습니다
(gend-xai 가 그랬습니다). 새 워크로드에 linkerd.io/inject: enabled 를 붙일 때
config.linkerd.io/proxy-cpu-request: "20m" 를 함께 적으십시오.
근본 원인 — 클러스터가 커질 수 없다
az aks nodepool list --cluster-name aks-genos-prod -g <rg> \
-o table --query "[].{name:name,auto:enableAutoScaling,min:minCount,max:maxCount}"
2026-08-09 실측:
| 노드풀 | 오토스케일 | taint |
|---|---|---|
gendpool | False (고정 5) | 없음 |
syspool2 | False (고정 2) | 없음 |
codexecpool | True (1~2) | workload=codexec:NoSchedule |
일반 워크로드를 받을 수 있는 두 풀이 모두 고정 크기입니다. 유일하게
오토스케일하는 풀은 taint 때문에 일반 파드를 못 받습니다 —
NotTriggerScaleUp: 1 node(s) had untolerated taint(s) 이벤트가 그것입니다.
즉 request 를 아무리 줄여도 상한은 고정입니다. 라이트사이징은 시간을 벌 뿐이고,
근본 해결은 gendpool/syspool2 에 cluster autoscaler 를 켜는 것입니다
(비용이 늘어나므로 운영 판단이 필요합니다).
대처 — 효과 순
| 조치 | 확보 | 소유 | 위험 |
|---|---|---|---|
① gendpool 오토스케일 활성화 | 상한 자체 해제 | Azure (운영 판단) | 비용 증가 |
| ② linkerd 프록시 50m→20m | 780m | 이 리포 | 낮음 — 이미 적용 |
③ llmops LimitRange defaultRequest 100m→25m | ~2.3 CPU | 이 리포(선언)/타 팀(영향) | 타 팀 QoS 저하 — 협의 필요 |
④ gend 워크로드 개별 라이트사이징 | 수백 m | 이 리포 | 중 — 워크로드별 실측 필요 |
③ 을 왜 함부로 못 하나
infra/k8s/quota-llmops.yaml 의 LimitRange 는 리소스를 선언하지 않은 컨테이너에
100m 을 주입합니다. llmops 컨테이너 31개가 정확히 100m 인 게 그 결과입니다.
이건 원래 GenD 를 보호하려고 넣은 것입니다 — GenOS 앱이 BestEffort 라
스케줄러가 requests=0 으로 보고 포화 노드에 GenD 를 계속 얹던 문제를 막았습니다.
낮추면 그 보호가 약해집니다. 그리고 LimitRange 는 기존 파드를 소급 적용하지 않으므로, 낮춰도 각 워크로드가 다음에 롤아웃될 때까지 효과가 없습니다.
롤아웃 전에 확인할 것
# ① 노드별 **여유** (allocatable − allocated). 사용량이 아니라 여유를 찍는다.
for n in $(kubectl get nodes -o name | cut -d/ -f2); do
taint=$(kubectl get node "$n" -o jsonpath='{.spec.taints[*].key}')
[ -n "$taint" ] && continue # taint 붙은 풀은 일반 파드가 못 간다
used=$(kubectl describe node "$n" | awk '/Allocated resources/,/^Events/' \
| grep -E '^[[:space:]]+cpu' | head -1 | awk '{print $2}' | tr -d 'm')
alloc=$(kubectl get node "$n" -o jsonpath='{.status.allocatable.cpu}' | tr -d 'm')
printf '%-40s 여유 %sm\n' "$n" "$(( alloc - used ))"
done | sort -t' ' -k2 -n
# ② 띄우려는 파드의 **유효** request — 아래 danger 블록의 명령을 쓴다.
# ★ Deployment 템플릿이 아니라 **실행 중 파드**에서 떠야 한다.
:::danger 템플릿의 containers[*] 를 쓰지 마십시오
kubectl get deploy <name> -o jsonpath='{.spec.template.spec.containers[*].resources.requests.cpu}'
이건 admission 이전의 값입니다. linkerd(또는 istio) injector 가 붙이는
사이드카 request 가 빠져 있어 실제보다 작게 나옵니다 — gend-xai 는 템플릿상
100m 인데 실제 파드는 프록시를 더해 200m 이었습니다.
실행 중 파드에서 spec.containers[*] 를 합산하고, 네이티브 사이드카
(initContainers 중 restartPolicy: Always)도 더해야 스케줄러가 보는 값과 같습니다:
kubectl -n gend get pod <pod> -o json | python3 -c '
import json,sys
d=json.load(sys.stdin)["spec"]
def cpu(v):
v=str(v or 0); return int(float(v[:-1])) if v.endswith("m") else int(float(v)*1000)
n=sum(cpu((c.get("resources",{}).get("requests") or {}).get("cpu")) for c in d["containers"])
n+=sum(cpu((c.get("resources",{}).get("requests") or {}).get("cpu"))
for c in (d.get("initContainers") or []) if c.get("restartPolicy")=="Always")
print(f"{n}m")'
:::
:::warning 이 측정은 노이즈가 크다 — 한 번만 재지 마십시오
CronJob 파드(trino-acl-sync 5분 · trino-group-sync 15분 · trivy 스캔 등)가
계속 뜨고 지므로 여유가 분 단위로 수백 m 씩 출렁입니다. 실측: 같은 절차를
연속 두 번 돌렸더니 합계 827m / 최대 255m → 합계 1,227m / 최대 426m 였습니다.
그래서 "지금 400m 이 들어간다" 는 지금만 참일 수 있습니다. 여러 번 재고 최솟값으로 판단하십시오. :::
여유가 없으면 회전·롤아웃을 시작하지 마십시오. maxSurge: 0 워크로드는
옛 파드가 먼저 내려가므로 그 시점부터 장애입니다.
관련
- S3 자격증명 회전 — 이 사고가 난 작업
- LDAP 자격증명 회전