IT/Kubernets

In-place Pod Resize, GA는 됐지만 우리 팀은 아직 조심스럽게 쓴다 — 내부 동작과 함정

gfrog 2026. 8. 16. 06:22

작년 12월 1.35에서 In-place Pod Resize가 드디어 GA로 승격됐다. KEP-1287, alpha가 1.27에서 붙었으니 거의 3년을 굴러 온 셈이다. 사내에서도 "이제 VPA를 Recreate 없이 돌릴 수 있는 거냐"는 얘기가 나왔고, 실제로 몇 개 워크로드에는 붙여봤다. 근데 붙여보고 나서 든 생각은, 스펙 문서만 보고 판단하기엔 내부 동작이 좀 미묘하다는 거였다.

이 글은 우리 팀이 실제로 어떤 지점에서 걸렸고, 왜 GA인데도 아직 전 클러스터 롤아웃을 안 했는지에 대한 이야기다. 스펙 요약이 아니라 kubelet과 컨테이너 런타임 경계에서 실제로 무슨 일이 벌어지는지를 파고들어 본다.

리사이즈 요청이 실제로 통과하는 경로

kubectl로 파드 리소스를 patch하면, 그 요청은 pod의 spec.containers[].resources를 바꾼다. 여기까지는 그냥 API 서버 레벨의 업데이트다. 재밌는 건 그 다음이다.

kubelet의 pod worker는 파드 spec 변경을 감지하고 SyncPod를 트리거한다. 여기서 resize actuator가 등장한다. actuator는 대략 이런 순서로 동작한다.

  1. 현재 노드에 여유 리소스가 있는지 체크 (admit)
  2. 컨테이너 런타임에 UpdateContainerResources gRPC 호출
  3. 성공하면 status.resources를 새 값으로 업데이트
  4. 성공하면 pod의 status.resize""(빈 문자열, InProgress 해제)로 마킹

여기서 첫 번째 함정. status.resize는 원래 알파 시절에 Proposed / InProgress / Deferred / Infeasible 같은 상태값이 있었는데, GA 오면서 조건(Conditions) 기반으로 바뀌었다. PodResizePending, PodResizeInProgress 두 개 컨디션이 파드에 붙는다. 알파/베타 시절 튜토리얼 보고 그대로 스크립트 짜면 안 먹힌다. 우리 팀 자동화 스크립트도 처음에 이거 때문에 한번 갈아엎었다.

진짜 재밌는 지점: cgroup은 어떻게 바뀌는가

CPU 리사이즈는 상대적으로 얌전하다. cgroup v2 기준 cpu.max, cpu.weight 값을 갈아 끼우면 끝. 컨테이너 프로세스는 다음 스케줄링 사이클부터 바뀐 할당량으로 돈다. CFS 밴드위스는 워낙 다이나믹해서 아무도 안 놀란다.

메모리가 문제다. memory.max (cgroup v2) 값을 늘리는 건 안전하다. 줄이는 건 얘기가 다르다.

컨테이너가 이미 새 limit보다 많이 쓰고 있으면? 커널 입장에서는 두 가지 선택지가 있다. 하나는 reclaim을 최대한 밀어붙여서 새 값에 맞춰 페이지를 뱉게 하는 것, 다른 하나는 OOM killer를 부르는 것. cgroup v2에서 memory.max를 낮추면 커널은 리클레임을 먼저 시도하고, 그래도 못 내리면 OOM으로 컨테이너를 죽인다.

이 부분에서 kubelet은 파드 스펙에 resizePolicy를 두고 컨테이너별로 재시작 여부를 지정할 수 있게 해뒀다.

resources:
  requests:
    cpu: 200m
    memory: 512Mi
  limits:
    cpu: 500m
    memory: 1Gi
resizePolicy:
- resourceName: cpu
  restartPolicy: NotRequired
- resourceName: memory
  restartPolicy: RestartContainer

메모리는 축소 리사이즈 시 컨테이너를 재시작하는 게 안전하다는 게 대체적인 컨센서스다. 근데 여기서 우리 팀이 처음 놓친 게 있었다. restartPolicy: RestartContainer는 리사이즈 방향에 상관없이 항상 재시작한다. 늘릴 때조차 재시작이다. 우리는 처음에 "메모리는 위험하니까 RestartContainer로 두자"고 했다가, VPA가 메모리를 늘리는 상황에서도 파드가 재시작되는 걸 보고 나서야 이 정책이 방향 구분을 안 한다는 걸 깨달았다. 결국 커스텀 컨트롤러로 방향에 따라 policy를 동적으로 조절하는 우회로를 짰다. 좀 지저분하다.

노드 리소스 회계, 그리고 여기서 새는 부분

리사이즈가 성공하면 kubelet은 노드의 allocatable을 다시 계산한다. 이 부분은 얼핏 당연해 보이지만, 스케줄러가 보는 값과 kubelet이 보는 값 사이에 미묘한 지연이 있다.

예를 들어 파드 A가 500m→2000m CPU로 리사이즈된 상황. kubelet이 승인하고 실제 cgroup까지 반영되는 데 걸리는 시간 동안, API 서버의 파드 status는 새 값으로, 하지만 스케줄러의 노드 뷰는 아직 옛날 회계로 남아 있다. 이 창(window) 동안 새 파드가 스케줄링되면? 오버커밋이 생긴다. 심하지 않은 오버커밋이라 CPU에서는 대개 티가 안 나지만, 메모리라면 얘기가 다르다.

이거 때문에 우리는 처음에 메모리 확장 리사이즈는 무조건 노드에 여유가 있는 쪽으로 제한을 걸었다. admit 단계에서 kubelet이 다시 계산은 하지만, 여러 개가 동시에 리사이즈되면 그 사이에 순간적으로 노드 리소스가 부족해질 수 있다. 실제로 스테이징에서 memory pressure로 pod eviction이 튀는 걸 목격했다. 재현이 어려워서 원인 잡는데 하루가 걸렸다.

VPA와의 통합, 그리고 아직 남아 있는 어색함

VPA(Vertical Pod Autoscaler)와 in-place resize를 붙이는 게 원래 이 KEP의 핵심 use case였다. VPA의 updateMode: InPlaceOrRecreate가 1.34쯤부터 stable로 굳어졌고, 이제 컨피그만 켜면 된다.

근데 실전에서 굴려보면 좀 어색한 지점이 있다. VPA의 recommendation 주기는 기본 15초 정도인데, 이게 in-place resize와 만나면 파드가 계속 미세하게 조정된다. cgroup writes가 하루에 수백 번씩 발생한다. 커널 입장에서 부담은 아니지만, 모니터링/감사 로그가 지저분해진다.

이거 완화하려고 우리는 VPA의 minRecommendationInterval을 5분으로 늘렸고, 리사이즈 delta가 request의 15% 미만이면 무시하도록 커스텀 mutating webhook을 앞에 걸었다. 이 부분은 아직 시행착오 중이고, 표준 답은 없는 것 같다.

그래서, 지금 켤 만한가

우리 팀은 이렇게 정리했다. 개발/스테이징 클러스터는 전부 켰다. 프로덕션은 stateless 워크로드에만 켰고, 그것도 CPU만 in-place resize를 허용하고 메모리는 여전히 Recreate로 돌린다. DB나 큐 같은 stateful 워크로드는 아직 안 켰다. 위에 적은 함정들이 정리되고 조직 내 SRE 룰북에 반영될 때까지는 좀 더 볼 생각이다.

GA라는 게 "안전하다"의 뜻은 아니라는 걸 이번에 다시 느꼈다. 스펙이 안정됐다는 것과 운영이 안정됐다는 건 다른 얘기다. 그래도 이거 없이 pod resize 하려고 VPA Recreate 모드로 돌리던 시절이랑 비교하면 삶의 질은 확실히 나아졌다.

혹시 프로덕션에서 in-place resize를 적극적으로 쓰시는 팀 있으면 어떻게 정책 잡으셨는지 댓글로 공유 부탁드립니다. 특히 메모리 축소 케이스가 궁금하다.