콘텐츠로 이동
Study NoteCKA

클러스터 아키텍처

무엇이 어디서 돌고 있는가

클러스터는 두 층으로 되어 있다

섹션 제목: “클러스터는 두 층으로 되어 있다”

컨트롤 플레인 (Control Plane)

클러스터의 뇌. “무엇이 있어야 하는가”를 결정한다.

kube-apiserver · etcd · kube-scheduler · kube-controller-manager · cloud-controller-manager(클라우드일 때)

노드 (Node) / 데이터 플레인

실제로 컨테이너를 돌리는 곳.

kubelet · kube-proxy · 컨테이너 런타임(containerd 등)

컨트롤 플레인 노드에도 kubelet과 kube-proxy는 있다. 컨트롤 플레인 컴포넌트 자체가 Pod으로 돌기 때문이다 — 뒤에서 다룬다.

컨트롤 플레인의 apiserver·etcd·scheduler·controller-manager와 워커 노드의 kubelet·kube-proxy·containerd가 apiserver 하나를 관문으로 삼는 구조

화살표 방향이 핵심이다. 모든 컴포넌트가 API 서버를 향한다. API 서버가 다른 컴포넌트를 호출하지 않는다.

컴포넌트들이 서로 직접 통신하고 각자 상태를 들고 있으면, “지금 클러스터가 어떤 상태인가”에 대한 답이 여러 개가 된다. 그래서 모든 읽기·쓰기를 문 하나로 모은다 — 그 문이 API 서버다.

  • 클러스터의 모든 읽기·쓰기가 여기를 통과한다. 예외 없다
  • 상태를 직접 갖지 않는다 — stateless. etcd에만 쓴다
  • 그래서 수평 확장이 가능하다 — HA(High Availability · 고가용성) 컨트롤 플레인의 근거다
  • REST API를 제공한다. kubectl은 그 위의 얇은 클라이언트일 뿐

시작하기 전에의 루프는 “선언된 상태(spec)“가 어딘가에 남아 있어야 돌 수 있다. API 서버가 stateless일 수 있는 것도, 그 상태를 전부 맡아 주는 저장소가 따로 있기 때문이다 — 그게 etcd다.

  • 분산 key-value 저장소. 클러스터의 모든 오브젝트가 여기 있다
  • Raft 합의 알고리즘 → 홀수 노드(3, 5)로 구성한다. 과반이 살아야 쓰기가 된다
  • etcd만 백업하면 클러스터 전체를 복구할 수 있다 (PV(PersistentVolume — 스토리지) 안의 데이터는 제외)
  • API 서버 외에는 아무도 etcd에 직접 접근하지 않는다

Raft는 여러 노드가 같은 기록을 갖도록 합의하는 알고리즘이다. 짝수(2, 4)로 구성하면 반반으로 갈렸을 때 과반이 안 나와 쓰기가 멈추기 때문에 홀수로 둔다. 알고리즘 내부까지 팔 필요는 없다 — “과반이 살아야 쓴다”는 결과만 기억하면 된다.

터미널 창
# etcd 안의 키 구조 — 오브젝트 경로가 그대로 키다
/registry/pods/default/nginx
/registry/deployments/kube-system/coredns
/registry/secrets/default/my-secret

kube-scheduler — 자리를 정할 뿐이다

섹션 제목: “kube-scheduler — 자리를 정할 뿐이다”

spec이 etcd에 저장되는 것만으로는 아무 일도 일어나지 않는다. Pod을 어느 노드에서 돌릴지 누군가 정해야 하는데, 그 결정만 전담하는 것이 스케줄러다.

스케줄러가 Filtering과 Scoring을 거쳐 spec.nodeName만 채우고, 실행은 kubelet이 맡으며, 전부 걸러지면 Pending에 머무는 흐름
  • spec.nodeName이 비어 있는 Pod을 찾는다
  • 조건에 맞는 노드를 골라 spec.nodeName을 채운다. 그게 전부다
  • 컨테이너를 직접 실행하지 않는다. 실행은 kubelet의 몫

두 단계로 고른다

  1. Filtering — 못 놓는 노드를 거른다 (리소스 부족, taint, nodeSelector 불일치…)
  2. Scoring — 남은 노드에 점수를 매겨 최고점을 고른다

kube-controller-manager — 루프들의 모음

섹션 제목: “kube-controller-manager — 루프들의 모음”

시작하기 전에의 축 — “선언된 상태와 실제 상태의 차이를 줄이는 루프” — 가 실제로 도는 곳이 여기다. 리소스 종류마다 전담 루프(컨트롤러)가 있고, 그 루프들을 한 프로세스에 담은 것이 컨트롤러 매니저다.

kube-controller-manager 프로세스 하나가 Deployment·ReplicaSet·Node·Job·EndpointSlice·ServiceAccount·PV/PVC 컨트롤러 루프를 함께 돌린다
컨트롤러하는 일
DeploymentReplicaSet을 만들고 롤아웃을 조율
ReplicaSetPod 개수를 맞춘다
Node노드가 응답 없으면 NotReady 표시, 이후 Pod 축출
Job / CronJobPod을 만들고 완료를 추적
Endpoint(Slice)Service 셀렉터에 맞는 Pod IP 목록을 유지
ServiceAccount네임스페이스마다 default SA를 만든다
PV / PVC바인딩과 회수(reclaim)를 처리

“컨트롤러가 죽었다”의 증상은 각기 다르다. Deployment를 만들어도 Pod이 안 생기면 컨트롤러 매니저, Pod은 생겼는데 Pending이면 스케줄러다.

장 첫머리 카드에 있던 cloud-controller-manager는 이 루프들 중 클라우드 제공자 API를 불러야 하는 것들(LoadBalancer 프로비저닝, 클라우드 쪽 노드 수명 관리)만 떼어낸 프로세스다. 시험 환경(kubeadm 클러스터)에는 보통 없다 — 이름과 역할만 알아두면 된다.

스케줄러가 nodeName을 채워도 그건 아직 etcd 안의 기록일 뿐이다. 그 기록을 노드 위의 진짜 컨테이너로 만드는 실행자가 노드마다 있어야 한다 — 그게 kubelet이다.

  • 노드마다 하나씩 돈다. Pod이 아니라 systemd 서비스다
  • API 서버에서 “내 노드에 배정된 Pod” 목록을 받아온다
  • 컨테이너 런타임(CRI, Container Runtime Interface — 아래 절)에게 컨테이너 생성·삭제를 지시한다
  • 컨테이너 상태·노드 상태를 API 서버에 보고한다 (이게 끊기면 노드가 NotReady)
  • probe(liveness/readiness/startup)를 실행하는 주체도 kubelet이다 (컨테이너가 살아 있는지·트래픽 받을 준비가 됐는지 묻는 헬스체크 — Pod)

kube-proxy — Service를 노드의 규칙으로 번역한다

섹션 제목: “kube-proxy — Service를 노드의 규칙으로 번역한다”

Service의 ClusterIP는 어느 인터페이스에도 붙어 있지 않은 가상 IP다 (Service 자체는 Service). 이 주소로 온 패킷을 실제 Pod IP로 바꿔 줄 무언가가 노드마다 필요하다 — 그 번역 규칙을 심는 것이 kube-proxy다.

kube-proxy가 Service와 EndpointSlice를 watch 해 노드 커널에 규칙만 심고, 실제 패킷은 커널이 DNAT 해 대상 Pod로 보낸다
  • 노드마다 하나씩. 보통 DaemonSet(모든 노드에 Pod을 하나씩 두는 워크로드 — 워크로드)으로 돈다
  • Service와 EndpointSlice(Service 셀렉터에 걸린 실제 Pod IP 목록 — Service)를 지켜보다가 노드의 패킷 규칙을 갱신한다
  • 모드: iptables(기본) / ipvs / nftables
  • 트래픽이 kube-proxy를 통과하지 않는다. 규칙만 심고 빠진다 — 커널이 처리한다

모드 셋의 내부 차이까지 팔 필요는 없다 — 기본이 iptables라는 것과, 셋 다 “커널에 규칙을 심는 방식”의 변형이라는 것만 알면 된다.

컨테이너를 실제로 만들고 지우는 것은 kubelet이 아니라 컨테이너 런타임이다. kubelet이 특정 런타임에 종속되지 않도록 둘 사이를 표준 인터페이스로 끊어 둔 것이 CRI(Container Runtime Interface)다 — Docker 제거(v1.24)가 가능했던 것도 이 경계 덕분이다.

  • kubelet은 CRI라는 gRPC(HTTP/2 기반 원격 호출 프로토콜 — 이름만 알면 된다) 규약으로 런타임과 대화한다
  • 현재 표준은 containerd. (CRI-O도 쓰인다)
  • Docker는 v1.24에서 제거되었다 — dockershim이 빠졌다
  • 그래서 노드에서 컨테이너를 직접 볼 때는 crictl 을 쓴다
터미널 창
# 노드 안에서 (kubectl이 안 될 때의 생명줄)
sudo crictl ps # 실행 중 컨테이너
sudo crictl ps -a # 죽은 것 포함
sudo crictl logs <container-id>
sudo crictl pods # Pod 샌드박스 목록

crictl logs는 Pod 이름이나 컨테이너 이름이 아니라 crictl ps -a의 첫 번째 열인 컨테이너 ID를 받는다. 예를 들어 kubeadm 스태틱 Pod에서 etcd-controlplane은 Pod 이름, etcd는 컨테이너 이름이다. 둘 중 하나를 crictl logs 뒤에 직접 쓰면 안 되고, 조회한 ID를 넘겨야 한다.

터미널 창
sudo crictl ps -a --name etcd
# CONTAINER 열의 ID를 복사
sudo crictl logs <etcd-container-id>

Pod 샌드박스만 보이면 crictl pods -a의 Pod ID로 그 안의 컨테이너를 다시 찾는다.

터미널 창
sudo crictl ps -a --pod <pod-id>
sudo crictl logs <container-id>

컨트롤 플레인은 어떻게 떠 있나 — 스태틱 Pod

섹션 제목: “컨트롤 플레인은 어떻게 떠 있나 — 스태틱 Pod”

kubeadm으로 만든 클러스터에서, 컨트롤 플레인 컴포넌트는 Pod이다. 그런데 특별한 Pod이다.

정적 Pod는 kubelet이 매니페스트 디렉터리를 직접 읽어 띄우고 API 서버에는 읽기 전용 미러 Pod만 등록한다
  • kubelet이 디렉터리를 직접 읽어서 띄운다. API 서버도 스케줄러도 거치지 않는다
  • 경로: /etc/kubernetes/manifests/
  • 그래야 “API 서버를 띄우기 위해 API 서버가 필요한” 순환을 피할 수 있다
터미널 창
ls /etc/kubernetes/manifests/
# etcd.yaml kube-apiserver.yaml kube-controller-manager.yaml kube-scheduler.yaml
  • 이름 뒤에 노드 이름이 자동으로 붙는다 — kube-apiserver-controlplane
  • API 서버에는 읽기 전용 미러 Pod으로 보인다
  • kubectl delete pod 해도 되살아난다. kubelet이 파일을 다시 읽기 때문
  • kubelet 설정의 staticPodPath 로 경로가 정해진다
/etc/kubernetes/manifests
# kubelet이 어느 디렉터리를 보는지 확인
sudo grep staticPodPath /var/lib/kubelet/config.yaml

스태틱 Pod은 스케줄러를 거치지 않으므로 taint·affinity의 영향을 받지 않는다. 컨트롤 플레인 노드가 NoSchedule taint를 가져도 컨트롤 플레인 컴포넌트가 뜨는 이유다.

모든 요청이 API 서버 하나로 모인다는 것은, 검문도 거기 한 곳에 모을 수 있다는 뜻이다. 그래서 인증·인가·정책 검사가 전부 이 안에서 정해진 순서로 일어난다. kubectl apply -f pod.yaml 을 쳤을 때 API 서버 안에서 벌어지는 일.

API 서버 요청이 인증 → 인가 → Mutating Admission → Validating Admission → 스키마 검증을 거쳐 etcd에 저장되고, 각 단계에서 401·403·위반으로 거부되는 경로

인증·인가는 “누가 요청했나”까지만 본다. 요청의 내용이 규칙에 맞는지 — 특권 컨테이너 금지, 빠진 기본값 채우기 같은 — 를 검사할 단계가 따로 필요한데, 그게 admission이다. 요청을 고쳐 주는 Mutating과 검사만 하는 Validating 둘로 나뉜다.

이 순서를 알면 에러 코드로 원인을 짚을 수 있다. 401 = 인증, 403 = RBAC, 그 외 거부 = admission (Admission에서 자세히).

저장된 다음 — 컨트롤러들의 릴레이

섹션 제목: “저장된 다음 — 컨트롤러들의 릴레이”
Deployment 생성 한 번이 apiserver의 watch 를 통해 Deployment · ReplicaSet 컨트롤러와 스케줄러, kubelet 으로 이어지는 순서

아무도 서로를 직접 호출하지 않는다. 전부 API 서버를 통한 watch다. watch는 반복 조회(폴링)가 아니라 변경 스트림 구독이다 — 오브젝트가 바뀌는 순간 API 서버가 이벤트를 밀어 주기 때문에, 그림의 릴레이가 지연 없이 이어진다. 느슨하게 결합되어 있어서 컴포넌트 하나가 죽어도 나머지는 계속 돈다.

앞 절까지가 “누가 무엇을 하는가”였다면, 이 절은 그들이 주고받는 물건의 모양이다. 루프가 돌려면 “원하는 상태”와 “실제 상태”가 한 오브젝트 안에 나란히 있어야 한다 — 그래서 모든 Kubernetes 오브젝트는 같은 뼈대를 가진다.

apiVersion: apps/v1 # 어느 API 그룹의 어느 버전인가
kind: Deployment # 무엇인가
metadata: # 이름·네임스페이스·라벨·애노테이션
name: web
namespace: default
labels:
app: web
spec: # 내가 원하는 상태 ← 사람이 쓴다
replicas: 3
status: # 실제 상태 ← 컨트롤러가 쓴다
readyReplicas: 3
사람은 spec 을 쓰고 컨트롤러가 status 를 쓰며, 사람이 status 를 직접 편집하지는 않는다는 역할 구분
  • spec은 사람이, status는 시스템이 쓴다. status를 직접 편집하지 않는다
  • apiVersion이 v1이면 core 그룹(그룹 이름이 없다), apps/v1이면 apps 그룹
  • 이 구조가 같으니 처음 보는 리소스도 읽는 법은 똑같다

리소스가 수백 종류인데 전부 한 묶음이면 버전을 따로 올릴 수가 없다. 그래서 관련된 리소스끼리 API 그룹으로 나누고 그룹마다 버전을 매긴다 — apps/v1의 apps가 그룹, v1이 그 그룹의 버전이다. Pod·Service처럼 초기부터 있던 것은 그룹 이름이 없는 core 그룹(apiVersion: v1)에 남아 있다. kind와 apiVersion을 짝지어 못 쓰면 apply가 그대로 실패하니, 아래 두 명령으로 확인한다.

터미널 창
kubectl api-resources # 전체 리소스 목록 (약칭·그룹·네임스페이스 여부)
kubectl api-resources --namespaced=false # 클러스터 스코프만 (Node, PV, ClusterRole …)
kubectl api-versions # 사용 가능한 group/version
터미널 창
kubectl explain pod.spec.containers.resources # 필드 설명
kubectl explain deployment.spec.strategy --recursive # 하위 전부 펼치기

이름과 선택 — 네임스페이스·라벨

섹션 제목: “이름과 선택 — 네임스페이스·라벨”

오브젝트를 어떻게 격리하고(네임스페이스), 어떻게 골라내는가(라벨). 이 둘이 이후 모든 장의 전제다.

네임스페이스 안에 있는 Pod·Service·ConfigMap·Role 과 클러스터 스코프인 Node·PV·StorageClass·ClusterRole 의 구분, 그리고 네임스페이스가 네트워크 격리가 아니라는 경고
터미널 창
kubectl get ns
kubectl create ns dev
kubectl get pods -A # 전체 네임스페이스
kubectl config set-context --current --namespace=dev # 기본 네임스페이스 변경

라벨과 셀렉터 — 연결의 접착제

섹션 제목: “라벨과 셀렉터 — 연결의 접착제”
Pod 라벨은 Service·ReplicaSet·NetworkPolicy·PodDisruptionBudget 의 선택 대상이고, 애노테이션은 선택 대상이 아니라 도구가 읽는 메타데이터다
  • 라벨(label): 선택하기 위한 key-value. Service·ReplicaSet·NetworkPolicy가 전부 이걸로 대상을 찾는다
  • 애노테이션(annotation): 선택 대상이 아닌 부가 정보. 도구가 읽는 메타데이터
터미널 창
kubectl label pod nginx tier=frontend
kubectl label pod nginx tier=backend --overwrite
kubectl label pod nginx tier- # 삭제 (뒤에 하이픈)
kubectl get pods -l tier=frontend
kubectl get pods -l 'tier in (frontend,backend)'
kubectl get pods -l '!tier' # 라벨이 없는 것
kubectl get pods --show-labels
터미널 창
kubectl get pods --field-selector status.phase=Running
kubectl get pods --field-selector spec.nodeName=node01
kubectl get events --field-selector type=Warning

필드 셀렉터는 라벨 셀렉터만큼 자주 쓰이진 않는다 — status.phase · spec.nodeName, 그리고 이벤트 필터(type=Warning) 정도가 실전 레퍼토리의 거의 전부다.

ownerReferences — 오브젝트가 누구에게서 만들어졌는지 기록한다. 지운 Pod이 자꾸 되살아날 때 “누가 만들었나”를 추적하는 근거가 이 필드다.

Deployment → ReplicaSet → Pod 로 이어지는 ownerReference 사슬과, 부모를 지우면 가비지 컬렉터가 자식을 지우는 cascade 동작
터미널 창
kubectl get pod web-abc-123 -o jsonpath='{.metadata.ownerReferences[0].kind}'
# ReplicaSet
  • Deployment → ReplicaSet → Pod 으로 이어지는 소유 사슬이 여기 기록된다
  • 부모를 지우면 가비지 컬렉터가 자식을 지운다 (cascade)
  • kubectl delete deploy web --cascade=orphan 을 쓰면 Pod을 남길 수 있다

kubectl이 보여주는 노드의 상태와, kubectl이 안 될 때 직접 뒤져야 하는 노드 위의 파일들. 트러블슈팅(트러블슈팅)의 기초 체력이다.

터미널 창
kubectl get nodes -o wide
kubectl describe node node01
kubectl get node node01 -o yaml

describe node 에서 반드시 볼 곳:

항목의미
ConditionsReady, MemoryPressure, DiskPressure, PIDPressure
Taints이 노드가 밀어내는 조건 (스케줄링)
Capacity / Allocatable전체 자원 / 실제 배정 가능한 자원
Allocated resources현재 요청(request) 합계 — 사용량이 아니다
Non-terminated Pods이 노드의 Pod 목록

파일이 어디 있는지 — 트러블슈팅의 지도

섹션 제목: “파일이 어디 있는지 — 트러블슈팅의 지도”
  • 디렉터리etc/
    • 디렉터리kubernetes/
      • 디렉터리manifests/ 컨트롤 플레인 스태틱 Pod YAML
        • …
      • 디렉터리pki/ 인증서·키 (CA, apiserver, etcd …)
        • …
      • admin.conf 관리자 kubeconfig
      • kubelet.conf kubelet의 kubeconfig
    • 디렉터리cni/
      • 디렉터리net.d/ CNI 설정
        • …
  • 디렉터리var/
    • 디렉터리lib/
      • 디렉터리kubelet/
        • config.yaml kubelet 설정 (staticPodPath, cgroupDriver …)
      • 디렉터리etcd/ etcd 데이터 디렉터리
        • …
    • 디렉터리log/
      • 디렉터리pods/ 컨테이너 로그 실체
        • …
      • 디렉터리containers/ 위로 향하는 심볼릭 링크
        • …
  • 컨트롤 플레인(결정) + 노드(실행). 모든 화살표는 API 서버를 향한다
  • etcd에 전부 들어 있다 — 백업 하나로 클러스터를 되살린다
  • 스케줄러는 nodeName만 채운다. 실행은 kubelet
  • kubelet은 Pod이 아니라 systemd 서비스 — kubectl로 못 고친다
  • 컨트롤 플레인은 /etc/kubernetes/manifests/의 스태틱 Pod — 파일을 고치면 즉시 반영
  • 요청은 인증 → 인가 → admission → etcd 순서로 지나간다
  • kubectl explain과 kubectl api-resources 는 오프라인 문서다