In-place Pod Resize, GA는 됐지만 우리 팀은 아직 조심스럽게 쓴다 — 내부 동작과 함정
작년 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는 대략 이런 순서로 동작한다.
- 현재 노드에 여유 리소스가 있는지 체크 (
admit) - 컨테이너 런타임에
UpdateContainerResourcesgRPC 호출 - 성공하면
status.resources를 새 값으로 업데이트 - 성공하면 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를 적극적으로 쓰시는 팀 있으면 어떻게 정책 잡으셨는지 댓글로 공유 부탁드립니다. 특히 메모리 축소 케이스가 궁금하다.