18장. 한 대로 안 될 때 — 오케스트레이션, Pod와 Deployment
Pod를 지웠더니 이름이 다른 Pod가 생겼습니다. 재시작 횟수는 0이고요. 고쳐서 다시 세운 게 아니라 버리고 새로 만든 겁니다.
{/* 삽화 자리: 선반 위에 같은 모양의 물건 셋이 놓여 있고, 관리인이 그중 하나를 집어 상자에 버린다. 그러자 옆에서 똑같이 생긴 새 물건이 하나 만들어져 빈자리에 놓인다. 버려진 것을 고치려는 시도는 없다. 선반 옆에는 “셋”이라고 적힌 숫자판만 걸려 있고 관리인은 그 숫자만 본다. 프롬프트: docs/book/illustration-prompts-container.md */}
배경
17장이 두 가지를 남겼습니다.
기계가 죽으면 아무도 컨테이너를 안 띄운다는 것. 재시작 정책을 집행하는 Docker 데몬이 그 기계 위에 있었으니까요.
새 코드를 반영하려면 컨테이너를 다시 만들어야 한다는 것도 남았죠. 껐다 켜는 걸로는 안 됐습니다.
두 문제의 답이 같은 곳에 있습니다. 컨테이너를 관리하는 쪽을 기계 바깥으로 옮기는 거예요.
그 일을 하는 물건을 오케스트레이터라고 부릅니다. 지금은 사실상 하나뿐이고요. 쿠버네티스입니다.
5부는 그 안에서 앞에서 본 문제들이 어떤 이름으로 다시 나오는지를 봅니다.
스토리
실습용 클러스터를 하나 띄웠습니다. kind는 노드 셋짜리 클러스터를 만들어 줍니다.
# 설정 파일에 control-plane 1대 + worker 2대를 적었습니다
kind create cluster --name ch18 --config kind-cluster.yaml
# 우리 앱 이미지는 어느 레지스트리에도 없으니 직접 넣어줍니다
kind load docker-image container-demo:exec --name ch18
kubectl get nodes=== 1. 클러스터의 노드 ===
ch18-control-plane Ready control-plane
ch18-worker Ready <none>
ch18-worker2 Ready <none>✅ 실측 (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/ch18/)
노드가 셋입니다. 기계 세 대라는 뜻이죠.
그런데 제 노트북은 한 대입니다. 저 셋이 어디 있을까요.
=== 2. 그 노드들은 사실 무엇인가 ===
ch18-control-plane 이미지=kindest/node:v1.37.0
ch18-worker 이미지=kindest/node:v1.37.0
ch18-worker2 이미지=kindest/node:v1.37.0
ch18-worker 의 1번 프로세스: systemd✅ 실측 (같은 환경, 같은 소스)
docker ps에 나옵니다. 노드가 컨테이너예요.
5장에서 시작한 이야기가 여기서 한 바퀴 돕니다. 컨테이너는 프로세스라고 했죠. 그 안의 1번 자리에 이번엔 systemd가 앉아 있고, 그 systemd가 containerd와 kubelet을 띄웁니다. kubelet은 노드마다 하나씩 도는 에이전트예요. “이 노드에서 이 Pod들을 띄워라”를 받아 실제로 컨테이너를 만드는 쪽입니다.
진짜 서버에서도 PID 1은 대개 systemd이고 그게 kubelet을 띄웁니다. 다른 건 그 systemd가 지금 컨테이너 안에 있다는 것뿐이에요. 덕분에 노트북에서 노드 셋을 띄울 수 있고, 진짜 서버 세 대에서도 명령은 똑같습니다.
핵심
세 겹으로 만들어집니다
앱을 올려봅시다. Docker에서는 docker run이었는데, 여기서는 원하는 상태를 파일에 적습니다.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: app
image: container-demo:exec
# imagePullPolicy·ports 는 논점이 아니라 잘랐습니다
# 전체: docs/book/code/container/ch18/web-deployment.yamlkubectl apply로 올리면 이렇게 됩니다.
=== 3. Deployment 를 만들면 무엇이 생기나 ===
Deployment web 준비=3/3
ReplicaSet web-79c46d79d7 원하는=3 현재=3
Pod web-79c46d79d7-7rsrp 노드=ch18-worker IP=10.244.2.2
Pod web-79c46d79d7-ppzhq 노드=ch18-worker2 IP=10.244.1.10
Pod web-79c46d79d7-qzssw 노드=ch18-worker IP=10.244.2.3✅ 실측 (같은 환경, 같은 소스)
파일 하나를 올렸는데 세 종류가 생겼습니다.
| 이름 | 하는 일 |
|---|---|
| Deployment | ”이 템플릿으로 3개” 라는 뜻을 보관한다. 템플릿이 바뀌면 새 ReplicaSet을 만들고, 개수만 바뀌면 쓰던 걸 그대로 쓴다 |
| ReplicaSet | 같은 Pod를 정확히 3개로 유지한다 |
| Pod | 실제로 컨테이너가 도는 단위 |
Pod 셋이 두 노드에 나뉘어 붙었고 각자 IP를 받았습니다. 어느 노드에 올릴지는 스케줄러가 정해요. 내가 정하지 않습니다.
따라 하면 분포가 다를 수 있습니다. 넷이 한 노드에 몰릴 수도 있어요. 볼 건 분포가 아니라 “내가 안 정했다”는 쪽입니다.
ReplicaSet 이름 끝의 79c46d79d7도 제가 지은 게 아닙니다. 템플릿을 해시한 값이에요. 뒤에서 이 해시가 일을 합니다.
이게 Docker와 가장 다른 점입니다. Docker에서는 “이 컨테이너를 띄워라”라고 명령했죠. 여기서는 “3개가 떠 있는 상태여야 한다”고 적어두고, 그 상태를 맞추는 일은 클러스터가 합니다.
지우면 고치지 않고 새로 만듭니다
그 “맞추는 일”이 어떻게 생겼는지 봅시다. Pod 하나를 지워봤어요.
=== 4. Pod 를 지우면 (17장의 재시작과 비교) ===
before: web-79c46d79d7-7rsrp
before: web-79c46d79d7-ppzhq
before: web-79c46d79d7-qzssw
after : web-79c46d79d7-86c64 Running 재시작 0회
after : web-79c46d79d7-ppzhq Running 재시작 0회
after : web-79c46d79d7-qzssw Running 재시작 0회✅ 실측 (같은 환경, 같은 소스)
7rsrp가 사라지고 86c64가 생겼습니다. 여전히 셋이고요.
17장과 비교해 보세요. docker restart는 같은 컨테이너를 껐다 켜서 RestartCount가 올라갔죠. 여기서는 새 Pod의 재시작 횟수가 0입니다. 되살린 게 아니라 버리고 새로 만든 거예요.
17장의 두 번째 문제가 여기서 저절로 풀립니다. 새 이미지를 반영하려면 컨테이너를 다시 만들어야 했죠. 쿠버네티스에는 Pod를 고쳐 쓰는 법이 없습니다. 적어둔 내용이 바뀌면 Pod 자체를 새로 만들어요.
Pod를 고쳐 쓰겠다는 생각을 버리는 게 이 장의 핵심입니다. Pod는 일회용이에요. 이름도 매번 바뀌고요.
다만 Pod 안의 컨테이너는 이야기가 다릅니다. 그건 잠시 뒤에 봅니다.
Pod는 컨테이너가 아닙니다
여기서 용어를 하나 정리해야 합니다. Pod와 컨테이너는 같은 말이 아니에요.
컨테이너 하나만 적은 Pod를 노드 안에서 들여다봤습니다. 노드가 컨테이너니까 들어가는 건 docker exec이고, 그 안에서는 docker 대신 crictl을 씁니다.
docker exec ch18-worker crictl pods --name web-79c46d79d7-7p4lf=== 5. Pod 안에는 컨테이너가 몇 개인가 ===
대상 Pod: web-79c46d79d7-86c64 (노드 ch18-worker2)
앱 컨테이너 PID 3200 (java)
샌드박스 PID 3175 (pause)✅ 실측 (같은 환경, 같은 소스)
프로세스가 둘입니다. 제가 적은 자바 말고, CRI가 샌드박스라고 부르는 pause가 하나 더 있어요.
pause는 아무 일도 안 합니다. 시그널을 기다리며 잠들어 있을 뿐이에요. 그런데 이게 Pod의 정체입니다.
5장에서 /proc/<pid>/ns/를 읽어 프로세스의 소속을 확인했죠. 그 목록을 둘에 대해 나란히 찍어봤습니다.
--- 5장에서 읽은 그 소속 목록 ---
net 앱=net:[4026532941] pause=net:[4026532941] 같다
uts 앱=uts:[4026533811] pause=uts:[4026533811] 같다
ipc 앱=ipc:[4026533812] pause=ipc:[4026533812] 같다
pid 앱=pid:[4026533825] pause=pid:[4026533813] 다르다
mnt 앱=mnt:[4026533824] pause=mnt:[4026533810] 다르다
cgroup 앱=cgroup:[4026533826] pause=cgroup:[4026533975] 다르다✅ 실측 (같은 환경, 같은 소스)
위 셋은 같고 아래 셋은 다릅니다.
net이 같다는 건 둘이 같은 네트워크 namespace에 있다는 뜻이에요. 같은 IP를 쓰고, 서로를 localhost로 부를 수 있습니다. uts가 같으니 호스트 이름도 같고요.
mnt가 다르니 파일시스템은 각자 봅니다. pid도 달라서 서로의 프로세스 목록은 안 보이고요. cgroup도 따로라 자원 제한도 컨테이너별입니다.
pid는 기본값이지 규칙이 아닙니다. shareProcessNamespace: true를 적으면 공유돼요. 그러면 12장에서 본 그 1번 자리를 pause가 맡아 좀비까지 거둡니다.
그러니까 Pod는 네트워크와 호스트 이름을 공유하는 컨테이너 묶음입니다. 6장에서 unshare로 손수 만들던 그 선을, 여기서는 여러 컨테이너가 함께 쓰는 거죠.
pause가 하는 일이 그겁니다. 그 namespace들은 pause와 함께 만들어지고, pause가 살아 있는 한 사라지지 않아요. namespace는 마지막 프로세스가 나가면 없어지는데, pause가 그 안에서 자면서 버티는 겁니다.
컨테이너 하나만 적어도 pause는 늘 생깁니다. 사이드카를 붙이면 그때 같은 pause에 하나 더 붙는 거고요.
컨테이너는 제자리에서 다시 뜹니다
앞에서 “Pod를 고쳐 쓰는 법은 없다”고 했죠. 그 안의 컨테이너는 다릅니다. 앱 컨테이너만 골라 죽여봤어요.
=== 5-2. 앱 컨테이너만 죽이면 Pod 는 어떻게 되나 ===
before: Pod=web-79c46d79d7-7p4lf IP=10.244.2.2 재시작=0 pausePID=1075 appPID=1144
after : Pod=web-79c46d79d7-7p4lf IP=10.244.2.2 재시작=1 pausePID=1075 appPID=1480✅ 실측 (같은 환경, 같은 소스)
Pod 이름도 IP도 그대로고 재시작 횟수만 1이 됐습니다. 앱 프로세스 번호만 바뀌었고요(1144 → 1480).
pause의 번호는 안 바뀌었습니다. 안 죽었으니까요. 그래서 namespace가 유지됐고, 그래서 IP도 유지된 겁니다. 17장의 docker restart와 비슷해 보이지만, 여기서는 같은 Pod 안에서 컨테이너만 갈아 끼운 거예요.
kubectl get pods의 RESTARTS 칼럼이 그 횟수입니다. 이 숫자가 계속 올라가는 Pod를 20장에서 다시 만납니다.
내가 쓴 적 없는 값이 들어 있습니다
Pod를 열어보다 이상한 걸 발견했습니다.
=== 5-3. 내가 쓴 적 없는 값이 Pod 에 들어 있다 ===
node.kubernetes.io/not-ready 300초
node.kubernetes.io/unreachable 300초✅ 실측 (같은 환경, 같은 소스)
제 매니페스트에는 tolerations를 쓴 적이 없습니다. 클러스터가 Pod를 받을 때 끼워 넣은 값이에요.
여는 글에서 시작한 이야기가 여기서 또 나옵니다. 제가 안 쓴 것들이 동작을 정합니다. 저 300초가 무슨 일을 하는지는 바로 다음 절에서 보게 돼요.
기계가 죽으면
이제 17장의 첫 번째 질문입니다. 노드 하나를 통째로 정지시켜봤어요. 컨테이너니까 docker stop이면 됩니다.
=== 6. 기계가 죽으면 누가 띄우나 ===
죽이기 전: 2 ch18-worker 2 ch18-worker2
30초: ch18-worker=Ready Pod[ 4 Running]
60초: ch18-worker=NotReady Pod[ 4 Running]
121초: ch18-worker=NotReady Pod[ 4 Running]
242초: ch18-worker=NotReady Pod[ 4 Running]
332초: ch18-worker=NotReady Pod[ 4 Running]
362초: ch18-worker=NotReady Pod[ 4 Running 2 Terminating]
최종 분포: 2 ch18-worker 4 ch18-worker2✅ 실측 (같은 환경, 같은 소스. 중간 줄은 지면을 위해 덜어냈습니다)
옮겨줍니다. 다만 6분 걸렸어요.
그 6분이 중요합니다. 노드는 1분 만에 NotReady가 됐는데, 그 위의 Pod들은 계속 Running으로 보고됐어요. 실제로는 정지된 기계 위에 있는데도요.
클러스터 전체 상태를 쥔 쪽(컨트롤 플레인)이 노드와 연락이 끊겼을 때 두 가지를 구분할 수 없기 때문입니다. 기계가 죽은 건지, 네트워크만 잠깐 끊긴 건지요. 후자인데 성급히 Pod를 옮기면 같은 앱이 두 군데서 도는 상태가 됩니다.
그래서 기본값이 보수적입니다. 노드가 응답을 멈추면 50초 뒤에 NotReady로 표시하고, 그러고도 300초를 더 기다린 다음에야 Pod를 축출해요. 합쳐서 5분 50초, 실측의 362초와 맞습니다.
뒤의 300초가 방금 본 그 값입니다. 제가 안 썼는데 Pod에 들어 있던 tolerationSeconds요. 앞의 50초는 컨트롤 플레인 설정이고요.
📄 문서 기반 (node-monitor-grace-period 기본값 50초 — 이 클러스터는 플래그를 명시하지 않아 기본값이 적용됩니다)
마지막 줄을 세어보면 Pod가 6개입니다. replicas는 4인데요. 죽은 노드 위의 2개가 Terminating인 채로 안 없어지거든요. Pod 삭제는 그 노드의 kubelet이 “치웠다”고 확인해줘야 끝나는데, 그 kubelet이 같이 죽었으니까요. 노드를 살리면 바로 사라집니다.
여기서 16장의 교훈이 되돌아옵니다. 그때 DNS가 docker pause로 얼려둔 컨테이너를 못 알아봤죠(이름만 같고 이 장의 pause 컨테이너와는 다른 물건입니다). 여기서는 kubectl get pods가 죽은 기계 위의 Pod를 Running이라고 말하고, 끝난 적 없는 삭제를 Terminating이라고 말합니다. 상태 표시는 관찰의 결과지 사실이 아니에요.
그래서 어떻게 잡나
| 상황 | 하면 좋은 것 |
|---|---|
| Pod를 직접 만들고 싶을 때 | 만들지 않기. Deployment가 만들게 한다. 직접 만든 Pod는 죽으면 끝이다 |
| Pod에 뭔가 저장하고 싶을 때 | 하지 않기. 이름도 IP도 매번 바뀌는 일회용이다 |
| 노드 장애 대응 | 6분은 기본값이다. 더 빨라야 하면 Pod spec의 tolerations에 tolerationSeconds를 직접 적어 줄이되, 네트워크 깜빡임에 Pod가 튀는 걸 감수해야 한다 |
Running을 믿을 수 있나 | 노드가 NotReady면 그 위의 Running은 오래된 정보다 |
정리
- kind의 노드는 컨테이너입니다. 그 안의 1번 프로세스가
systemd이고, 그게 kubelet을 띄워 기계 한 대인 척해요. - Deployment를 올리면 Deployment → ReplicaSet → Pod 세 겹이 생깁니다. Deployment는 뜻을 보관하고, ReplicaSet은 개수를 유지하고, Pod가 실제로 돕니다.
- 어느 노드에 올릴지는 스케줄러가 정합니다. 실측에서 Pod 셋이 두 노드에 나뉘어 붙었어요.
- Pod를 지우면 고치지 않고 새로 만듭니다. 실측에서 이름이 바뀌었고 재시작 횟수는 0이었습니다. 17장의
docker restart와 정반대예요. - 다만 Pod 안의 컨테이너는 제자리에서 다시 뜹니다. 앱 컨테이너만 죽였더니 Pod 이름도 IP도 그대로고
RESTARTS만 1이 됐어요. - Pod는 컨테이너가 아닙니다. 컨테이너 하나를 적어도
pause가 함께 생겨요. - 그 둘은
net·uts·ipc를 공유하고pid·mnt·cgroup은 따로 씁니다. 5장에서 읽은 그 소속 목록으로 확인했어요. Pod란 네트워크와 호스트 이름을 함께 쓰는 컨테이너 묶음입니다. pause가 namespace를 붙잡고 있어서, 앱 컨테이너가 죽었다 살아나도 Pod의 IP는 안 바뀝니다. 실측에서pause의 프로세스 번호는 그대로였어요.- 노드가 죽으면 Pod를 옮겨줍니다. 다만 실측에서 362초 걸렸어요.
NotReady판정 50초 + 축출 유예 300초입니다. - 그 300초는 제가 쓴 적 없는 값입니다. 클러스터가 Pod에 끼워 넣어요.
- 그 6분 동안
kubectl get pods는 죽은 기계 위의 Pod를Running이라고 말했습니다. 축출 뒤에도 그 둘은Terminating인 채 남고요.
생각해볼 질문
이제 앱이 세 개 떠 있습니다. 기계가 죽어도 옮겨주고요.
그런데 부를 방법이 없습니다.
Pod마다 IP가 따로 붙었는데, 방금 본 것처럼 Pod는 일회용이에요. 지우면 새로 생기고 이름도 IP도 바뀝니다. 노드가 죽으면 통째로 다른 곳에 다시 생기고요.
16장에서 같은 문제를 한 번 풀었습니다. compose의 내장 DNS가 서비스 이름을 주소로 바꿔줬죠. 그때 남은 한계도 기억납니다 — 이름이 여러 주소로 풀리는데 그게 부하 분산은 아니었고, 못 받는 상태인 컨테이너도 계속 주소를 줬어요.
쿠버네티스는 이 문제를 어떻게 풀까요. 그리고 그때 남은 한계는 여기서 해결될까요.