Pod resize, kubelet은 사실 어떻게 하는가 — 1.35 GA 내부 동작
Pod resize, kubelet은 사실 어떻게 하는가 — 1.35 GA 내부 동작
작년 12월 1.35에서 in-place pod resize가 드디어 GA로 올라왔다. 1.27 알파(2023년 봄), 1.33 베타(2025년 봄)를 거쳐 약 2년 반 만이다. 베타 시점부터 우리 팀 일부 워크로드에 켜놨고, GA 이후엔 좀 더 적극적으로 쓰고 있다. 그런데 운영하다 보면 "왜 이건 resize가 한 번에 안 되지?", "왜 어떤 컨테이너는 restart 되고 어떤 건 안 되지?" 같은 질문이 자꾸 나온다.
사실 내부적으로는 kubelet이 꽤 복잡한 상태 머신을 돌리고 있다. KEP-1287과 1.33/1.35 GA 변경분을 같이 읽어보면 그림이 좀 잡힌다. 이 글에서는 kubelet → CRI → 컨테이너 런타임 → cgroup으로 이어지는 흐름을 따라가본다.
Spec, Allocated, Actuated — 세 가지 상태
먼저 알아야 할 건 kubelet이 컨테이너 리소스를 세 가지 상태로 분리해서 들고 있다는 점이다.
첫째는 PodSpec의 resources.requests/resources.limits. API 서버가 들고 있는 값이고 사용자가 patch로 바꾸는 대상이다.
둘째는 allocated resources. kubelet이 노드 입장에서 "이 Pod에 이만큼 할당했다"고 판단한 값이다. 노드 capacity 체크를 통과한 spec 값이 여기 들어간다. checkpoint 파일로 디스크에 저장돼서 kubelet 재시작에도 살아남는다.
셋째는 actuated resources. 실제로 컨테이너 런타임이 적용에 성공해서 응답을 돌려준 값이다. 이것도 1.33부터 checkpoint된다. 이 분리가 1.33 베타에서 추가된 핵심 변경이고, GA에서 더 다듬어졌다.
왜 이렇게 셋으로 나눠야 했냐면, "사용자가 요청한 값"과 "노드가 받아들이기로 한 값"과 "실제로 적용된 값"이 다 시점이 다를 수 있기 때문이다. 예전 알파/초기 베타에서는 이 셋을 헷갈리게 다뤘다가 fail close 안 되는 케이스가 종종 있었다.
resize subresource — patch 라우팅의 분리
kubectl patch pod --subresource=resize ... 호출이 API 서버에 들어오면 일반 spec patch와 다른 경로로 라우팅된다. resize subresource는 spec.containers[*].resources만 수정할 수 있고 나머지 필드는 immutable 그대로 유지된다.
이걸 분리한 이유는 권한 관리 때문이다. resize만 할 수 있는 RBAC를 따로 만들 수 있고, VPA 같은 컴포넌트가 pod의 다른 필드까지 건드릴 권한을 받지 않아도 된다. 1.33 베타에서 이 subresource가 도입됐고, kubectl 1.32 이상이 필요하다.
kubelet의 SyncPod 안에서 일어나는 일
resize 요청이 들어오면 API 서버에서 pod object가 업데이트되고, kubelet은 watch를 통해 변경을 감지한다. 그러면 다음 syncPod 사이클에서 흐름이 시작된다.
1. PodWorker: 변경된 spec을 받아옴
2. canResizePod 체크
- 노드 capacity 여유 있나?
- 다른 pod의 allocated와 합쳐서 over-commit 안 되나?
- QoS class가 바뀌지는 않나? (1.35에서도 여전히 immutable)
3. allocated resources 업데이트 + checkpoint
4. doPodResizeAction 실행
- resizePolicy 보고 restart 필요한지 결정
- 필요하면 CRI UpdateContainerResources 호출
- 결과 받아서 actuated 업데이트 + checkpoint
5. pod status에 conditions 반영
여기서 흥미로운 게 PodResizePending과 PodResizeInProgress 두 가지 condition이다. capacity가 부족해서 deferred 됐을 때는 Pending, 실제 적용 중이면 InProgress가 붙는다. 1.33 베타에서 도입되고 GA에서 의미가 좀 더 명확해졌다. 운영자는 이거 두 개를 모니터링 알람으로 잡아두면 "resize가 영원히 pending인 pod" 같은 케이스를 잡을 수 있다.
CRI는 어떻게 받아 처리하는가
kubelet은 컨테이너 런타임에 UpdateContainerResources gRPC를 호출한다. 이 API 자체는 1.20부터 이미 있었다. containerd와 CRI-O 모두 구현돼 있고, 내부적으로는 OCI runtime spec의 resources 필드를 업데이트하고 cgroup write를 수행한다.
cgroup v2 환경에서는 메모리 제한이 memory.max, CPU가 cpu.max와 cpu.weight로 매핑된다. CPU request → cpu.weight, CPU limit → cpu.max, memory limit → memory.max 식이다. 이 write들은 사실 단순한 파일 쓰기다. 신기할 게 없다.
근데 memory를 줄이는 경우가 문제다. memory.max를 현재 RSS보다 낮게 쓰면 커널이 reclaim을 시도하고, reclaim 실패하면 OOM kill 한다. 그래서 in-place resize는 memory 감소를 보수적으로 다룬다. 1.35 기준으로 memory limit을 줄이는 동작은 기본적으로 NotRequired면 그대로 적용을 시도하지만, RestartContainer 정책을 같이 쓰는 게 안전하다. CPU는 즉시 적용해도 어차피 throttling으로 끝나니까 큰 위험이 없다.
cgroup v1은 사실상 지원 대상에서 빠졌다. 1.31에서 cgroup v1이 maintenance mode로 들어갔고, 1.35 in-place resize는 cgroup v2 전제로 설계됐다. 아직 v1 노드 굴리고 있으면 이 기능은 잊자.
resizePolicy — 컨테이너별 결정권
PodSpec의 containers[*].resizePolicy가 컨테이너별로 어떤 리소스 변경 시 restart가 필요한지 선언한다.
resizePolicy:
- resourceName: cpu
restartPolicy: NotRequired
- resourceName: memory
restartPolicy: RestartContainer
이렇게 두면 CPU는 무중단으로 바뀌고, memory 변경은 컨테이너 재시작을 동반한다. JVM처럼 시작 시점에 -Xmx로 힙을 잡는 앱은 memory를 RestartContainer로 두는 게 맞다. resize 자체는 즉시 적용해도, 앱이 새 메모리를 못 쓰면 의미 없다.
Go로 짠 stateless 서버 같으면 둘 다 NotRequired로 두고 GOMEMLIMIT 환경변수만 같이 업데이트해주면 된다. 사실 GOMEMLIMIT 동기화 문제는 또 별개 이슈인데, 이건 다음 글에서 다루겠다.
sidecar/init과 QoS의 immutable 영역
GA에서도 여전히 안 되는 게 있다.
먼저 QoS class는 못 바꾼다. Guaranteed였던 pod를 Burstable로 만들 수 없고 반대도 마찬가지다. 이걸 풀어주면 스케줄러와 eviction manager가 기존 가정이 다 깨진다.
init container 중 sidecar(restartPolicy: Always)는 resize 가능하지만, 일반 init container는 이미 끝난 상태일 가능성이 높아서 의미 없다.
Pod-level resources(spec.resources — 1.32에서 들어온 그 필드)는 1.35에서 알파 수준으로 resize 지원이 들어왔다. 아직 프로덕션 권장은 아니다.
운영자가 봐야 할 신호
우리 팀에서 in-place resize 도입 후 본 시그널 몇 가지를 적어두면:
kubelet_pod_resize_*메트릭. 1.33부터 노출된다. resize 시도 수, 성공/실패, deferred 카운트.- pod의
status.conditions[type=PodResizePending]. Reason이Deferred면 capacity 부족,Infeasible이면 영원히 안 됨. - CRI UpdateContainerResources의 latency. containerd 메트릭에서 잡힌다.
가장 자주 보는 함정은 노드 reservation 계산이다. resize로 request가 늘어났는데 노드의 allocatable이 안 늘어서 PodResizePending=Deferred가 박혀 있는 경우. 이건 VPA를 in-place 모드로 쓸 때 특히 자주 본다.
그래서 결론은
in-place resize는 "Pod 재시작 없이 cgroup만 다시 쓴다"는 한 줄로 요약되지만, 그 한 줄을 안전하게 만들기 위해 kubelet은 세 가지 상태를 분리해서 들고 있고, condition으로 단계를 노출하고, resizePolicy로 사용자에게 결정권을 넘긴다. 굳이 정리하면 "낙관적으로 시도하되, 적용된 사실을 분리해서 기록한다"는 설계다.
1.35 GA 이후로는 베타 시절보다 훨씬 안정적이긴 하다. 그래도 memory 감소는 여전히 조심해야 하고, VPA in-place 모드는 노드 capacity 헤드룸을 좀 넉넉히 잡고 시작하는 걸 추천한다. 우리 팀은 일단 stateless 서비스 일부에만 적용하고 stateful은 좀 더 지켜보고 있다.
다음에는 이 기능을 VPA in-place 모드와 같이 굴렸을 때 일어나는 흥미로운 케이스들을 정리해보려고 한다.