Skip to content

필사 모드: kube-proxy 없는 쿠버네티스 — 3노드 홈랩을 kubeadm 1.34 + Cilium eBPF로 재구축하고 실측으로 증명하기

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

사건의 시작 — DHCP가 클러스터를 죽였다

홈랩 쿠버네티스가 어느 날 응답하지 않았습니다. 증상은 이랬습니다.

E0818 15:15:12 memcache.go:265] "Unhandled Error" err="couldn't get current server
API group list: Get \"https://10.0.0.111:6443/api?timeout=32s\":
dial tcp 10.0.0.111:6443: connect: no route to host"

진단은 5분 만에 끝났습니다.

$ ip -4 addr show enp3s0 | grep inet
    inet 10.0.0.120/24 metric 100 brd 10.0.0.255 scope global dynamic enp3s0

컨트롤 플레인 노드의 IP가 .111에서 .120으로 바뀌어 있었습니다. scope global dynamic — DHCP 임대였고, 갱신 과정에서 다른 주소를 받은 것입니다.

결정적인 증거는 인증서에 있었습니다.

$ openssl x509 -in /etc/kubernetes/pki/apiserver.crt -noout -text | grep -A2 "Subject Alternative Name"
    X509v3 Subject Alternative Name:
        DNS:cp-1, DNS:kubernetes, ..., IP Address:10.96.0.1, IP Address:10.0.0.111

SAN에 .120이 없습니다. etcd와 kube-apiserver는 존재하지 않는 .111에 바인드하려다 실패해 CrashLoopBackOff에 빠졌고, kube-scheduler와 controller-manager는 127.0.0.1에 바인드하므로 프로세스는 살아 있되 아무 일도 못 하는 상태였습니다.

IP를 되돌려 복구할 수도 있었지만, 이 기회에 런타임을 cri-dockerd에서 containerd로 옮기고 CNI를 Cilium으로 바꿔 처음부터 다시 세우기로 했습니다.

재구축 설계

항목이전이후
쿠버네티스v1.32.5v1.34.10
컨트롤 플레인 IP.111 (DHCP).120 (static)
런타임cri-dockerdcontainerd 1.7.27
CNICalicoCilium 1.20.1
kube-proxy있음없음 (eBPF 대체)

노드는 세 대입니다. cp-1이 컨트롤 플레인, gpu-a과 gpu-b가 워커이고 각각 RTX 3090과 5090을 갖고 있습니다. 커널은 전부 6.14로, eBPF 기능을 쓰기에 충분히 최신입니다.

kube-proxy를 설치하지 않는다

이번 재구축의 핵심입니다. kubeadm init에 한 줄을 추가합니다.

kubeadm init \
  --apiserver-advertise-address=10.0.0.120 \
  --control-plane-endpoint=10.0.0.120 \
  --pod-network-cidr=10.244.0.0/16 \
  --cri-socket=unix:///run/containerd/containerd.sock \
  --skip-phases=addon/kube-proxy

--skip-phases=addon/kube-proxy 는 kube-proxy DaemonSet을 아예 만들지 않습니다. 나중에 지우는 것과는 다릅니다. 지우는 방식은 이미 노드에 새겨진 iptables 규칙이 남지만, 처음부터 설치하지 않으면 그 규칙이 생기지도 않습니다.

이 상태에서는 CNI를 올리기 전까지 서비스가 동작하지 않습니다. Cilium이 그 역할을 넘겨받습니다.

Cilium 설치 — 무엇을 켜는가

helm install cilium cilium/cilium --version 1.20.1 \
  --namespace kube-system \
  --set ipam.mode=kubernetes \
  --set kubeProxyReplacement=true \
  --set k8sServiceHost=10.0.0.120 \
  --set k8sServicePort=6443 \
  --set bpf.masquerade=true \
  --set l7Proxy=true \
  --set bandwidthManager.enabled=true \
  --set bandwidthManager.bbr=true \
  --set hostFirewall.enabled=true \
  --set encryption.enabled=true \
  --set encryption.type=wireguard \
  --set hubble.enabled=true \
  --set hubble.relay.enabled=true \
  --set hubble.ui.enabled=true

각 플래그가 무엇을 바꾸는지 정리하면 이렇습니다.

kubeProxyReplacement=true — 서비스 로드밸런싱을 eBPF 맵으로 처리합니다. iptables 방식은 서비스가 늘어날수록 규칙 체인이 선형으로 길어지지만, eBPF는 해시맵 조회라 서비스 수와 무관하게 일정합니다.

k8sServiceHostk8sServicePort를 함께 주는 이유가 있습니다. kube-proxy가 없으면 Cilium 자신이 API 서버에 접속할 때 kubernetes 서비스 VIP를 쓸 수 없습니다 — 그 VIP를 만들어 줄 주체가 Cilium 자신이기 때문입니다. 그래서 실제 주소를 직접 알려줘야 합니다.

bpf.masquerade=true — SNAT도 iptables가 아닌 eBPF로 처리합니다.

l7Proxy=true — HTTP 메서드와 경로 단위 정책을 가능하게 합니다. 뒤에서 실측합니다.

hostFirewall=true — 파드뿐 아니라 노드 자체에도 네트워크 정책을 적용합니다.

encryption.type=wireguard — 노드 간 파드 트래픽을 투명하게 암호화합니다.

검증 1 — kube-proxy가 정말 없는가

주장만으로는 부족합니다. 실제로 확인했습니다.

kube-proxy 파드 수: 0
노드의 iptables KUBE- 체인 개수: 0

kube-proxy를 쓰는 클러스터라면 KUBE-SERVICES, KUBE-SVC-*, KUBE-SEP-* 체인이 서비스 수에 비례해 수십에서 수백 개 존재합니다. 0개입니다.

그러면 서비스는 어떻게 동작할까요. Cilium이 관리하는 서비스 목록입니다.

ID   Frontend                Service Type   Backend
4    10.96.0.10:53/TCP       ClusterIP      1 => 10.244.0.150:53/TCP (active)
                                            2 => 10.244.0.172:53/TCP (active)
7    10.96.0.1:443/TCP       ClusterIP      1 => 10.0.0.120:6443/TCP (active)

한 단계 더 내려가 eBPF 맵 자체를 덤프하면 이렇습니다.

SERVICE ADDRESS             BACKEND ADDRESS (REVNAT_ID) (SLOT)
10.96.0.10:53/TCP (1)       10.244.0.150:53/TCP (4) (1)
10.96.0.10:53/UDP (2)       10.244.0.172:53/UDP (5) (2)
10.96.0.1:443/TCP (1)       10.0.0.120:6443/TCP (1) (1)

커널 안의 해시맵에 프론트엔드와 백엔드가 직접 들어 있습니다. 패킷이 커널 네트워크 스택을 타고 올라오기 전에 eBPF 프로그램이 이 맵을 조회해 목적지를 바꿉니다.

데이터패스 상태도 확인했습니다.

KubeProxyReplacement:   True      [enp2s0  10.0.0.117 (Direct Routing)]
Routing:                Network: Tunnel [vxlan]   Host: BPF
Masquerading:           BPF   [enp2s0]   10.244.2.0/24
Host firewall:          Enabled   [enp2s0]
BandwidthManager:       EDT with BPF [BBR] [enp2s0]
Encryption:             Wireguard [cilium_wg0 (Port: 51871, Peers: 2)]
Cilium:                 Ok   1.20.1
Modules Health:         Stopped(0) Degraded(0) OK(92)

Host: BPF, Masquerading: BPF — 호스트 라우팅과 SNAT까지 eBPF입니다. WireGuard의 Peers: 2는 워커 두 대와 암호화 터널이 맺어졌다는 뜻입니다.

검증 2 — L7 정책, iptables로는 불가능한 것

여기가 eBPF의 진가입니다. 같은 IP와 같은 포트인데 HTTP 메서드로 트래픽을 가르는 것은 iptables로 할 수 없습니다. L3/L4 계층에서는 메서드가 보이지 않기 때문입니다.

nginx를 띄우고 정책 없이 먼저 호출했습니다.

GET  /200
POST /200

이제 GET만 허용하는 정책을 겁니다.

apiVersion: cilium.io/v2
kind: CiliumNetworkPolicy
metadata: { name: l7-get-only }
spec:
  endpointSelector:
    matchLabels: { app: api }
  ingress:
    - fromEndpoints:
        - matchLabels: { app: client }
      toPorts:
        - ports: [{ port: "80", protocol: TCP }]
          rules:
            http:
              - method: "GET"
                path: "/"

적용 후 결과입니다.

GET  /200
POST /403

같은 목적지인데 메서드로 갈렸습니다. 파드에 사이드카를 주입하지 않았고, 애플리케이션 코드도 그대로입니다.

검증 3 — Hubble, 사이드카 없는 L7 관측

서비스 메시를 쓰면 파드마다 Envoy 사이드카가 붙습니다. Cilium은 eBPF로 커널에서 직접 관측하므로 그럴 필요가 없습니다.

07:50:38.064: ebpf-demo/client:47918 -> ebpf-demo/api:80  http-request  FORWARDED (HTTP/1.1 GET  http://api/)
07:50:38.065: ebpf-demo/client:47918 <- ebpf-demo/api:80  http-response FORWARDED (HTTP/1.1 200 2ms GET)
07:50:38.223: ebpf-demo/client:47932 -> ebpf-demo/api:80  http-request  DROPPED   (HTTP/1.1 POST http://api/)
07:50:38.223: ebpf-demo/client:47932 <- ebpf-demo/api:80  http-response FORWARDED (HTTP/1.1 403 0ms POST)

메서드, 경로, 응답 코드, 지연 시간이 전부 보이고 정책에 의한 DROPPED가 명시됩니다. 어떤 정책이 왜 막았는지 추적할 수 있습니다.

클러스터 기본 동작 검증

eBPF 기능만 확인하고 끝낼 수는 없습니다. 클러스터로서 제대로 동작하는지도 항목별로 점검했습니다.

── 1. 노드 상태
  ✅ cp-1 Ready   ✅ gpu-a Ready   ✅ gpu-b Ready

── 2. 컨트롤 플레인 구성요소
  ✅ etcd  ✅ kube-apiserver  ✅ kube-controller-manager  ✅ kube-scheduler

── 3. CNI (cilium)
  ✅ cilium 전 노드 기동 (3/3)   ✅ cilium-operator Ready

── 4. CoreDNS
CoreDNS 2Ready

── 5. 워커 스케줄링 + 파드간 통신
  ✅ nginx 2 레플리카 배포 성공
    web-79b9668c96-qlfmw → gpu-b
    web-79b9668c96-wdn6q → gpu-a
  ✅ 워커 노드에 스케줄됨 (2개 노드)

── 6. 서비스 DNS + 파드→서비스 연결
  ✅ 서비스 DNS 해석 + HTTP 200 (파드 → Service → 파드)
  ✅ 클러스터 DNS 조회 정상

════════ 결과: 통과 14 / 실패 0 ════════

레플리카가 두 워커에 분산됐고, 파드에서 서비스 이름으로 접속해 200을 받았습니다. kube-proxy 없이도 서비스 디스커버리와 로드밸런싱이 정상 동작한다는 뜻입니다.

재발 방지 — IP를 고정한다

애초에 사고를 부른 원인을 없애야 합니다. netplan으로 static 설정을 넣었습니다.

network:
  version: 2
  ethernets:
    enp3s0:
      dhcp4: false
      dhcp6: false
      addresses: [10.0.0.120/24]
      routes:
        - to: default
          via: 10.0.0.1
      nameservers:
        addresses: [10.0.0.1, 1.1.1.1]

두 가지를 함께 해야 합니다. cloud-init이 네트워크 설정을 다시 생성하지 못하게 막고, 공유기에서도 해당 MAC에 DHCP 예약을 걸거나 그 주소를 풀에서 제외해야 합니다. netplan만 고정하면 다른 기기가 같은 주소를 임대받아 충돌할 수 있습니다.

echo 'network: {config: disabled}' > /etc/cloud/cloud.cfg.d/99-disable-network-config.cfg

적용 후 확인하면 dynamic 표시가 사라집니다.

inet 10.0.0.120/24 brd 10.0.0.255 scope global enp3s0

겪은 문제 하나 — 단일 노드에서 Pending

kubeadm init 직후 hubble-relay와 hubble-ui가 Pending에 머물렀습니다.

Warning  FailedScheduling  0/1 nodes are available: 1 node(s) had untolerated taint(s).

컨트롤 플레인 노드에는 node-role.kubernetes.io/control-plane:NoSchedule taint가 걸려 있고, hubble 구성요소는 DaemonSet이 아니라 Deployment라 이를 톨러레이트하지 않습니다. CoreDNS는 기본적으로 control-plane 톨러레이션을 갖고 있어 정상 기동했던 것과 대비됩니다.

이건 오류가 아니라 정상 동작입니다. 워커가 조인하자 바로 해소됐습니다. 단일 노드로 테스트할 때만 taint를 제거하면 됩니다.

kubectl taint nodes cp-1 node-role.kubernetes.io/control-plane:NoSchedule-

정리

항목결과
노드3대 전부 Ready (v1.34.10)
기본 검증14/14 통과
kube-proxy 파드0개
iptables KUBE- 체인0개
L7 정책GET 200 / POST 403
Hubble사이드카 없이 L7 흐름 관측
암호화WireGuard, Peers 2
Modules HealthOK 92, Degraded 0

DHCP 사고가 계기였지만, 결과적으로 얻은 것이 더 큽니다. kube-proxy 없는 클러스터가 실제로 동작한다는 것을 주장이 아니라 측정으로 확인했습니다. iptables 체인 0개, eBPF 맵 덤프, 메서드별로 갈리는 403 응답이 그 증거입니다.

홈랩에 Cilium을 얹을지 고민 중이시라면, 커널 버전만 확인하시면 됩니다. 5.10 이상이면 대부분의 기능이 동작하고, 6.x라면 XDP 가속과 BIG TCP까지 열립니다.

🧠 이해도 체크 퀴즈

1. kube-proxy를 설치한 뒤 삭제하는 것과 처음부터 설치하지 않는 것의 차이는 무엇인가요?

이미 설치되어 동작했다면 노드의 iptables에 KUBE-SERVICES, KUBE-SVC-, KUBE-SEP- 체인이 새겨집니다. DaemonSet을 지워도 이 규칙은 남아 eBPF 데이터패스와 충돌하거나 혼란을 줄 수 있습니다. --skip-phases=addon/kube-proxy로 처음부터 만들지 않으면 그 규칙이 생기지도 않습니다.

2. kubeProxyReplacement를 켤 때 k8sServiceHost와 k8sServicePort를 반드시 지정해야 하는 이유는 무엇인가요?

kube-proxy가 없으면 kubernetes 서비스의 ClusterIP(보통 10.96.0.1)를 실제 API 서버 주소로 바꿔줄 주체가 없습니다. 그런데 그 역할을 할 Cilium 자신이 아직 기동하기 전이므로, 부트스트랩 시점에는 VIP를 쓸 수 없습니다. 그래서 실제 노드 IP와 포트를 직접 알려줘야 합니다.

3. HTTP 메서드 단위 정책이 iptables로는 불가능한 이유는 무엇인가요?

iptables는 L3/L4 계층에서 동작하므로 IP와 포트, 프로토콜까지만 볼 수 있습니다. HTTP 메서드는 TCP 페이로드 안에 있어 L7 파싱이 필요합니다. Cilium은 l7Proxy를 켜면 해당 트래픽을 Envoy로 넘겨 파싱한 뒤 정책을 적용하는데, 이때 사이드카 주입 없이 노드 단위 프록시를 씁니다.

4. 인증서 SAN에 새 IP가 없으면 왜 IP만 바꿔서는 복구가 안 되나요?

apiserver.crt의 Subject Alternative Name에 없는 주소로 접속하면 TLS 검증이 실패합니다. 이 사례에서는 SAN에 .111만 있었으므로, 클러스터를 .120으로 옮기려면 인증서를 전부 재발급하고 etcd 멤버 URL과 모든 kubeconfig를 수정한 뒤 전 노드를 재조인해야 합니다. 원래 IP를 되찾는 편이 훨씬 간단합니다.

참고 자료

현재 단락 (1/171)

홈랩 쿠버네티스가 어느 날 응답하지 않았습니다. 증상은 이랬습니다.

작성 글자: 0원문 글자: 7,691작성 단락: 0/171