Skip to content
Published on

Kubernetes 내부 구조 완전 해부 — etcd Raft, 스케줄러, 컨트롤러 리컨사일, CRI/CNI/CSI까지

Authors

들어가며 — kubectl apply는 마법이 아니다

"kubectl apply -f deployment.yaml를 쳤더니 30초 뒤 Pod가 떠 있더라." 이 한 줄 뒤에서는 최소 6개의 컴포넌트가 순차적으로 reconcile 루프를 돌고, Raft 합의가 한 번 일어나고, 가상 네트워크 인터페이스가 생성되고, 컨테이너 런타임이 OCI 스펙으로 컨테이너를 띄운다. Kubernetes를 쓰는 사람은 많지만, 이 30초를 단계별로 설명할 수 있는 사람은 드물다.

이전 글 컨테이너와 Docker 내부 구조에서 namespace와 cgroup, OverlayFS, runc까지 봤다. 이번엔 그 위에 올라탄 Kubernetes가 어떻게 수천 개의 컨테이너를 선언적(declarative)으로 관리하는지, 특히 "선언적"이라는 말이 내부적으로 무엇을 의미하는지를 파헤친다.

장애가 났을 때 kubectl get events만 보고 원인을 추측하는 것과, "지금 scheduler의 PreFilter 단계에서 NodeAffinity가 실패했고, 그래서 Pending이다"라고 정확히 짚는 것의 차이는 내부 구조를 아는지 여부에서 갈린다.


1. Kubernetes의 10,000피트 뷰 — 컨트롤 플레인과 데이터 플레인

Kubernetes 클러스터는 크게 두 층으로 나뉜다.

컨트롤 플레인(Control Plane) — "무엇을 해야 하는가"를 결정하는 뇌:

  • kube-apiserver — REST API 게이트웨이. 모든 요청의 유일한 입구
  • etcd — 클러스터의 진실 소스(single source of truth). Raft 합의 기반 분산 KV 스토어
  • kube-scheduler — Pod를 어느 Node에 배치할지 결정
  • kube-controller-manager — Deployment, ReplicaSet, StatefulSet 등 40+개 컨트롤러의 집합
  • cloud-controller-manager — 클라우드 제공자별 로드밸런서, 볼륨, 라우트 관리

데이터 플레인(Data Plane) — "실제로 실행"하는 근육:

  • kubelet — 각 Node에서 Pod를 띄우고 상태를 보고하는 에이전트
  • kube-proxy — Service의 iptables/IPVS 룰을 관리하는 네트워크 프록시
  • 컨테이너 런타임 — containerd, CRI-O 등 CRI 구현체

핵심 원칙 하나만 기억하면 된다: 모든 컴포넌트는 etcd를 통해서만 통신하고, 자신의 관심사(resource)를 watch하며 원하는 상태(desired state)와 현재 상태(current state)의 차이를 좁힌다. 이것이 "선언적"의 정체다.


2. kubectl apply 한 줄의 여정 — 30초를 밀리초 단위로

다음 YAML을 apply했다고 하자.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
spec:
  replicas: 3
  selector:
    matchLabels: { app: nginx }
  template:
    metadata:
      labels: { app: nginx }
    spec:
      containers:
      - name: nginx
        image: nginx:1.25

T+0ms — kubectl 클라이언트

  1. ~/.kube/config 읽어서 API Server 주소와 인증서 로드
  2. YAML을 JSON으로 변환
  3. PATCH /apis/apps/v1/namespaces/default/deployments/nginx로 전송 (Server-Side Apply 사용 시)

T+5ms — kube-apiserver (HTTP 레이어)

  1. TLS 종료, 클라이언트 인증서 검증
  2. Authentication: 누구인가? (x509, Bearer Token, OIDC 등)
  3. Authorization: 무엇을 할 권한이 있는가? (RBAC, ABAC, Webhook)
  4. Admission Controllers (Mutating): 요청을 수정할 수 있나? (기본 값 주입, 사이드카 주입)
  5. Schema Validation: OpenAPI 스펙에 맞는가?
  6. Admission Controllers (Validating): 정책 위반 없는가? (ResourceQuota, PodSecurity)

T+20ms — etcd 쓰기

  1. API Server가 etcd에 PUT /registry/deployments/default/nginx
  2. Raft 리더가 proposal을 만들어 팔로워들에게 AppendEntries RPC
  3. 과반수(quorum)가 응답하면 commit
  4. 리더가 API Server에 ACK
  5. API Server는 같은 트랜잭션에서 resourceVersion을 올린다

T+25ms — Watch 이벤트 팬아웃

  • API Server가 Deployment의 watcher들에게 ADDED 이벤트 송신
  • kube-controller-manager의 Deployment Controller가 이벤트 수신

T+30~100ms — Deployment Controller 리컨사일

  1. Deployment spec을 읽음 (replicas: 3)
  2. 해당 라벨로 ReplicaSet 조회 → 없음
  3. ReplicaSet을 생성 (이것도 API Server 경유 → etcd 저장)

T+100~200ms — ReplicaSet Controller 리컨사일

  1. ReplicaSet을 watch하던 컨트롤러가 이벤트 수신
  2. spec.replicas=3, 현재 Pod 수=0
  3. Pod 3개를 생성 (nodeName 비어있음, Pending 상태)

T+200~500ms — Scheduler

  1. spec.nodeName == ""인 Pod를 watch하던 스케줄러가 이벤트 수신
  2. Filter 단계: 61개 Node 중 리소스가 부족하거나 taint가 있는 것을 제거 → 15개 남음
  3. Score 단계: 15개 Node에 점수 매김 (LeastRequestedPriority, ImageLocalityPriority 등)
  4. 최고점 Node 선택 → API Server에 POST /pods/{name}/binding 호출
  5. API Server가 Pod의 spec.nodeName을 업데이트

T+500ms~2s — kubelet

  1. 해당 Node의 kubelet이 자기 Node에 바인딩된 Pod를 watch하다가 이벤트 수신
  2. SyncPod 리컨사일 루프:
    • 볼륨 마운트 (CSI 플러그인 호출)
    • 네트워크 네임스페이스 생성 후 CNI 플러그인 호출 → IP 할당
    • CRI를 통해 컨테이너 런타임에 "이 이미지로 컨테이너 만들어"
  3. containerd가 이미지 pull → 이전 글의 OverlayFS 레이어 구성
  4. runc가 namespace/cgroup 설정 후 execve로 nginx 실행

T+3s~30s — 상태 전파

  1. kubelet이 컨테이너 상태를 주기적으로 API Server에 보고 (status subresource)
  2. ReplicaSet Controller는 Pod Running 카운트를 보고 status 업데이트
  3. Deployment Controller는 ReplicaSet status를 보고 자신의 status 업데이트
  4. kubectl get deploy에 "3/3 Ready"가 뜬다

이 30초 동안 등장한 핵심 패턴 두 개만 기억하면 된다.

  1. Level-triggered reconciliation — 이벤트를 놓쳐도 상태를 주기적으로 다시 확인한다
  2. Watch-based coordination — 모든 것이 API Server의 watch stream을 통해 eventual하게 수렴한다

3. etcd와 Raft — 클러스터의 유일한 진실

왜 etcd인가?

Kubernetes 초창기(2014~2015)에는 ZooKeeper, Consul, etcd 모두 후보였다. etcd가 선택된 이유:

  • gRPC 기반 API (HTTP/2 + Protocol Buffers)
  • Watch: 클라이언트가 키 변경을 스트리밍으로 구독 가능
  • MVCC (Multi-Version Concurrency Control): 과거 버전 조회 가능
  • Raft 기반 (ZooKeeper의 ZAB보다 이해하기 쉽고 구현이 단순)

etcd는 Kubernetes API 객체 하나하나를 /registry/<resource>/<namespace>/<name> 경로에 저장한다. 예:

/registry/deployments/default/nginx
/registry/pods/default/nginx-6d4cf56db6-abc12
/registry/services/default/nginx

Raft 합의 — 5대 장비에서 정답 하나를 뽑는 법

etcd 클러스터는 보통 3대 또는 5대의 홀수로 구성한다. 이유는 과반수(quorum) 때문이다.

  • 3대 → 2대가 살아있으면 동작
  • 5대 → 3대가 살아있으면 동작 (동시에 2대까지 실패 허용)
  • 7대 → 4대 필요 (네트워크 RTT 증가로 보통 비추천)

Raft의 세 가지 역할:

  • Leader — 모든 쓰기를 받고 복제
  • Follower — Leader의 명령을 따름
  • Candidate — 선거 중

Leader Election:

  1. Follower가 election timeout(150~300ms 랜덤) 동안 leader 하트비트를 못 받으면 Candidate로 전환
  2. term을 +1하고 자신에게 투표, 다른 노드에 RequestVote RPC
  3. 과반수 표를 얻으면 Leader, 못 얻으면 다음 term으로

Log Replication:

  1. 클라이언트 요청이 Leader에게 도착 → log entry 추가
  2. Leader가 AppendEntries RPC로 모든 Follower에게 복제
  3. 과반수가 디스크에 fsync 완료했다고 응답하면 commit
  4. Leader는 commit된 entry를 상태 머신(etcd의 BoltDB)에 적용
  5. 다음 AppendEntries에서 commit index를 알려 Follower들도 적용

etcd 디스크 성능이 Kubernetes의 심장인 이유

모든 쓰기마다 fsync가 일어난다. 즉 etcd의 디스크 fsync 지연이 API 서버의 쓰기 지연이다.

운영 기준:

  • fsync p99가 10ms 미만 → 건강
  • fsync p99가 100ms 이상 → API 요청 타임아웃 발생, 클러스터 불안정
  • 디스크는 로컬 SSD 권장, NFS/EBS gp2는 비추

대규모 클러스터(5000+ node)에서 etcd가 포화되면 전체 컨트롤 플레인이 먹통이 된다. 그래서 Google 내부에서는 이벤트(Event) 전용 etcd 클러스터를 분리하는 패턴을 쓴다 (--etcd-servers-overrides=/events#https://events-etcd:2379).


4. Watch와 Informer — 수천 대를 0초에 가깝게 동기화

순진한 방법은 망한다

만약 모든 컨트롤러가 "1초마다 Deployment 전체 목록을 가져옴"으로 동작한다면? 5000개 Deployment × 40개 컨트롤러 × 1Hz = 초당 200,000번의 API 호출. 클러스터가 즉사한다.

Watch — gRPC 스트림으로 변경만 받기

API Server는 etcd의 watch를 래핑해서 HTTP long-polling 또는 HTTP/2 스트림으로 클라이언트에게 변경 이벤트만 보낸다.

GET /api/v1/pods?watch=true&resourceVersion=12345

클라이언트가 받는 이벤트 타입:

  • ADDED — 새 객체
  • MODIFIED — 변경된 객체
  • DELETED — 삭제된 객체
  • BOOKMARK — "아직 살아있어요" 하트비트 (Kubernetes 1.16+)

Informer — 모든 컨트롤러의 공통 기반

직접 watch를 쓰는 건 고통스럽다. 연결이 끊기면? 재시작 후 처음부터 받나? 그래서 client-go의 Informer 패턴이 탄생했다.

┌──────────────┐    List+Watch   ┌──────────────┐
│  API Server  │ ──────────────> │  Reflector   │
└──────────────┘                 └──────┬───────┘
                                        │ Delta
                                 ┌──────────────┐
                                 │ DeltaFIFO    │
                                 └──────┬───────┘
                                        │ Pop
                                 ┌──────────────┐      ┌──────────────┐
                                 │    Indexer   │◄────►│ Thread-safe  │
                                 │  (local cache)│     │    Store     │
                                 └──────┬───────┘      └──────────────┘
                                        │ Event
                                 ┌──────────────┐
                                 │ EventHandler │
                                 └──────┬───────┘
                                        │ Enqueue
                                 ┌──────────────┐
                                 │  WorkQueue   │
                                 └──────┬───────┘
                                        │ Process
                                 ┌──────────────┐
                                 │  Reconciler  │
                                 └──────────────┘

핵심 아이디어:

  1. Reflector: 첫 List로 전체 상태 받고, 그 뒤로는 Watch로 delta만 받음
  2. Indexer: 로컬 메모리 캐시. 컨트롤러는 여기서만 읽음(API Server 호출 X)
  3. WorkQueue: rate-limiting + retry + dedup
  4. Reconciler: 객체 이름만 받아서, Indexer에서 최신 상태를 읽고 처리

이 구조 덕분에 컨트롤러 하나가 수만 개의 객체를 추적하면서도 API Server 부하는 최소화된다.


5. 스케줄러 — "이 Pod를 어디에 둘까?"

2단계 파이프라인: Filter → Score

Pod가 Pending으로 들어오면 scheduler는 Node 목록을 두 번 훑는다.

1단계: Filter (Predicate) — Node가 조건을 만족하는가? 예/아니오

  • NodeResourcesFit — CPU/메모리 요청량이 Node 가용량보다 적은가?
  • NodeAffinity — nodeSelector/affinity 만족?
  • TaintToleration — taint를 tolerate하는가?
  • VolumeBinding — 요구한 PV가 해당 Node의 zone에 있는가?
  • InterPodAffinity — 다른 Pod와의 어피니티 규칙?

2단계: Score (Priority) — 필터를 통과한 Node들을 0~100점으로 채점

  • LeastAllocated — 리소스 여유가 많은 쪽 선호
  • BalancedAllocation — CPU와 메모리가 균형있게 사용되는 쪽
  • ImageLocality — 이미 이미지가 있는 Node 선호 (pull 시간 절약)
  • TopologySpreadConstraints — zone/region별로 골고루 분산

각 플러그인이 점수를 매기고, 가중치를 곱해서 합산 후 최고점 Node가 승자.

Framework 플러그인 — v1.19+의 확장점

1.19부터 scheduler가 Scheduling Framework로 재작성되어, 각 단계가 플러그인 훅으로 열렸다.

queueSort → preFilter → filter → postFilter → preScore → score →
  normalizeScore → reserve → permit → preBind → bind → postBind

커스텀 스케줄러를 만들고 싶으면 이 훅들을 구현해서 scheduler 바이너리에 플러그인으로 빌드하면 된다 (GPU 토폴로지 스케줄링, 가격 최적화 등).

Preemption — 우선순위 높은 Pod가 들어올 때

모든 Node가 꽉 차서 새 Pod가 스케줄 안 되는데, 그 Pod의 priority가 기존 Pod들보다 높다면?

  1. 스케줄러가 "누굴 쫓아내면 이 Pod가 들어갈까?"를 탐색
  2. 가장 낮은 우선순위 Pod들을 찾아 graceful termination으로 삭제
  3. 해당 Pod가 Pending에서 해당 Node로 스케줄

이 때문에 critical한 워크로드는 PriorityClass로 높은 값을 주고, 쫓겨나면 안 되는 건 preemptionPolicy: Never를 설정한다.


6. 컨트롤러 — Kubernetes의 진정한 엔진

컨트롤러 패턴 한 줄 정의

현재 상태를 관찰하고, 원하는 상태와 다르면 액션을 취해서 둘을 일치시킨다. 이 루프를 영원히 반복한다.

모든 컨트롤러의 핵심은 Reconcile(key) 함수 하나다.

func (c *DeploymentController) Reconcile(key string) error {
    namespace, name := splitKey(key)
    deployment, err := c.lister.Deployments(namespace).Get(name)
    if errors.IsNotFound(err) {
        return nil // 이미 삭제됨, 할 일 없음
    }
    if err != nil {
        return err
    }

    // 1. 원하는 상태
    desired := deployment.Spec.Replicas

    // 2. 현재 상태
    rsList, _ := c.rsLister.ReplicaSets(namespace).List(selector)
    current := totalReadyPods(rsList)

    // 3. 차이를 좁히기
    if current < desired {
        c.scaleUp(deployment, desired - current)
    } else if current > desired {
        c.scaleDown(deployment, current - desired)
    }

    // 4. 상태 업데이트
    c.updateStatus(deployment)
    return nil
}

kube-controller-manager에 들어있는 40+ 컨트롤러 (일부)

컨트롤러감시 대상하는 일
DeploymentDeploymentReplicaSet을 만들고 롤아웃 관리
ReplicaSetReplicaSetPod 수를 spec.replicas에 맞춤
StatefulSetStatefulSet순서대로 Pod 생성/삭제, stable network ID
DaemonSetDaemonSet모든 Node에 Pod 하나씩
Job / CronJobJobPod 완료까지 대기, 실패 시 재시도
NodeNodeNotReady Node의 Pod 축출
Endpoint / EndpointSliceService + PodService의 실제 Pod IP 목록 유지
HPAHPA + metricsCPU/메모리 기반 replicas 자동 조정
GC모든 owner reference부모 삭제 시 자식 삭제
ServiceAccount / TokenSA자동 토큰 생성
PV / PVCPV, PVC바인딩, 프로비저닝

Operator — 사용자 정의 컨트롤러의 패턴

CoreOS가 2016년에 제시한 패턴. **CRD(Custom Resource Definition)**로 도메인 객체를 정의하고, 그 객체를 감시하는 컨트롤러를 만든다.

apiVersion: postgresql.crunchydata.com/v1
kind: PostgresCluster
metadata:
  name: my-db
spec:
  replicas: 3
  storageClass: fast-ssd
  backup:
    schedule: "0 2 * * *"

PGO Operator는 이 CRD를 watch하다가:

  • Primary용 StatefulSet 1개 + Replica용 2개 생성
  • Pgbouncer Deployment 생성
  • Backup용 CronJob 생성
  • Primary 장애 시 Replica를 승격

이 모든 게 "Kubernetes 네이티브"로, 그냥 kubectl apply로 시작된다.


7. Service와 kube-proxy — Pod IP가 바뀌어도 접속이 끊기지 않는 마법

문제 — Pod IP는 언제든 바뀐다

Pod가 재시작되면 IP가 바뀐다. 클라이언트가 Pod IP를 직접 참조하면 매번 깨진다. 그래서 Service라는 가상 IP(ClusterIP)가 필요하다.

iptables 모드 — 초창기 방식

kube-proxy가 각 Node에서 iptables 룰을 동적으로 업데이트한다.

# 간략화한 iptables 체인
KUBE-SERVICES → KUBE-SVC-XXX (Service ClusterIP 매칭)
KUBE-SVC-XXX → KUBE-SEP-A (33% 확률 DNAT → Pod A)
             → KUBE-SEP-B (33% 확률 DNAT → Pod B)
             → KUBE-SEP-C (33% 확률 DNAT → Pod C)

요청이 Service IP로 오면 랜덤으로 Pod IP 하나로 DNAT된다. 문제는 Pod가 많을수록 iptables 룰이 선형적으로 증가해서, 수천 개 Pod에서는 커널이 매 패킷마다 수만 개 규칙을 순회하며 느려진다.

IPVS 모드 — 해시 테이블 기반

--proxy-mode=ipvs로 바꾸면 Linux IPVS(IP Virtual Server)를 쓴다.

  • 해시 테이블 기반 → O(1) 조회
  • 라운드로빈, Least Connection, Source Hash 등 다양한 알고리즘
  • 대규모 클러스터(1000+ Service)에서 필수

eBPF 모드 — Cilium의 세상

Cilium은 kube-proxy를 완전히 대체한다. iptables/IPVS 대신 eBPF 프로그램이 커널의 XDP/TC hook에서 패킷을 가로채 직접 Pod로 라우팅한다. 장점:

  • 훨씬 빠름 (hook이 커널 초기에 위치)
  • iptables 룰 폭증 없음
  • 네트워크 정책, 관측성까지 하나의 프레임워크로

최근(2024~2025) 많은 대규모 클러스터가 kube-proxy-replacement=strict로 kube-proxy를 아예 꺼버린다.


8. CRI / CNI / CSI — 3대 플러그인 인터페이스

Kubernetes의 우아함은 세 가지 핵심 기능을 인터페이스로 추상화한 데 있다.

CRI (Container Runtime Interface) — "컨테이너를 어떻게 띄울까"

  • Why: 초창기에는 Docker만 지원했는데, rkt, CRI-O 등이 등장하면서 매번 kubelet 코드를 수정할 수 없었다
  • How: kubelet이 gRPC로 RuntimeServiceImageService를 호출
  • 구현체: containerd (기본), CRI-O (Red Hat 선호), Mirantis cri-dockerd (Docker 호환용)

주요 RPC:

RunPodSandbox    — pause 컨테이너로 namespace 그룹 생성
CreateContainer  — 해당 sandbox에 컨테이너 생성
StartContainer   — 컨테이너 시작
ExecSync         — kubectl exec의 실체

2022년 1.24에서 dockershim이 제거된 이후 기본은 containerd. Docker 자체는 containerd를 내부적으로 쓰기 때문에 "Docker 이미지"는 그대로 돌아간다.

CNI (Container Network Interface) — "Pod에 IP를 어떻게 줄까"

  • Why: Kubernetes는 "모든 Pod는 IP를 가진다, 같은 클러스터의 Pod끼리는 NAT 없이 통신한다"만 규정. 구현은 자유
  • How: CNI 플러그인은 단순한 바이너리. kubelet이 namespace 경로를 인자로 주면 플러그인이 veth/IP/라우트를 설정하고 결과 JSON 반환

대표 플러그인:

  • Flannel — VXLAN 오버레이. 간단하지만 정책 없음
  • Calico — BGP 기반 라우팅. NetworkPolicy 네이티브
  • Cilium — eBPF 기반. L3~L7 정책, 관측성
  • AWS VPC CNI — Pod에게 VPC ENI의 secondary IP를 직접 할당 (클라우드 네이티브)
  • Weave — 소프트웨어 메시. 소규모에서 쉬움

CSI (Container Storage Interface) — "볼륨을 어떻게 마운트할까"

  • Why: in-tree 플러그인(kubernetes 소스에 직접 포함)은 릴리스 속도를 묶어버렸다
  • How: CSI 드라이버는 Controller 플러그인(볼륨 생성/삭제)과 Node 플러그인(마운트)으로 구성된 gRPC 서비스

플로우:

  1. PVC 생성 → external-provisioner가 CSI Controller에 CreateVolume 호출 → 클라우드 API로 실제 디스크 생성
  2. Pod 스케줄링 → external-attacher가 ControllerPublishVolume → 디스크를 Node에 attach
  3. kubelet이 CSI Node에 NodeStageVolume + NodePublishVolume → 실제 마운트

주요 드라이버: AWS EBS, GCP PD, Azure Disk, Ceph RBD, Longhorn, Rook.


9. API Server 확장 — Aggregation Layer와 Admission Webhook

CRD vs Aggregated API Server

커스텀 리소스 만드는 방법 두 가지:

  1. CRD (CustomResourceDefinition) — YAML로 스키마만 정의하면 자동으로 REST endpoint 생성. 99%의 경우 이걸로 충분
  2. Aggregation Layer — 별도 API Server를 띄워 /apis/metrics.k8s.io/v1beta1 같은 경로를 담당. metrics-server, kube-aggregator가 이 방식

Admission Webhook — 요청 가로채기

  • Mutating Webhook — 요청 바디를 수정 (예: Istio의 사이드카 주입)
  • Validating Webhook — 요청을 거부 (예: OPA Gatekeeper 정책)

웹훅이 느리면 API Server가 멈춘다. failurePolicy: Fail 설정한 웹훅이 죽으면 해당 리소스에 대한 모든 요청이 실패한다 — 초보자의 흔한 장애 원인.


10. 실전 디버깅 — 5가지 증상과 근본 원인

증상 1: Pod가 Pending

확인 순서:

kubectl describe pod <name>

Events 섹션을 본다.

  • FailedScheduling: 0/10 nodes are available: 10 Insufficient cpu → 리소스 부족
  • FailedScheduling: node(s) had taints → taint/toleration 누락
  • FailedScheduling: pod has unbound immediate PersistentVolumeClaims → PVC 프로비저닝 실패

증상 2: Pod는 Running인데 Ready 아님

readinessProbe가 실패 중. 자주 있는 실수:

  • probe가 /healthz를 때리는데 앱은 /health에서 응답
  • probe의 initialDelaySeconds가 너무 짧아 앱이 뜨기 전에 실패
  • probe가 HTTPS인데 scheme 설정 누락

증상 3: CrashLoopBackOff

kubectl logs <pod> --previous로 이전 컨테이너 로그 본다. 전형적 원인:

  • OOM → kubectl describe Events에 OOMKilled
  • 설정 파일 경로 틀림
  • 의존하는 Service/DB가 아직 안 떠서 startup이 실패 (initContainer로 해결)

증상 4: Service 연결 안 됨

kubectl get endpoints <service>
  • 비어있음 → Service selector가 Pod label과 불일치
  • 있는데도 안 됨 → NetworkPolicy가 차단 중일 확률 높음

증상 5: kubectl exec가 먹통

  • API Server → kubelet의 exec 채널 문제. kubelet 로그 확인
  • SNI/인증서 문제 — kubectl exec --v=8로 TLS 레벨까지 찍어본다

11. 스케일의 벽 — Kubernetes가 포기하는 지점

공식 최대치 (1.29 기준):

  • Node 5,000
  • Pod 150,000
  • 컨테이너 300,000
  • Node당 Pod 110

이 벽을 넘으려면:

  1. Cluster Federation / Multi-cluster — 여러 클러스터를 묶어 운영 (Karmada, Cluster API)
  2. etcd 분할 — Event 전용 etcd 분리
  3. API Priority and Fairness — API Server가 중요한 요청부터 처리
  4. Informer 최적화 — 컨트롤러가 fieldSelector로 관심 있는 객체만 구독

Uber, Airbnb 같은 곳은 클러스터당 5000 Node를 수십 개 운영하고, 그 위에 멀티 클러스터 컨트롤 플레인을 얹는다.


12. 보안 레이어 — RBAC, NetworkPolicy, Pod Security

RBAC — 누가 무엇을 할 수 있는가

kind: Role
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch"]
---
kind: RoleBinding
subjects:
- kind: ServiceAccount
  name: my-app
roleRef:
  kind: Role
  name: pod-reader

원칙: Least Privilege. * verb나 cluster-admin은 필요할 때만.

NetworkPolicy — 기본은 모두 허용

Kubernetes의 기본 정책은 "모든 Pod끼리 통신 허용"이다. 이건 보안상 위험하다. NetworkPolicy로 default-deny를 만들고 필요한 것만 열어준다.

kind: NetworkPolicy
spec:
  podSelector: {}  # 모든 Pod에 적용
  policyTypes: [Ingress, Egress]
  # 규칙 없음 → 모두 차단

NetworkPolicy는 CNI가 구현해야 한다. Flannel은 기본으로 지원 안 하고, Calico/Cilium이 필요하다.

Pod Security Standards — Admission으로 강제

1.25부터 PodSecurityPolicy가 제거되고 Pod Security Admission으로 대체:

  • privileged — 아무거나 허용 (기본)
  • baseline — 최소한의 보안 (root 허용 but privileged: false)
  • restricted — 가장 엄격 (runAsNonRoot, readOnlyRootFilesystem 등)

네임스페이스에 라벨로 적용:

metadata:
  labels:
    pod-security.kubernetes.io/enforce: restricted

13. GitOps와 선언적 운영 — Kubernetes의 궁극 형태

kubectl apply를 사람이 치는 시대는 지났다. 운영 환경에서는:

  1. Git 저장소가 진실 — 모든 YAML이 git에 있음
  2. ArgoCD/Flux가 git을 watch → 클러스터와 diff → reconcile
  3. 개발자는 PR만 올림, 머지되면 자동 배포

이것이 가능한 이유는 Kubernetes API가 멱등(idempotent)이고 선언적이기 때문. 같은 YAML을 100번 apply해도 결과는 같다.

이 패턴을 확장하면 Crossplane 같은 도구로 클라우드 리소스(RDS, S3, VPC)까지 Kubernetes CRD로 선언 가능하다. "Kubernetes를 universal control plane으로" 쓰는 움직임이다.


14. 실무 교훈 12가지

  1. etcd 백업을 매일 하라etcdctl snapshot save. 없으면 클러스터 복구 불가능
  2. 컨트롤 플레인 노드는 전용 Node로 — 워크로드와 섞으면 OOM으로 죽을 때 클러스터가 같이 죽는다
  3. requestslimits를 항상 명시 — 없으면 스케줄러가 판단 불가, 노드 전체가 흔들림
  4. PodDisruptionBudget을 붙여라 — drain 중 서비스가 0개까지 떨어지는 걸 방지
  5. readinessProbe와 livenessProbe는 반드시 다르게 설계 — readiness는 트래픽 받을 준비, liveness는 살아있음 검사. 같으면 롤링 배포 중 재시작 지옥
  6. HPA의 scale down을 보수적으로 — 트래픽 스파이크 직후 스케일 다운하다 재포화
  7. namespace별 ResourceQuota — 한 팀이 클러스터 전체를 먹는 것 방지
  8. CNI 바꾸기 = 클러스터 재생성 — 프로덕션에서 CNI 스왑은 사실상 불가
  9. Operator 만들기 전에 controller-runtime 학습 — kubebuilder로 시작하면 쉬움
  10. Prometheus + kube-state-metrics + cAdvisor 삼위일체로 관측성 확보
  11. Secret은 etcd에 base64일 뿐 — SealedSecret, External Secrets Operator, Vault로 암호화
  12. Namespace delete는 영원할 수 있다 — finalizer 남은 리소스가 하나라도 있으면 멈춤. kubectl patch ... -p '{"metadata":{"finalizers":[]}}'로 수동 제거

다음 글 예고 — Service Mesh와 eBPF의 만남

Kubernetes가 "컨테이너 오케스트레이션의 운영체제"라면, 그 위에 올라타는 네트워크 계층은 최근 10년간 극적으로 변했다. 다음 글에서는:

  • Service Mesh가 해결한 문제 (회로 차단, 분산 트레이싱, mTLS)와 Sidecar 모델의 비용
  • Istio의 데이터 플레인(Envoy)과 컨트롤 플레인(istiod) 구조
  • Ambient Mesh — Sidecar 없는 새 접근
  • eBPF 기반 Service Mesh: Cilium의 서비스 메시 (Sidecar-less)
  • Envoy의 내부 — listener, filter chain, cluster, xDS API
  • gRPC-Go의 클라이언트 사이드 로드 밸런싱과 Mesh의 관계
  • WASM 필터 — Envoy 확장의 미래

지금도 istioctl을 쓰고 있지만 Envoy 설정을 직접 읽지 못하는 분, eBPF가 어떻게 kernel bypass로 지연을 줄이는지 궁금한 분이라면 다음 글에서 만나자.

"Kubernetes는 컨테이너를 배치하는 법을 알지만, 컨테이너끼리 어떻게 대화하는지는 별도 계층의 문제다." Service Mesh가 그 답을 제시했고, eBPF는 그걸 다시 커널로 끌어내렸다.