21장. 요청과 제한, 그리고 롤아웃 — requests/limits·terminationGracePeriodSeconds
매니페스트에 limits.cpu: 500m이라고 적었더니, Pod 안의 cpu.max에 50000 100000이 들어 있었습니다. 14장에서 docker run --cpus 0.5로 만든 그 값과 글자 하나 다르지 않습니다.
{/* 삽화 자리: 사람이 종이에 숫자를 적어 창구에 내민다. 창구 뒤로 이어진 관을 따라 그 종이가 흘러가더니, 반대편 끝에서 작업대 둘레에 그어지는 선이 되어 나온다. 종이에 적힌 숫자와 선의 폭이 정확히 같다. 사람은 선을 그은 적이 없다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
20장에서 프로브를 붙였습니다. 검사가 실패하면 트래픽이 끊기고, liveness면 앱이 죽었죠.
그 검사가 실패하는 흔한 원인이 앱 잘못이 아닐 때가 있습니다. 20장이 남긴 질문은 둘이었습니다. 자원의 선을 어디에 긋느냐, 그리고 Pod 셋을 하나씩 갈아 끼우는 동안 어떻게 안 끊기게 하느냐.
두 답이 같은 자리에 있습니다. 제가 안 쓴 값들이요.
그래서 선을 긋습니다. 매니페스트에 네 줄이면 되고요. 그런데 그 네 줄이 전부 같은 곳으로 가지는 않습니다.
스토리
매니페스트에 네 줄을 적었습니다.
resources:
requests: { memory: 256Mi, cpu: 250m }
limits: { memory: 512Mi, cpu: 500m }그리고 Pod 안에 들어가 7장에서 만졌던 파일들을 읽었습니다.
=== 1. 내가 적은 숫자가 어디로 갔나 (7·13·14장의 그 파일) ===
매니페스트: requests(memory 256Mi, cpu 250m) limits(memory 512Mi, cpu 500m)
memory.max = 536870912
cpu.max = 50000 100000
cpu.weight = 35
memory.min = 0
QoS 등급: Burstable✅ 실측 (kind 0.33.0 / Kubernetes 1.37.0 / Docker Engine 29.8.0 / Docker Desktop 4.91.0 / Apple M1 / macOS 26.6, 2026-09. 전체 소스: docs/book/code/container/ch21/)
memory.max가 536870912입니다. 512MiB죠. 제가 적은 limits.memory가 그대로 들어갔습니다.
cpu.max는 50000 100000이고요. 14장에서 이 두 숫자를 읽는 법을 배웠습니다. 100밀리초마다 50밀리초. docker run --cpus 0.5가 만들던 값과 같습니다.
그러니까 5부 내내 새 개념처럼 보이던 것들 아래에 2부에서 만진 파일이 그대로 있습니다.
핵심
limits와 requests는 다른 곳으로 갑니다
requests는 저 출력에 안 보입니다. memory.min은 “최소한 이만큼은 뺏기지 않는다”를 적는 칸입니다. 거기가 0이에요. 제가 256Mi를 적었는데 cgroup에는 안 들어갔습니다.
cpu.weight가 35인 게 requests.cpu의 흔적입니다. 이건 제한이 아니라 몫이에요. 여러 컨테이너가 CPU를 두고 경쟁할 때 누가 더 가져갈지를 정하는 가중치입니다. 경쟁이 없으면 아무 일도 안 하고요.
값이 어떻게 정해지는지 훑어봤습니다.
=== 1-2. cpu.weight 는 requests.cpu 를 어떻게 옮긴 값인가 ===
requests.cpu=100m → cpu.weight=17
requests.cpu=250m → cpu.weight=35
requests.cpu=500m → cpu.weight=59
requests.cpu=1000m → cpu.weight=100
requests.cpu=2000m → cpu.weight=174✅ 실측 (같은 환경, 같은 소스)
CPU 하나가 100입니다. 그게 기준점이에요.
그런데 두 배로 늘려도 가중치는 두 배가 안 됩니다. 500m의 59에서 1000m의 100으로, 1.7배쯤만 오르죠. 정확한 변환식까지는 확인하지 않았습니다. 여기서 볼 건 requests.cpu가 상한이 아니라 이 가중치 하나로만 옮겨진다는 사실입니다. 14장에서 본 제동은 cpu.max가 걸고, 이 값은 경쟁할 때의 몫만 정합니다.
| 어디로 가나 | 무엇을 하나 | |
|---|---|---|
limits | cgroup 파일(memory.max, cpu.max) | 넘으면 죽이거나 제동을 건다 |
requests | 스케줄러, 그리고 cpu.weight | 어느 노드에 올릴지 정하고, 경쟁할 때 몫을 정한다 |
requests는 예약이고 limits는 상한입니다. requests는 스케줄러한테 하는 말이고, limits는 커널한테 하는 말이에요. requests는 “이만큼은 필요합니다”라고 요청하는 거고, limits는 “이 이상은 못 씁니다”라고 긋는 겁니다.
노드는 requests로 계산합니다
그럼 스케줄러는 그 숫자로 뭘 할까요. 노드를 들여다봤습니다.
=== 2. 노드는 그 숫자를 어떻게 세나 ===
Allocated resources:
(Total limits may be over 100 percent, i.e., overcommitted.)
Resource Requests Limits
-------- -------- ------
cpu 350m (4%) 500m (6%)
memory 306Mi (3%) 512Mi (6%)✅ 실측 (같은 환경, 같은 소스)
쿠버네티스는 두 열을 따로 셉니다. 그리고 괄호 안의 경고가 핵심이에요. limits 합계는 100%를 넘어도 됩니다.
스케줄러가 “이 노드에 자리가 있나”를 판단할 때 쓰는 건 requests입니다. limits는 안 봐요. 그래서 requests 1GiB에 limits 4GiB인 Pod를 노드 하나에 여러 개 올릴 수 있습니다. 다 같이 limits까지 쓰지는 않을 거라고 보는 거죠.
이게 오버커밋입니다. 자원을 아껴 쓰는 대신 위험을 하나 안습니다. 정말로 다 같이 쓰기 시작하면 메모리가 모자라고, 그때 13장의 그 OOMKilled가 나옵니다.
requests를 남은 자리보다 크게 걸면 어떻게 되는지도 봤습니다.
=== 3. requests 가 남은 자리보다 크면 ===
toobig Pending <none>
0/3 nodes are available: 1 node(s) had untolerated taint(s), 2 Insufficient memory.✅ 실측 (같은 환경, 같은 소스)
Pending입니다. 노드가 안 정해졌어요(<none>).
메시지가 친절합니다. 노드 셋 중 하나는 테인트 때문에 못 쓰고, 둘은 메모리가 모자랍니다. 18장에서 컨트롤 플레인에 앱 Pod가 안 올라가던 이유가 그 테인트고요.
Pod가 Pending으로 멈춰 있으면 대부분 이 메시지에 답이 있습니다.
QoS는 내가 안 쓴 값입니다
출력의 마지막 줄에 Burstable이 있었죠. 이것도 제가 적은 적 없습니다.
쿠버네티스는 requests와 limits를 어떻게 적었는지로 Pod에 등급을 매깁니다.
조합을 바꿔가며 확인했습니다.
=== 3-2. QoS 등급은 무엇으로 정해지나 ===
cpu·memory 둘 다 requests == limits → Guaranteed
memory 만 requests == limits → Burstable
limits 만 적음 (requests 생략) → Guaranteed
아무것도 안 적음 → BestEffort✅ 실측 (같은 환경, 같은 소스)
두 번째 줄과 세 번째 줄이 의외입니다. memory만 맞춰서는 Guaranteed가 안 됩니다. cpu와 memory 둘 다 있어야 해요.
그리고 limits만 적어도 Guaranteed가 됩니다. requests를 생략하면 쿠버네티스가 limits 값으로 채워 넣기 때문이에요. 여기서도 제가 안 쓴 값이 들어옵니다.
| 등급 | 조건 |
|---|---|
Guaranteed | 모든 컨테이너가 cpu·memory 둘 다 limits를 갖고, requests가 그 값과 같다 |
Burstable | Guaranteed는 아닌데, 컨테이너 하나라도 requests나 limits가 있다 |
BestEffort | 모든 컨테이너가 아무것도 안 적었다 |
노드 메모리가 모자랄 때 문턱이 둘입니다. kubelet이 먼저 알아채면 Pod를 골라 내쫓고, 그보다 급하게 터지면 커널의 OOM killer가 13장처럼 프로세스를 죽입니다.
두 경로 모두 BestEffort를 먼저 봅니다. 커널 쪽은 숫자로 확인할 수 있어요.
--- 그 등급이 커널에도 반영되나 ---
qg QoS=Guaranteed oom_score_adj=-997
qz QoS=BestEffort oom_score_adj=1000✅ 실측 (같은 환경, 같은 소스)
oom_score_adj는 OOM killer가 누구를 고를지 정할 때 쓰는 보정값입니다. 클수록 먼저 죽어요. Guaranteed는 -997이고 BestEffort는 1000입니다.
그러니까 아무것도 안 적는 게 제일 위험합니다. 자유롭게 쓰는 게 아니라 제일 먼저 쫓겨나는 거예요.
📄 문서 기반 (kubelet 쪽 축출 순서는 Pod Priority에도 영향을 받습니다. 노드를 압박해야 재볼 수 있어서 여기서는 확인하지 않았습니다)
셋을 갈아 끼우는 동안
이제 17장이 남긴 질문입니다. 새 이미지를 반영하려면 컨테이너를 다시 만들어야 하는데, 셋을 하나씩 갈아 끼우면서 안 끊기게 하려면요.
기본 전략부터 봅시다. maxSurge: 25%, maxUnavailable: 25%입니다. 이것도 제가 안 썼어요.
replicas가 3이면 maxUnavailable은 내림해서 0, maxSurge는 올림해서 1입니다. 즉 준비된 Pod가 3개 아래로 내려가면 안 되고, 하나를 더 띄운 뒤에 옛것을 내립니다.
쿠버네티스가 그걸 약속해 줍니다. 다만 그 약속은 “준비된”의 정의만큼만 정확해요.
요청을 0.1초 간격으로 계속 보내면서 롤링 업데이트를 걸어봤습니다. 세 판으로요.
=== 4. 롤링 업데이트 중 요청은 끊기나 ===
replicas=3 전략={"maxSurge":"25%","maxUnavailable":"25%"}
readinessProbe 없음 72 FAIL ready 엔드포인트 수: 3개×38회
readinessProbe 있음 4 FAIL 308 OK ready 엔드포인트 수: 3개×152회✅ 실측 (같은 환경, 같은 소스. 표본 수가 다른 건 롤아웃이 끝날 때까지만 세기 때문입니다. 프로브가 없으면 롤아웃이 훨씬 빨리 “끝납니다”)
첫 줄은 전멸입니다. 성공이 없어요. 다시 돌려도 0~1건입니다.
그런데 마지막 열을 보세요. ready 엔드포인트는 내내 3개였습니다. 0.2초마다 세었는데 한 번도 안 줄었어요.
여기가 제가 처음에 틀리게 생각했던 자리입니다. “잠깐 엔드포인트가 0이 돼서 20장의 그 has no endpoints 규칙에 걸렸겠지” 싶었는데, 재보니 아니었습니다.
실제로 벌어진 일은 이렇습니다. 프로브가 없으면 새 Pod는 컨테이너가 뜬 그 순간 Ready가 됩니다. 쿠버네티스는 그 말을 믿고 “3개 유지” 약속을 지켰다고 판단하고 옛 Pod를 내려요. 목록은 계속 3개로 꽉 차 있습니다. 다만 그 3개가 아직 8080을 안 연 새 Pod로 갈렸을 뿐이죠.
kube-proxy는 목록을 의심하지 않습니다. 19장에서 본 그 규칙대로 정직하게 그쪽으로 보내요. 그런데 톰캣이 없으니 커널이 연결을 거절하고, 클라이언트는 Connection refused를 받습니다.
20장의 has no endpoints와는 다른 실패입니다. 그때는 목록이 비어서 거절당했고, 여기서는 목록이 꽉 차 있는데도 거절당합니다. 숫자가 채워져 있다고 안전한 게 아니에요.
readinessProbe를 붙이니 4건으로 줄었습니다. 308번은 성공했고요.
그런데 4건이 남았습니다.
남은 실패는 나가는 쪽에 있습니다
들어오는 쪽은 프로브가 막아줬습니다. 나가는 쪽을 봤어요.
Pod를 지우고 두 시각을 쟀습니다. 앱에 SIGTERM이 도착한 시각과, 그 Pod가 엔드포인트 목록에서 빠진 시각.
=== 5. 남은 실패는 어디서 오나 (종료 순서를 잰다) ===
terminationGracePeriodSeconds: 30 (매니페스트에 쓴 적 없다)
delete 호출 →
앱에 SIGTERM 도착 : +0.05초
엔드포인트가 ready=false: +0.13초 ← kube-proxy 가 빼는 기준
목록에서 아예 제거 : +0.23초
SIGTERM 부터 ready=false 까지 0.08초 동안, 종료를 시작한 Pod 가 아직 받고 있었다✅ 실측 (같은 환경, 같은 소스)
SIGTERM이 먼저 도착합니다.
Pod를 지우면 두 가지가 동시에 시작됩니다. kubelet이 컨테이너에 SIGTERM을 보내는 일과, 엔드포인트 컨트롤러가 목록에서 빼는 일이요. 둘 사이에 순서 보장이 없습니다.
가운데 줄이 트래픽이 끊기는 시점입니다. kube-proxy는 목록에서 사라지기를 기다리지 않고 ready가 false로 뒤집히면 규칙에서 빼거든요.
실측에서 0.08초 차이가 났습니다. 그 사이 앱은 이미 종료를 시작했는데 Service는 아직 그 Pod로 보내고 있었어요. 남은 4건이 그겁니다.
여기서 잰 건 컨트롤 플레인까지입니다. 그 뒤에 노드마다 kube-proxy가 규칙을 다시 쓰는 시간이 또 붙어요. 노드가 많을수록 그쪽이 더 큽니다. 그래서 실무에서 잡는 유예는 0.1초가 아니라 몇 초 단위입니다.
여는 글의 그 유예가 여기 있습니다
출력의 첫 줄을 보세요. terminationGracePeriodSeconds: 30.
제가 안 썼습니다. 18장에서 tolerationSeconds: 300을 발견했던 것과 같아요.
여는 글에서 시작한 이야기가 여기서 끝납니다. 그때 docker stop이 유예를 다 쓰고 SIGKILL로 죽였죠. 그 유예가 문서에는 10초, 제 Docker Desktop에서는 3초였고요. 쿠버네티스에서는 30초이고, 이름이 terminationGracePeriodSeconds입니다.
12장에서 본 규칙이 여기서도 그대로입니다. kubelet이 SIGTERM을 보내고, 앱이 안 나가면 유예가 끝날 때 SIGKILL을 보냅니다. exec form이 아니면 여전히 SIGTERM이 자바에 안 닿고요. 5부까지 와도 3부에서 고친 Dockerfile이 그대로 일합니다.
preStop으로 순서를 뒤집습니다
0.22초 문제는 어떻게 고칠까요.
SIGTERM을 늦추면 됩니다. preStop 훅이 그 자리예요. 컨테이너를 종료하기 전에 뭔가를 실행하고, 그게 끝나야 SIGTERM을 보냅니다.
lifecycle:
preStop:
sleep:
seconds: 5아무것도 안 하고 5초를 기다립니다. 그 5초 동안 ready=false가 모든 노드의 kube-proxy까지 퍼지고, 그 뒤에야 앱이 종료를 시작합니다.
오래된 글에서는 이 자리에 exec: command: ["sh", "-c", "sleep 5"]를 씁니다. 그게 오래 쓰이던 관용구였어요. 다만 셸이 없는 이미지(distroless 같은)에서는 조용히 실패합니다. 12장에서 shell form이 셸의 존재를 전제하던 것과 같은 함정이죠. 지금은 sleep이 정식 필드라 셸이 필요 없습니다.
=== 6. preStop 으로 그 순서를 뒤집으면 ===
readinessProbe + preStop 1 FAIL 332 OK ready 엔드포인트 수: 3개×142회 4개×2회✅ 실측 (같은 환경, 같은 소스)
4건이 1건이 됐습니다.
이상해 보이는 해법입니다. 아무 일도 안 하는 sleep을 넣는 거니까요. 그런데 이게 하려는 일은 분명합니다. 앱이 종료를 시작하기 전에 트래픽이 먼저 끊기게 하는 거예요. 순서를 뒤집는 겁니다.
주의할 게 하나 있습니다. preStop에 쓰는 시간은 유예 안에 포함됩니다. 유예 카운트다운이 preStop보다 먼저 시작하거든요. 유예가 30초인데 preStop에서 25초를 쓰면 앱이 정리할 시간은 5초뿐이에요. 둘을 같이 계산해야 합니다.
숫자는 재서 정합니다
| 상황 | 하면 좋은 것 |
|---|---|
requests를 정할 때 | 실제로 쓰는 양을 재서 정한다. 13장의 RSS, 14장의 cpu.stat |
limits를 정할 때 | requests보다 크게, 다만 13장의 힙 바깥 몫을 남기고 |
| 아무것도 안 적기 | 제일 위험하다. BestEffort는 제일 먼저 쫓겨난다 |
Pod가 Pending일 때 | kubectl get events의 FailedScheduling 메시지를 읽는다 |
| 배포 중 5xx | readinessProbe가 먼저, 그다음이 preStop |
| 종료가 오래 걸리는 앱 | terminationGracePeriodSeconds를 늘리되 preStop 시간까지 합쳐 계산한다 |
| 그래도 SIGTERM이 안 닿을 때 | 3부로 돌아간다. CMD가 exec form인지 본다 |
정리
limits는 cgroup 파일로 갑니다. 실측에서limits.memory: 512Mi가memory.max=536870912로,limits.cpu: 500m이cpu.max=50000 100000으로 들어갔어요. 14장의--cpus 0.5와 같은 값입니다.requests는 cgroup에 거의 안 들어갑니다.memory.min은 0이었고,cpu.weight에만 흔적이 남았어요. requests는 예약이고 limits는 상한입니다.- 스케줄러는 requests로만 자리를 계산합니다. limits 합계는 100%를 넘어도 돼요. 그게 오버커밋이고, 다 같이 쓰기 시작하면 13장의
OOMKilled가 나옵니다. - requests가 남은 자리보다 크면 Pod가
Pending으로 멈춥니다. 이유는FailedScheduling이벤트에 적혀 있어요. - QoS 등급도 제가 안 쓴 값입니다.
Guaranteed가 되려면 cpu·memory 둘 다 있어야 하고,limits만 적어도 됩니다 — requests를 생략하면 limits 값으로 채워지거든요. - 그 등급이 커널에도 반영됩니다.
Guaranteed의oom_score_adj가 -997,BestEffort가 1000이었어요. - 롤링 업데이트 중 요청을 계속 보내봤습니다. readinessProbe가 없으면 전멸했고, 붙이니 312번 중 4번만 실패했어요.
- 전멸할 때도 ready 엔드포인트는 내내 3개였습니다. 목록이 비어서가 아니라, 그 목록이 아직 포트를 안 연 Pod로 갈려 있어서 거절당한 거예요. 20장의
has no endpoints와는 다른 실패입니다. - 남은 4번은 나가는 쪽 문제였습니다. SIGTERM이 +0.05초에 도착하고
ready=false가 +0.13초에 됐어요. 그 0.08초 동안 종료 중인 Pod가 아직 받고 있었습니다. - 그건 컨트롤 플레인까지의 시간이고, 노드마다 kube-proxy가 규칙을 다시 쓰는 시간이 뒤에 또 붙습니다. 노드가 많을수록 그쪽이 큽니다.
preStop으로 SIGTERM을 늦추면 그 겹침이 줄어듭니다. 실측에서 4건이 1건이 됐어요. 지금은preStop.sleep이 정식 필드라 셸 없는 이미지에서도 됩니다.terminationGracePeriodSeconds: 30도 제가 안 쓴 값입니다. 여는 글의 그 유예가 여기서 이름을 바꿔 돌아왔어요.preStop에 쓰는 시간은 그 유예 안에 포함됩니다. 둘을 같이 계산해야 합니다.
생각해볼 질문
여는 글에서 docker stop이 3.2초를 쓰고 exit 137로 죽는 걸 봤습니다. 그리고 방금 terminationGracePeriodSeconds: 30을 봤어요. 같은 물건입니다. 이름만 바뀌었죠.
본문은 여기서 끝납니다. 스물한 장을 거쳐오는 동안 이런 게 몇 번 있었어요. memory.max는 7장에서 손으로 만졌고 13장에서 JVM이 읽었고 여기서 매니페스트가 씁니다. cpu.max도, namespace도, 시그널도요.
새로 배운 게 아니라 같은 것이 다른 이름으로 다시 나온 겁니다.
그럼 1장의 chroot부터 여기까지, 정말로 하나였을까요.