"Everything about infra" 2026. 3. 23. 00:19
포스팅은 CloudNet@팀 서종호(Gasida)님이 진행하시는
AWS EKS Workshop Study 내용을 참고하여 작성합니다.

 

안녕하세요!

 

오늘은 VPC CNI에 대해 소개하고,

각 플러그인에 대한 차이점에 대해서 알아보도록 하겠습니다.

 

01. 오버레이(VXLAN) vs 언더레이(BGP / Calico)

1주차에서 EKS와 vanilla k8s에 대해서 소개하면서, 잠깐 데이터플레인 CNI쪽을 다뤄보았었습니다.

CNI가 해결해야 문제 3가지 정도를 아래와 같이 정리해보았습니다.

  • Pod는 NAT 없이 다른 모든 Pod와 통신할 수 있어야 한다.
  • 노드는 NAT 없이 모든 Pod와 통신할 수 있어야 한다.
  • Pod가 자신의 IP를 인식할 때 다른 Pod가 보는 IP와 동일해야 한다

이 3가지 문제를 어떻게 구현하느냐가 CNI마다 다르며,

"다른 노드에 있는 Pod끼리 어떻게 통신하게 만들 것인가?" 이걸 해결하는 방식이 크게 아래와 같이 나뉩니다.

 

1) Flannel (오버레이 방식)

오버레이 방식의 최대 장점은 캡슐화로 인해 Pod IP를 숨겨서 보내 통신이 가능하다는 점입니다.

각 노드마다 flannel.1 이라는 가상 네트워크 인터페이스를 만들고, 다른 노드로 가는 패킷을 UDP로 캡슐화해서 전송합니다.
수신 측 노드의 flannel.1 이 캡슐을 벗겨서 목적지 Pod에 전달하며 구조적으로 물리 네트워크가 Pod IP를 몰라도 된다.
온프렘이든 클라우드든 VM이든 어떤 환경에서도 적용 가능하며, 물리 네트워크 설정을 건드릴 필요가 없다.

위와 같은 장점이 있지만, 주관적으로는 단점이 너무 명확한 것 같습니다.

첫째로 NetworkPolicy를 아예 구현하지 않는다.
Flannel 단독으로는 Pod 간 트래픽을 제어할 방법이 없고, NetworkPolicy가 필요하면 Calico를 network policy 엔진으로만
붙이는 조합(Canal)을 쓰거나 CNI 자체를 교체해야 한다는 단점이 있습니다.

둘째로, 캡슐화로 인한 성능 오버헤드가 있다. 
패킷마다 캡슐화·해제가 발생하고, MTU도 줄어든다(기본 1500 → 1450). 대역폭이 높은 워크로드에서는 바로 체감되는 수준.

셋째로 디버깅이 어렵다.
실제 패킷이 캡슐화된 채로 흘러다니기 때문에 tcpdump로 들여다봐도 Pod 레벨 트래픽이 잘 안 보입니다.

이러한 장단점을 비교해봤을 때 현업에서는 Flannel을 잘 안쓸 것 같네요.

(단순 구성, Dev/Stg 환경이라면 괜찮을 듯 합니다)

 

2) Calico (언더레이 방식)

Calico는 기본적으로 BGP(Border Gateway Protocol)로 Pod 라우팅을 처리하며 동작 방식은

  • 각 노드에 BIRD라는 BGP 데몬이 올라가고 이 데몬들이 서로 BGP 피어(Peer)를 맺는다.
  • "나는 이 Pod CIDR을 가지고 있다"를 Peer간 광고한다.
  • 다른 노드는 이 정보를 받아서 자신의 라우팅 테이블에 직접 경로를 추가

결과적으로 오버레이 방식과 다르게, 패킷이 캡슐화 없이 목적지 Pod까지 직접 라우팅된다.

네트워크 엔지니어였던 나로써는 BGP는 굉장히 대규모 프로덕션 환경에서 사용하고 있다는 것을 알고 있었습니다.

그렇다면 Calico는 대규모 환경에서만 사용하는가? 라고 생각했는데.. 아래와 같은 결론을 내렸습니다.

BGP 자체가 대규모 라우팅에 강한 프로토콜인 건 맞다.
그런데 Calico에서 BGP를 쓰는 이유는 대규모 때문이 아니라, "물리 네트워크 라우터까지 BGP로 Pod 경로를 광고해서
캡슐화 없이 라우팅하겠다"는 설계 때문이구나 라고 느꼈고, 소규모 클러스터에서도 잘 사용될 것 같습니다.

하지만 역시 아래와 같은 단점? 까다로운 점?이 존재합니다.

BGP가 물리 네트워크의 협력이 필요하다는 점입니다.
노드들이 직접 라우팅하려면 물리 스위치/라우터가 Pod CIDR 경로를 알아야 합니다 (피어 간 광고를 하여 라우팅 하는 방식이므로)
관리형 클라우드 환경(AWS VPC 등)에서는 VPC 라우터에 BGP를 붙이기 어렵기 때문에,
이 경우 Calico는 자동으로 VXLAN 모드로 폴백한다. 즉 Calico도 환경에 따라 오버레이를 쓴다.

 

3) Cilium (eBPF 방식)

일단, 전통적인 방식과 eBPF 방식이 무엇인지 설명 먼저 드리도록 하겠습니다.

 

📌 전통적인 방식(Flannel / Calico)

  • 전통적인 네트워킹은 패킷이 커널의 네트워크 스택 전체를 통과합니다.
  • TCP/IP 스택 → netfilter(iptables) → 소켓 → 애플리케이션 순서로 올라간다.

그렇다면 Calico나 Flannel은 결국 iptables 규칙을 조작해서 네트워크 정책과 라우팅을 구현하네요.

 

📌 eBPF (extended Berkeley Packet Filter)

  • 커널 코드를 재컴파일 없이 안전하게 주입할 수 있는 메커니즘
  • Cilium은 이걸 이용해서 패킷이 커널 네트워크 스택에 진입하는 가장 이른 시점에서 처리합니다.
  • iptables를 완전히 우회하고, XDP(eXpress Data Path) 레벨에서 패킷을 처리하여 성능이 근본적으로 다르다.

iptables의 근본적인 문제는 선형 탐색이다.
노드에 Pod가 많아질수록 iptables 규칙이 폭발적으로 늘어나며, 노드 하나에 Pod 100개면 규칙이 수천 개,
서비스까지 포함하면 수만 개가 됩니다. 추가로 패킷마다 이 규칙을 순서대로 순회하게 되겠죠.
Cilium은 eBPF 해시 맵으로 O(1) 조회를 하기 때문에 Pod 수가 늘어도 레이턴시가 선형으로 증가하지 않는다.

Calico나 Flannel의 NetworkPolicy는 IP와 포트(L3/L4)까지만 제어하는 반면 Cilium은 L7 네트워크 정책입니다.

  • HTTP 경로(/api/admin을 특정 서비스만 호출 가능),gRPC 메서드, Kafka 토픽 단위로 정책을 걸 수 있다.
  • 서비스 메시 없이 사이드카 없이 이게 가능하다.

하지만 위 장점들과 다르게 단점 역시 명확합니다.

커널 버전 요구사항이 높다.
기본 기능은 4.9 이상이면 되지만, XDP acceleration이나 Host Networking 등 고급 기능은 5.10 이상을 요구합니다.
오래된 온프렘 환경에서는 커널 업그레이드부터 해야 할 수도 있고, 기술력이 많이 필요한 만큼 운영 복잡도가 높다.
eBPF 프로그램이 잘못 로드되면 디버깅이 어렵고, 이슈 발생 시 커뮤니티 도움 없이는 근본 원인 파악이 힘들다고 합니다.

 


02. AWS VPC-CNI

챕터 1의 세 CNI는 전부 "노드 IP 위에 Pod 네트워크를 어떻게 올릴 것인가"를 고민하네요.

AWS사의 vpc-cni는 문제 자체를 다르게 정의합니다.

 

각 EC2 노드는 여러 개의 ENI(Elastic Network Interface)를 붙일 수 있고, ENI마다 여러 개의 Secondary IP를 가질 수 있다.

  • vpc-cni는 이 Secondary IP를 Pod에 직접 할당한다
  • Pod가 뜨면 ENI의 Secondary IP 중 하나가 그 Pod의 IP가 된다.

위 구성이 vpc-cni의 아키텍처 인데, 이렇게 된다면 오버레이 방식이 필요가 없게됩니다.

Pod IP = VPC IP이기 때문에 VPC 라우팅 테이블이 Pod까지의 경로를 이미 알고 있다.
RDS, ElastiCache, Lambda에서 Pod IP로 직접 접근 가능하고, Security Group을 Pod 단위로 적용할 수 있다.

하지만 단점 역시 아래와 같이 명확합니다.

EC2 인스턴스 타입마다 붙일 수 있는 ENI 수와 ENI당 IP 수가 제한되어서 IP 고갈문제는 현업에서 자주 만나는 이슈입니다.

 

최대 파드 생성 갯수는 아래와 같은 공식을 활용합니다.

(Number of network interfaces for the instance type × (the number of IP addressess per network interface - 1)) + 2

최초 네트워킹 구성을 할 때 서비스 해야할 파드 수를 고려하여 잘 구성을 하게 된다면

aws사의 vpc-cni 방식 (EKS 활용)으로 네트워크 구성하는 것이 가장 바람직한 방법이라고 생각됩니다.

 

최종적으로 비교를 해보자면 아래와 같습니다.

 


03. vpc-cni 동작 방식 실습

📌 Network-Multitool 디플로이먼트 생성

cat <<EOF | kubectl apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: netshoot-pod
spec:
  replicas: 3
  selector:
    matchLabels:
      app: netshoot-pod
  template:
    metadata:
      labels:
        app: netshoot-pod
    spec:
      containers:
      - name: netshoot-pod
        image: praqma/network-multitool
        ports:
        - containerPort: 80
        - containerPort: 443
        env:
        - name: HTTP_PORT
          value: "80"
        - name: HTTPS_PORT
          value: "443"
      terminationGracePeriodSeconds: 0
EOF

 

파드 내부 정보 확인

현재 'netshoot' 이라는 deployment를 생성하였고, 각 다른 노드에 pods들이 생성된 것을 확인할 수 있다.

이 상태에서 '192.168.3.254' IP를 가진 노드에 터미널로 접속하여 들어왔고, Pods에 들어가서 정보를 확인해보자.

기본 게이트웨이가 169.254.1.1 입니다.
AWS link-local 대역인데, Pod는 모든 트래픽을 이 가상 게이트웨이로 보내고, 노드의 veth 인터페이스가 받아서 처리하는 구조
Flannel이나 Calico였으면 게이트웨이가 실제 노드 IP나 브리지 IP였겠죠?!

서브넷 마스크가 /32 입니다.
Pod 입장에선 자기 자신만 있는 네트워크고, 나머지는 전부 169.254.1.1 게이트웨이를 통해 나가겠네요.
다른 CNI는 보통 /24같은 서브넷이 붙는다고 합니다.

10.100.0.10이 CoreDNS ClusterIP 입니다.
이 CoreDNS에 대한 동작 흐름은 바로 뒤에서 설명드리도록 하겠습니다.

파드1에서 파드2로 icmp 테스트 및 tcp dump 결과 확인

출발지 IP가 192.168.2.206 그대로입니다. 노드 IP(192.168.3.254)로 바뀌지 않았고, NAT 없는 직접 통신 증명 완료.
eni16500444173   In     192.168.2.206 > 192.168.9.116 echo request
ens6                       Out   192.168.2.206 > 192.168.9.116 echo request '노드 밖으로 나감'
ens6                        In     192.168.9.116 > 192.168.2.206 echo reply '응답 들어옴'
eni16500444173  Out    192.168.9.116 > 192.168.2.206 echo reply 'Pod로 전달'

 


 

파드 외부 통신 테스트

노드에서 tcp dump를 띄워놓고, pod에서 외부로 ping test를 해보았습니다.

eni16500444173 In   192.168.2.206 > 142.251.155.119  ← Pod IP에서 출발지
ens5           Out  192.168.3.254 > 142.251.155.119  ← 노드 IP로 바뀌어서 나감 ← SNAT!
ens5           In   142.251.155.119 > 192.168.3.254  ← 노드 IP로 응답 들어옴
eni16500444173 Out  142.251.155.119 > 192.168.2.206  ← Pod IP로 복원돼서 전달

즉, Pods에서 외부로 나갈 때 출발지가 Node IP로 변경된 것을 확인할 수 있습니다. (SNAT 동작)

 


04. CoreDNS 동작 방식

왜 내부 DNS가 필요할까? Pods IP는 Pod가 죽고 다시 뜨면 바뀝니다.

192.168.2.206이었던 Pod가 재시작하면 192.168.2.180이 되고, IP로 직접 통신하면 매번 IP를 다시 찾아야 한다.

그래서 K8s는 서비스 이름으로 통신하게 설계했고, 이름 → IP 변환을 CoreDNS가 담당한다.

 

📌 CoreDNS 란?

  • K8s 클러스터 안에서 돌아가는 내부 DNS 서버
  • kube-system 네임스페이스에 Deployment로 떠있고, ClusterIP 10.100.0.10으로 고정된 주소를 가진다.

모든 Pod의 `/etc/resolv.conf`에 `nameserver 10.100.0.10`이 박혀있는 이유는
Pod가 DNS 조회를 하면 무조건 CoreDNS로 갑니다.

---

## FQDN 구조 이해

K8s 내부 도메인은 아래와 같은 구조를 가집니다.
```
<서비스명>.<네임스페이스>.svc.<클러스터도메인>

mario.default.svc.cluster.local
  │      │     │       │
  │      │     │       └── 클러스터 도메인 (기본값 cluster.local)
  │      │     └────────── 항상 svc로 고정
  │      └──────────────── 네임스페이스
  └─────────────────────── 서비스 이름

CoreDNS는 이 쿼리를 받으면 해당 서비스의 ClusterIP를 돌려줍니다.

 

실제 통신 흐름을 도식화 한다면? 아래와 같습니다.

여기서 'search' 도메인이 하는 일이 뭘까?

Pod 안에서 curl http://mario 라고 짧게 써도 되는 이유가 search 도메인 때문입니다.
DNS 라이브러리가 자동으로 뒤에 붙여서 조회한다.
"mario" 조회 시 순서
─────────────────────────────────────────────
1. mario.default.svc.cluster.local  → 히트! ClusterIP 반환
   (같은 네임스페이스면 여기서 끝)

다른 네임스페이스 서비스 조회 시
─────────────────────────────────────────────
"mario-svc.production" 조회
1. mario-svc.production.default.svc.cluster.local → 미스
2. mario-svc.production.svc.cluster.local         → 미스
3. mario-svc.production.cluster.local             → 히트!

그래서 같은 네임스페이스면 서비스 이름만으로, 다른 네임스페이스면 서비스명.네임스페이스로 호출하면 된다.

(FQDN 전체를 다 쓸 필요 없음)

 


 

긴 글 읽어주셔서 감사합니다.

 

다음은 EKS 관련된 서비스에 대해서 포스팅 해보도록 하겠습니다.

감사합니다.