콘텐츠로 이동

2. 클러스터 아키텍처

무엇이 어디서 돌고 있는가

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

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

컨트롤 플레인 (Control Plane)

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

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

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

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

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

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

flowchart LR
    U["kubectl / 클라이언트"] --> API

    subgraph CP["컨트롤 플레인"]
      API["kube-apiserver<br/>유일한 관문"]
      ETCD[("etcd<br/>유일한 저장소")]
      SCH["kube-scheduler"]
      CM["kube-controller-manager"]
      API <--> ETCD
      SCH --> API
      CM --> API
    end

    subgraph N1["워커 노드"]
      KL["kubelet"]
      KP["kube-proxy"]
      CR["containerd"]
      KL --> CR
    end

    KL --> API
    KP --> API

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef store fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class API key
    class ETCD store
    class SCH,CM,KL,KP,CR,U mute

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

  • 클러스터의 모든 읽기·쓰기가 여기를 통과한다. 예외 없다
  • 상태를 직접 갖지 않는다 — stateless. etcd에만 쓴다
  • 그래서 수평 확장이 가능하다 (HA 컨트롤 플레인의 근거)
  • REST API를 제공한다. kubectl은 그 위의 얇은 클라이언트일 뿐
  • 분산 key-value 저장소. 클러스터의 모든 오브젝트가 여기 있다
  • Raft 합의 알고리즘 → 홀수 노드(3, 5)로 구성한다. 과반이 살아야 쓰기가 된다
  • etcd만 백업하면 클러스터 전체를 복구할 수 있다 (PV 안의 데이터는 제외)
  • API 서버 외에는 아무도 etcd에 직접 접근하지 않는다
Terminal window
# etcd 안의 키 구조 — 오브젝트 경로가 그대로 키다
/registry/pods/default/nginx
/registry/deployments/kube-system/coredns
/registry/secrets/default/my-secret

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

섹션 제목: “kube-scheduler — 자리를 정할 뿐이다”
flowchart LR
    W["spec.nodeName 이 비어 있는 Pod"] --> F["Filtering<br/>못 놓는 노드를 거른다"]
    F --> S["Scoring<br/>남은 노드에 점수"]
    S --> B["spec.nodeName 을 채운다<br/>★ 여기까지가 전부"]
    B -.->|"실행은 kubelet 의 몫"| KL["kubelet"]
    F -.->|"리소스 부족 · taint<br/>nodeSelector 불일치 …"| X["전부 걸러지면 Pending"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class F,S key
    class X bad
    class W,B,KL mute
  • spec.nodeName비어 있는 Pod을 찾는다
  • 조건에 맞는 노드를 골라 spec.nodeName을 채운다. 그게 전부다
  • 컨테이너를 직접 실행하지 않는다. 실행은 kubelet의 몫

두 단계로 고른다

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

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

섹션 제목: “kube-controller-manager — 루프들의 모음”
flowchart LR
    CM["kube-controller-manager<br/>프로세스 하나"]
    CM --> L1["Deployment 컨트롤러"]
    CM --> L2["ReplicaSet 컨트롤러"]
    CM --> L3["Node 컨트롤러"]
    CM --> L4["Job / CronJob 컨트롤러"]
    CM --> L5["EndpointSlice 컨트롤러"]
    CM --> L6["ServiceAccount 컨트롤러"]
    CM --> L7["PV / PVC 컨트롤러"]
    L1 --> LOOP["각 루프: 원하는 상태 관찰<br/>→ 실제 상태 관찰<br/>→ 차이만큼 API 호출"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class CM,LOOP key
    class L1,L2,L3,L4,L5,L6,L7 mute
컨트롤러 하는 일
Deployment ReplicaSet을 만들고 롤아웃을 조율
ReplicaSet Pod 개수를 맞춘다
Node 노드가 응답 없으면 NotReady 표시, 이후 Pod 축출
Job / CronJob Pod을 만들고 완료를 추적
Endpoint(Slice) Service 셀렉터에 맞는 Pod IP 목록을 유지
ServiceAccount 네임스페이스마다 default SA를 만든다
PV / PVC 바인딩과 회수(reclaim)를 처리

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

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

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

섹션 제목: “kube-proxy — Service를 노드의 규칙으로 번역한다”
flowchart LR
    API["kube-apiserver"] -->|"Service · EndpointSlice watch"| KP["kube-proxy<br/>DaemonSet"]
    KP -->|"규칙만 심고 빠진다"| K["노드 커널<br/>iptables · ipvs · nftables"]
    C["클라이언트 Pod"] -->|"패킷"| K
    K -->|"DNAT"| P["대상 Pod"]
    KP -.->|"트래픽은 여기를 통과하지 않는다"| K

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef kern fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class KP key
    class K kern
    class API,C,P mute
  • 노드마다 하나씩. 보통 DaemonSet으로 돈다
  • Service와 EndpointSlice를 지켜보다가 노드의 패킷 규칙을 갱신한다
  • 모드: iptables(기본) / ipvs / nftables
  • 트래픽이 kube-proxy를 통과하지 않는다. 규칙만 심고 빠진다 — 커널이 처리한다
  • kubelet은 CRI(Container Runtime Interface) 라는 gRPC 규약으로 런타임과 대화한다
  • 현재 표준은 containerd. (CRI-O도 쓰인다)
  • Docker는 v1.24에서 제거되었다 — dockershim이 빠졌다
  • 그래서 노드에서 컨테이너를 직접 볼 때는 crictl 을 쓴다
Terminal window
# 노드 안에서 (kubectl이 안 될 때의 생명줄)
sudo crictl ps # 실행 중 컨테이너
sudo crictl ps -a # 죽은 것 포함
sudo crictl logs <container-id>
sudo crictl pods # Pod 샌드박스 목록

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

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

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

flowchart LR
    F["/etc/kubernetes/manifests/*.yaml"] -->|"kubelet 이 직접 읽는다"| KL["kubelet"]
    KL --> CR["containerd"] --> POD["컨트롤 플레인 Pod"]
    KL -.->|"읽기 전용 미러 Pod 등록"| API["kube-apiserver"]
    NOTE["API 서버도 스케줄러도 거치지 않는다<br/>→ 순환 의존을 피한다"] -.- F

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class F key
    class NOTE warn
    class KL,CR,POD,API mute
  • kubelet이 디렉터리를 직접 읽어서 띄운다. API 서버도 스케줄러도 거치지 않는다
  • 경로: /etc/kubernetes/manifests/
  • 그래야 “API 서버를 띄우기 위해 API 서버가 필요한” 순환을 피할 수 있다
Terminal window
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를 가져도 컨트롤 플레인 컴포넌트가 뜨는 이유다.

kubectl apply -f pod.yaml 을 쳤을 때 API 서버 안에서 벌어지는 일.

flowchart LR
    R["요청"] --> A["Authentication<br/>너는 누구인가"]
    A -->|"실패 401"| X1["거부"]
    A --> Z["Authorization<br/>해도 되는가 · RBAC"]
    Z -->|"실패 403"| X2["거부"]
    Z --> M["Mutating Admission<br/>요청을 고친다"]
    M --> V["Validating Admission<br/>규칙 위반인가"]
    V -->|"위반"| X3["거부"]
    V --> S["스키마 검증 → etcd 저장"]

    classDef ok fill:#dcfce7,stroke:#16a34a,color:#14532d
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class S ok
    class X1,X2,X3 bad
    class A,Z key
    class R,M,V mute

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

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

섹션 제목: “저장된 다음 — 컨트롤러들의 릴레이”
sequenceDiagram
    participant K as kubectl
    participant A as apiserver
    participant D as Deployment 컨트롤러
    participant R as ReplicaSet 컨트롤러
    participant S as scheduler
    participant L as kubelet
    K->>A: Deployment 생성
    A->>D: watch 이벤트
    D->>A: ReplicaSet 생성
    A->>R: watch 이벤트
    R->>A: Pod 생성 (nodeName 비어 있음)
    A->>S: watch 이벤트
    S->>A: nodeName 채움 (binding)
    A->>L: 내 노드의 Pod
    L->>L: 이미지 pull → 컨테이너 실행
    L->>A: 상태 보고 (Running)

아무도 서로를 직접 호출하지 않는다. 전부 API 서버를 통한 watch다. 느슨하게 결합되어 있어서 컴포넌트 하나가 죽어도 나머지는 계속 돈다.

모든 Kubernetes 오브젝트는 같은 뼈대를 가진다.

apiVersion: apps/v1 # 어느 API 그룹의 어느 버전인가
kind: Deployment # 무엇인가
metadata: # 이름·네임스페이스·라벨·애노테이션
name: web
namespace: default
labels:
app: web
spec: # 내가 원하는 상태 ← 사람이 쓴다
replicas: 3
status: # 실제 상태 ← 컨트롤러가 쓴다
readyReplicas: 3
flowchart LR
    H["사람"] -->|"쓴다"| SPEC["spec<br/>원하는 상태"]
    SPEC --> CTRL["컨트롤러"]
    CTRL -->|"현실을 맞춘다"| REAL["실제 클러스터"]
    REAL --> STATUS["status<br/>실제 상태"]
    CTRL -->|"쓴다"| STATUS
    H -.->|"직접 편집하지 않는다 ❌"| STATUS

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef bad fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class SPEC key
    class STATUS bad
    class H,CTRL,REAL mute
  • spec은 사람이, status는 시스템이 쓴다. status를 직접 편집하지 않는다
  • apiVersionv1이면 core 그룹(그룹 이름이 없다), apps/v1이면 apps 그룹
  • 이 구조가 같으니 처음 보는 리소스도 읽는 법은 똑같다
Terminal window
kubectl api-resources # 전체 리소스 목록 (약칭·그룹·네임스페이스 여부)
kubectl api-resources --namespaced=false # 클러스터 스코프만 (Node, PV, ClusterRole …)
kubectl api-versions # 사용 가능한 group/version
Terminal window
kubectl explain pod.spec.containers.resources # 필드 설명
kubectl explain deployment.spec.strategy --recursive # 하위 전부 펼치기
flowchart LR
    subgraph NS["네임스페이스 안 — 이름의 유효 범위"]
      P["Pod"]
      S["Service"]
      C["ConfigMap · Secret"]
      RB["Role · RoleBinding"]
    end
    subgraph CL["클러스터 스코프 — 네임스페이스가 없다"]
      N["Node"]
      PV["PersistentVolume"]
      SC["StorageClass"]
      CR["ClusterRole"]
    end
    NET["네트워크 격리가 아니다 ⚠️<br/>다른 네임스페이스와 통신은 그대로 된다<br/>막으려면 NetworkPolicy · 12장"] -.- NS

    classDef ns fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef cl fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef warn fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
    class P,S,C,RB ns
    class N,PV,SC,CR cl
    class NET warn
Terminal window
kubectl get ns
kubectl create ns dev
kubectl get pods -A # 전체 네임스페이스
kubectl config set-context --current --namespace=dev # 기본 네임스페이스 변경

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

섹션 제목: “라벨과 셀렉터 — 연결의 접착제”
flowchart LR
    L["Pod 라벨<br/>app=web"] --> SVC["Service selector"]
    L --> RS["ReplicaSet selector"]
    L --> NP["NetworkPolicy podSelector"]
    L --> PDB["PodDisruptionBudget selector"]
    A["애노테이션<br/>선택 대상이 아니다"] -.->|"도구가 읽는 메타데이터"| TOOL["kubectl · 컨트롤러 · 외부 도구"]

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class L key
    class SVC,RS,NP,PDB,A,TOOL mute
  • 라벨(label): 선택하기 위한 key-value. Service·ReplicaSet·NetworkPolicy가 전부 이걸로 대상을 찾는다
  • 애노테이션(annotation): 선택 대상이 아닌 부가 정보. 도구가 읽는 메타데이터
Terminal window
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
Terminal window
kubectl get pods --field-selector status.phase=Running
kubectl get pods --field-selector spec.nodeName=node01
kubectl get events --field-selector type=Warning

ownerReferences — 오브젝트가 누구에게서 만들어졌는지 기록한다.

flowchart LR
    D["Deployment web"] -->|ownerReference| RS["ReplicaSet web-5d4f"]
    RS -->|ownerReference| P["Pod web-5d4f-x7k2p"]
    DEL["부모를 지우면"] -->|"가비지 컬렉터가 자식을 지운다 · cascade"| P
    ORPH["--cascade=orphan"] -.->|"Pod 을 남긴다"| P

    classDef key fill:#dbeafe,stroke:#2563eb,color:#1e3a8a
    classDef warn fill:#fef3c7,stroke:#d97706,color:#78350f
    classDef mute fill:#f1f5f9,stroke:#94a3b8,color:#334155
    class D key
    class ORPH warn
    class RS,P,DEL mute
Terminal window
kubectl get pod web-abc-123 -o jsonpath='{.metadata.ownerReferences[0].kind}'
# ReplicaSet
  • Deployment → ReplicaSet → Pod 으로 이어지는 소유 사슬이 여기 기록된다
  • 부모를 지우면 가비지 컬렉터가 자식을 지운다 (cascade)
  • kubectl delete deploy web --cascade=orphan 을 쓰면 Pod을 남길 수 있다
Terminal window
kubectl get nodes -o wide
kubectl describe node node01
kubectl get node node01 -o yaml

describe node 에서 반드시 볼 곳:

항목 의미
Conditions Ready, MemoryPressure, DiskPressure, PIDPressure
Taints 이 노드가 밀어내는 조건 (7장)
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 explainkubectl api-resources 는 오프라인 문서다