Kubernetes CNI 딥다이브: Pod 가상 인터페이스와 패킷 흐름 추적
Kubernetes CNI를 Pod network namespace, veth, bridge, flannel.1, VXLAN, kube-proxy/eBPF 흐름으로 나눠서 실제 점검 명령과 함께 파고드는 네트워크 분석 글입니다.
Kubernetes에서 CNI를 이해한다는 건 플러그인 이름을 외우는 일이 아닙니다. Pod가 뜨는 순간 어떤 network namespace가 생기고, `eth0`가 어떤 veth의 한쪽 끝인지, host 쪽 peer가 bridge로 들어가는지 route로 처리되는지, 다른 노드로 나갈 때 VXLAN이나 BGP가 어디서 개입하는지를 따라갈 수 있어야 합니다.
Flannel VXLAN을 기준으로 패킷 흐름을 끝까지 따라갑니다. 중간중간 Calico, Cilium 같은 다른 CNI에서는 같은 지점이 어떻게 달라지는지도 같이 적었습니다. 실습 명령은 Ubuntu + containerd + kubeadm 계열 노드에서 바로 확인할 수 있는 형태로 정리했습니다.
1. CNI가 맡는 범위부터 끊어보기
Kubernetes 네트워크는 한 덩어리로 보이면 헷갈립니다. Pod IP를 붙이고 Pod까지의 길을 만드는 일, Service VIP를 실제 endpoint로 바꾸는 일, NetworkPolicy를 강제하는 일은 서로 붙어 있지만 같은 책임은 아닙니다.
| 구간 | 주요 담당 | 확인 지점 |
|---|---|---|
| Pod network namespace 생성 | container runtime | CRI sandbox, pause container, `/proc/<pid>/ns/net` |
| Pod 인터페이스 생성 | CNI plugin | `eth0`, veth peer, route, IPAM |
| 노드 내부 Pod 간 통신 | CNI datapath | bridge, host route, tc, eBPF |
| 노드 간 Pod 통신 | CNI datapath | VXLAN, IPIP, BGP route, cloud route |
| Service ClusterIP/NodePort | kube-proxy 또는 eBPF datapath | iptables, IPVS, BPF map |
| NetworkPolicy | CNI plugin 구현 | iptables/nftables, tc, eBPF policy |
공식 CNI spec 기준으로 CNI는 runtime과 plugin 사이의 실행 규약입니다. runtime은 설정을 읽고 plugin 바이너리를 실행합니다. plugin은 `ADD`, `DEL`, `CHECK`, `GC`, `VERSION` 같은 명령을 받아 네트워크를 붙이거나 정리합니다.
2. 테스트 Pod 두 개 만들기
흐름을 보려면 Pod가 어느 노드에 떠 있는지 먼저 고정하는 편이 좋습니다. 아래 예시는 `pod-a`와 `pod-b`를 서로 다른 worker에 올립니다. 같은 노드 흐름을 보고 싶으면 둘 다 같은 `nodeName`으로 배치하면 됩니다.
kubectl create namespace cni-lab
kubectl get nodes -o wide
# 노드 이름은 실습 환경에 맞게 바꾼다.
NODE_A=k8s-w1
NODE_B=k8s-w2
kubectl -n cni-lab run pod-a \
--image=nicolaka/netshoot:latest \
--overrides='{"spec":{"nodeName":"'"${NODE_A}"'","containers":[{"name":"pod-a","image":"nicolaka/netshoot:latest","command":["sleep","1d"]}]}}'
kubectl -n cni-lab run pod-b \
--image=nicolaka/netshoot:latest \
--overrides='{"spec":{"nodeName":"'"${NODE_B}"'","containers":[{"name":"pod-b","image":"nicolaka/netshoot:latest","command":["sleep","1d"]}]}}'
kubectl -n cni-lab get pods -o wide3. CNI 설정 파일과 바이너리 위치
containerd 같은 runtime은 CNI 설정 디렉터리와 바이너리 디렉터리를 봅니다. 보통 설정은 `/etc/cni/net.d`, 바이너리는 `/opt/cni/bin`에 있습니다. Kubernetes 1.24 이후 kubelet이 예전처럼 CNI를 직접 관리하는 구조로 보면 안 되고, runtime이 CNI 설정을 읽는 쪽을 먼저 확인해야 합니다.
# 모든 노드에서 확인
sudo crictl info | jq '.config.cni'
sudo ls -al /etc/cni/net.d
sudo ls -al /opt/cni/bin | sed -n '1,80p'
# CNI 설정 파일은 conflist 순서가 중요하다.
for f in /etc/cni/net.d/*; do
echo "### ${f}"
sudo cat "${f}" | jq. 2>/dev/null || sudo cat "${f}"
doneFlannel이면 `10-flannel.conflist` 안에 flannel plugin과 portmap plugin이 같이 보일 수 있습니다. Calico, Cilium, cloud CNI도 결국 runtime 입장에서는 설정 파일과 plugin 실행이라는 같은 입구를 탑니다.
4. Pod가 생성될 때 CNI ADD가 받는 정보
Pod가 스케줄되면 kubelet은 CRI를 통해 runtime에 Pod sandbox 생성을 요청합니다. runtime은 sandbox network namespace를 만든 뒤 CNI plugin을 호출합니다. 실제 plugin 호출은 대략 이런 모양입니다.
CNI_COMMAND=ADD
CNI_CONTAINERID=<pod sandbox id>
CNI_NETNS=/proc/<sandbox-pid>/ns/net
CNI_IFNAME=eth0
CNI_PATH=/opt/cni/bin
stdin:
{
"cniVersion": "1.0.0",
"name": "k8s-pod-network",
"plugins": [
{
"type": "<main-plugin>",
"ipam": { "type": "<ipam-plugin>" }
},
{
"type": "portmap",
"capabilities": { "portMappings": true }
}
]
}여기서 중요한 값은 `CNI_NETNS`와 `CNI_IFNAME`입니다. plugin은 `CNI_NETNS`가 가리키는 network namespace 안에 `eth0`를 만들거나 조정합니다. bridge plugin 기준으로는 veth pair를 만들고 한쪽을 Pod namespace로 옮긴 뒤 이름을 `eth0`로 바꿉니다.
5. Pod 안에서 보이는 인터페이스
먼저 Pod sandbox의 PID를 찾아서 해당 network namespace 안을 직접 봅니다. `kubectl exec ip addr`로 봐도 되지만, host에서 `nsenter`로 들어가면 runtime과 netns 관계까지 같이 확인할 수 있습니다.
POD=pod-a
NS=cni-lab
SANDBOX_ID=$(sudo crictl pods --namespace "${NS}" --name "${POD}" -q | head -n 1)
sudo crictl inspectp -o json "${SANDBOX_ID}" | jq '.status.metadata,.info.pid'
PID=$(sudo crictl inspectp -o json "${SANDBOX_ID}" | jq -r '.info.pid')
echo "sandbox pid=${PID}"
sudo nsenter -t "${PID}" -n ip -br addr
sudo nsenter -t "${PID}" -n ip route
sudo nsenter -t "${PID}" -n ip neighPod network namespace
lo
- Pod 안의 loopback.
- 같은 Pod 안의 여러 컨테이너가 localhost로 만나는 지점.
eth0
- CNI가 만든 veth pair의 Pod 쪽 끝.
- Pod IP가 붙는 인터페이스.
- default route는 보통 CNI gateway 또는 host 쪽 link-local next-hop을 향한다.
veth peer
- eth0의 반대쪽 끝.
- host network namespace에 남는다.
- bridge형 CNI에서는 cni0 같은 bridge에 붙고, routed형 CNI에서는 host route/tc/eBPF 처리 지점이 된다.6. Pod eth0와 host veth peer 매칭
`eth0`만 보면 절반만 본 겁니다. veth는 항상 pair로 움직입니다. Pod 안의 `eth0` 반대쪽은 host network namespace에 남아 있고, CNI 종류에 따라 bridge에 붙거나 route/eBPF 처리 대상으로 잡힙니다.
# Pod netns 안에서 eth0의 peer_ifindex를 확인한다.
PEER_IFINDEX=$(sudo nsenter -t "${PID}" -n ethtool -S eth0 | awk '/peer_ifindex/ {print $2}')
echo "peer_ifindex=${PEER_IFINDEX}"
# host netns에서 같은 ifindex를 가진 veth를 찾는다.
ip -o link | awk -F': ' -v idx="${PEER_IFINDEX}" '$1 == idx {print}'
# host 쪽 veth 상세
HOST_VETH=$(ip -o link | awk -F': ' -v idx="${PEER_IFINDEX}" '$1 == idx {print $2}' | cut -d@ -f1)
ip -d link show "${HOST_VETH}"이 매칭이 잡히면 패킷 추적이 쉬워집니다. Pod 안에서 나간 패킷은 반드시 host 쪽 veth peer로 나오기 때문입니다. 여기까지 확인하면 CNI 장애를 감으로 보는 단계에서 벗어납니다.
7. 같은 노드 Pod 간 흐름
bridge형 CNI에서는 같은 노드의 Pod끼리 통신할 때 L2 bridge가 핵심입니다. Pod A의 `eth0`에서 나온 frame은 host veth peer를 지나 `cni0` 같은 bridge로 들어가고, bridge FDB가 목적지 MAC을 보고 Pod B 쪽 veth로 내보냅니다.
Pod A app
-> Pod A eth0
-> host veth peer
-> cni0 bridge 또는 host routing path
-> Pod B host veth peer
-> Pod B eth0
-> Pod B appPOD_A_IP=$(kubectl -n cni-lab get pod pod-a -o jsonpath='{.status.podIP}')
POD_B_IP=$(kubectl -n cni-lab get pod pod-b -o jsonpath='{.status.podIP}')
echo "pod-a=${POD_A_IP} pod-b=${POD_B_IP}"
# 같은 노드에 두 Pod를 배치한 경우 bridge/FDB/ARP를 같이 본다.
ip -br link show type bridge
ip -d link show cni0 2>/dev/null || true
bridge link show
bridge fdb show br cni0 2>/dev/null | sed -n '1,80p'
kubectl -n cni-lab exec pod-a -- ping -c 3 "${POD_B_IP}"
kubectl -n cni-lab exec pod-a -- ip route get "${POD_B_IP}"
kubectl -n cni-lab exec pod-a -- ip neighCalico의 기본 routed datapath처럼 bridge를 중심으로 움직이지 않는 CNI도 있습니다. 그 경우에는 host routing table, `cali*` veth, iptables 또는 eBPF hook 지점이 더 중요합니다. 같은 Kubernetes Pod-to-Pod 통신이라도 Linux에서 보는 장면은 CNI별로 달라집니다.
8. 다른 노드 Pod 간 흐름: Flannel VXLAN
Flannel VXLAN에서는 노드마다 PodCIDR 일부가 배정되고, 원격 PodCIDR로 가는 길이 `flannel.1` VXLAN device를 향합니다. 내부 패킷의 source/destination은 Pod IP 그대로 유지되고, 노드 사이 underlay 구간에서만 Node IP를 source/destination으로 하는 VXLAN 패킷으로 감쌉니다.
Pod A app
-> Pod A eth0
-> host veth peer
-> cni0
-> node A routing table
-> flannel.1 VXLAN device
-> outer UDP packet: nodeA_IP:random -> nodeB_IP:8472
-> physical NIC
-> underlay network
-> node B physical NIC
-> flannel.1 VXLAN decapsulation
-> node B routing table
-> cni0
-> Pod B host veth peer
-> Pod B eth0
-> Pod B app
inner packet:
src=PodA_IP, dst=PodB_IP
outer packet:
src=NodeA_IP, dst=NodeB_IP, udp dst port=8472, vxlan vni=<flannel vni># Pod가 서로 다른 노드에 있을 때, source Pod가 있는 노드에서 확인
POD_B_IP=$(kubectl -n cni-lab get pod pod-b -o jsonpath='{.status.podIP}')
ip route get "${POD_B_IP}"
ip route | egrep '10\.244|flannel|cni0'
ip -d link show flannel.1
bridge fdb show dev flannel.1 2>/dev/null | sed -n '1,80p'
kubectl -n kube-flannel get pods -o wide
kubectl -n kube-flannel get cm kube-flannel-cfg -o yaml
kubectl -n kube-flannel logs -l app=flannel --tail=120이 구조에서 내부 Pod IP가 바뀌지 않는다는 점이 중요합니다. Kubernetes의 기본 Pod 네트워크 모델은 Pod끼리 직접 통신할 수 있어야 하고, 클러스터 내부 Pod-to-Pod 경로에서 NAT에 기대지 않는 방향으로 설계됩니다. 다만 외부 egress, Service, NodePort 같은 구간에서는 SNAT/DNAT가 별도로 개입할 수 있습니다.
9. tcpdump는 어디에 걸어야 하나
패킷 캡처는 한 지점만 보면 판단이 자주 빗나갑니다. Pod 안, host veth, bridge, overlay interface, 물리 NIC를 순서대로 보면 어느 지점에서 사라지는지 바로 갈립니다.
# terminal 1: Pod 안에서 ICMP 발생
kubectl -n cni-lab exec pod-a -- ping -c 5 "${POD_B_IP}"
# terminal 2: Pod A가 있는 노드의 cni0
sudo tcpdump -ni cni0 host "${POD_B_IP}" and icmp
# terminal 3: Pod A가 있는 노드의 flannel.1
sudo tcpdump -ni flannel.1 host "${POD_B_IP}" and icmp
# terminal 4: Pod A가 있는 노드의 물리 NIC
UNDERLAY_IF=$(ip route get "$(kubectl get node "${NODE_B}" -o jsonpath='{.status.addresses[?(@.type=="InternalIP")].address}')" | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1); exit}')
echo "${UNDERLAY_IF}"
sudo tcpdump -ni "${UNDERLAY_IF}" udp port 8472`cni0`에서는 inner Pod IP가 보이고, 물리 NIC에서는 VXLAN outer packet이 보입니다. 물리 NIC에서 UDP 8472가 안 보이면 라우팅이나 overlay 장치 이전에서 막힌 겁니다. 물리 NIC에는 보이는데 상대 노드에서 decapsulation 이후가 안 보이면 방화벽, MTU, FDB, flannel 상태를 봐야 합니다.
10. Calico에서는 장면이 어떻게 달라지나
Calico는 배포 방식에 따라 BGP 라우팅, IPIP, VXLAN, eBPF dataplane을 쓸 수 있습니다. bridge 중심으로만 찾으면 놓칩니다. host에 `cali*` veth가 생기고, 원격 PodCIDR은 BGP route나 tunnel device로 빠지는 형태가 흔합니다.
# Calico 계열에서 자주 보는 지점
ip -br link | egrep 'cali|tunl0|vxlan.calico|wireguard|bird|bpfin'
ip route | egrep 'cali|tunl0|vxlan.calico|proto bird|blackhole'
sudo calicoctl node status 2>/dev/null || true
sudo iptables-save | egrep 'cali-|KUBE-' | sed -n '1,120p'Calico에서 봐야 할 건 `cali*` 인터페이스, PodCIDR route, BGP session, tunnel device, policy chain입니다. Pod 인터페이스까지 붙었는데 통신만 막히면 NetworkPolicy나 host endpoint policy까지 같이 봐야 합니다.
11. Cilium에서는 장면이 어떻게 달라지나
Cilium은 eBPF dataplane을 중심으로 동작합니다. host에 `lxc*`, `cilium_host`, `cilium_net`, `cilium_vxlan` 같은 인터페이스가 보일 수 있고, Service load balancing과 NetworkPolicy도 BPF map과 program에서 처리될 수 있습니다.
# Cilium 계열에서 자주 보는 지점
ip -br link | egrep 'cilium|lxc|vxlan'
cilium status
cilium endpoint list
cilium bpf endpoint list
cilium service list
tc filter show dev cilium_host ingress 2>/dev/null || true
tc filter show dev cilium_host egress 2>/dev/null || trueCilium을 볼 때는 iptables만 뒤지면 반쪽만 보는 경우가 많습니다. endpoint list, BPF map, `tc filter`, Hubble flow를 같이 봐야 실제 판단이 됩니다.
12. Service 트래픽은 CNI만의 일이 아니다
Pod IP로 직접 통신하는 것과 ClusterIP Service로 통신하는 것은 다른 문제입니다. Pod IP까지의 L3 경로는 CNI가 만들지만, Service VIP를 endpoint Pod IP로 바꾸는 일은 kube-proxy iptables/IPVS 또는 CNI eBPF datapath가 담당합니다.
Pod -> ClusterIP Service
Pod app
-> Pod eth0
-> host veth peer
-> Service VIP match
-> kube-proxy iptables/IPVS DNAT 또는 CNI eBPF service load balancing
-> selected Endpoint Pod IP
-> local Pod path or remote Pod path
Service는 CNI 그 자체가 아니다.
CNI는 Pod IP까지의 길을 만들고, Service VIP 변환은 kube-proxy 또는 eBPF dataplane이 담당한다.kubectl -n cni-lab create deployment web --image=nginx:1.27
kubectl -n cni-lab scale deployment web --replicas=2
kubectl -n cni-lab expose deployment web --port=80
kubectl -n cni-lab get pod,svc,endpointslice -o wide
SVC_IP=$(kubectl -n cni-lab get svc web -o jsonpath='{.spec.clusterIP}')
kubectl -n cni-lab exec pod-a -- curl -I --max-time 3 "http://${SVC_IP}"
# kube-proxy iptables mode 확인
sudo iptables-save | egrep "KUBE-SVC|KUBE-SEP|${SVC_IP}" | sed -n '1,120p'
# IPVS mode일 경우
sudo ipvsadm -Ln 2>/dev/null | sed -n '1,120p' || true그래서 `PodIP -> PodIP`는 되는데 `ServiceIP -> Pod`가 안 되면 CNI만 볼 일이 아닙니다. endpoint가 있는지, kube-proxy가 정상인지, iptables/IPVS 규칙이 잡혔는지, eBPF CNI라면 service map이 정상인지 분리해서 봐야 합니다.
13. chained plugin: portmap, bandwidth, tuning
CNI conflist는 plugin을 여러 개 이어서 실행할 수 있습니다. main plugin이 인터페이스와 IP를 만들고, 뒤에 portmap, bandwidth, tuning 같은 plugin이 추가 작업을 합니다. Kubernetes 공식 문서에서도 `hostPort`는 portmap plugin, ingress/egress bandwidth annotation은 bandwidth plugin과 연결해서 설명합니다.
장애 분석 때 main CNI만 보면 `hostPort`나 bandwidth 제한 문제를 놓칠 수 있습니다. conflist의 plugin 순서, capability 설정, `/opt/cni/bin`에 실제 바이너리가 있는지를 같이 봐야 합니다.
14. MTU 문제는 overlay에서 자주 터진다
VXLAN은 원래 패킷을 한 번 더 감싸기 때문에 outer header만큼 MTU 여유가 필요합니다. 작은 ping은 되는데 큰 응답이나 TLS handshake에서 이상해지면 MTU를 의심해야 합니다.
# Pod, bridge, VXLAN, 물리 NIC MTU를 같이 본다.
sudo nsenter -t "${PID}" -n ip link show eth0
ip link show cni0 2>/dev/null || true
ip link show flannel.1 2>/dev/null || true
ip link show "${UNDERLAY_IF}"
# DF bit를 세우고 실제 payload 크기를 줄여가며 확인한다.
kubectl -n cni-lab exec pod-a -- ping -M do -s 1372 -c 2 "${POD_B_IP}"
kubectl -n cni-lab exec pod-a -- ping -M do -s 1472 -c 2 "${POD_B_IP}" || trueFlannel VXLAN이면 Pod eth0나 cni0 MTU가 물리 NIC보다 작게 잡히는 게 일반적입니다. CNI가 자동 조정해도 cloud underlay, VPN, nested virtualization이 섞이면 실제 경로 MTU가 더 작을 수 있습니다.
15. 장애 위치를 빠르게 가르는 순서
| 증상 | 먼저 볼 위치 | 판단 기준 |
|---|---|---|
| Node가 NotReady | kubelet log, CNI config | `NetworkPluginNotReady`, 설정 파일 없음, plugin binary 없음 |
| Pod가 ContainerCreating | `describe pod`, CRI sandbox | CNI ADD 실패, IPAM 실패, image pull과 구분 |
| Pod IP는 있는데 통신 불가 | Pod route, host veth, bridge/route | veth peer, route, FDB, ARP 확인 |
| 같은 노드는 되고 다른 노드는 안 됨 | overlay/BGP/cloud route | flannel.1, UDP 8472, BGP session, node firewall |
| PodIP는 되는데 ServiceIP가 안 됨 | kube-proxy/eBPF service path | endpoint, iptables/IPVS/BPF map |
| 간헐적 timeout | MTU, conntrack, policy | DF ping, conntrack table, NetworkPolicy |
# kubelet이 CNI를 못 찾는 경우
sudo journalctl -u kubelet -n 200 --no-pager | egrep -i 'cni|network|notready|plugin'
sudo crictl info | jq '.config.cni'
sudo ls -al /etc/cni/net.d /opt/cni/bin
# Pod sandbox 생성 실패
kubectl -n cni-lab describe pod pod-a
sudo crictl pods --namespace cni-lab
sudo crictl inspectp "${SANDBOX_ID}" | jq '.status,.info.runtimeSpec.linux.namespaces'
# IPAM 할당 흔적
sudo find /var/lib/cni -maxdepth 4 -type f | sort | sed -n '1,120p'
# 라우팅과 reverse path filtering
ip rule
ip route table main
sysctl net.ipv4.conf.all.rp_filter
sysctl net.ipv4.conf.default.rp_filter16. 머릿속에 남겨둘 최종 흐름
CNI를 볼 때는 항상 세 층으로 나누면 덜 헷갈립니다. 첫째, Pod namespace 안의 `lo`, `eth0`, route. 둘째, host namespace의 veth peer, bridge 또는 route/eBPF hook. 셋째, 노드 간 전송을 담당하는 VXLAN, IPIP, BGP, cloud route입니다.
Flannel VXLAN 기준으로는 `Pod eth0 -> host veth -> cni0 -> flannel.1 -> physical NIC -> 상대 node flannel.1 -> cni0 -> 상대 Pod veth -> 상대 Pod eth0` 흐름을 외워두면 됩니다. Calico나 Cilium으로 바뀌어도 앞뒤 개념은 같습니다. 달라지는 건 중간 datapath입니다.
실습 정리
테스트 namespace는 지우고 마무리합니다. CNI 상태 디렉터리는 장애 분석 자료가 될 수 있으니 운영 환경에서는 함부로 지우지 않습니다.
kubectl delete namespace cni-lab
# 실습 VM에서만 사용한다. 운영 노드에서 임의 삭제 금지.
sudo find /var/lib/cni -maxdepth 4 -type f | sort | sed -n '1,120p'