Skip to Content

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가 걸고, 이 값은 경쟁할 때의 몫만 정합니다.

어디로 가나무엇을 하나
limitscgroup 파일(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가 그 값과 같다
BurstableGuaranteed는 아닌데, 컨테이너 하나라도 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 메시지를 읽는다
배포 중 5xxreadinessProbe가 먼저, 그다음이 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부터 여기까지, 정말로 하나였을까요.

▶ 닫는 글 · 선은 벽이 아니다

Last updated on