In-place Pod Resize가 사실 내부적으로는 이렇게 돌아간다
작년 말 v1.35에서 In-place Pod Resize가 드디어 GA로 승격됐다. 알파(v1.27)에서 GA까지 8개 마이너 버전이 걸렸으니 꽤 오래 묵힌 기능이다. 우리 팀에서도 몇 주 전부터 스테이징에 얹어보기 시작했는데, 처음에는 "그냥 patch 한 방이면 되는 거 아니야?" 정도로 가볍게 봤다가 실제 이벤트 흐름을 뜯어보고 나서 생각이 좀 바뀌었다. Pod을 죽이지 않고 리소스를 바꾼다는 게 API 표면에서 보이는 것보다 훨씬 많은 컴포넌트가 얽혀 있다.
이 글은 kubectl에서 시작해 API 서버, 스케줄러, kubelet, CRI, 마지막으로 cgroup까지 실제 요청이 어떻게 흘러가는지 순서대로 따라가 본 노트다. 사내 세션 자료로 만들었던 걸 조금 다듬어서 올린다.
resize subresource가 따로 있는 이유
기존에는 Pod의 리소스를 바꾸려면 pods 리소스 자체를 patch해야 했는데, GA로 오면서 pods/resize 서브리소스가 정식으로 분리됐다. 이게 좀 재미있는 결정이다. 왜 별도 서브리소스로 뺐을까?
이유는 권한 분리와 admission 흐름 분리다. Pod 전체를 수정하는 권한을 가진 주체와, 리소스 조정만 하고 싶은 주체(예: VPA, ScaleOps 같은 오토스케일러)의 권한을 RBAC 레벨에서 분리할 수 있어야 했다. 그리고 리소스 변경은 다른 spec 변경과 admission 정책이 완전히 다르다. 스토리지 클래스 변경이나 노드 셀렉터 변경은 immutable이지만, 리소스는 이제 mutable이다. API 서버가 이 둘을 같은 코드 경로에서 다루면 validation이 지저분해진다.
실제로 서브리소스가 하는 일은 spec.containers[*].resources만 업데이트하는 좁은 문이다. 그 외 필드는 손대지 못한다. 요청이 들어오면 API 서버는 우선 desired resources를 spec에 반영하고, 별도로 status.containerStatuses[*].resources에 actual resources를 유지한다. 이 두 필드가 어긋난 상태가 바로 "resize가 진행 중"인 상태다.
Pod status의 상태 머신
resize를 요청하고 나면 Pod status에 conditions가 두 개 추가된다. PodResizePending과 PodResizeInProgress다. 이 컨디션 두 개가 실제로 kubelet과 스케줄러 사이의 상태 머신 역할을 한다.
Deferred ── 스케줄러가 지금 노드에서 이 resize를 못 받는다고 판단
Infeasible ── 아예 불가능 (예: allocatable 초과)
InProgress ── kubelet이 cgroup 조정을 시작함
(condition 사라짐) ── 완료
Deferred는 재밌는 케이스인데, 예를 들어 노드에 여유 CPU가 지금 당장은 없지만 나중에 다른 Pod이 종료되면 가능할 수도 있는 상황이다. 이 경우 kubelet이 주기적으로 재평가한다. Infeasible은 노드의 allocatable 자체를 넘어서는 경우로, 이건 사용자가 요청을 취소하거나 Pod을 리스케줄해야 한다.
우리 팀에서 처음 실험할 때 Deferred 상태에 걸린 채로 몇 분씩 머무는 케이스를 봤다. 이유는 노드에 CPU manager가 static policy로 켜져 있어서, 다른 guaranteed Pod이 CPU를 잡고 있어 여유 코어가 안 났기 때문이었다. 그런데 로그를 보기 전에는 왜 안 되는지 알기가 어렵다. 이게 프로덕션에서 은근히 골치 아플 포인트다.
kubelet 안에서의 실제 흐름
kubelet이 resize 요청을 감지하는 방법은 특별한 이벤트가 아니라 그냥 평범한 Pod watch다. Pod spec의 resources와 status의 resources가 다르면 SyncPod 루틴이 이걸 감지하고 resize 파이프라인으로 들어간다.
파이프라인은 대략 이렇게 흐른다.
첫째, admission 재검사. resize를 승인해도 노드가 이걸 실제로 감당할 수 있는지 다시 본다. kubelet의 predicate admit handler가 requests의 총합이 node allocatable을 넘지 않는지 확인한다. 여기서 걸리면 Infeasible이 붙는다.
둘째, resource allocation. 특히 CPU manager, memory manager, device manager가 각자 판단한다. Guaranteed QoS + static CPU policy 조합에서는 CPU manager가 exclusive core를 재할당해야 하는데, 이게 실패하면 Deferred가 된다.
셋째, CRI 호출. 여기가 실제로 cgroup을 바꾸는 지점이다. kubelet은 UpdateContainerResources gRPC를 CRI 런타임에 보낸다. containerd나 CRI-O가 이 요청을 받아서 runc에 위임하고, runc가 cgroup 파일 시스템에 직접 쓴다.
넷째, status 반영. cgroup 변경이 성공하면 status.containerStatuses[*].resources가 desired와 같아지고, PodResizeInProgress condition이 사라진다.
cgroup 레벨에서 실제로 뭐가 바뀌나
cgroups v2 기준으로 CPU와 메모리가 바뀌는 파일은 이렇다.
# 어떤 Pod의 컨테이너 cgroup 경로
/sys/fs/cgroup/kubepods.slice/kubepods-burstable.slice/kubepods-burstable-pod<uid>.slice/<container>/
# CPU limit (millicores → quota/period)
cpu.max
# CPU request (shares)
cpu.weight
# 메모리 limit
memory.max
# 메모리 request (soft limit / low)
memory.low
여기서 은근히 놓치기 쉬운 게 memory.max를 줄이는 방향의 resize다. 컨테이너가 현재 이미 그 값보다 더 쓰고 있으면 어떻게 될까? runc는 그냥 커널에 값을 쓰고, 커널은 즉시 OOM 회수를 시도한다. 회수가 안 되면 그 컨테이너의 프로세스가 OOM kill 당한다. 즉 memory limit을 줄이는 방향은 여전히 위험하고, 실제로 kubelet은 memory limit 감소를 별도로 다룬다.
기본적으로 memory limit 감소는 정책에 따라 거부하거나(RestartContainer 정책이면 컨테이너를 재시작), 조심스럽게 진행한다. Pod spec의 resizePolicy 필드가 여기서 나온다. 컨테이너별로 CPU/메모리 각각에 대해 NotRequired(재시작 없이) 또는 RestartContainer(재시작 필요)를 지정할 수 있다.
메모리를 안전하게 줄이려면 대개 컨테이너 재시작이 필요하다. 우리 팀에서는 자바 프로세스에 대해서는 memory에 RestartContainer를 명시적으로 걸어두기로 했다. JVM은 시작 시점에 heap을 잡기 때문에 어차피 재시작 없이 memory limit을 줄여도 실제 사용량이 안 준다.
스케줄러는 뭐 하고 있나
여기가 조금 헷갈리는 지점인데, resize 요청 자체는 스케줄러를 거치지 않는다. Pod은 이미 노드에 바인딩돼 있고, resize는 그 노드 안에서 처리된다. 그럼 스케줄러는 이 상황을 어떻게 알까?
답은 노드의 allocatable 재계산이다. 노드에 있는 Pod들의 requests 합이 바뀌면, 새로 오는 Pod에 대한 스케줄링 결정에 영향을 준다. 스케줄러 캐시가 Pod status를 watch하고 있어서, resize가 완료되면 캐시의 노드 리소스 사용량이 자동으로 업데이트된다.
그런데 여기 미묘한 레이스 컨디션이 있다. resize가 InProgress 상태일 때, 즉 desired와 actual이 다를 때, 스케줄러는 어느 값을 기준으로 노드 여유를 계산해야 할까? 커뮤니티 결론은 "actual + desired 중 큰 값"이다. 요컨대 노드 여유를 보수적으로 잡는다. 이렇게 안 하면 resize 중인 노드에 새 Pod을 스케줄했다가 resize가 완료되는 순간 노드가 초과 할당 상태로 빠질 수 있다.
우리가 실제로 부딪힌 것들
스테이징에서 며칠 굴려보면서 몇 가지 배웠다.
VPA와 같이 쓸 때는 조심해야 한다. VPA가 in-place resize를 지원하기 시작한 게 최근인데, 아직도 recreation 모드로 돌리는 곳이 많다. 만약 VPA와 다른 컨트롤러(예: HPA)가 동시에 리소스를 만지려고 하면 상태가 꼬인다. 우리는 우선 특정 워크로드만 opt-in하는 식으로 접근하고 있다.
메트릭 관측이 애매하다. 기존에 kube_pod_container_resource_requests로 대시보드를 만들어놨었는데, 이게 spec 기준이라 resize 요청 순간 바로 값이 바뀐다. 실제 cgroup에 반영된 값은 status.containerStatuses[*].resources인데, 이걸 export하는 메트릭이 별도로 필요하다. 관측 갭이 생긴다.
Node local storage나 hugepages는 여전히 resize 대상이 아니다. GA에 포함된 건 CPU와 메모리뿐이다. 실무에서는 이 사실을 팀 내에 확실히 공유해둬야 나중에 "어라 왜 안 되지" 하는 일이 안 생긴다.
마무리
Pod을 죽이지 않고 리소스를 바꾼다는 발상은 오래 전부터 있었지만, 이걸 실제로 안전하게 만드는 데 필요한 조각이 생각보다 많다. API 표면(subresource, condition), 상태 머신(Deferred/InProgress/Infeasible), kubelet의 admission과 allocation, CRI를 통한 cgroup 조작, 스케줄러 캐시의 보수적 계산까지. 어떤 부분 하나만 놓쳐도 사용자 입장에서는 "왜 안 되지?" 상태에 빠지기 쉽다.
우리 팀에서는 아직 프로덕션에는 안 넣었다. 좀 더 관측 파이프라인을 정리한 뒤에 짜증나는 Pod들부터 점진적으로 opt-in할 계획이다. 그전까지는 사내 위키에 위 흐름도를 붙여놓고 팀원들이 이해하도록 하는 게 우선.
다음에는 CPU manager static policy와 in-place resize가 정확히 어떻게 상호작용하는지도 정리해보려고 한다. 이게 실은 아까 짧게 언급한 Deferred 케이스의 핵심이다.