들어가며 — kubectl apply는 마법이 아니다
"`kubectl apply -f deployment.yaml`를 쳤더니 30초 뒤 Pod가 떠 있더라." 이 한 줄 뒤에서는 **최소 6개의 컴포넌트가 순차적으로 reconcile 루프를 돌고, Raft 합의가 한 번 일어나고, 가상 네트워크 인터페이스가 생성되고, 컨테이너 런타임이 OCI 스펙으로 컨테이너를 띄운다.** Kubernetes를 쓰는 사람은 많지만, 이 30초를 단계별로 설명할 수 있는 사람은 드물다.
> 이전 글 [컨테이너와 Docker 내부 구조](/blog/culture/2026-04-15-container-docker-internals-namespace-cgroup-overlayfs-seccomp-capabilities-deep-dive-guide-2025)에서 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+ 컨트롤러 (일부)
| 컨트롤러 | 감시 대상 | 하는 일 |
|---|---|---|
| Deployment | Deployment | ReplicaSet을 만들고 롤아웃 관리 |
| ReplicaSet | ReplicaSet | Pod 수를 spec.replicas에 맞춤 |
| StatefulSet | StatefulSet | 순서대로 Pod 생성/삭제, stable network ID |
| DaemonSet | DaemonSet | 모든 Node에 Pod 하나씩 |
| Job / CronJob | Job | Pod 완료까지 대기, 실패 시 재시도 |
| Node | Node | NotReady Node의 Pod 축출 |
| Endpoint / EndpointSlice | Service + Pod | Service의 실제 Pod IP 목록 유지 |
| HPA | HPA + metrics | CPU/메모리 기반 replicas 자동 조정 |
| GC | 모든 owner reference | 부모 삭제 시 자식 삭제 |
| ServiceAccount / Token | SA | 자동 토큰 생성 |
| PV / PVC | PV, 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로 `RuntimeService`와 `ImageService`를 호출
- **구현체**: 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. **`requests`와 `limits`를 항상 명시** — 없으면 스케줄러가 판단 불가, 노드 전체가 흔들림
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는 그걸 다시 커널로 끌어내렸다.
현재 단락 (1/364)
"`kubectl apply -f deployment.yaml`를 쳤더니 30초 뒤 Pod가 떠 있더라." 이 한 줄 뒤에서는 **최소 6개의 컴포넌트가 순차적으로 reconcil...