3노드 클러스터 환경에서 Control Plane부터 실제 애플리케이션 배포까지
Day 1에서 클러스터의 기본 구조를 이해했다면, Day 2는 실전이었습니다. Control Plane이 어떻게 동작하는지, Pod 간 통신이 실제로 어떻게 이루어지는지, 그리고 마침내 외부에서 접근 가능한 웹 애플리케이션을 배포하는 것까지 경험했습니다.
특히 이번 실습에서는 예상치 못한 네트워킹 문제를 직접 해결하면서, Calico CNI의 동작 원리와 Linux 네트워킹에 대해 깊이 이해할 수 있었습니다.
첫 번째로 etcd가 실제로 무엇을 저장하는지 확인했습니다.
kubectl exec -n kube-system etcd-cpu1 -- sh -c \
"ETCDCTL_API=3 etcdctl \
--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 \
get /registry/ --prefix --keys-only" | head -20
출력 결과:
/registry/apiregistration.k8s.io/apiservices/v1.
/registry/apiregistration.k8s.io/apiservices/v1.admissionregistration.k8s.io
/registry/clusterrolebindings/calico-kube-controllers
/registry/deployments/calico-system/calico-kube-controllers
/registry/pods/calico-system/calico-node-ftrzj
/registry/services/endpoints/default/kubernetes
...
모든 리소스가 /registry/ 아래에 계층 구조로 저장되어 있었습니다. Deployment, Pod, Service 모두 etcd에 영구 저장되는 것을 직접 확인했습니다.
kubectl get --raw /version
{
"major": "1",
"minor": "31",
"gitVersion": "v1.31.13"
}
API Server는 80개의 API 리소스를 제공하고 있었습니다. kubectl이 실제로는 이 REST API를 호출하는 클라이언트에 불과하다는 것을 깨달았습니다.
Deployment를 생성하면 Controller Manager가 ReplicaSet을 생성하고, ReplicaSet Controller가 Pod를 생성하고, Scheduler가 노드를 선택하는 전체 체인을 추적했습니다.
kubectl get events --sort-by='.lastTimestamp' | grep test-controller
Successfully assigned default/test-controller-xxx to cpu2
Pulling image "nginx:alpine"
Created container nginx
Started container nginx
이 과정이 1초도 안 걸렸습니다. 각 컴포넌트가 얼마나 빠르게 동작하는지 놀라웠습니다.
처음에는 "Pod 통신이 Layer 2라서 UDP로 감싸야 한다"고 완전히 잘못 이해했습니다. 이 오해를 바로잡는 과정이 Day 2의 가장 큰 학습이었습니다.
핵심 질문:
"BGP로 라우터 정보 넣어서 미리 CIDR 땡겨오게 설정해두면 VXLAN을 사용하지 않아도 되는 것처럼 설명했는데 그게 맞아? Layer 2라서 UDP로 감아야 된다며?"
답변:
Pod 통신은 원래 Layer 3 (IP 기반)입니다!
Pod는 각자 고유한 IP 주소를 가지고 있으며, IP 패킷으로 통신합니다. VXLAN은 "필수"가 아니라 "특정 상황에서의 해결책"입니다.
[Pod A: 10.244.1.10]
↓ (IP routing)
[Node1: 172.30.1.43]
↓ (BGP 라우팅 정보 교환)
[Node2: 172.30.1.80]
↓ (IP routing)
[Pod B: 10.244.2.20]
[Pod A: 10.244.1.10]
↓ (IP packet)
[VXLAN 캡슐화: UDP 4789]
↓ (Outer IP: 172.30.1.43 → 172.30.1.80)
[물리 네트워크]
↓
[VXLAN 역캡슐화]
↓
[Pod B: 10.244.2.20]
같은 서브넷:
[Pod A] → [Direct IP routing] → [Pod B] (MTU 1500)
다른 서브넷:
[Pod A] → [VXLAN tunnel] → [Pod B] (MTU 1450)
VXLAN이 필요한 경우:
1. 클라우드 환경 (AWS VPC, GCP, Azure)에서 BGP 불가
2. 물리 라우터가 BGP를 지원하지 않음
3. 보안 정책으로 BGP peer 설정 불가
4. 서로 다른 데이터센터/서브넷을 연결
VXLAN이 불필요한 경우:
1. 물리 라우터가 BGP 지원 (Calico BGP mode 사용)
2. 같은 L2 네트워크 내 (Calico CrossSubnet의 direct routing)
3. 노드가 적고 static route로 충분
우리 클러스터는?
ip -d link show type vxlan
20: vxlan.calico: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450
vxlan id 4096 local 172.30.1.43 dev enp2s0 srcport 0 0 dstport 4789
VXLAN을 사용할 때의 헤더 구조:
[Outer Ethernet: 14 bytes]
[Outer IP: 20 bytes]
[UDP: 8 bytes]
[VXLAN: 8 bytes]
[Inner Ethernet: 14 bytes]
[Inner IP: 20 bytes]
[Payload]
Total Overhead: 14+20+8+8 = 50 bytes
물리 인터페이스 MTU가 1500이므로:
우리 클러스터는 Direct routing을 사용하므로 실제로는 overhead가 없지만, VXLAN으로 전환 시를 대비해 1450으로 설정되어 있습니다.
우리 클러스터는 모든 노드가 172.30.1.0/24 서브넷에 있어서, VXLAN 없이 직접 라우팅을 사용합니다:
ip route | grep 10.244
10.244.5.192/26 via 172.30.1.38 dev enp2s0 proto 80 onlink # gpu1으로 가는 경로
10.244.102.128/26 via 172.30.1.80 dev enp2s0 proto 80 onlink # cpu2로 가는 경로
해석:
via 172.30.1.38: gpu1 노드 IP를 next-hop으로 사용dev enp2s0: 물리 Ethernet 인터페이스 사용 (VXLAN 터널 아님!)proto 80: Calico가 설정한 라우팅 (BIRD BGP)onlink: Gateway가 직접 연결된 링크에 있음확인 방법: VXLAN이 실제로 사용되는지?
# VXLAN 인터페이스의 트래픽 카운터 확인
ip -s link show vxlan.calico
20: vxlan.calico: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450
RX: bytes packets errors dropped
0 0 0 0 # ← 수신 패킷 0
TX: bytes packets errors dropped
0 0 0 0 # ← 송신 패킷 0
결과: VXLAN 인터페이스를 통한 트래픽이 전혀 없음! Direct routing만 사용되고 있습니다.
오해:
진실:
비유:
각 Pod는 완전히 격리된 network namespace를 가지며, veth pair라는 가상 이더넷 케이블로 호스트와 연결됩니다.
Pod 내부에서 확인:
kubectl exec nettest -- ip addr show
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536
inet 127.0.0.1/8 scope host lo
2: eth0@if22: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450
inet 10.244.102.137/32 scope global eth0
호스트에서 확인:
ip link show | grep cali
22: calie107cf6613e@if2: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1450 qdisc noqueue
link-netns cni-2594a3d8-7097-90e6-9db2-a3aaef8edf77
처음에는 이렇게 이해했습니다 (잘못됨):
eth0@if22 → "if22는 22번 인터페이스를 의미하니까... 호스트도 22번이겠지?"22: cali...@if2 → "22번 인터페이스 맞네! 근데 @if2는 뭐지?"이건 완전히 틀렸습니다!
올바른 이해:
Pod 입장: 2: eth0@if22
Host 입장: 22: calie107cf6613e@if2
@ifN의 진정한 의미: "저 너머 네임스페이스에 있는 상대방의 인터페이스 번호"
┌─────────────────────────────────┐ ┌──────────────────────────┐
│ Pod nettest Namespace │ │ Host Namespace │
│ │ │ │
│ 1: lo (127.0.0.1) │ │ 1: lo │
│ 2: eth0@if22 ←────────────────┼───────┼─→ 22: cali...@if2 │
│ 10.244.102.137/32 │ │ (no IP) │
│ │ │ │
│ ↓ │ │ ↓ │
│ default via 169.254.1.1 │ │ 10.244.102.137 dev cali │
│ │ │ (proxy ARP) │
└─────────────────────────────────┘ └──────────────────────────┘
veth pair 특징:
1. 항상 쌍으로 존재 (한쪽만 있을 수 없음)
2. 한쪽 끝이 다른 네임스페이스에 있음
3. 호스트 측 인터페이스는 IP가 없음 (Layer 2 bridge 역할)
4. Pod 측 인터페이스는 Pod IP 할당
Pod에서 외부로 패킷을 보낼 때:
1. Pod nettest (10.244.102.137)
→ "10.109.60.89로 가고 싶어!"
2. Pod의 라우팅 테이블 확인
→ default via 169.254.1.1 dev eth0
→ eth0@if22로 전송
3. veth pair 통과
→ 호스트의 22번 인터페이스 (calie107cf6613e)로 도착
4. 호스트 라우팅 테이블 확인
→ 10.109.60.89는 Service IP → iptables 규칙 적용
→ DNAT: 10.109.60.89 → 10.244.184.84 (실제 Pod IP)
5. 호스트 라우팅 다시 확인
→ 10.244.184.84는 cpu1 노드에 있음
→ 같은 노드이므로 다른 veth pair로 전달
6. 목적지 Pod의 veth pair 통과
→ nginx Pod에 도달!
중요한 발견: @ifN 표기는 반대편 네임스페이스의 인터페이스 번호입니다!
kubectl exec nettest -- ip route
default via 169.254.1.1 dev eth0
169.254.1.1 dev eth0 scope link
Calico는 모든 Pod에 동일한 gateway (169.254.1.1)를 할당하지만, 실제 라우팅은 호스트의 veth pair에서 처리합니다.
kubectl create deployment nginx-test --image=nginx:alpine --replicas=3
kubectl expose deployment nginx-test --port=80 --type=ClusterIP
NAME TYPE CLUSTER-IP PORT(S)
nginx-test ClusterIP 10.109.60.89 80/TCP
kubectl get endpoints nginx-test -o yaml
subsets:
- addresses:
- ip: 10.244.102.138
nodeName: cpu2
- ip: 10.244.184.84
nodeName: cpu1
- ip: 10.244.5.203
nodeName: gpu1
ports:
- port: 80
Service의 selector (app=nginx-test)와 일치하는 모든 Pod IP가 자동으로 Endpoints에 추가됩니다!
iptables-save | grep nginx-test
-A KUBE-SERVICES -d 10.109.60.89/32 ... -j KUBE-SVC-W67AXLFK7VEUVN6G
-A KUBE-SVC-W67AXLFK7VEUVN6G ... --probability 0.33333 -j KUBE-SEP-SOT6P6LQ532M4OEI
-A KUBE-SVC-W67AXLFK7VEUVN6G ... --probability 0.50000 -j KUBE-SEP-3DGSCWRDZYIA2HSW
-A KUBE-SEP-SOT6P6LQ532M4OEI ... -j DNAT --to-destination 10.244.102.138:80
-A KUBE-SEP-3DGSCWRDZYIA2HSW ... -j DNAT --to-destination 10.244.184.84:80
동작 원리:
1. Service IP (10.109.60.89)로 들어오는 트래픽을 캡처
2. 확률적으로 분산 (33%, 50%, 나머지 17%)
3. DNAT로 실제 Pod IP:Port로 변환
statistic random 모듈을 사용한 간단하지만 효과적인 로드밸런싱입니다!
kubectl create configmap webapp-config --from-literal=index.html='
<!DOCTYPE html>
<html>
<head>
<title>Kubernetes Web App</title>
</head>
<body>
<h1>Welcome to Kubernetes!</h1>
<p>This page is served from a ConfigMap</p>
</body>
</html>
'
apiVersion: apps/v1
kind: Deployment
metadata:
name: webapp
spec:
replicas: 2
selector:
matchLabels:
app: webapp
template:
spec:
containers:
- name: nginx
image: nginx:alpine
volumeMounts:
- name: html-volume
mountPath: /usr/share/nginx/html
volumes:
- name: html-volume
configMap:
name: webapp-config
ConfigMap이 Volume으로 마운트되어 nginx가 해당 HTML을 서비스합니다!
kubectl expose deployment webapp --type=NodePort --port=80
NAME TYPE CLUSTER-IP PORT(S)
webapp NodePort 10.103.193.83 80:32065/TCP
모든 노드의 32065 포트로 접근 가능합니다:
curl http://172.30.1.43:32065
<h1>Welcome to Kubernetes!</h1>
<p>This page is served from a ConfigMap</p>
성공! 처음으로 외부에서 접근 가능한 웹 애플리케이션을 배포했습니다.
apiVersion: v1
kind: Pod
metadata:
name: emptydir-test
spec:
containers:
- name: writer
image: busybox:1.28
command: ['sh', '-c', 'echo "Hello from EmptyDir" > /data/message.txt && sleep 3600']
volumeMounts:
- name: shared-data
mountPath: /data
- name: reader
image: busybox:1.28
command: ['sh', '-c', 'sleep 10 && cat /data/message.txt && sleep 3600']
volumeMounts:
- name: shared-data
mountPath: /data
volumes:
- name: shared-data
emptyDir: {}
kubectl logs emptydir-test -c reader
Hello from EmptyDir
writer 컨테이너가 작성한 파일을 reader 컨테이너가 성공적으로 읽었습니다. EmptyDir는 같은 Pod 내 컨테이너 간 데이터 공유에 완벽합니다!
가장 큰 오해를 바로잡았습니다.
처음에는 "Pod 통신은 Layer 2이고, 물리적으로 연결되지 않은 노드 간 통신을 위해 VXLAN으로 Layer 2를 터널링해야 한다"고 잘못 생각했습니다.
실제로는:
실전 적용:
우리 클러스터는 모든 노드가 같은 서브넷(172.30.1.0/24)에 있어서 VXLAN을 전혀 사용하지 않고 enp2s0 물리 인터페이스로 직접 라우팅합니다. ip -s link show vxlan.calico로 확인하면 패킷이 0개!
이 이해를 바탕으로 이제 네트워크 문제가 생기면:
1. 먼저 라우팅 테이블 확인 (ip route | grep 10.244)
2. Calico가 올바른 인터페이스 사용 중인지 확인 (IP_AUTODETECTION_METHOD)
3. BIRD BGP 테이블과 kernel 라우팅 테이블 비교
@ifN 표기의 진정한 의미veth pair를 이해하는 데 한참 헤맸습니다.
2: eth0@if22 → "내 인터페이스는 2번, 상대방은 22번"22: cali...@if2 → "내 인터페이스는 22번, 상대방은 2번"@ifN은 "상대편 네임스페이스의 인터페이스 번호"
이 개념을 이해하니 Pod 네트워킹 디버깅이 훨씬 쉬워졌습니다. veth pair의 한쪽 끝을 찾으면 반대편도 바로 찾을 수 있습니다.
VXLAN의 역할 재정의:
성능 고려사항:
프로덕션 환경 선택 가이드:
Control Plane의 각 컴포넌트는:
이 모델이 Kubernetes의 자동화와 자가 치유를 가능하게 합니다.
Deployment 하나 생성하면:
1. API Server → etcd 저장
2. Deployment Controller → ReplicaSet 생성 감지
3. ReplicaSet Controller → Pod 생성 요청
4. Scheduler → 노드 선택
5. kubelet → 컨테이너 실행
전체 과정이 1초 미만! 각 컴포넌트가 독립적으로 자기 역할만 수행하는 아름다운 설계입니다.
고급 로드밸런서가 있는 줄 알았는데, kube-proxy가 생성한 iptables 규칙만으로 구현되어 있었습니다.
-A KUBE-SVC-xxx ... --probability 0.33333 -j KUBE-SEP-A
-A KUBE-SVC-xxx ... --probability 0.50000 -j KUBE-SEP-B
-A KUBE-SVC-xxx ... -j KUBE-SEP-C
-A KUBE-SEP-A ... -j DNAT --to-destination 10.244.102.138:80
statistic random 모듈로 확률적 분산. 간단하지만 효과적입니다!
이제 Service 트래픽이 어디로 가는지 추적할 수 있습니다:
iptables-save | grep <service-name>
코드와 설정을 완전히 분리할 수 있습니다. 같은 이미지로 dev/staging/prod 환경을 다르게 구성할 수 있습니다.
더 나아가:
각 Pod는 완전히 격리된 네트워크 환경을 가지며, veth pair가 호스트와의 유일한 연결 고리입니다.
Linux 네트워킹 스택의 놀라운 활용:
Kubernetes는 새로운 기술을 발명한 게 아니라, 기존 Linux 커널 기능을 정교하게 조합한 것입니다. 이 점이 매우 인상적이었습니다.
같은 노드의 Pod끼리는 통신이 되는데, 다른 노드의 Pod와는 통신이 안 되는 현상 발생:
kubectl exec nettest -- ping 10.244.184.84
--- 10.244.184.84 ping statistics ---
2 packets transmitted, 0 received, 100% packet loss
nginx가 정상 동작하는지 확인
kubectl exec nginx-pod -- netstat -tlnp
→ 80 포트 정상 listening ✅
노드 간 연결 확인
ping 172.30.1.80 # cpu2
→ 노드 간 ping 정상 ✅
라우팅 테이블 확인
ip route | grep 10.244
→ cpu2, gpu1로 가는 라우팅 규칙이 없음! ❌
Calico 로그 확인
kubectl logs -n calico-system calico-node-vd4ls --tail=50
Interface down, will retry if it goes up. ifaceName="wlp3s0"
범인 발견! Calico가 WiFi 인터페이스 (wlp3s0)를 사용하려다 실패하고 있었습니다.
Calico가 Ethernet 인터페이스 (enp2s0)를 사용하도록 설정:
kubectl set env daemonset/calico-node -n calico-system \
IP_AUTODETECTION_METHOD=interface=enp.*
cpu1과 gpu1의 calico-node Pod를 재시작:
kubectl delete pod -n calico-system calico-node-vd4ls # cpu1
kubectl delete pod -n calico-system calico-node-wnwwt # gpu1
재시작 후 라우팅 확인:
ip route | grep 10.244
10.244.5.192/26 via 172.30.1.38 dev enp2s0 proto 80 onlink # gpu1 ✅
10.244.102.128/26 via 172.30.1.80 dev enp2s0 proto 80 onlink # cpu2 ✅
성공! enp2s0 인터페이스를 통해 라우팅되고 있습니다.
IP_AUTODETECTION_METHOD=first-found는 위험하다
interface=enp.* 또는 can-reach=<IP> 사용 권장Calico 로그는 디버깅의 보물창고
kubectl logs -n calico-system calico-node-xxx는 필수BIRD BGP 테이블과 kernel 라우팅 테이블은 다르다
kubectl exec -n calico-system calico-node-xxx -- birdcl show route로 확인 가능DaemonSet 업데이트가 모든 Pod를 재시작하지 않을 수 있다
kubectl rollout status로 확인WiFi에서 유선(Ethernet)으로 인터넷 연결을 변경하면서 네트워크 인터페이스가 바뀌었습니다.
변경 내역:
IP는 동일하게 설정했지만, 네트워크 인터페이스가 달라지면서 Calico가 올바른 인터페이스를 찾지 못하는 문제가 발생했습니다. 이것이 바로 IP_AUTODETECTION_METHOD=first-found의 위험성입니다.
교훈:
IP_AUTODETECTION_METHOD를 명시적으로 설정 (interface=enp.*)처음에는 Pod의 eth0@if22가 호스트의 22번 인터페이스를 의미하는 줄 알았으나, 실제로는:
eth0@if22 → 호스트의 22번 인터페이스와 연결됨22: cali...@if2 → Pod의 2번 인터페이스(eth0)와 연결됨@ifN은 상대방 쪽의 인터페이스 번호입니다!
Day 2에서 기본적인 애플리케이션 배포까지 완료했으니, Day 3에서는 운영 시나리오를 다룰 계획입니다:
Day 2는 Day 1보다 훨씬 실전적이었습니다. Control Plane의 동작 원리를 깊이 이해하고, 네트워킹 문제를 직접 해결하면서 Calico와 Linux 네트워킹에 대한 자신감이 생겼습니다.
특히 "Service IP로 접근이 안 된다"는 문제를 만났을 때, 체계적으로 디버깅하여 원인을 찾고 해결한 경험이 가장 값졌습니다. 이제 클러스터에 문제가 생겨도 당황하지 않고 로그와 상태를 확인하며 접근할 수 있을 것 같습니다.
Day 3에서는 실제 프로덕션 환경에서 필요한 운영 기능들을 다뤄볼 예정입니다. Rolling Update, Health Check, Resource Limits 등 안정적인 서비스 운영을 위한 필수 요소들을 학습하겠습니다!