본문으로 건너뛰기

클러스터 용량 — CPU request 과할당

prod 클러스터는 CPU request 로 꽉 차 있는데 실사용은 10~29% 입니다. 그 결과 롤아웃이 새 파드를 띄울 자리를 못 찾고, 실제로 서비스가 내려갔습니다 (#3267).

:::danger 총 여유가 늘어도 롤아웃은 여전히 막힐 수 있다

파드는 한 노드 안에 들어가야 합니다. 그래서 실질 상한은 총합이 아니라 최대 단일 노드 여유입니다. 2026-08-09 실측:

합계 여유최대 단일 노드최대 파드 request
라이트사이징 전1097m395m400m (trino-worker)
프록시 1건 조정 후1357m296m400m

총 여유는 260m 늘었는데 최대 단일 노드 여유는 99m 줄었습니다. 파드가 재배치되며 파편화가 달라진 것입니다. 즉 사이드카를 조금씩 줄이는 방식은 총량은 늘리지만 큰 파드의 자리를 보장하지 못합니다. :::

무슨 일이 났나

rollout restartmaxSurge > 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-daemonmaxSurge: 0 입니다. 싱글턴 스케줄러라 2 replicas 가 불가능해 이 설정 자체는 옳지만, 여유가 없으면 그 공백이 무기한이 됩니다.

실측 (2026-08-09)

노드

노드 8개 · gendpool 5 · syspool2 2 · codexecpool 1(taint). request 8899% / 실사용 1029%.

gendpoolsyspool2 둘 다 taint 가 없고 gend 파드에 nodeSelector 도 없습니다 — gend 워크로드는 두 풀 어디에나 뜹니다. 여유를 잴 때 gendpool 만 보면 과소평가합니다.

:::caution gendpool 은 버스터블 SKU 다

노드풀SKU
gendpoolStandard_B4as_v2 (B 시리즈 = 버스터블)
syspool2Standard_D4as_v4
codexecpoolStandard_B2as_v2

B 시리즈는 베이스라인을 넘는 지속 사용이 VM 레벨에서 스로틀됩니다. 지금은 사용률이 10~29% 라 안 물리지만, "사용률이 낮으니 과감히 오버커밋해도 된다" 는 논리에는 kubectl top 에 보이지 않는 천장이 있습니다. 크레딧 잔량은 Azure Monitor 에서 봐야 합니다. :::

무엇이 request 를 먹고 있나

kubectl get resourcequota -A 기준:

네임스페이스requests.cpu소유
llmops12,420mgenonai/gitops — 이 리포 아님
gend6,955m이 리포

그리고 서비스 메시 사이드카가 8 CPU 를 예약합니다:

개수예약
llmops istio-proxy585,530m
gend linkerd-proxy281,340m (전환 도중: 26개 × 50m + 2개 × 20m)
linkerd / linkerd-viz101,000m

:::tip *-init 컨테이너는 예약에 포함되지 않는다

istio-init · linkerd-init일회성 init 컨테이너입니다. 파드의 실효 request 는 max(앱 컨테이너 합 + 네이티브 사이드카, 가장 큰 init) 이라 보통 앱 합에 묻힙니다. 이걸 그냥 더하면 llmops 를 18 CPU 로 2배 넘게 과대평가하게 됩니다(실제로 한 번 틀렸습니다).

★ 손으로 재구현하지 말고 kubectl describe nodeAllocated resources 를 쓰십시오 — 스케줄러 자신의 회계입니다. 직접 계산하면 네이티브 사이드카·QoS 처리에서 어긋납니다(실측: 재구현 3404m vs describe 3814m). :::

linkerd 프록시 — 요청이 실사용의 80배였다

Prometheus 7일, gend ns 482 파드:

중앙 파드 중앙값0.6m
최악 파드 p9513.2m
선언된 request50m (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
gendpoolFalse (고정 5)없음
syspool2False (고정 2)없음
codexecpoolTrue (1~2)workload=codexec:NoSchedule

일반 워크로드를 받을 수 있는 두 풀이 모두 고정 크기입니다. 유일하게 오토스케일하는 풀은 taint 때문에 일반 파드를 못 받습니다 — NotTriggerScaleUp: 1 node(s) had untolerated taint(s) 이벤트가 그것입니다.

request 를 아무리 줄여도 상한은 고정입니다. 라이트사이징은 시간을 벌 뿐이고, 근본 해결은 gendpool/syspool2 에 cluster autoscaler 를 켜는 것입니다 (비용이 늘어나므로 운영 판단이 필요합니다).

대처 — 효과 순

조치확보소유위험
gendpool 오토스케일 활성화상한 자체 해제Azure (운영 판단)비용 증가
② linkerd 프록시 50m→20m780m이 리포낮음 — 이미 적용
③ llmops LimitRange defaultRequest 100m→25m~2.3 CPU이 리포(선언)/타 팀(영향)타 팀 QoS 저하 — 협의 필요
gend 워크로드 개별 라이트사이징수백 m이 리포중 — 워크로드별 실측 필요

③ 을 왜 함부로 못 하나

infra/k8s/quota-llmops.yamlLimitRange리소스를 선언하지 않은 컨테이너에 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[*] 를 합산하고, 네이티브 사이드카 (initContainersrestartPolicy: 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 워크로드는 옛 파드가 먼저 내려가므로 그 시점부터 장애입니다.

관련