Kubernetes Study · 02 전반 ①
쿠버네티스 전반 ① — 클러스터 운영
입문에서 쿠버네티스가 무엇이고 왜 필요한지 봤다면, 전반에서는 어떻게 쓰고 안에서 어떻게 도는지를 봐요. 학습 내용을 빠짐없이 담되 배우기 좋은 순서로 다시 묶었고, 입문에서 다룬 내용도 복습 삼아 다시 짚어요. 학습 자료에 없지만 꼭 알아야 하는 내용과, 학습 자료가 나온 뒤 바뀐 최신 내용도 함께 넣었어요. 전반은 세 노트로 나눴어요 — 이 노트(①)는 1~6부 클러스터 구조 · 리소스 · 스케줄링 · 모니터링 · 앱 라이프사이클 · 유지보수, 이어서 ② 보안 · 스토리지 · 네트워크, ③ 설치 · 도구 · 트러블슈팅이에요.
01. 클러스터 한눈에 보기
입문 2부 01에서 클러스터를 마스터 노드(결정)와 워커 노드(실행)로 나눠 봤어요. 전반에서는 마스터 노드를 정식 이름인 컨트롤 플레인(control plane)으로 부르고, 그 안의 부품 하나하나가 정확히 무슨 일을 하는지 들어가 봐요.
학습 자료는 화물선 비유를 써요. 컨트롤 플레인은 항구의 관제 본부, 워커 노드는 컨테이너를 싣고 다니는 화물선이에요. 본부는 어느 배에 어떤 화물을 실을지 정하고 기록하고 감시하며, 배는 실제로 화물을 싣고 다녀요.
1. 컨트롤 플레인과 워커 노드
| 구성 요소 | 사는 곳 | 하는 일 | 화물선 비유 |
|---|---|---|---|
| kube-apiserver | 컨트롤 플레인 | 모든 요청을 받는 정문. 다른 부품은 전부 API 서버를 통해서만 이야기해요 | 접수 창구 |
| etcd | 컨트롤 플레인 | 클러스터의 모든 상태를 저장하는 키-값 저장소 | 화물 장부 |
| kube-scheduler | 컨트롤 플레인 | 새 Pod를 어느 노드에 둘지 결정만 해요 | 배정 담당 |
| kube-controller-manager | 컨트롤 플레인 | 원하는 상태와 현재 상태를 계속 맞추는 컨트롤러 묶음 | 현장 감독관들 |
| cloud-controller-manager | 컨트롤 플레인 (클라우드에서만) | 로드밸런서 · 디스크처럼 클라우드 자원을 만들고 연결 | 외부 업체 담당 |
| kubelet | 모든 노드 | 자기 노드에 배정된 Pod를 실행하고 상태를 보고 | 선장 |
| kube-proxy | 모든 노드 | Service로 들어온 요청이 Pod에 닿도록 노드의 네트워크 규칙을 관리 | 배끼리의 통신 담당 |
| 컨테이너 런타임 | 모든 노드 | 실제로 이미지를 받아 컨테이너를 실행 (보통 containerd) | 크레인 |
직접 설치한 클러스터(kubeadm)에서는 컨트롤 플레인 부품들도 kube-system 네임스페이스에서 Pod로 돌아요. 그래서 kubectl로 볼 수 있어요.
$ kubectl get pods -n kube-system
NAME READY STATUS RESTARTS AGE
coredns-668d6bf9bc-4tq7z 1/1 Running 0 3d
coredns-668d6bf9bc-x9vbn 1/1 Running 0 3d
etcd-controlplane 1/1 Running 0 3d
kube-apiserver-controlplane 1/1 Running 0 3d
kube-controller-manager-controlplane 1/1 Running 0 3d
kube-proxy-7xkqp 1/1 Running 0 3d
kube-proxy-m2d8s 1/1 Running 0 3d
kube-scheduler-controlplane 1/1 Running 0 3d
kube-system에 etcd나 kube-apiserver Pod가 보이지 않아요. 이번 실습은 컨트롤 플레인까지 직접 보이는 kubeadm 클러스터를 기준으로 해요.
2. kubectl apply 한 번이 지나가는 길
부품 이름만 외우면 금방 잊어요. 대신 Deployment 하나를 만들 때 각 부품이 차례로 무슨 일을 하는지 따라가 보면 역할이 한 번에 잡혀요. 그림에서 모든 화살표가 API 서버를 거친다는 점을 눈여겨보세요.
이 순서는 kubectl get events에도 그대로 남아요. 무언가 안 될 때 어느 단계에서 멈췄는지 보는 첫 번째 단서예요.
$ kubectl get events --sort-by=.metadata.creationTimestamp
REASON OBJECT MESSAGE
ScalingReplicaSet deployment/web Scaled up replica set web-7d4b9c to 3
SuccessfulCreate replicaset/web-7d4b9c Created pod: web-7d4b9c-x2kfp
Scheduled pod/web-7d4b9c-x2kfp Successfully assigned default/web-7d4b9c-x2kfp to node01
Pulling pod/web-7d4b9c-x2kfp Pulling image "nginx:1.27"
Created pod/web-7d4b9c-x2kfp Created container: nginx
Started pod/web-7d4b9c-x2kfp Started container nginx
| 이벤트 | 남긴 부품 | 여기서 멈추면 의심할 곳 |
|---|---|---|
ScalingReplicaSet · SuccessfulCreate | 컨트롤러 매니저 | Pod가 아예 안 생김 → ResourceQuota, 어드미션 거절 (2부 · 7부) |
Scheduled | 스케줄러 | Pending에 머묾 → 자원 부족, taint, affinity (3부) |
Pulling · Created · Started | kubelet | 이미지 이름 · 권한, 컨테이너 시작 실패 (16부) |
02. 컨테이너 런타임
입문 1부에서는 docker run으로 컨테이너를 띄웠어요. 그런데 요즘 쿠버네티스 노드에는 Docker가 깔려 있지 않은 경우가 대부분이에요. 대신 containerd라는 런타임이 컨테이너를 띄워요. 왜 그렇게 됐는지 역사를 따라가 보면 CRI · OCI 같은 용어가 자연스럽게 정리돼요.
1. Docker · containerd · CRI의 관계
| 용어 | 정체 | 한 줄 정리 |
|---|---|---|
| OCI | 규격 (Open Container Initiative) | 이미지는 이렇게 생겨야 하고(image spec), 컨테이너는 이렇게 실행해야 한다(runtime spec)는 업계 표준 |
| CRI | 규격 (Container Runtime Interface) | kubelet이 런타임에게 “Pod 만들어 · 이미지 받아 · 컨테이너 시작해”라고 말하는 약속된 방법 |
| containerd | 런타임 (CNCF 졸업 프로젝트) | 원래 Docker 안에 있던 부품. 독립해서 CRI를 지원하고, 지금 가장 널리 쓰여요 |
| CRI-O | 런타임 | 쿠버네티스 전용으로 만든 가벼운 런타임 (OpenShift 등에서 사용) |
| runc | 저수준 런타임 | 실제로 리눅스 네임스페이스 · cgroup을 걸어 프로세스를 띄우는 가장 아래 단계 (입문 1부 03) |
2. dockershim이 빠진 이유
Docker는 CRI가 생기기 전부터 있던 도구라 CRI를 따르지 않았어요. 그래서 쿠버네티스는 kubelet 안에 Docker용 통역기인 dockershim을 따로 넣어 관리해 왔어요. 하지만 Docker 안에는 이미 containerd가 들어 있으니 kubelet → dockershim → Docker → containerd로 한 바퀴 돌아갈 이유가 없었죠. 결국 1.24(2022년)에서 dockershim이 제거됐어요.
docker build로 만든 이미지는 OCI 규격이라 containerd에서 그대로 돌아요. 개발자 PC에서 Docker로 이미지를 만들고 레지스트리에 올리는 흐름은 그대로예요. 다만 노드에 들어가서 docker ps를 치면 “명령을 찾을 수 없음”이 나올 수 있어요. 그때 쓰는 도구가 다음의 crictl이에요.
3. ctr · nerdctl · crictl
Docker 없이 노드의 컨테이너를 볼 때 쓰는 명령줄 도구가 세 가지 있어요. 이름이 비슷해서 헷갈리지만 누구를 위한 도구인지로 구분하면 쉬워요.
| 도구 | 만든 곳 | 대상 | 언제 쓰나 |
|---|---|---|---|
ctr | containerd | containerd 자체 | containerd 디버깅용. 불편하고 기능이 적어요 |
nerdctl | containerd 커뮤니티 | containerd | Docker와 거의 같은 명령(run · ps · build)을 containerd로 쓰고 싶을 때 |
crictl | 쿠버네티스 (cri-tools) | CRI를 지원하는 모든 런타임 | 쿠버네티스 노드 디버깅. Pod 단위로 보여 줘요 |
$ crictl pods # 이 노드의 Pod 목록
POD ID CREATED STATE NAME NAMESPACE
3f2a1c9e8b7d4 2 hours ago Ready web-7d4b9c-x2kfp default
$ crictl ps # 실행 중인 컨테이너
$ crictl images # 받아 둔 이미지
$ crictl logs 9b1e0c7a2f3d # 컨테이너 로그 (컨테이너 ID)
kubectl로, crictl은 API 서버가 죽어서 kubectl이 안 될 때 노드에서 상황을 보는 용도로 써요 (16부 컨트롤 플레인 장애).
03. etcd
etcd는 클러스터의 기억이에요. 노드 목록, Pod, Deployment, ConfigMap, Secret, 권한 설정까지 kubectl get으로 보는 모든 것이 etcd에 저장돼 있어요. 입문에서 말한 “원하는 상태”가 실제로 적혀 있는 곳이죠.
1. 클러스터 상태를 담는 키-값 저장소
엑셀처럼 행과 열이 있는 표(관계형 DB)와 달리, 키-값 저장소는 이름표(키) 하나에 내용(값) 하나를 붙여 두는 단순한 구조예요. 쿠버네티스는 /registry/종류/네임스페이스/이름 모양의 키를 써요. Dart로 치면 거대한 Map<String, Object> 하나라고 보면 돼요.
| 알아 둘 것 | 내용 |
|---|---|
| 포트 | 2379 클라이언트(API 서버)용 · 2380 etcd끼리(복제)용 |
| 누가 접근하나 | API 서버만. 다른 부품은 API 서버를 거쳐요 |
| 여러 대로 운영 | Raft라는 합의 방식으로 3대 · 5대처럼 홀수로 복제 → 12부 etcd HA |
| 백업 | etcd를 잃으면 클러스터가 모든 기억을 잃어요 → 6부 백업과 복구 |
2. etcdctl로 들여다보기
kubeadm 클러스터에서 etcd는 kube-system의 Pod로 돌아요. 그 안의 etcdctl로 어떤 키가 있는지 볼 수 있어요. etcd는 통신을 TLS 인증서로 보호하기 때문에 인증서 세 개를 함께 넘겨야 해요(인증서는 7부 04).
$ kubectl exec -n kube-system etcd-controlplane -- etcdctl \
--cacert /etc/kubernetes/pki/etcd/ca.crt \
--cert /etc/kubernetes/pki/etcd/server.crt \
--key /etc/kubernetes/pki/etcd/server.key \
get /registry/pods --prefix --keys-only
/registry/pods/default/web-7d4b9c-x2kfp
/registry/pods/kube-system/coredns-668d6bf9bc-4tq7z
/registry/pods/kube-system/etcd-controlplane
/registry/pods/kube-system/kube-apiserver-controlplane
ETCDCTL_API=3
예전 etcdctl은 기본이 v2 API라서 export ETCDCTL_API=3을 먼저 해야 했어요. etcd 3.4부터는 v3가 기본이라 지금은 생략해도 돼요. 학습 자료에 나오면 “예전 버전 습관”으로 이해하면 돼요.
04. kube-apiserver
API 서버는 클러스터의 정문이에요. kubectl, 대시보드, 컨트롤러, 스케줄러, 각 노드의 kubelet까지 모두 API 서버와만 이야기해요. 정체는 HTTPS(기본 포트 6443)로 요청을 받는 REST API 서버예요. kubectl 명령도 결국 HTTP 요청으로 바뀌어 전달돼요.
$ kubectl get pods -v=6
... GET https://192.168.0.10:6443/api/v1/namespaces/default/pods?limit=500 200 OK in 12 milliseconds
NAME READY STATUS RESTARTS AGE
web-7d4b9c-x2kfp 1/1 Running 0 2h
1. 요청 처리 순서: 인증 → 인가 → 어드미션 → 저장
요청 하나가 들어오면 API 서버는 정해진 관문을 차례로 통과시켜요. 앞 관문에서 막히면 뒤로 가지 않아요. 이 순서는 7부 보안 전체의 뼈대라서 지금 확실히 잡아 두면 좋아요.
| 관문 | 묻는 것 | 실패하면 | 자세히 |
|---|---|---|---|
| 인증 (Authentication) | 누구세요? — 인증서 · 토큰 확인 | 401 Unauthorized | 7부 02 · 04 |
| 인가 (Authorization) | 그 사람이 이걸 해도 되나요? — 보통 RBAC | 403 Forbidden | 7부 07 |
| 어드미션 (Admission) | 규칙에 맞나요? 기본값을 채울까요? | 요청 거절 (메시지는 플러그인마다 다름) | 7부 09 |
| 저장 | etcd에 쓰고 응답 | etcd 장애면 500 계열 | 1부 03 |
$ kubectl get pods --as dev-user
Error from server (Forbidden): pods is forbidden: User "dev-user" cannot list resource "pods" in API group "" in the namespace "default"
2. 설정 파일은 어디에 있나
API 서버는 실행할 때 붙이는 옵션(플래그)으로 설정해요. 설치 방법에 따라 설정 파일 위치가 달라요.
| 설치 방법 | 어떻게 도나 | 설정 위치 |
|---|---|---|
| kubeadm | Static Pod (3부 07) | /etc/kubernetes/manifests/kube-apiserver.yaml |
| 직접 설치 (“the hard way”) | systemd 서비스 | /etc/systemd/system/kube-apiserver.service |
| 어느 쪽이든 | 실행 중인 프로세스 확인 | ps -aux | grep kube-apiserver |
spec:
containers:
- command:
- kube-apiserver
- --advertise-address=192.168.0.10
- --etcd-servers=https://127.0.0.1:2379 # etcd 주소 (1부 03)
- --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt
- --client-ca-file=/etc/kubernetes/pki/ca.crt # 인증서 인증 (7부 04)
- --authorization-mode=Node,RBAC # 인가 방식 (7부 07)
- --enable-admission-plugins=NodeRestriction # 어드미션 (7부 09)
- --service-cluster-ip-range=10.96.0.0/12 # Service IP 대역 (11부 04)
image: registry.k8s.io/kube-apiserver:v1.35.2
kubectl 자체가 안 돼요. 고치기 전에 원본을 복사해 두고, 안 뜨면 노드에서 crictl ps -a와 crictl logs로 원인을 봐요.
05. kube-controller-manager
입문 1부 05에서 쿠버네티스의 핵심은 원하는 상태를 선언하면 현재 상태를 맞춰 주는 것이라고 했어요. 그 “맞춰 주는 일”을 실제로 하는 게 컨트롤러예요. 컨트롤러는 종류별로 수십 개가 있는데, 이걸 한 프로그램으로 묶어 실행하는 게 kube-controller-manager예요.
1. 컨트롤러 = 제어 루프
모든 컨트롤러는 같은 일을 끝없이 반복해요. 지켜보고(watch) → 비교하고 → 차이가 있으면 고친다. 에어컨 온도 조절기처럼 “설정 24도, 지금 27도 → 냉방”을 계속 도는 거예요.
| 컨트롤러 | 지켜보는 것 | 고치는 방법 |
|---|---|---|
| Deployment | Deployment의 이미지 · 개수 | 새 ReplicaSet을 만들고 옛 것을 줄임 (5부 01) |
| ReplicaSet | replicas와 실제 Pod 수 | 모자라면 Pod 생성, 남으면 삭제 |
| Node (노드 수명 주기) | 노드의 상태 보고 | 보고가 끊기면 NotReady 표시 · taint로 Pod 내보내기 |
| Job · CronJob | 작업 완료 여부 · 일정 | Pod를 만들어 작업 실행 (5부 08) |
| Namespace | 지워지는 네임스페이스 | 안의 리소스를 모두 정리 |
| ServiceAccount | 새 네임스페이스 | default ServiceAccount 생성 (7부 08) |
| EndpointSlice | Service의 셀렉터와 Pod | Service가 보낼 Pod 주소 목록 갱신 (2부 03) |
어떤 컨트롤러를 켤지는 --controllers 플래그로 정해요. 기본값 *는 “기본으로 켜지는 것 전부”라는 뜻이에요.
- kube-controller-manager
- --controllers=*,bootstrapsigner,tokencleaner
- --kubeconfig=/etc/kubernetes/controller-manager.conf
- --leader-elect=true # 여러 대일 때 한 대만 일하게 (12부 02)
2. 노드 컨트롤러와 ReplicaSet 컨트롤러
두 컨트롤러가 함께 일하는 대표 장면이 노드가 죽었을 때예요. 입문 2부 01에서 “일정 시간 뒤 다른 노드에 다시 생긴다”고만 했던 부분을 단계별로 쪼개 봐요.
$ kubectl get nodes
NAME STATUS ROLES AGE VERSION
controlplane Ready control-plane 3d v1.35.2
node01 Ready <none> 3d v1.35.2
node02 NotReady <none> 3d v1.35.2
$ kubectl describe node node02 | grep -A1 Taints
Taints: node.kubernetes.io/unreachable:NoExecute
node.kubernetes.io/unreachable:NoSchedule
$ kubectl get pod web-7d4b9c-x2kfp -o yaml # tolerations 부분만
tolerations:
- effect: NoExecute
key: node.kubernetes.io/not-ready
operator: Exists
tolerationSeconds: 300
- effect: NoExecute
key: node.kubernetes.io/unreachable
operator: Exists
tolerationSeconds: 300
not-ready · unreachable taint를 300초 동안 참는다는 toleration이 자동으로 붙어요. 그래서 노드가 NotReady가 된 뒤 약 5분이 지나야 Pod가 내보내져요. 잠깐의 네트워크 끊김에 Pod를 성급히 옮기지 않으려는 장치예요. Pod마다 이 시간을 바꿀 수도 있어요(taint와 toleration은 3부 03).
06. kube-scheduler
스케줄러는 새 Pod를 어느 노드에 둘지 결정만 해요. 실제로 Pod를 띄우는 건 그 노드의 kubelet이에요. 화물을 어느 배에 실을지 정하는 배정 담당이 직접 크레인을 몰지 않는 것과 같아요.
1. 어느 노드에 둘지만 정한다
스케줄러는 API 서버를 지켜보다가 spec.nodeName이 비어 있는 Pod를 발견하면 노드를 골라서 그 칸을 채워요(바인딩). 칸이 채워지는 순간 해당 노드의 kubelet이 자기 몫이라는 걸 알아채요. 그래서 스케줄러가 멈추면 새 Pod는 전부 Pending에 머물러요. 반대로 YAML에 nodeName을 직접 써 두면 스케줄러를 거치지 않아요(3부 01).
2. 필터링과 점수 매기기
노드를 고르는 과정은 크게 두 단계예요. 필터링으로 “아예 안 되는 노드”를 빼고, 남은 노드에 점수를 매겨 가장 높은 곳에 둬요.
| 필터링에서 걸리는 이유 | 예시 | 자세히 |
|---|---|---|
| 자원 부족 | 요청한 CPU · 메모리를 줄 수 없음 | 3부 06 |
| taint를 견디지 못함 | GPU 전용 노드 · 컨트롤 플레인 노드 | 3부 03 |
| 노드 조건 불일치 | nodeSelector · Node Affinity | 3부 04 |
| 다른 Pod와의 관계 | Pod Anti-Affinity · Topology Spread | 3부 05 |
$ kubectl describe pod big-job | tail -3
Events:
Type Reason Message
Warning FailedScheduling 0/4 nodes are available: 1 node(s) had untolerated taint {gpu: true}, 3 Insufficient cpu.
07. kubelet과 kube-proxy
지금까지는 컨트롤 플레인 이야기였어요. 이제 모든 노드에서 도는 두 부품을 봐요. 입문 2부 02에서 워커 노드를 확대해 봤던 그림의 주인공들이에요.
1. kubelet: 노드의 관리인
kubelet은 배의 선장이에요. 노드를 클러스터에 등록하고, 자기 노드에 배정된 Pod를 발견하면 컨테이너 런타임에게 CRI로 실행을 시키고, 헬스 체크(5부 06)를 돌리고, 결과를 API 서버에 보고해요.
$ systemctl status kubelet # 서비스 상태
● kubelet.service - kubelet: The Kubernetes Node Agent
Active: active (running) since ...
$ ps -aux | grep kubelet # 실행 옵션
... /usr/bin/kubelet --kubeconfig=/etc/kubernetes/kubelet.conf --config=/var/lib/kubelet/config.yaml ...
$ grep staticPodPath /var/lib/kubelet/config.yaml
staticPodPath: /etc/kubernetes/manifests
마지막 줄의 staticPodPath가 바로 1부 04에서 본 /etc/kubernetes/manifests예요. kubelet은 이 폴더의 YAML을 API 서버 없이도 직접 Pod로 띄워요. 컨트롤 플레인이 Pod로 뜰 수 있는 비밀이 이거예요(3부 07 Static Pod).
2. kube-proxy: Service 규칙 만들기
Service의 IP(ClusterIP)는 어느 기계에도 실제로 붙어 있지 않은 가상 주소예요. 그런데도 그 주소로 요청을 보내면 Pod에 닿는 건, 모든 노드의 kube-proxy가 “이 주소로 오는 건 저 Pod들로 보내”라는 규칙을 노드에 적어 두기 때문이에요. kube-proxy는 kubeadm에서 DaemonSet으로 노드마다 하나씩 떠요.
| 모드 | 방식 | 상태 |
|---|---|---|
iptables | 리눅스 iptables 규칙으로 전달 | 리눅스의 오랜 기본값 |
ipvs | 리눅스 IPVS(커널 로드밸런서) | 1.35에서 지원 중단 예고(deprecated) |
nftables | iptables의 후속인 nftables | 1.33에서 정식(GA) — 새 클러스터의 다음 기본 후보 |
$ kubectl get daemonset kube-proxy -n kube-system
NAME DESIRED CURRENT READY NODE SELECTOR AGE
kube-proxy 3 3 3 kubernetes.io/os=linux 3d
$ kubectl get configmap kube-proxy -n kube-system -o yaml | grep "mode:"
mode: "" # 비어 있으면 iptables
$ iptables -t nat -S | grep web-svc # 노드에서
-A KUBE-SERVICES -d 10.96.120.15/32 -p tcp -m comment --comment "default/web-svc cluster IP" -m tcp --dport 80 -j KUBE-SVC-W5RQ3PZ2LJ6YT4AB
3. 1부 셀프 체크
컨트롤 플레인의 네 부품과 각자 하는 일을 한 줄씩 말해 보면?
API 서버 — 모든 요청의 정문. etcd — 클러스터 상태 저장. 스케줄러 — 새 Pod를 둘 노드 결정. 컨트롤러 매니저 — 원하는 상태와 현재 상태를 맞추는 제어 루프 묶음.스케줄러는 kubelet에게 어떻게 “이 Pod를 실행해”라고 알리나요?
직접 알리지 않아요. 스케줄러는 API 서버에 Pod의nodeName을 기록할 뿐이고, 그 노드의 kubelet이 API 서버를 지켜보다가(watch) 자기 몫을 발견해요.dockershim이 제거된 뒤에도 Docker로 만든 이미지를 쓸 수 있는 이유는?
Docker 이미지는 OCI 이미지 규격을 따르기 때문이에요. 바뀐 건 노드에서 컨테이너를 띄우는 프로그램(Docker → containerd)뿐이에요.kubectl이 안 될 때 노드에서 컨테이너 상태를 볼 수 있는 도구는?
crictl이에요.crictl ps -a,crictl logs로 API 서버 컨테이너가 왜 죽었는지 볼 수 있어요.etcd에 직접 접근하는 부품은 무엇이고, etcd를 잃으면 어떻게 되나요?
API 서버만 접근해요. etcd를 잃으면 클러스터가 모든 리소스 정의를 잊어버리기 때문에 정기적인 etcd 백업이 필수예요(6부).API 서버가 요청을 처리하는 순서와, 각 단계에서 막혔을 때의 응답은?
인증(401) → 인가(403) → 어드미션(거절) → etcd 저장 → 응답.노드가 NotReady가 된 뒤 Pod가 다른 노드로 옮겨지기까지 약 5분이 걸리는 이유는?
Pod에unreachable·not-readytaint를 300초 동안 참는 toleration이 자동으로 붙기 때문이에요. 그 뒤 Pod가 내보내지고, ReplicaSet 컨트롤러가 다른 노드에 새로 만들어요.kubelet이 Static Pod가 아니라 systemd 서비스로 도는 이유는?
Pod를 띄우는 주체가 kubelet이라, kubelet 자신은 Pod로 띄울 수 없기 때문이에요. 그래서 모든 노드에 직접 설치해요.
01. Pod와 YAML
1부에서 클러스터의 속을 봤으니, 이제 그 위에 올라가는 리소스를 다시 정리해요. 입문에서 다 본 내용이지만, 이번에는 “YAML 한 칸이 클러스터 안에서 무엇이 되는지”와 “명령어로 빠르게 다루는 법”에 초점을 맞춰요. 전반 실습과 이후 모든 부의 바탕이 되는 부분이에요.
1. YAML 네 가지 필드 복습
입문 2부 05에서 본 것처럼 모든 리소스 YAML은 네 칸으로 시작해요. 우리가 쓰는 건 위 세 칸 반(apiVersion · kind · metadata · spec)이고, status는 클러스터가 채워요.
apiVersion을 외울 필요는 없어요. 헷갈릴 때는 kubectl api-resources로 바로 확인해요.
$ kubectl api-resources
NAME SHORTNAMES APIVERSION NAMESPACED KIND
pods po v1 true Pod
services svc v1 true Service
namespaces ns v1 false Namespace
resourcequotas quota v1 true ResourceQuota
deployments deploy apps/v1 true Deployment
replicasets rs apps/v1 true ReplicaSet
jobs batch/v1 true Job
ingresses ing networking.k8s.io/v1 true Ingress
v1과 apps/v1의 차이
/ 앞이 API 그룹이에요. Pod · Service처럼 처음부터 있던 리소스는 그룹 이름이 없는 core 그룹이라 v1만 쓰고, 나중에 생긴 리소스는 apps/v1 · batch/v1처럼 그룹이 붙어요. API 그룹은 7부 06에서 RBAC와 함께 다시 봐요.
2. kubectl explain으로 필드 찾기
“resources 아래에 뭐가 들어가더라?” 같은 건 문서를 찾지 않아도 돼요. kubectl explain은 클러스터가 알고 있는 정확한 필드 이름과 설명을 바로 보여 줘요. 점(.)으로 한 단계씩 들어가요.
$ kubectl explain deployment.spec.strategy
GROUP: apps
KIND: Deployment
VERSION: v1
FIELD: strategy <DeploymentStrategy>
FIELDS:
rollingUpdate <RollingUpdateDeployment>
Rolling update config params. Present only if DeploymentStrategyType = RollingUpdate.
type <string>
enum: Recreate, RollingUpdate
Type of deployment. Can be "Recreate" or "RollingUpdate". Default is RollingUpdate.
$ kubectl explain pod.spec.containers --recursive | less # 아래 필드를 트리로 한 번에
| 옵션 | 보여 주는 것 | 언제 |
|---|---|---|
kubectl explain pod.spec | 바로 아래 필드와 설명 | 필드 이름 · 타입이 헷갈릴 때 |
--recursive | 아래 모든 필드를 이름만 트리로 | 어디쯤 있는지 위치를 찾을 때 |
kubectl explain <리소스> | 그룹 · 버전 · 종류 | apiVersion을 확인할 때 |
02. ReplicaSet과 Deployment
Pod 하나만 띄우면 그 Pod가 죽었을 때 끝이에요(입문 4부 02). 그래서 실제로는 Deployment를 만들고, Deployment가 ReplicaSet을, ReplicaSet이 Pod를 관리하는 세 층 구조로 운영해요. 이번에는 이 세 층이 서로를 어떻게 알아보는지를 봐요.
1. 개수 지키기 — ReplicaSet
ReplicaSet의 핵심은 두 칸이에요. replicas(몇 개)와 selector(어떤 Pod를 내 것으로 셀지). ReplicaSet은 Pod를 이름으로 세지 않고 라벨로 세요.
apiVersion: apps/v1
kind: ReplicaSet
metadata:
name: web-rs
spec:
replicas: 3
selector: # 이 라벨이 붙은 Pod를 “내 것”으로 센다
matchLabels:
app: web
template: # 모자랄 때 이 모양으로 새로 만든다
metadata:
labels:
app: web # ← selector와 반드시 맞아야 함
spec:
containers:
- name: nginx
image: nginx:1.27
selector does not match template labels 오류가 나오면 두 라벨부터 비교하세요. 학습 자료에 나오는 ReplicationController는 ReplicaSet 이전의 옛 버전이라, 새로 쓸 일은 없어요.
$ kubectl scale rs web-rs --replicas=5 # 명령으로 (파일은 그대로 3)
$ kubectl edit rs web-rs # 실행 중인 객체를 바로 편집
$ kubectl apply -f replicaset.yaml # 파일의 replicas를 고친 뒤 적용 (권장)
2. Deployment가 ReplicaSet을 다루는 방식
| 이름 | 규칙 | 예 |
|---|---|---|
| Deployment | 내가 지은 이름 | web |
| ReplicaSet | Deployment 이름 + Pod 템플릿의 해시 | web-7d4b9c |
| Pod | ReplicaSet 이름 + 무작위 5글자 | web-7d4b9c-x2kfp |
Pod 이름만 봐도 어느 ReplicaSet · Deployment에서 왔는지 알 수 있어요. 템플릿이 바뀌면 해시가 바뀌니 새 ReplicaSet이 생기는 거고요. 롤링 업데이트와 롤백의 자세한 동작은 5부 01에서 다시 봐요.
$ kubectl create deployment web --image=nginx:1.27 --replicas=3
$ kubectl get deploy,rs,pods -l app=web
NAME READY UP-TO-DATE AVAILABLE
deployment.apps/web 3/3 3 3
NAME DESIRED CURRENT READY
replicaset.apps/web-7d4b9c 3 3 3
NAME READY STATUS
pod/web-7d4b9c-x2kfp 1/1 Running
pod/web-7d4b9c-5mq8r 1/1 Running
pod/web-7d4b9c-t7wzd 1/1 Running
03. Service
Pod는 다시 만들어질 때마다 IP가 바뀌어요. 그래서 Pod 앞에 바뀌지 않는 주소를 하나 세워 두는 게 Service예요(입문 5부 01). 1부 07에서 이 주소가 kube-proxy의 규칙으로만 존재한다는 것도 봤어요.
1. ClusterIP · NodePort · LoadBalancer 복습
세 종류는 바깥으로 얼마나 열리느냐의 차이예요(입문 5부 02). 실습에서 가장 많이 헷갈리는 건 NodePort의 포트 세 개예요. 요청이 들어오는 방향 그대로 따라가면 쉬워요.
apiVersion: v1
kind: Service
metadata:
name: web-svc
spec:
type: NodePort # ClusterIP(기본) · NodePort · LoadBalancer
selector:
app: web # 이 라벨의 Pod로 보낸다
ports:
- port: 80 # Service의 포트 (클러스터 안에서 부를 때)
targetPort: 8080 # Pod 컨테이너가 실제로 듣는 포트 (생략하면 port와 같음)
nodePort: 30080 # 노드에 열리는 포트 (30000-32767, 생략하면 자동)
| 종류 | 누가 접근하나 | 포트 | 주로 |
|---|---|---|---|
| ClusterIP (기본) | 클러스터 안의 Pod | port → targetPort | 서비스끼리 내부 통신 |
| NodePort | + 노드 IP로 밖에서 | nodePort → port → targetPort | 실습 · 간단한 외부 노출 |
| LoadBalancer | + 클라우드 로드밸런서 IP로 | NodePort 위에 LB가 추가 | 클라우드 운영 환경 |
2. 셀렉터와 EndpointSlice
Service는 Pod를 직접 기억하지 않아요. 셀렉터만 들고 있고, 1부 05의 EndpointSlice 컨트롤러가 셀렉터에 맞고 준비된(Ready) Pod의 IP를 모아 EndpointSlice라는 목록을 계속 갱신해요. kube-proxy가 규칙을 만들 때 보는 게 이 목록이에요.
$ kubectl get endpointslices -l kubernetes.io/service-name=web-svc
NAME ADDRESSTYPE PORTS ENDPOINTS
web-svc-8xq2m IPv4 8080 10.244.1.5,10.244.2.7
$ kubectl describe svc web-svc | grep -E "Selector|Endpoints"
Selector: app=web
Endpoints: 10.244.1.5:8080,10.244.2.7:8080
Endpoints라는 리소스가 나와요. 예전 방식으로, 큰 클러스터에서 목록 하나가 너무 커지는 문제가 있어 여러 조각으로 나눈 EndpointSlice로 바뀌었고, Endpoints API는 1.33부터 지원 중단 예고(deprecated) 상태예요. kubectl describe svc의 Endpoints 줄은 계속 보여서 확인용으로는 그대로 쓸 수 있어요.
04. Namespace
Namespace는 한 클러스터를 이름 · 권한 · 자원 한도가 따로인 칸으로 나누는 방법이에요(입문 7부 01). 팀별 · 환경별(dev · prod)로 나누는 게 일반적이에요.
1. 칸 나누기와 DNS 이름
| 기본 Namespace | 용도 |
|---|---|
default | -n을 안 쓰면 들어가는 곳 |
kube-system | 컨트롤 플레인 · CoreDNS · kube-proxy 등 시스템 부품 (1부 01) |
kube-public | 인증 없이도 읽을 수 있는 공개 정보 (거의 쓰지 않음) |
kube-node-lease | 노드 하트비트용 Lease 객체 (1부 05) |
$ kubectl create namespace dev
$ kubectl get pods -n dev # 한 번만 dev에서
$ kubectl get pods -A # 모든 Namespace (--all-namespaces)
$ kubectl config set-context --current --namespace=dev # 앞으로 기본값을 dev로
$ kubectl api-resources --namespaced=false # Namespace에 속하지 않는 리소스 (노드 · PV 등)
2. ResourceQuota
칸마다 쓸 수 있는 총량을 정하는 게 ResourceQuota예요. 한 팀이 클러스터 자원을 다 써 버리는 걸 막아요.
apiVersion: v1
kind: ResourceQuota
metadata:
name: dev-quota
namespace: dev
spec:
hard:
pods: "10" # Pod 최대 10개
requests.cpu: "4" # 모든 Pod의 CPU 요청 합계 최대 4
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
requests.cpu처럼 자원 쿼터가 걸린 Namespace에서는 Pod에 requests · limits가 없으면 생성이 거절돼요. 매번 쓰기 번거로우면 기본값을 채워 주는 LimitRange를 같이 걸어요(3부 06). 또 Deployment로 만들 때 쿼터에 걸리면 kubectl apply는 성공해 보이고, 실제 거절은 ReplicaSet의 이벤트(FailedCreate)에 남아요.
$ kubectl describe quota dev-quota -n dev
Resource Used Hard
-------- ---- ----
pods 3 10
requests.cpu 1500m 4
$ kubectl describe rs web-7d4b9c -n dev | grep FailedCreate
Warning FailedCreate ... Error creating: pods "web-7d4b9c-q8w2z" is forbidden: exceeded quota: dev-quota, requested: pods=1, used: pods=10, limited: pods=10
05. 명령형과 선언형
입문 1부 05에서 “이걸 해라”(명령형)와 “이런 상태여야 한다”(선언형)를 봤어요. 실제로는 둘을 섞어 써요. 빠르게 만들 땐 명령형, 오래 관리할 땐 선언형 파일이 원칙이에요. 특히 시험처럼 시간이 정해진 상황에서는 명령형으로 YAML 뼈대를 뽑아 고치는 게 가장 빨라요.
1. kubectl run · create로 빠르게 만들기
| 만들 것 | 명령 |
|---|---|
| Pod | kubectl run nginx --image=nginx --port=80 --labels=app=web |
| Pod + 같은 이름 Service | kubectl run httpd --image=httpd:alpine --port=80 --expose |
| Deployment | kubectl create deployment web --image=nginx --replicas=3 |
| Service (Pod 라벨을 셀렉터로) | kubectl expose deployment web --port=80 --target-port=8080 --name=web-svc |
| NodePort (포트 지정) | kubectl create service nodeport web --tcp=80:8080 --node-port=30080 |
| Namespace | kubectl create namespace dev |
| 개수 바꾸기 | kubectl scale deployment web --replicas=5 |
| 이미지 바꾸기 | kubectl set image deployment/web nginx=nginx:1.28 |
expose와 create service의 차이
kubectl expose는 대상(Pod · Deployment)의 라벨을 그대로 셀렉터로 가져와요. kubectl create service는 셀렉터를 app=<이름>으로 가정해서, 라벨이 다르면 EndpointSlice가 비어요(2부 03). 대신 --node-port를 지정할 수 있어요. 보통은 expose로 뽑고 nodePort만 YAML에서 고쳐요.
2. --dry-run=client -o yaml로 YAML 뽑기
YAML을 처음부터 손으로 쓰면 들여쓰기 하나로 시간이 다 가요. 명령형 명령에 --dry-run=client -o yaml을 붙이면 실제로 만들지 않고 YAML만 출력해요. 이걸 파일로 저장해서 필요한 칸만 고치는 게 가장 빠른 방법이에요.
$ kubectl create deployment web --image=nginx --replicas=3 \
--dry-run=client -o yaml > web.yaml
$ vi web.yaml # resources · probe 등 필요한 칸 추가
$ kubectl apply -f web.yaml
$ kubectl get deploy web -o yaml > web-live.yaml # 이미 있는 객체에서 YAML 뽑기 (status 등은 지우고 쓰기)
| 방식 | 명령 | 특징 |
|---|---|---|
| 명령형 명령 | run · create · expose · scale · set | 빠르지만 기록이 남지 않음 |
| 명령형 파일 | create -f · replace -f · delete -f | 이미 있으면 create는 실패, replace는 통째로 교체 |
| 선언형 파일 | apply -f | 있으면 바뀐 부분만 반영, 없으면 생성 — 파일을 Git으로 관리 |
3. apply는 무엇과 비교하나
kubectl apply는 똑똑하게 “바뀐 것만” 반영하는데, 그러려면 비교할 대상이 필요해요. apply는 세 가지를 비교해요. 내 파일, 실제 객체, 그리고 지난번 apply한 내용이에요.
$ kubectl get deploy web -o yaml | grep -A1 last-applied
kubectl.kubernetes.io/last-applied-configuration: |
{"apiVersion":"apps/v1","kind":"Deployment","metadata":{"labels":{"app":"web"},"name":"web",...
kubectl apply --server-side로 API 서버가 비교하게 할 수도 있어요. 이때는 어노테이션 대신 “어느 필드를 누가 관리하는지”를 managedFields에 기록해서, 여러 도구가 같은 객체를 고칠 때 충돌을 알려 줘요. Helm · 컨트롤러가 많이 쓰는 방식이라 13부에서 다시 만나요.
replicas: 3이 있는데 kubectl scale로 5로 늘리면, 다음 apply 때 다시 3으로 돌아가요(그림 마지막 단계). HPA처럼 다른 무언가가 개수를 관리한다면 파일에서 replicas 칸을 아예 빼는 게 좋아요(5부 07).
4. 2부 셀프 체크
YAML의 네 칸 중 내가 쓰지 않는 칸과, 그 칸을 채우는 쪽은?
status예요. 클러스터(주로 kubelet과 컨트롤러)가 현재 상태를 채워요. 우리는spec에 원하는 상태를 써요.Deployment의
apiVersion이 기억나지 않을 때 확인하는 명령 두 가지는?kubectl api-resources(APIVERSION 열) 또는kubectl explain deployment(GROUP · VERSION).ReplicaSet은 자기 Pod를 무엇으로 세나요? 그리고 그게 틀리면?
라벨 셀렉터로 세요.selector와template의 라벨이 다르면 API 서버가 생성을 거절해요.Pod 이름
web-7d4b9c-x2kfp에서 각 부분의 뜻은?web= Deployment 이름,7d4b9c= Pod 템플릿 해시(ReplicaSet 이름의 끝),x2kfp= 무작위 글자.NodePort Service에서
nodePort·port·targetPort는 각각 어디의 포트인가요?nodePort= 노드에 열리는 포트(30000-32767),port= Service의 포트,targetPort= Pod 컨테이너가 듣는 포트. 요청은 이 순서로 지나가요.Service에 요청했는데 아무 응답이 없을 때 가장 먼저 볼 것은?
EndpointSlice(또는describe svc의 Endpoints)가 비어 있지 않은지 봐요. 비어 있으면 셀렉터와 Pod 라벨이 다르거나, Pod가 Ready가 아닌 거예요.devNamespace의 Pod가prodNamespace의 Servicedb를 부를 때 주소는?db.prod또는 전체 이름db.prod.svc.cluster.local. 같은 Namespace 안에서는db만 써도 돼요.YAML을 빠르게 만드는 방법은?
명령형 명령에--dry-run=client -o yaml > 파일을 붙여 뼈대를 뽑고, 필요한 칸만 고쳐서kubectl apply -f해요.kubectl apply가 파일에서 지운 필드를 실제 객체에서도 지울 수 있는 이유는?지난번 apply한 내용을last-applied-configuration어노테이션에 저장해 두고, 이번 파일과 비교해서 “내가 넣었다가 뺀 필드”를 알아내기 때문이에요.
01. 스케줄링 원리
1부 06에서 스케줄러는 필터링 → 점수 매기기 → 바인딩으로 노드를 고른다고 했어요. 3부는 그 과정에 우리가 끼어드는 방법을 모은 부예요. “이 Pod는 GPU 노드에만”, “이 노드에는 아무나 오지 마”, “같은 앱은 노드마다 흩어 놔” 같은 요구를 YAML로 어떻게 표현하는지 봐요.
1. 스케줄러 없이 직접 배치: nodeName
가장 직접적인 방법은 스케줄러를 아예 건너뛰는 거예요. Pod를 만들 때 spec.nodeName에 노드 이름을 써 두면, 그 노드의 kubelet이 바로 가져가요. 스케줄러가 없는 클러스터에서 Pod가 Pending에 머무를 때 쓸 수 있는 방법이에요.
apiVersion: v1
kind: Pod
metadata:
name: nginx
spec:
nodeName: node02 # 스케줄러를 건너뛰고 바로 node02로
containers:
- name: nginx
image: nginx
nodeName은 만든 뒤에는 고칠 수 없어요. 이미 Pending인 Pod를 배정하려면 스케줄러가 하는 것처럼 Binding 객체를 API로 보내거나, 보통은 그냥 Pod를 지우고 nodeName을 넣어 다시 만들어요(kubectl replace --force -f).
2. 스케줄러가 노드를 고르는 과정
스케줄러를 거칠 때 우리가 쓸 수 있는 손잡이는 아래와 같아요. 이번 부의 지도라고 보면 돼요.
| 손잡이 | 누가 정하나 | 뜻 | 절 |
|---|---|---|---|
| nodeSelector · Node Affinity | Pod | “이런 라벨의 노드로 가고 싶다” | 04 |
| Taint · Toleration | 노드 (+ Pod) | “이 노드는 허락받은 Pod만 받는다” | 03 |
| Pod Affinity · Topology Spread | Pod | “다른 Pod와 붙어서 / 떨어져서 가고 싶다” | 05 |
| requests | Pod | “이만큼 자리가 있는 노드여야 한다” | 06 |
| PriorityClass | Pod | “자리가 없으면 덜 중요한 Pod를 밀어내라” | 08 |
| schedulerName · 프로필 | Pod · 관리자 | “다른 스케줄러 · 다른 규칙으로 골라라” | 09 |
02. Labels와 Selectors
라벨은 쿠버네티스의 거의 모든 “고르기”의 바탕이에요(입문 3부 02). Service가 Pod를, ReplicaSet이 Pod를, Pod가 노드를 고를 때 모두 라벨을 써요.
1. 라벨로 묶고 고르기
$ kubectl get pods -l app=web # 같다
$ kubectl get pods -l app=web,env=prod # AND
$ kubectl get pods -l 'env in (dev,stg)' # 집합
$ kubectl get pods -l '!canary' # 라벨이 없는 것
$ kubectl get pods --show-labels # 라벨 같이 보기
$ kubectl label pod web-1 tier=front # 붙이기 ( 끝에 tier- 이면 떼기)
selector:
matchLabels: # 같다 (여러 개면 AND)
app: web
matchExpressions: # 조건식: In · NotIn · Exists · DoesNotExist
- key: env
operator: In
values: [dev, stg]
2. 어노테이션은 무엇이 다른가
어노테이션도 metadata에 붙는 키-값이지만, 고르는 데 쓸 수 없어요. 사람이나 도구가 읽을 부가 정보를 적는 칸이에요. 2부 05의 last-applied-configuration도 어노테이션이었죠.
| 라벨 (labels) | 어노테이션 (annotations) | |
|---|---|---|
| 셀렉터로 고르기 | 됨 | 안 됨 |
| 값 | 짧은 이름 (63자 이내 규칙) | 긴 글 · JSON도 가능 |
| 예 | app=web · env=prod · tier=front | 빌드 번호 · 담당자 연락처 · 도구 설정(Ingress 컨트롤러 옵션 등) |
03. Taints와 Tolerations
Taint(얼룩)는 노드에 붙이는 “접근 금지” 표시이고, Toleration(용인)은 Pod가 들고 다니는 “그 표시는 참을 수 있어요” 허가증이에요. 노드가 Pod를 밀어내는 방향이라는 점이 핵심이에요. 학습 자료의 비유로는 사람(노드)에게 벌레 퇴치 스프레이(taint)를 뿌리면, 그 냄새를 견디는 벌레(toleration이 있는 Pod)만 앉을 수 있어요.
1. 노드가 Pod를 밀어내는 방법
$ kubectl taint nodes node01 app=blue:NoSchedule # 키=값:효과
$ kubectl taint nodes node01 app=blue:NoSchedule- # 끝에 - 면 떼기
$ kubectl describe node node01 | grep Taints
Taints: app=blue:NoSchedule
spec:
tolerations:
- key: app
operator: Equal # Equal(값까지 같아야) · Exists(키만 있으면)
value: blue
effect: NoSchedule
containers:
- name: nginx
image: nginx
2. NoSchedule · PreferNoSchedule · NoExecute
| 효과 | 새 Pod | 이미 돌던 Pod |
|---|---|---|
NoSchedule | toleration 없으면 배치 안 함 | 그대로 둠 |
PreferNoSchedule | 가능하면 피함 (자리가 없으면 배치) | 그대로 둠 |
NoExecute | 배치 안 함 | toleration 없으면 쫓아냄 (tolerationSeconds만큼 기다렸다가) |
1부 05에서 본 node.kubernetes.io/unreachable:NoExecute가 바로 이 NoExecute예요. Pod에 자동으로 붙은 tolerationSeconds: 300 덕분에 5분을 기다린 거고요.
3. 컨트롤 플레인 노드에 Pod가 안 뜨는 이유
kubeadm은 컨트롤 플레인 노드에 node-role.kubernetes.io/control-plane:NoSchedule taint를 붙여 둬요. 그래서 우리 앱은 워커 노드에만 가요. 실습 환경처럼 노드가 하나뿐이라 컨트롤 플레인에도 Pod를 띄워야 하면 이 taint를 떼요.
$ kubectl describe node controlplane | grep Taints
Taints: node-role.kubernetes.io/control-plane:NoSchedule
$ kubectl taint nodes controlplane node-role.kubernetes.io/control-plane:NoSchedule-
04. Node Selector와 Node Affinity
Taint가 노드 쪽에서 밀어내는 방법이라면, 이번에는 Pod 쪽에서 노드를 고르는 방법이에요. 노드에 라벨을 붙이고, Pod는 그 라벨로 갈 곳을 정해요.
1. nodeSelector
$ kubectl label nodes node01 size=large # 노드에 라벨 붙이기
$ kubectl get nodes -L size # 라벨을 열로 보기
spec:
nodeSelector:
size: large # 이 라벨이 있는 노드에만
간단하지만 “large 또는 medium”, “small만 아니면” 같은 조건은 못 써요. 그래서 나온 게 Node Affinity예요.
2. required와 preferred
spec:
affinity:
nodeAffinity:
requiredDuringSchedulingIgnoredDuringExecution: # 반드시
nodeSelectorTerms:
- matchExpressions:
- key: size
operator: In # In · NotIn · Exists · DoesNotExist · Gt · Lt
values: [large, medium]
preferredDuringSchedulingIgnoredDuringExecution: # 가능하면
- weight: 80 # 1~100, 점수에 더해짐
preference:
matchExpressions:
- key: disk
operator: In
values: [ssd]
| 이름의 앞 절반 | 뜻 | 뒤 절반 IgnoredDuringExecution |
|---|---|---|
requiredDuringScheduling… | 맞는 노드가 없으면 Pending | 이미 실행 중인 Pod는 노드 라벨이 바뀌어도 그대로 둔다 |
preferredDuringScheduling… | 맞는 노드에 점수를 더 줄 뿐, 없으면 다른 곳에 배치 |
3. Taint와 Affinity를 함께 쓰는 이유
“파란 팀 노드에는 파란 Pod만, 파란 Pod는 파란 노드에만”을 만들고 싶다고 해 봐요. 하나씩만 쓰면 빈틈이 생겨요.
| 방법 | 막아 주는 것 | 남는 빈틈 |
|---|---|---|
| Taint · Toleration만 | 다른 Pod가 파란 노드에 오는 것 | 파란 Pod가 다른 노드로 갈 수 있음 |
| Node Affinity만 | 파란 Pod가 다른 노드로 가는 것 | 다른 Pod가 파란 노드에 올 수 있음 |
| 둘 다 | 양쪽 다 | 없음 — 전용 노드(GPU · 팀 전용)를 만들 때의 정석 |
05. Pod끼리의 배치
지금까지는 “어느 노드로”였다면, 이번에는 “다른 Pod 옆으로 / 다른 Pod와 떨어져서”예요. 학습 자료에는 없지만 실제 운영에서 아주 자주 쓰는 설정이라 넣었어요. 레플리카 3개가 한 노드에 몰려 있으면 그 노드 하나만 죽어도 서비스가 멈추니까요.
1. Pod Affinity · Anti-Affinity
spec:
affinity:
podAntiAffinity:
requiredDuringSchedulingIgnoredDuringExecution:
- labelSelector:
matchLabels:
app: web # app=web Pod가 있는
topologyKey: kubernetes.io/hostname # “같은 노드”에는 가지 마
topologyKey는 “같은 곳”의 기준이에요. kubernetes.io/hostname이면 노드 단위, topology.kubernetes.io/zone이면 가용 영역(데이터센터) 단위예요. 반대로 캐시 Pod를 웹 Pod 옆에 두고 싶으면 podAffinity를 써요.
2. Topology Spread Constraints
Anti-Affinity의 required는 “한 노드에 하나만”이라 노드보다 레플리카가 많으면 Pending이 생겨요. Topology Spread는 “영역 사이 개수 차이가 maxSkew 이하”라는 더 유연한 규칙이라, 요즘은 고르게 퍼뜨릴 때 이쪽을 더 많이 써요.
spec:
topologySpreadConstraints:
- maxSkew: 1 # 영역 사이 개수 차이 최대 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule # 못 지키면 Pending (ScheduleAnyway면 최대한만)
labelSelector:
matchLabels:
app: web
06. Resource Requirements
입문 3부 06에서 requests(최소 보장)와 limits(최대 한도)를 봤어요. 전반에서는 이게 스케줄링 · 실행 · 축출 세 단계에서 각각 어떻게 쓰이는지 정리해요.
1. requests와 limits
resources:
requests:
cpu: 250m # 0.25 코어 (1 = vCPU 1개, 1m = 0.001)
memory: 256Mi # Mi = 1024², M = 1000² — 보통 Mi · Gi를 씀
limits:
cpu: 500m
memory: 512Mi
| 단계 | 쓰이는 값 | 일어나는 일 |
|---|---|---|
| 스케줄링 | requests | 노드의 Allocatable에서 이미 예약된 requests 합을 빼고 남는 자리를 봐요. 실제 사용량은 안 봐요 |
| 실행 중 · CPU | limits.cpu | 넘으면 느려짐(throttling). 죽지는 않아요 |
| 실행 중 · 메모리 | limits.memory | 넘으면 컨테이너가 OOMKilled(exit 137) — 입문 1부 03의 cgroup |
| 노드가 부족할 때 | QoS 등급 | kubelet이 Pod를 축출(eviction) — 아래 3번 |
2. LimitRange
Pod마다 requests · limits를 쓰는 걸 잊기 쉬워요. LimitRange는 Namespace 단위로 기본값과 최소 · 최대를 정해서, 안 쓴 Pod에는 기본값을 채워 주고 범위를 벗어난 Pod는 거절해요. 2부 04의 ResourceQuota가 “칸 전체 합계”라면 LimitRange는 “컨테이너 하나당”이에요.
apiVersion: v1
kind: LimitRange
metadata:
name: cpu-mem-defaults
namespace: dev
spec:
limits:
- type: Container
defaultRequest: # requests를 안 쓰면 이 값
cpu: 100m
memory: 128Mi
default: # limits를 안 쓰면 이 값
cpu: 500m
memory: 512Mi
max: # 이보다 크게 쓰면 거절
cpu: "2"
memory: 2Gi
3. QoS 클래스와 축출 순서
requests · limits를 어떻게 썼느냐에 따라 Pod는 자동으로 QoS 클래스를 받아요. 노드 메모리가 모자라면 kubelet은 이 등급과 사용량을 보고 덜 보호된 Pod부터 축출해요.
| QoS 클래스 | 조건 | 메모리가 부족할 때 |
|---|---|---|
| Guaranteed | 모든 컨테이너가 CPU · 메모리 모두 requests = limits | 가장 늦게 쫓겨남 |
| Burstable | requests나 limits가 일부만 있음 | requests보다 많이 쓰는 Pod부터 |
| BestEffort | requests · limits가 전혀 없음 | 가장 먼저 쫓겨남 |
$ kubectl get pod web-1 -o jsonpath='{.status.qosClass}'
Burstable
07. DaemonSet과 Static Pod
지금까지는 “스케줄러가 어디에 둘지 고르는” Pod였어요. 이번에는 노드마다 하나씩 있어야 하는 Pod를 띄우는 두 방법이에요. 둘 다 노드에 붙어 있다는 점은 같지만, 만드는 주체가 달라요.
1. DaemonSet 복습
DaemonSet은 모든 노드(또는 고른 노드)에 Pod를 하나씩 띄워요(입문 4부 04). 로그 수집기, 모니터링 에이전트, 1부 07의 kube-proxy, CNI 플러그인이 대표적이에요. 노드가 추가되면 자동으로 하나가 더 생겨요.
$ kubectl get daemonsets -A
NAMESPACE NAME DESIRED CURRENT READY NODE SELECTOR
kube-system kube-proxy 3 3 3 kubernetes.io/os=linux
$ kubectl create deployment log-agent --image=fluent/fluent-bit --dry-run=client -o yaml > ds.yaml
# kind를 DaemonSet으로, replicas · strategy 줄을 지우면 DaemonSet YAML 완성
2. kubelet이 직접 띄우는 Static Pod
Static Pod는 API 서버도 스케줄러도 거치지 않아요. kubelet이 자기 노드의 특정 폴더(보통 /etc/kubernetes/manifests)를 지켜보다가, 거기 있는 YAML을 직접 Pod로 띄워요. 1부 07에서 본 staticPodPath가 그 폴더예요.
$ ps -aux | grep kubelet | grep -o -- '--config=[^ ]*'
--config=/var/lib/kubelet/config.yaml
$ grep staticPodPath /var/lib/kubelet/config.yaml
staticPodPath: /etc/kubernetes/manifests
$ ls /etc/kubernetes/manifests
etcd.yaml kube-apiserver.yaml kube-controller-manager.yaml kube-scheduler.yaml
$ kubectl get pods -A | grep -- -controlplane # 이름 끝에 노드 이름이 붙으면 Static Pod
kube-system etcd-controlplane 1/1 Running
3. 컨트롤 플레인이 Static Pod로 뜨는 이유
API 서버를 Pod로 띄우고 싶은데, Pod를 만들려면 API 서버가 있어야 해요. 닭과 달걀 문제죠. Static Pod는 API 서버 없이 kubelet만으로 뜨니까 이 문제를 풀어 줘요. kubeadm이 컨트롤 플레인 노드의 폴더에 etcd · kube-apiserver · kube-controller-manager · kube-scheduler YAML을 넣어 두는 이유예요.
| DaemonSet | Static Pod | |
|---|---|---|
| 만드는 주체 | DaemonSet 컨트롤러 (API 서버 경유) | 각 노드의 kubelet |
| 스케줄러 | 거침 | 안 거침 |
| 고치는 법 | kubectl edit · apply | 노드의 YAML 파일 수정 |
| 주로 | 노드 에이전트 (로그 · 모니터링 · kube-proxy) | 컨트롤 플레인 부품 |
08. Priority Class
클러스터 자리가 꽉 찼을 때 무엇이 먼저냐를 정하는 게 PriorityClass예요. 결제 서비스와 야간 배치 작업이 자리를 다툰다면, 결제 서비스가 이겨야 하죠.
1. Pod의 우선순위
apiVersion: scheduling.k8s.io/v1
kind: PriorityClass
metadata:
name: high-priority
value: 1000000 # 클수록 우선 (사용자는 10억 이하)
globalDefault: false # true면 클래스를 안 쓴 Pod의 기본값
preemptionPolicy: PreemptLowerPriority # Never면 기다리기만 하고 밀어내지 않음
description: "결제 등 핵심 서비스"
---
# Pod에서 사용
spec:
priorityClassName: high-priority
| PriorityClass | value | 누가 |
|---|---|---|
system-node-critical | 2000001000 | 노드에 꼭 있어야 하는 부품 (kube-proxy 등) |
system-cluster-critical | 2000000000 | 클러스터 핵심 부품 (CoreDNS 등) |
| 사용자 정의 | ~ 1000000000 | 우리 앱 |
| (없음) | 0 | 클래스를 안 쓴 Pod (globalDefault가 없을 때) |
2. 자리가 없을 때 선점(Preemption)
09. 여러 스케줄러
기본 스케줄러의 규칙이 맞지 않으면 다른 규칙의 스케줄러를 쓸 수 있어요. 방법은 두 가지예요. 스케줄러를 하나 더 띄우거나, 스케줄러 하나에 프로필을 여러 개 두는 것.
1. 스케줄러를 하나 더 띄우기
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: my-scheduler
leaderElection:
leaderElect: false # 한 대만 띄울 때
spec:
schedulerName: my-scheduler # 비우면 default-scheduler
$ kubectl get events -o wide | grep Scheduled
... Scheduled pod/nginx my-scheduler Successfully assigned default/nginx to node01
$ kubectl logs -n kube-system my-scheduler
추가 스케줄러는 보통 기본 스케줄러와 같은 이미지(registry.k8s.io/kube-scheduler)를 Deployment나 Static Pod로 띄우고, 위 설정 파일을 --config로 넘겨요. 설정 파일은 ConfigMap으로 만들어 볼륨으로 넣는 게 일반적이에요(5부 03).
2. 스케줄러 프로필과 플러그인
스케줄러를 여러 대 띄우면 서로 같은 노드에 동시에 Pod를 놓으려다 부딪칠 수 있어요. 그래서 요즘은 스케줄러 하나에 프로필을 여러 개 두는 방식을 권해요. 1부 06의 필터링 · 점수 단계는 실제로 확장 지점(extension point)마다 꽂힌 플러그인들이에요.
| 확장 지점 | 하는 일 | 대표 플러그인 |
|---|---|---|
queueSort | 대기열 순서 | PrioritySort (08의 우선순위) |
filter | 안 되는 노드 빼기 | NodeResourcesFit · NodeName · TaintToleration · NodeAffinity · NodeUnschedulable |
score | 남은 노드에 점수 | NodeResourcesFit · ImageLocality · TaintToleration · NodeAffinity |
bind | 노드에 묶기 | DefaultBinder |
apiVersion: kubescheduler.config.k8s.io/v1
kind: KubeSchedulerConfiguration
profiles:
- schedulerName: default-scheduler
- schedulerName: no-scoring-scheduler # 같은 스케줄러, 다른 규칙
plugins:
preScore:
disabled:
- name: '*'
score:
disabled:
- name: '*'
3. 3부 셀프 체크
스케줄러가 없는 클러스터에서 Pod를 특정 노드에 띄우는 방법은?
Pod를 만들 때spec.nodeName을 써요. 이미 있는 Pod는 nodeName을 바꿀 수 없어서 Binding 객체를 보내거나, 지우고 다시 만들어요.라벨과 어노테이션의 가장 큰 차이는?
라벨은 셀렉터로 고를 수 있고, 어노테이션은 고를 수 없는 부가 정보예요.node01에
app=blue:NoScheduletaint가 있고 Pod에 맞는 toleration이 있으면, 그 Pod는 반드시 node01에 가나요?아니요. toleration은 “갈 수 있다”는 허락일 뿐이에요. 반드시 보내려면 Node Affinity(또는 nodeSelector)를 같이 써요.NoSchedule과 NoExecute의 차이는?
NoSchedule은 새 Pod만 막고, NoExecute는 이미 돌던 Pod도 toleration이 없으면 쫓아내요.requiredDuringSchedulingIgnoredDuringExecution에서 뒤 절반의 뜻은?이미 실행 중인 Pod는 나중에 노드 라벨이 바뀌어도 쫓아내지 않는다는 뜻이에요.레플리카 3개를 노드마다 흩어 놓고 싶어요. 노드가 2대일 때 required Anti-Affinity와 Topology Spread(maxSkew 1)의 결과 차이는?
required Anti-Affinity는 2개만 배치되고 1개가 Pending이에요. Topology Spread는 2:1로 3개 모두 배치돼요(차이 1 이하).CPU limit과 메모리 limit을 넘으면 각각 어떻게 되나요?
CPU는 느려지고(throttling), 메모리는 OOMKilled로 컨테이너가 재시작돼요.스케줄러는 노드 자리를 실제 사용량으로 계산하나요?
아니요, requests의 합으로 계산해요. 그래서 실제로는 한가해도 requests가 꽉 차면 Pending이 생겨요.Static Pod를
kubectl delete로 지우면?API에 보이는 건 거울(mirror) Pod라서, kubelet이 폴더의 YAML을 보고 다시 띄워요. 지우려면 노드의/etc/kubernetes/manifests에서 파일을 지워요.높은 PriorityClass의 Pod가 왔는데 자리가 없으면?
스케줄러가 낮은 우선순위 Pod를 선점(축출)해서 자리를 만들어요.preemptionPolicy: Never면 밀어내지 않고 기다려요.Pod를 다른 스케줄러로 배치시키려면?
Pod의spec.schedulerName에 그 스케줄러(또는 프로필)의 이름을 써요. 누가 배치했는지는 이벤트의 source로 확인해요.
01. 무엇을 지켜봐야 하나
클러스터가 돌기 시작하면 “지금 괜찮은가?”를 계속 확인해야 해요. 확인할 거리는 크게 지표(metrics) — 숫자로 된 상태, 로그(logs) — 프로그램이 남긴 글, 이벤트(events) — 쿠버네티스가 남긴 사건 기록, 이렇게 세 가지예요. 입문 7부 03에서 맛본 내용을 실제 명령과 구조로 정리해요.
1. 노드 · Pod · 컴포넌트 지표
| 대상 | 지켜볼 것 | 기본으로 보는 방법 |
|---|---|---|
| 노드 | CPU · 메모리 사용률, 디스크 · PID 부족, Ready 여부 | kubectl top node · kubectl describe node의 Conditions |
| Pod · 컨테이너 | CPU · 메모리 사용량, 재시작 횟수, 상태 | kubectl top pod · kubectl get pods |
| 컨트롤 플레인 | API 서버 · etcd · 스케줄러 · 컨트롤러가 살아 있는지 | kubectl get pods -n kube-system · /readyz |
| 애플리케이션 | 요청 수 · 오류율 · 응답 시간 (앱이 직접 내보내는 지표) | 쿠버네티스 기본 기능에는 없음 → 04의 Prometheus |
2. metrics-server와 kubectl top
각 노드의 kubelet 안에는 cAdvisor라는 부품이 있어서 컨테이너별 CPU · 메모리 사용량을 재고 있어요. metrics-server는 모든 노드의 kubelet에서 이 숫자를 주기적으로 모아 메모리에만 들고 있다가, API 서버를 통해 kubectl top과 HPA(5부 07)에 내줘요.
$ kubectl apply -f https://github.com/kubernetes-sigs/metrics-server/releases/latest/download/components.yaml
$ kubectl top node
NAME CPU(cores) CPU(%) MEMORY(bytes) MEMORY(%)
controlplane 212m 10% 1180Mi 31%
node01 95m 4% 812Mi 21%
$ kubectl top pod -A --sort-by=memory # 메모리 많이 쓰는 순
$ kubectl top pod web-7d4b9c-x2kfp --containers # 컨테이너별로
error: Metrics API not available
metrics-server가 없거나 아직 준비가 안 된 거예요. 설치 직후에는 첫 수집까지 1분 정도 걸려요. 실습 클러스터에서 kubelet 인증서 문제로 수집에 실패하면 metrics-server에 --kubelet-insecure-tls 옵션을 추가하는 경우가 많은데, 실습용 우회라 운영 환경에서는 쓰지 않아요.
02. 클러스터 컴포넌트 모니터링
1부에서 본 부품들이 살아 있는지 확인하는 방법이에요. 핵심은 부품이 어떻게 떠 있느냐에 따라 보는 방법이 다르다는 점이에요. Static Pod로 뜬 컨트롤 플레인은 kubectl로, systemd 서비스인 kubelet은 노드에 들어가서 봐요.
1. 컨트롤 플레인 Pod 상태 보기
$ kubectl get pods -n kube-system # 컨트롤 플레인 Pod가 Running인가
$ kubectl logs -n kube-system kube-apiserver-controlplane # Static Pod 로그도 kubectl logs로
$ kubectl get --raw='/readyz?verbose' # API 서버의 자체 점검
[+]ping ok
[+]log ok
[+]etcd ok
...
readyz check passed
$ kubectl describe node node01 | grep -A6 Conditions # 노드 상태
Type Status Reason
MemoryPressure False KubeletHasSufficientMemory
DiskPressure False KubeletHasNoDiskPressure
PIDPressure False KubeletHasSufficientPID
Ready True KubeletReady
kubectl get componentstatuses
예전에는 이 명령으로 스케줄러 · 컨트롤러 · etcd 상태를 한 번에 봤어요. 1.19부터 지원 중단 예고 상태라 결과가 정확하지 않을 수 있어요. 지금은 위처럼 kube-system Pod와 /readyz · /livez를 보는 게 표준이에요.
2. kubelet은 systemd 서비스: systemctl · journalctl
노드가 NotReady면 대개 그 노드의 kubelet에 문제가 있어요. kubelet은 Pod가 아니라서 kubectl logs로는 못 보고, 노드에 접속해서 systemd 도구로 봐요. 노드 장애 대응(16부)의 기본 순서예요.
| 부품 | 떠 있는 방식 | 로그 · 상태 보는 법 |
|---|---|---|
| API 서버 · etcd · 스케줄러 · 컨트롤러 | Static Pod (kubeadm) | kubectl logs -n kube-system … / API 서버가 죽었으면 노드에서 crictl ps -a · crictl logs |
| kube-proxy · CoreDNS | DaemonSet · Deployment | kubectl logs -n kube-system … |
| kubelet · containerd | systemd 서비스 | 노드에서 systemctl status kubelet · journalctl -u kubelet |
| 직접 설치(“the hard way”)한 컨트롤 플레인 | systemd 서비스 | journalctl -u kube-apiserver 등 |
03. 애플리케이션 로그
컨테이너 앱은 로그를 파일이 아니라 표준 출력(stdout) · 표준 에러(stderr)로 내보내는 게 원칙이에요. 그러면 컨테이너 런타임이 받아서 노드에 저장하고, kubectl logs로 어디서든 볼 수 있어요.
1. kubectl logs
$ kubectl logs web-7d4b9c-x2kfp # 기본 (컨테이너가 하나일 때)
$ kubectl logs web-7d4b9c-x2kfp -c log-agent # 컨테이너가 여러 개면 -c로 지정 (5부 05)
$ kubectl logs -f web-7d4b9c-x2kfp # 계속 따라 보기 (tail -f처럼)
$ kubectl logs web-7d4b9c-x2kfp --previous # 재시작 직전 컨테이너의 로그 (CrashLoopBackOff 원인 찾기)
$ kubectl logs web-7d4b9c-x2kfp --tail=50 --since=10m
$ kubectl logs deploy/web # Deployment의 Pod 중 하나
$ kubectl logs -l app=web --prefix --all-containers # 라벨로 여러 Pod 한꺼번에
2. 로그는 노드 어디에 쌓이나
$ ls /var/log/pods/ # 노드에서
default_web-7d4b9c-x2kfp_6f1c2a9e-.../
$ ls /var/log/pods/default_web-7d4b9c-x2kfp_*/nginx/
0.log 1.log # 숫자 = 재시작 횟수
$ ls -l /var/log/containers/ | head -2 # 찾기 쉬운 이름의 바로가기(심볼릭 링크)
web-7d4b9c-x2kfp_default_nginx-9b1e0c7a....log -> /var/log/pods/...
3. Events로 원인 찾기
로그가 앱이 남긴 기록이라면, Event는 쿠버네티스 부품이 남긴 기록이에요. 1부 01에서 본 Scheduled · Pulling · Started가 Event였죠. Pod가 아예 안 뜨면 앱 로그가 없으니 Event가 유일한 단서예요.
$ kubectl describe pod web-7d4b9c-x2kfp | tail -5 # 맨 아래 Events
$ kubectl events --for pod/web-7d4b9c-x2kfp # 특정 객체의 이벤트
$ kubectl get events --field-selector type=Warning -A # 경고만, 모든 Namespace
$ kubectl get events --sort-by=.lastTimestamp # 시간순
| 자주 보는 Warning | 남긴 곳 | 뜻 |
|---|---|---|
FailedScheduling | 스케줄러 | 갈 노드가 없음 (3부) |
FailedCreate | 컨트롤러 | 쿼터 · 어드미션에 막혀 Pod를 못 만듦 (2부 04) |
Failed · ErrImagePull | kubelet | 이미지를 못 받음 |
BackOff | kubelet | 컨테이너가 계속 죽어서 재시작 대기 (CrashLoopBackOff) |
Unhealthy | kubelet | probe 실패 (5부 06) |
Evicted | kubelet | 노드 자원 부족으로 축출 (3부 06) |
--event-ttl)만 보관해요. 어제 일어난 일은 Event로 찾을 수 없으니, 오래 남겨야 하면 로그처럼 수집 도구로 따로 모아요.
04. 모니터링 도구 맛보기
metrics-server는 “지금”만 알아요. “어젯밤 3시에 메모리가 왜 튀었지?”, “오류율이 5%를 넘으면 알려 줘” 같은 일은 별도 도구로 해요. 사실상 표준 조합은 Prometheus + Grafana예요. 학습 범위 밖이지만, 실무에서 반드시 만나는 도구라 구조만 짚어요.
| 구성 요소 | 하는 일 |
|---|---|
| Prometheus | 대상들의 /metrics를 주기적으로 가져와서(pull) 시계열로 저장. PromQL로 조회 |
| node-exporter | 노드의 CPU · 메모리 · 디스크 · 네트워크 지표 (DaemonSet) |
| kube-state-metrics | 쿠버네티스 객체 상태를 지표로 (Deployment의 원하는/현재 레플리카, Pod 재시작 수 등) |
| Grafana | Prometheus를 데이터 소스로 대시보드 · 그래프 |
| Alertmanager | Prometheus 경고 규칙이 울리면 슬랙 · 메일 등으로 알림 (중복 묶기 · 무음 처리) |
# 지난 5분 동안 Pod별 CPU 사용량 (코어)
sum by (pod) (rate(container_cpu_usage_seconds_total{namespace="default"}[5m]))
# 최근 1시간 재시작이 있었던 컨테이너
increase(kube_pod_container_status_restarts_total[1h]) > 0
kube-prometheus-stack Helm 차트를 많이 써요(Helm은 13부). 로그 쪽은 Loki(+Grafana), 지표 · 로그 · 추적을 하나의 규격으로 모으는 OpenTelemetry도 함께 알아 두면 좋아요.
1. 4부 셀프 체크
kubectl top이 보여 주는 숫자는 누가 재고, 누가 모으나요?각 노드 kubelet 안의 cAdvisor가 재고, metrics-server가 주기적으로 모아 Metrics API로 내줘요.metrics-server로 “어제 오후 3시의 메모리 사용량”을 볼 수 있나요?
없어요. metrics-server는 최신 값만 메모리에 들고 있어요. 과거 기록은 Prometheus 같은 시계열 저장소가 필요해요.노드가 NotReady일 때 kubelet 로그를 보는 방법은?
노드에 접속해systemctl status kubelet으로 상태를,journalctl -u kubelet으로 로그를 봐요. kubelet은 Pod가 아니라서kubectl logs로는 못 봐요.컨테이너가 계속 재시작될 때 죽기 직전 로그를 보는 옵션은?
kubectl logs <pod> --previous(-p). 바로 이전 컨테이너 하나의 로그만 남아 있어요.컨테이너가 두 개인 Pod의 로그를 볼 때 필요한 옵션은?
-c <컨테이너 이름>. 모두 보려면--all-containers.운영 환경에서 로그 수집기를 따로 두는 이유는?
노드의 로그 파일은 크기 제한으로 돌려 쓰이고 Pod가 지워지거나 노드가 죽으면 사라지기 때문이에요. DaemonSet 수집기로 중앙 저장소에 보내요.Pod가 Pending에서 안 넘어가요. 로그와 Event 중 무엇을 먼저 보나요?
Event예요. 컨테이너가 뜨지 않았으니 앱 로그가 없어요.kubectl describe pod의 Events에서 FailedScheduling 같은 원인을 봐요.Prometheus가 지표를 모으는 방식은 push와 pull 중 무엇인가요?
pull이에요. 대상들이/metrics로 지표를 내놓으면 Prometheus가 주기적으로 가져가요.
01. 롤링 업데이트와 롤백
앱을 만들고, 설정을 넣고, 새 버전으로 바꾸고, 문제가 생기면 되돌리고, 부하에 따라 늘리고 줄이는 것까지 — 앱이 태어나서 운영되는 동안의 일을 모은 부예요. 먼저 새 버전 배포부터 봐요. 입문 4부 03에서 롤링 업데이트가 “하나씩 바꾼다”는 걸 봤다면, 여기서는 몇 개씩, 어떤 순서로 바꿀지 정하는 손잡이를 봐요.
1. RollingUpdate와 Recreate
spec:
replicas: 4
strategy:
type: RollingUpdate # 기본값. Recreate면 전부 지우고 새로 띄움
rollingUpdate:
maxSurge: 25% # 원하는 개수보다 “더” 띄워도 되는 수 (기본 25%)
maxUnavailable: 25% # 원하는 개수보다 “덜” 있어도 되는 수 (기본 25%)
minReadySeconds: 10 # Ready 후 이만큼 버텨야 “사용 가능”으로 인정
revisionHistoryLimit: 10 # 롤백용으로 남길 옛 ReplicaSet 수
2. maxSurge · maxUnavailable
| 설정 (replicas 4) | 한 번에 | 특징 |
|---|---|---|
maxSurge: 1 · maxUnavailable: 1 (기본 25%) | 최대 5개까지 · 최소 3개 유지 | 균형. 대부분 이걸로 충분 |
maxSurge: 1 · maxUnavailable: 0 | 새 것 하나 Ready → 옛 것 하나 삭제 | 가용 수가 절대 안 줄어 안전, 대신 느리고 자원이 더 필요 |
maxSurge: 0 · maxUnavailable: 1 | 하나 지우고 → 하나 띄우기 | 추가 자원 없이 교체 (노드가 빠듯할 때) |
type: Recreate | 전부 지우고 전부 띄우기 | 중단 시간 발생. 두 버전이 동시에 돌면 안 되는 앱에만 |
3. rollout status · history · undo
$ kubectl set image deployment/web nginx=nginx:1.28
$ kubectl annotate deployment/web kubernetes.io/change-cause="nginx 1.28로 업데이트" # 기록 남기기
$ kubectl rollout status deployment/web # 끝날 때까지 진행 상황
Waiting for deployment "web" rollout to finish: 2 of 4 updated replicas are available...
deployment "web" successfully rolled out
$ kubectl rollout history deployment/web
REVISION CHANGE-CAUSE
1 <none>
2 nginx 1.28로 업데이트
$ kubectl rollout undo deployment/web # 바로 이전 리비전으로
$ kubectl rollout undo deployment/web --to-revision=1
$ kubectl rollout restart deployment/web # 같은 버전으로 Pod만 차례로 새로 (설정 다시 읽기 등)
--record
예전에는 kubectl set image ... --record로 CHANGE-CAUSE를 남겼어요. 이 옵션은 지원 중단 예고 상태라, 지금은 위처럼 kubernetes.io/change-cause 어노테이션을 직접 붙여요.
02. 명령과 인자
컨테이너가 시작할 때 무엇을 실행할지는 이미지에 정해져 있어요. 쿠버네티스는 이걸 Pod YAML에서 바꿀 수 있게 해 주는데, Docker와 이름이 달라서 헷갈려요. 짝만 외우면 끝이에요.
1. Docker의 ENTRYPOINT · CMD
FROM ubuntu
ENTRYPOINT ["sleep"] # 항상 실행할 프로그램
CMD ["5"] # 기본 인자 (바꿀 수 있음)
2. 쿠버네티스의 command · args
spec:
containers:
- name: sleeper
image: ubuntu-sleeper
command: ["sleep2.0"] # ENTRYPOINT를 덮어씀
args: ["10"] # CMD를 덮어씀
| Docker (이미지) | 쿠버네티스 (Pod) | 쓰지 않으면 |
|---|---|---|
ENTRYPOINT | command | 이미지의 ENTRYPOINT |
CMD | args | 이미지의 CMD |
$ kubectl run sleeper --image=ubuntu-sleeper -- 10 # -- 뒤는 args
$ kubectl run sleeper --image=busybox --command -- sleep 1000 # --command면 command
command는 Docker의 CMD가 아니에요. ENTRYPOINT예요. 그리고 배열은 원소마다 따로 써야 해요. command: ["sleep 10"]은 “sleep 10”이라는 이름의 프로그램을 찾다가 실패해요. ["sleep", "10"]으로 나눠요.
03. 환경 변수와 ConfigMap
같은 이미지를 dev · prod에서 다른 설정으로 돌리려면 설정을 이미지 밖에 둬야 해요(입문 6부 01). 가장 단순한 방법은 환경 변수이고, 여러 값을 묶어 관리하는 게 ConfigMap이에요.
1. env로 값 넣기
spec:
containers:
- name: web
image: web:1.0
env:
- name: APP_COLOR # 직접 값
value: blue
- name: APP_MODE # ConfigMap의 키 하나
valueFrom:
configMapKeyRef:
name: app-config
key: APP_MODE
- name: DB_PASSWORD # Secret의 키 하나 (04)
valueFrom:
secretKeyRef:
name: db-secret
key: DB_PASSWORD
2. ConfigMap — 통째로 넣기와 파일로 넣기
$ kubectl create configmap app-config \
--from-literal=APP_COLOR=blue --from-literal=APP_MODE=prod
$ kubectl create configmap nginx-conf --from-file=nginx.conf # 파일 이름이 키, 내용이 값
spec:
containers:
- name: web
image: web:1.0
envFrom: # 모든 키를 환경 변수로
- configMapRef:
name: app-config
volumeMounts: # 키마다 파일 하나로
- name: conf
mountPath: /etc/nginx/conf.d
volumes:
- name: conf
configMap:
name: nginx-conf
| 넣는 방법 | ConfigMap을 바꾸면 | 주로 |
|---|---|---|
env · envFrom | 바뀌지 않음 — 컨테이너를 다시 시작해야 반영 (rollout restart) | 짧은 설정값 |
| 볼륨 (파일) | 잠시 뒤(보통 1분 안팎) 파일이 자동으로 바뀜. 앱이 다시 읽어야 효과 (subPath로 넣은 파일은 안 바뀜) | 설정 파일 통째 |
immutable: true를 쓰면 실수로 고치는 걸 막고, kubelet이 변경을 계속 지켜볼 필요가 없어 큰 클러스터에서 API 서버 부담도 줄어요. 바꾸려면 새 이름으로 만들어 Deployment가 가리키게 해요.
04. Secret과 암호화
비밀번호 · 토큰 · 인증서는 ConfigMap 대신 Secret에 둬요(입문 6부 02). 사용법은 ConfigMap과 거의 같지만, 이름과 달리 기본 상태로는 그렇게 비밀스럽지 않다는 점을 꼭 알아야 해요.
1. Secret 사용법
$ kubectl create secret generic db-secret \
--from-literal=DB_HOST=mysql --from-literal=DB_PASSWORD='p@ssw0rd'
$ kubectl get secret db-secret -o yaml
data:
DB_HOST: bXlzcWw=
DB_PASSWORD: cEBzc3cwcmQ=
spec:
containers:
- name: web
image: web:1.0
envFrom:
- secretRef:
name: db-secret # 모든 키를 환경 변수로
volumeMounts:
- name: db
mountPath: /etc/db # 키마다 파일 (메모리 기반 tmpfs라 디스크에 안 남음)
readOnly: true
volumes:
- name: db
secret:
secretName: db-secret
2. base64는 암호화가 아니다
$ echo 'cEBzc3cwcmQ=' | base64 -d
p@ssw0rd
3. etcd에 암호화해서 저장하기
API 서버에 EncryptionConfiguration 파일을 주면, Secret을 etcd에 쓸 때 암호화하고 읽을 때 풀어요. 사용자 입장에서는 아무것도 바뀌지 않고, etcd를 통째로 훔쳐 가도 값을 읽을 수 없게 돼요.
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc: # 맨 위 provider로 “쓰기”
keys:
- name: key1
secret: <32바이트 키를 base64로> # head -c 32 /dev/urandom | base64
- identity: {} # 암호화 안 된 옛 데이터 “읽기”용
# kube-apiserver.yaml에 플래그 추가 + 위 파일을 볼륨으로 마운트
- --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
$ kubectl get secrets -A -o json | kubectl replace -f - # 이미 있던 Secret을 다시 써서 암호화
$ etcdctl get /registry/secrets/default/db-secret | hexdump -C | head -2
... 6b 38 73 3a 65 6e 63 3a 61 65 73 63 62 63 3a 76 |k8s:enc:aescbc:v| ← 암호화됨
kms provider나, Vault · 클라우드 비밀 관리자와 연결하는 External Secrets · Secrets Store CSI Driver를 많이 써요.
05. 멀티 컨테이너 Pod
Pod 하나에 컨테이너를 여러 개 넣을 수 있어요(입문 3부 03). 같은 Pod의 컨테이너는 네트워크(localhost)와 볼륨을 공유하고, 함께 만들어지고 함께 사라져요.
1. 한 Pod에 여러 컨테이너를 두는 이유
원칙은 “Pod 하나 = 앱 하나”예요. 그래도 컨테이너를 묶는 건 본 앱을 돕는 작은 일이 본 앱과 반드시 같이 있어야 할 때예요. 웹 서버와 DB처럼 따로 늘리고 줄여야 하는 것은 묶지 않아요.
2. 디자인 패턴: 사이드카 · 어댑터 · 앰배서더
3. Init Containers
Init 컨테이너는 본 컨테이너보다 먼저, 하나씩 차례로 실행되고 끝나야 하는 컨테이너예요. DB가 뜰 때까지 기다리기, 설정 파일 내려받기, 권한 맞추기 같은 준비 작업에 써요. 하나라도 실패하면 Pod의 restartPolicy에 따라 다시 시도하고, 본 컨테이너는 시작하지 않아요.
spec:
initContainers:
- name: wait-db # 1번: DB Service가 DNS에 뜰 때까지
image: busybox
command: ["sh", "-c", "until nslookup db; do sleep 2; done"]
- name: fetch-config # 2번: 1번이 끝나야 시작
image: busybox
command: ["sh", "-c", "wget -O /work/app.conf http://config/app.conf"]
containers:
- name: web # init이 모두 끝나야 시작
image: web:1.0
$ kubectl get pod web
NAME READY STATUS RESTARTS
web 0/1 Init:1/2 0 # init 2개 중 1개 완료
$ kubectl logs web -c wait-db # init 컨테이너 로그도 -c로
4. 네이티브 사이드카 컨테이너
예전 사이드카는 그냥 containers에 같이 넣었어요. 그러면 시작 순서를 보장할 수 없고, Job에서는 본 작업이 끝나도 사이드카가 안 끝나서 Job이 완료되지 않는 문제가 있었어요. 그래서 initContainers에 restartPolicy: Always를 단 네이티브 사이드카가 생겼고, 1.33에서 정식(GA)이 됐어요.
spec:
initContainers:
- name: log-shipper
image: fluent/fluent-bit
restartPolicy: Always # ← 이 한 줄이 “사이드카”라는 표시
containers:
- name: web
image: web:1.0
| 일반 init 컨테이너 | 네이티브 사이드카 | 예전 방식 사이드카 (containers) | |
|---|---|---|---|
| 시작 | 본 컨테이너 전, 차례로 | 본 컨테이너 전에 시작 | 본 컨테이너와 동시 (순서 없음) |
| 끝 | 끝나야 다음으로 | 끝나지 않음 · 본 컨테이너 후에 종료 | 본 컨테이너와 따로 |
| Job에서 | — | Job 완료를 막지 않음 | 사이드카가 안 끝나 Job이 완료 안 됨 |
| probe | 없음 | readiness · liveness · startup 가능 | 가능 |
06. 헬스 체크
학습 자료에는 없지만 운영에서 빠질 수 없는 설정이에요(입문 3부 05). 컨테이너 프로세스가 살아 있어도 앱이 멈춰 있거나 아직 준비 중일 수 있어요. kubelet은 probe로 앱에 직접 물어보고, 결과에 따라 트래픽을 끊거나 컨테이너를 재시작해요.
containers:
- name: web
image: web:1.0
startupProbe: # 성공할 때까지 아래 둘을 멈춰 둠
httpGet: { path: /healthz, port: 8080 }
failureThreshold: 30 # 30번 × 10초 = 최대 5분 기다림
periodSeconds: 10
readinessProbe: # 실패하면 Service 트래픽에서 제외
httpGet: { path: /ready, port: 8080 }
periodSeconds: 5
livenessProbe: # 연속 실패하면 컨테이너 재시작
httpGet: { path: /healthz, port: 8080 }
periodSeconds: 10
failureThreshold: 3
| probe | 실패하면 | 주의 |
|---|---|---|
| startupProbe | 계속 기다림 (한도를 넘으면 재시작) | 시작이 느린 앱에 — 이게 있으면 나머지는 성공 후에 시작 |
| readinessProbe | EndpointSlice에서 빠짐 (2부 03) | Pod는 그대로. 잠깐 바쁠 때 트래픽만 쉬게 |
| livenessProbe | 컨테이너 재시작 | DB 같은 외부 의존성까지 검사하면 DB 장애 때 전부 재시작되는 사고가 남 |
검사 방식은 httpGet(2xx · 3xx면 성공), tcpSocket(포트 연결), exec(명령 종료 코드 0), grpc 네 가지예요. 기본값은 periodSeconds: 10 · timeoutSeconds: 1 · failureThreshold: 3이에요.
07. 오토스케일링
부하에 맞춰 자원을 바꾸는 방법은 두 방향이에요. Pod 개수를 바꾸는 수평(HPA), Pod 크기(requests · limits)를 바꾸는 수직(VPA · In-place Resize). 노드 개수를 바꾸는 클러스터 오토스케일러는 입문 7부 02에서 봤어요.
1. HPA
$ kubectl autoscale deployment web --cpu-percent=50 --min=2 --max=10
$ kubectl get hpa
NAME REFERENCE TARGETS MINPODS MAXPODS REPLICAS
web Deployment/web cpu: 90%/50% 2 10 3
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: web
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: web
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 50 # requests 대비 평균 50%를 목표로
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # 줄일 때는 5분간 지켜보고 (기본값)
2. In-place Pod Resize (재시작 없이 자원 바꾸기)
예전에는 Pod의 requests · limits를 바꾸려면 Pod를 새로 만들어야 했어요. 1.35에서 정식(GA)이 된 In-place Pod Resize는 실행 중인 Pod의 CPU · 메모리를 그 자리에서 바꿔요. 학습 자료의 “Place Resize of Pods”가 이거예요.
$ kubectl patch pod web-1 --subresource resize --patch \
'{"spec":{"containers":[{"name":"web","resources":{"requests":{"cpu":"1"},"limits":{"cpu":"1"}}}]}}'
$ kubectl get pod web-1 -o jsonpath='{.status.containerStatuses[0].resources}' # 실제 적용된 값
containers:
- name: web
image: web:1.0
resizePolicy: # 자원별로 재시작이 필요한지
- resourceName: cpu
restartPolicy: NotRequired # 재시작 없이 (기본값)
- resourceName: memory
restartPolicy: RestartContainer # 메모리는 컨테이너 재시작으로 반영
노드에 자리가 없으면 바로 적용되지 않고 Pod 조건에 PodResizePending이 붙어요. Deployment의 템플릿은 바뀌지 않으니, 영구적으로 바꾸려면 Deployment도 같이 고쳐요.
3. VPA
VPA(Vertical Pod Autoscaler)는 사용량 기록을 보고 적정 requests를 추천하고, 설정에 따라 적용까지 해요. 쿠버네티스 기본 기능이 아니라 별도로 설치하는 프로젝트예요(kubernetes/autoscaler).
| updateMode | 동작 |
|---|---|
Off | 추천만 계산 (kubectl describe vpa로 확인). 처음엔 이걸로 시작해 보는 게 안전 |
Initial | 새로 만들어지는 Pod에만 추천값 적용 |
Recreate | 추천과 많이 다르면 Pod를 축출하고 다시 만들어 적용 |
InPlaceOrRecreate | 먼저 In-place Resize로 시도하고, 안 되면 다시 만들기 (최근 버전) |
Off)만 쓰는 식으로 나눠요.
08. Job과 CronJob
웹 서버처럼 계속 떠 있는 게 아니라, 할 일을 마치면 끝나는 작업용 리소스예요(입문 4부 05). 학습 목차엔 없지만 CKA 범위이고 실무에서도 자주 써서 넣었어요.
1. 끝나는 작업 — Job
apiVersion: batch/v1
kind: Job
metadata:
name: report
spec:
completions: 5 # 성공이 5번 필요
parallelism: 2 # 동시에 2개까지
backoffLimit: 4 # 실패 재시도 한도 (기본 6)
activeDeadlineSeconds: 600 # 10분 넘으면 강제 종료
ttlSecondsAfterFinished: 3600 # 끝나고 1시간 뒤 자동 삭제
template:
spec:
restartPolicy: Never # Job은 Never 또는 OnFailure만
containers:
- name: report
image: busybox
command: ["sh", "-c", "echo making report; sleep 5"]
2. 정해진 시간에 실행 — CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
name: nightly-report
spec:
schedule: "30 2 * * *" # 분 시 일 월 요일 → 매일 02:30
timeZone: "Asia/Seoul" # 없으면 컨트롤러 기준 시간대(보통 UTC)
concurrencyPolicy: Forbid # 이전 실행이 안 끝났으면 건너뜀 (Allow · Replace)
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 1
jobTemplate:
spec:
template:
spec:
restartPolicy: OnFailure
containers:
- name: report
image: busybox
command: ["sh", "-c", "date; echo nightly"]
$ kubectl create cronjob nightly-report --image=busybox --schedule="30 2 * * *" -- date
$ kubectl create job manual-run --from=cronjob/nightly-report # 일정과 상관없이 지금 한 번
$ kubectl get cronjob,job,pods
3. 5부 셀프 체크
replicas 4, maxSurge 1, maxUnavailable 0이면 업데이트 중 Pod 수는 어떻게 움직이나요?
최대 5개까지 늘었다가, 새 Pod가 Ready가 될 때마다 옛 Pod를 하나씩 지워요. 사용 가능한 Pod는 4개 아래로 내려가지 않아요.배포가 잘못됐을 때 바로 전 버전으로 되돌리는 명령은?
kubectl rollout undo deployment/web. 특정 리비전은--to-revision=N.Docker의 ENTRYPOINT와 CMD에 대응하는 Pod의 칸은?
ENTRYPOINT ↔command, CMD ↔args.ConfigMap 값을 바꿨는데 앱이 옛 값을 써요. 왜 그럴까요?
환경 변수(env·envFrom)로 넣었다면 컨테이너가 시작할 때 한 번만 읽어요.kubectl rollout restart로 다시 시작해야 해요. 볼륨으로 넣었다면 파일은 바뀌지만 앱이 다시 읽어야 해요.Secret의 값이 base64로 되어 있으니 안전한가요?
아니요. base64는 누구나 풀 수 있는 인코딩이에요. etcd 저장 암호화(EncryptionConfiguration) · RBAC · 외부 비밀 관리자로 보호해요.EncryptionConfiguration을 적용한 뒤 기존 Secret도 암호화하려면?
kubectl get secrets -A -o json | kubectl replace -f -로 다시 써요. 새로 쓰일 때 암호화돼요.init 컨테이너와 네이티브 사이드카의 차이는?
init은 차례로 실행되어 끝나야 다음으로 가요. 네이티브 사이드카(restartPolicy: Always)는 본 컨테이너보다 먼저 떠서 끝까지 함께 돌고, 본 컨테이너가 끝난 뒤 멈춰요.readiness와 liveness가 실패했을 때의 차이는?
readiness 실패는 트래픽에서만 제외, liveness 실패는 컨테이너 재시작이에요.HPA 목표 50%, 현재 4개가 평균 75%면 몇 개로 늘어나나요?
올림(4 × 75 ÷ 50) = 6개.Pod를 재시작하지 않고 CPU를 늘리는 방법은?
In-place Pod Resize:kubectl patch pod ... --subresource resize또는kubectl edit pod ... --subresource resize.CronJob이 이전 실행이 끝나지 않았을 때 새 실행을 건너뛰게 하려면?
concurrencyPolicy: Forbid.Replace는 이전 것을 멈추고 새로 시작해요.
01. 노드 점검과 OS 업그레이드
노드도 결국 리눅스 서버라 보안 패치 · 커널 업데이트 · 재부팅이 필요해요. 그냥 껐다 켜면 그 위의 Pod가 갑자기 사라지니, 미리 Pod를 다른 노드로 비워 두고 작업하는 게 정석이에요.
1. drain · cordon · uncordon
$ kubectl cordon node01 # 새 Pod 배치만 막기 (기존 Pod는 그대로)
$ kubectl drain node01 --ignore-daemonsets # cordon + Pod 내보내기
node/node01 cordoned
evicting pod default/web-7d4b9c-x2kfp
pod/web-7d4b9c-x2kfp evicted
node/node01 drained
$ kubectl get nodes
node01 Ready,SchedulingDisabled <none> 3d v1.35.2
# … OS 업데이트 · 재부팅 …
$ kubectl uncordon node01 # 다시 Pod를 받을 수 있게
| 상황 | drain이 하는 일 | 필요한 옵션 |
|---|---|---|
| Deployment · ReplicaSet Pod | 내보내면 컨트롤러가 다른 노드에 새로 만듦 | — |
| DaemonSet Pod | 내보내도 바로 다시 생기니 그대로 둠 | --ignore-daemonsets (없으면 drain이 멈춤) |
| emptyDir를 쓰는 Pod | 임시 데이터가 사라짐을 확인받음 | --delete-emptydir-data |
| 컨트롤러 없는 단독 Pod | 지우면 다시 안 생김 — 기본은 거부 | --force (정말 지워도 될 때만) |
2. PodDisruptionBudget
노드 여러 대를 동시에 drain하면 같은 앱의 Pod가 한꺼번에 내보내져 서비스가 끊길 수 있어요. PodDisruptionBudget(PDB)은 “이 앱은 자발적인 중단(drain · 업그레이드) 중에도 최소 몇 개는 살아 있어야 한다”는 약속이에요. 학습 자료에는 없지만 실제 운영의 노드 점검에서 꼭 쓰여요.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: web-pdb
spec:
minAvailable: 2 # 또는 maxUnavailable: 1 (둘 중 하나)
selector:
matchLabels:
app: web
$ kubectl create pdb web-pdb --selector=app=web --min-available=2
$ kubectl get pdb
NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS
web-pdb 2 N/A 1
$ kubectl drain node02 --ignore-daemonsets
error when evicting pods/"web-7d4b9c-5mq8r" (will retry after 5s): Cannot evict pod as it would violate the pod's disruption budget.
kubectl delete pod로 직접 지우는 것은 막지 못해요. 또 minAvailable을 레플리카 수와 같게 잡으면 drain이 영원히 끝나지 않으니 여유를 둬야 해요.
02. 클러스터 업그레이드
쿠버네티스는 약 4개월마다 새 마이너 버전(1.35 → 1.36 …)이 나오고, 최근 3개 버전만 패치를 받아요. 1년 남짓 지나면 지원이 끝나니 정기적인 업그레이드는 피할 수 없어요. 이 절은 직접 설치한(kubeadm) 클러스터 기준이에요. GKE 같은 관리형은 컨트롤 플레인을 클라우드가 올려 주고 노드 풀만 우리가 올려요.
1. 버전 규칙과 컴포넌트 간 버전 차이
| 컴포넌트 | API 서버가 1.36일 때 허용 | 규칙 |
|---|---|---|
| kube-apiserver (여러 대) | 1.36 · 1.35 | 서로 1 마이너 이내 — 모든 것의 기준 |
| controller-manager · scheduler | 1.36 · 1.35 | API 서버보다 새로우면 안 됨, 1 낮은 것까지 |
| kubelet · kube-proxy | 1.36 ~ 1.33 | API 서버보다 새로우면 안 됨, 3 낮은 것까지 |
| kubectl | 1.37 · 1.36 · 1.35 | 위아래 1 |
2. kubeadm으로 컨트롤 플레인 → 워커 순서로 올리기
$ sudo vi /etc/apt/sources.list.d/kubernetes.list # 저장소 주소의 v1.35 → v1.36
$ sudo apt-get update
$ sudo apt-mark unhold kubeadm && sudo apt-get install -y kubeadm='1.36.*' && sudo apt-mark hold kubeadm
$ sudo kubeadm upgrade plan # 올릴 수 있는 버전 · 바뀌는 것 확인
$ sudo kubeadm upgrade apply v1.36.2 # API 서버 · 컨트롤러 · 스케줄러 · etcd(Static Pod) 교체
$ kubectl drain controlplane --ignore-daemonsets
$ sudo apt-mark unhold kubelet kubectl && sudo apt-get install -y kubelet='1.36.*' kubectl='1.36.*' && sudo apt-mark hold kubelet kubectl
$ sudo systemctl daemon-reload && sudo systemctl restart kubelet
$ kubectl uncordon controlplane
$ kubectl drain node01 --ignore-daemonsets # 내 PC(컨트롤 플레인)에서
$ ssh node01
$ (저장소 주소 변경 · apt-get update 후) kubeadm 패키지를 1.36으로
$ sudo kubeadm upgrade node # 워커는 apply가 아니라 node
$ (kubelet · kubectl 패키지를 1.36으로) && sudo systemctl daemon-reload && sudo systemctl restart kubelet
$ exit
$ kubectl uncordon node01
$ kubectl get nodes # VERSION 열 확인
apt.kubernetes.io 저장소는 2024년에 닫혔어요. 지금은 pkgs.k8s.io를 쓰고, 저장소 주소에 마이너 버전이 들어가서(…/core:/stable:/v1.36/deb/) 업그레이드할 때마다 이 주소부터 바꿔야 새 버전이 보여요. 다른 컨트롤 플레인 노드가 더 있으면 그 노드들에서는 kubeadm upgrade apply 대신 kubeadm upgrade node를 써요.
03. 백업과 복구
1부 03에서 etcd를 잃으면 클러스터가 모든 기억을 잃는다고 했어요. 업그레이드처럼 큰 작업 전에는 꼭, 평소에도 정기적으로 백업해요.
1. 무엇을 백업하나: 리소스 YAML과 etcd
| 방법 | 무엇이 남나 | 장단점 |
|---|---|---|
리소스 YAML (Git · kubectl get -o yaml) | 내가 만든 리소스 정의 | 사람이 읽고 고칠 수 있음. 관리형(GKE)에서도 가능. 빠뜨린 리소스는 못 살림 |
| etcd 스냅샷 | 클러스터 상태 전부 (한 시점) | 완전하지만 etcd에 직접 접근해야 해서 직접 운영하는 클러스터에서만. 볼륨 데이터는 미포함 |
| Velero 같은 백업 도구 | 리소스 + PV 스냅샷 → 오브젝트 스토리지 | Namespace 단위 복구 · 다른 클러스터로 이전도 가능 |
$ kubectl get all -A -o yaml > all-resources.yaml # 간단하지만 'all'에 ConfigMap · Secret 등은 빠져 있음
2. etcd 스냅샷 저장과 복구
$ ETCDCTL_API=3 etcdctl snapshot save /opt/backup/etcd-snap.db \
--endpoints=https://127.0.0.1:2379 \
--cacert=/etc/kubernetes/pki/etcd/ca.crt \
--cert=/etc/kubernetes/pki/etcd/server.crt \
--key=/etc/kubernetes/pki/etcd/server.key
Snapshot saved at /opt/backup/etcd-snap.db
$ etcdutl snapshot status /opt/backup/etcd-snap.db -w table # 파일 확인
$ etcdutl snapshot restore /opt/backup/etcd-snap.db \
--data-dir /var/lib/etcd-from-backup # 새 폴더에 풀기
$ sudo vi /etc/kubernetes/manifests/etcd.yaml # 데이터 폴더를 새 경로로
volumes:
- hostPath:
path: /var/lib/etcd-from-backup # ← /var/lib/etcd 에서 변경
type: DirectoryOrCreate
name: etcd-data
etcdctl snapshot restore
etcd 3.5까지는 복구도 etcdctl snapshot restore로 했고 학습 자료도 그렇게 보여 줘요. etcd 3.6에서 복구 · 상태 확인 명령은 etcdctl에서 빠지고 etcdutl로 옮겨졌어요. 저장(save)은 실행 중인 etcd에 접속해야 해서 여전히 etcdctl, 복구(restore)는 파일만 다루니 etcdutl이라고 기억하면 쉬워요. 실습 환경의 etcd 버전에 맞춰 써요.
| 복구 후 확인 | 이유 |
|---|---|
etcd Pod가 다시 떴는가 (crictl ps · kubectl get pods -n kube-system) | Static Pod라 파일을 고치면 kubelet이 재시작 — 잠시 API 서버도 응답이 없을 수 있어요 |
| 지워졌던 리소스가 돌아왔는가 | 스냅샷 시점 이후의 변경은 사라져요 |
(직접 설치형이면) systemctl로 etcd 서비스의 --data-dir 변경 | Static Pod가 아니라 systemd로 도는 etcd라면 서비스 파일을 고쳐요 |
3. 백업 도구 Velero 맛보기
Velero는 쿠버네티스 API로 리소스를 내려받고, 클라우드 디스크 스냅샷이나 파일 복사로 볼륨 데이터까지 S3 같은 오브젝트 스토리지에 저장하는 도구예요. etcd에 접근할 수 없는 관리형 클러스터에서도 쓸 수 있고, Namespace 하나만 골라 복구하거나 다른 클러스터로 옮길 때도 써요.
$ velero backup create nightly --include-namespaces prod
$ velero schedule create daily --schedule="0 3 * * *"
$ velero restore create --from-backup nightly
4. 6부 셀프 체크
cordon과 drain의 차이는?
cordon은 새 Pod 배치만 막아요. drain은 cordon에 더해 기존 Pod까지 내보내요.drain이 DaemonSet Pod 때문에 멈췄어요. 어떻게 하나요?
--ignore-daemonsets를 붙여요. DaemonSet Pod는 내보내도 바로 다시 생기니 그대로 두고 진행해요.컨트롤러 없이 만든 단독 Pod가 있는 노드를
--force로 drain하면?그 Pod는 지워지고 다시 만들어지지 않아요. 그래서 기본은 거부하고--force를 요구해요.uncordon하면 다른 노드로 옮겨 간 Pod가 돌아오나요?
아니요. 앞으로 새로 만들어지는 Pod만 배치될 수 있어요.PDB
minAvailable: 2인 Pod 3개가 있을 때 두 노드를 동시에 drain하면?하나는 내보낼 수 있지만 두 번째 축출은 2개 미만이 되므로 거절되고 재시도해요. 대체 Pod가 Ready가 되면 진행돼요.API 서버가 1.36일 때 kubelet으로 허용되는 가장 낮은 버전은?
1.33 (3 마이너 낮은 것까지). kubelet이 API 서버보다 높은 건 안 돼요.1.34 클러스터를 1.36으로 올리는 순서는?
마이너를 건너뛰지 않고 1.34 → 1.35 → 1.36. 각 단계에서 컨트롤 플레인 먼저(kubeadm upgrade apply), 워커 노드는 하나씩(drain → kubeadm upgrade node → kubelet → uncordon).워커 노드에서 쓰는 kubeadm 명령은 apply와 node 중 무엇인가요?
kubeadm upgrade node예요.apply는 첫 컨트롤 플레인 노드에서만.etcd 스냅샷을 저장 · 복구하는 명령은?
저장은etcdctl snapshot save(인증서 · 엔드포인트 필요), 복구는etcdutl snapshot restore --data-dir 새폴더후 etcd 설정의 데이터 경로를 새 폴더로 바꿔요.etcd 스냅샷으로 DB Pod의 데이터도 돌아오나요?
아니요. 리소스 정의만 돌아와요. 볼륨 속 데이터는 볼륨 스냅샷이나 앱 백업으로 따로 지켜요.