删掉 kube-proxy 后,Service 规则不见了
한국어 원문으로 표시합니다.
목표
kube-proxy 없이 도는 진짜 Cilium 1.20.1 에서 서비스 부하 분산이 어느 BPF 맵과 어느 eBPF 프로그램으로 옮겨 갔는지 직접 읽습니다. iptables 가 비어 있음을 확인하고, 서비스 맵·conntrack 맵·파드 장치의 프로그램·NodePort·백엔드 없는 서비스의 응답까지 따라갑니다.
왜 중요한가
kube-proxy 의 iptables 모드는 서비스와 엔드포인트마다 규칙을 만들고, 규칙은 위에서부터 차례로 평가됩니다. 서비스가 늘면 규칙이 늘고, 바뀔 때마다 규칙을 다시 씁니다. Cilium 의 kube-proxy 대체는 서비스를 해시 맵 항목으로 두고, 파드와 노드 장치에 붙은 eBPF 프로그램이 패킷이 지나갈 때 맵을 찾아 목적지를 바꿉니다.
이 차이를 알아야 장애 때 어디를 볼지 압니다. "서비스가 안 된다" 는 신고에 iptables-save 를 뒤지면 아무것도 나오지 않습니다. 대신 서비스 맵의 백엔드 슬롯, conntrack 의 항목, 파드 장치에 프로그램이 붙어 있는지를 봐야 합니다.
VM 안의 개인 k3s(Cilium 1.20.1, kubeProxyReplacement=true)만 사용하세요. 환경 준비에 약 5분이 걸립니다. 세션이 끝나면 /root/cca-datapath 의 파일은 사라집니다.
단계
kubectl apply -f /opt/fixtures/cca-datapath/web.yaml로 cca-dp 네임스페이스에 web(레플리카 2)과 ClusterIP 서비스 web 을 띄우세요. 파드가 Ready 가 되면 VM 셸에서iptables-save를 읽어/root/cca-datapath/iptables.json에 기록합니다 —cluster_ip(서비스 web 의 ClusterIP),kube_svc_lines(KUBE-SVC 를 포함한 줄 수),cluster_ip_lines(그 ClusterIP 를 포함한 줄 수),cilium_lines(CILIUM 을 포함한 줄 수),kube_proxy_replacement(에이전트cilium-dbg status의 KubeProxyReplacement 값). 수는 숫자로 적습니다.- 에이전트의
cilium-dbg service list -o json에서 cca-dp/web 의 ClusterIP 항목을 찾고,cilium-dbg bpf lb list에서 같은 프런트엔드의 슬롯을 확인하세요./root/cca-datapath/svc.json에service_id(숫자),frontend(ClusterIP:80),backends(백엔드 ip:port 2개 목록),backend_pods(그 IP 를 가진 web 파드 이름 2개) 를 기록합니다. - web 을 레플리카 4개로 늘리세요. 네 파드가 Ready 가 되고
bpf lb list의 web 프런트엔드 백엔드 슬롯이 4개가 될 때까지 짧게 폴링한 뒤,/root/cca-datapath/scale.json에backends(지금 맵에 있는 ip:port 4개),new_backends(svc.json 에 없던 것 2개) 를 기록합니다. EndpointSlice 의 ready 주소와도 같아야 합니다. kubectl apply -f /opt/fixtures/cca-datapath/holder.yaml로 holder 파드를 띄우세요. holder 는 출발 포트 40404 로 web 서비스(ClusterIP:80)에 TCP 연결 하나를 열고 쥐고 있으며, 로그에 앱이 getpeername 으로 본 상대 주소를 찍습니다. 세 곳에서 같은 연결을 보세요 — holder 로그, holder 안netstat -tn, 에이전트의cilium-dbg bpf ct list global의:40404항목./root/cca-datapath/ct.json에client(holderIP:40404),app_peer(로그의 상대 주소 ip:port),socket_peer(holder netstat 의 Foreign Address),backend(conntrack OUT 항목의 목적지 ip:port),backend_pod(그 IP 의 web 파드 이름),svc_entries(:40404 를 가진 TCP SVC 항목 수, 숫자),socket_lb_coverage(cilium-dbg status --verbose의 Socket LB Coverage) 를 기록합니다. 백엔드 파드 안 netstat 에도 holder 의 IP:40404 가 보여야 합니다.- holder 의 엔드포인트 번호(
kubectl -n cca-dp get cep holder의 status.id)로 에이전트cilium-dbg endpoint get <번호> -o json을 읽어 호스트 쪽 장치 이름과 ifindex 를 찾고, VM 셸에서bpftool net show dev <장치>와tc filter show dev <장치> ingress를 비교하세요./root/cca-datapath/prog.json에endpoint_id,interface,ifindex,attach(bpftool 이 보여 준 붙은 자리),program(프로그램 이름),policy_map(cilium-dbg map list에서 찾은 이 엔드포인트의 정책 맵 이름),tc_filter_lines(tc filter 출력 줄 수, 숫자) 를 기록합니다. - cca-dp 에 NodePort 서비스
web-np를 만드세요 — selectorapp=web, port 80, targetPort 8080, nodePort30780. 노드 InternalIP 의 30780 으로 VM 셸에서 요청해 200 을 확인하고,/root/cca-datapath/nodeport.json에node_ip,node_port,http_code(숫자),iptables_lines(iptables-save 에서 30780 을 포함한 줄 수),listen_sockets(ss -Htln 'sport = :30780'줄 수),service_id(에이전트 목록의 0.0.0.0:30780 NodePort 항목 번호) 를 기록합니다. - cca-dp 에 selector
app=ghost, port 80 → targetPort 8080 인 ClusterIP 서비스ghost를 만드세요(그 라벨의 파드는 만들지 않습니다). curl 파드probe(이미지curlimages/curl:8.10.1@sha256:d9b4541e214bcd85196d6e92e2753ac6d0ea699f0af5741f8c6cccbfcf00ef4b, 명령sleep 86400)를 띄워 그 안에서 ghost 의 ClusterIP 로 5초 제한 요청을 보냅니다./root/cca-datapath/ghost.json에cluster_ip,backends(에이전트 목록의 백엔드 수, 숫자),curl_exit(curl 종료 코드),seconds(curl 의 time_total, 숫자),no_backend_response(cilium-dbg config -a의 ServiceNoBackendResponse 값) 를 기록합니다. /root/cca-datapath/report.txt에키=값일곱 줄을 씁니다 —kube_proxy_replacement,socket_lb_coverage(status --verbose 의 Socket LB Coverage),web_backends(지금 bpf lb list 의 web 백엔드 슬롯 수),holder_backend(지금 conntrack 에서 holder 연결이 향한 ip:port),holder_program(prog.json 의 program),nodeport_iptables_lines(지금 30780 을 포함한 iptables 줄 수),ghost_no_backend_response. 값은 모두 지금 상태와 앞 기록에 일치해야 합니다.
참고
- Kubernetes Without kube-proxy(소켓 LB, hostNamespaceOnly, NodePort): https://docs.cilium.io/en/v1.20/network/kubernetes/kubeproxy-free/
- eBPF 맵 목록과 크기: https://docs.cilium.io/en/v1.20/network/ebpf/maps/
- BPF 와 XDP 참고 가이드: https://docs.cilium.io/en/v1.20/reference-guides/bpf/index.html
- 에이전트 명령:
kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg service list | bpf lb list | bpf ct list global | map list | endpoint get <번호>. - VM 셸에는 bpftool·tc·ss·iptables-save·jq 가 있습니다. python 이미지 파드 안에는 busybox netstat 이 있습니다.
kube-proxy 가 없는데 서비스는 누가 돌리나
kubectl apply -f /opt/fixtures/cca-datapath/web.yaml 로 cca-dp 네임스페이스에 web(레플리카 2)과 ClusterIP 서비스 web 을 띄우세요. 파드가 Ready 가 되면 VM 셸에서 iptables-save 를 읽어 /root/cca-datapath/iptables.json 에 기록합니다 — cluster_ip(서비스 web 의 ClusterIP), kube_svc_lines(KUBE-SVC 를 포함한 줄 수), cluster_ip_lines(그 ClusterIP 를 포함한 줄 수), cilium_lines(CILIUM 을 포함한 줄 수), kube_proxy_replacement(에이전트 cilium-dbg status 의 KubeProxyReplacement 값). 수는 숫자로 적습니다.
kube-proxy(iptables 모드)는 서비스마다 KUBE-SVC·KUBE-SEP 체인을 만들어 목적지를 바꿉니다. 그 흔적이 있는지 줄 수로 세어 보세요. grep -c 는 하나도 없을 때 종료 코드 1 을 내므로 set -e 아래에서는 조심하세요. 에이전트 명령은 kubectl -n kube-system exec ds/cilium -c cilium-agent -- cilium-dbg ... 로 실행합니다.
서비스 번호표를 BPF 맵에서 찾는다
에이전트의 cilium-dbg service list -o json 에서 cca-dp/web 의 ClusterIP 항목을 찾고, cilium-dbg bpf lb list 에서 같은 프런트엔드의 슬롯을 확인하세요. /root/cca-datapath/svc.json 에 service_id(숫자), frontend(ClusterIP:80), backends(백엔드 ip:port 2개 목록), backend_pods(그 IP 를 가진 web 파드 이름 2개) 를 기록합니다.
서비스 목록 JSON 의 spec.flags 에 이름·네임스페이스·유형이, spec.frontend-address 와 spec.backend-addresses 에 주소가 있습니다. bpf lb list 의 한 프런트엔드에는 괄호 속 번호 0 인 줄(서비스 자체)과 1 부터 시작하는 백엔드 줄이 있습니다. 파드 IP 는 kubectl get pod -o wide 와 맞춰 보세요.
레플리카를 늘리자 맵이 먼저 안다
web 을 레플리카 4개로 늘리세요. 네 파드가 Ready 가 되고 bpf lb list 의 web 프런트엔드 백엔드 슬롯이 4개가 될 때까지 짧게 폴링한 뒤, /root/cca-datapath/scale.json 에 backends(지금 맵에 있는 ip:port 4개), new_backends(svc.json 에 없던 것 2개) 를 기록합니다. EndpointSlice 의 ready 주소와도 같아야 합니다.
쿠버네티스는 EndpointSlice 를 갱신하고, 에이전트는 그것을 보고 서비스 맵의 백엔드 슬롯을 다시 씁니다. iptables 규칙 수천 줄을 다시 쓰는 대신 맵 항목 몇 개만 바뀝니다. 고정 sleep 대신 슬롯 수를 세는 루프를 쓰세요.
앱은 서비스에 붙었다고 믿지만 소켓은 이미 파드에 붙어 있다
kubectl apply -f /opt/fixtures/cca-datapath/holder.yaml 로 holder 파드를 띄우세요. holder 는 출발 포트 40404 로 web 서비스(ClusterIP:80)에 TCP 연결 하나를 열고 쥐고 있으며, 로그에 앱이 getpeername 으로 본 상대 주소를 찍습니다. 세 곳에서 같은 연결을 보세요 — holder 로그, holder 안 netstat -tn, 에이전트의 cilium-dbg bpf ct list global 의 :40404 항목. /root/cca-datapath/ct.json 에 client(holderIP:40404), app_peer(로그의 상대 주소 ip:port), socket_peer(holder netstat 의 Foreign Address), backend(conntrack OUT 항목의 목적지 ip:port), backend_pod(그 IP 의 web 파드 이름), svc_entries(:40404 를 가진 TCP SVC 항목 수, 숫자), socket_lb_coverage(cilium-dbg status --verbose 의 Socket LB Coverage) 를 기록합니다. 백엔드 파드 안 netstat 에도 holder 의 IP:40404 가 보여야 합니다.
kube-proxy 대체의 소켓 LB 는 파드가 connect() 를 부르는 순간 cgroup 에 붙은 eBPF 가 목적지를 백엔드로 바꿉니다. 그래서 패킷에는 처음부터 서비스 주소가 없고, 앱에게는 getpeername 을 되돌려 서비스 주소를 보여 줍니다. 세 관측이 서로 다른 주소를 말하는 이유를 이 순서로 설명해 보세요. 백엔드가 보는 출발지가 노드 IP 로 바뀌었는지도 확인합니다.
파드 옆 장치에 붙은 프로그램과 그 파드만의 정책 맵
holder 의 엔드포인트 번호(kubectl -n cca-dp get cep holder 의 status.id)로 에이전트 cilium-dbg endpoint get <번호> -o json 을 읽어 호스트 쪽 장치 이름과 ifindex 를 찾고, VM 셸에서 bpftool net show dev <장치> 와 tc filter show dev <장치> ingress 를 비교하세요. /root/cca-datapath/prog.json 에 endpoint_id, interface, ifindex, attach(bpftool 이 보여 준 붙은 자리), program(프로그램 이름), policy_map(cilium-dbg map list 에서 찾은 이 엔드포인트의 정책 맵 이름), tc_filter_lines(tc filter 출력 줄 수, 숫자) 를 기록합니다.
Cilium 은 파드마다 호스트 쪽 veth(lxc…)에 프로그램을 붙이고, 파드마다 따로 정책 맵을 둡니다. 맵 이름의 끝 숫자와 엔드포인트 번호를 비교해 보세요. 최신 커널에서는 프로그램을 tcx 로 붙이는데, 옛 tc 명령은 tcx 부착을 보여 주지 않습니다.
리슨하는 프로세스가 없는데 NodePort 가 답한다
cca-dp 에 NodePort 서비스 web-np 를 만드세요 — selector app=web, port 80, targetPort 8080, nodePort 30780. 노드 InternalIP 의 30780 으로 VM 셸에서 요청해 200 을 확인하고, /root/cca-datapath/nodeport.json 에 node_ip, node_port, http_code(숫자), iptables_lines(iptables-save 에서 30780 을 포함한 줄 수), listen_sockets(ss -Htln 'sport = :30780' 줄 수), service_id(에이전트 목록의 0.0.0.0:30780 NodePort 항목 번호) 를 기록합니다.
NodePort 는 포트를 여는 프로세스가 아니라 노드 장치에 붙은 eBPF 프로그램이 받아 서비스 맵으로 보냅니다. 그래서 ss 로도 iptables 로도 보이지 않는 포트가 응답합니다. 서비스 목록 JSON 에서 frontend-address 의 ip 가 0.0.0.0 이고 flags.type 이 NodePort 인 항목을 고르세요.
파드가 하나도 없는 서비스는 기다리지 않는다
cca-dp 에 selector app=ghost, port 80 → targetPort 8080 인 ClusterIP 서비스 ghost 를 만드세요(그 라벨의 파드는 만들지 않습니다). curl 파드 probe(이미지 curlimages/curl:8.10.1@sha256:d9b4541e214bcd85196d6e92e2753ac6d0ea699f0af5741f8c6cccbfcf00ef4b, 명령 sleep 86400)를 띄워 그 안에서 ghost 의 ClusterIP 로 5초 제한 요청을 보냅니다. /root/cca-datapath/ghost.json 에 cluster_ip, backends(에이전트 목록의 백엔드 수, 숫자), curl_exit(curl 종료 코드), seconds(curl 의 time_total, 숫자), no_backend_response(cilium-dbg config -a 의 ServiceNoBackendResponse 값) 를 기록합니다.
백엔드가 없을 때 데이터패스가 패킷을 조용히 버리면 클라이언트는 제한 시간까지 기다리고, 거절 응답을 돌려주면 곧바로 실패합니다. curl 종료 코드 7 과 28 이 무엇을 뜻하는지 비교하세요. curl 의 -w '%{time_total}' 은 실패해도 찍힙니다.
kube-proxy 가 하던 일의 새 주소록
/root/cca-datapath/report.txt 에 키=값 일곱 줄을 씁니다 — kube_proxy_replacement, socket_lb_coverage(status --verbose 의 Socket LB Coverage), web_backends(지금 bpf lb list 의 web 백엔드 슬롯 수), holder_backend(지금 conntrack 에서 holder 연결이 향한 ip:port), holder_program(prog.json 의 program), nodeport_iptables_lines(지금 30780 을 포함한 iptables 줄 수), ghost_no_backend_response. 값은 모두 지금 상태와 앞 기록에 일치해야 합니다.
앞 단계의 파일을 다시 쓰는 대신 지금 상태를 다시 읽어 적으세요. holder 가 재시작해 다시 연결했다면 백엔드가 달라졌을 수 있으니 ct.json 도 새로 만들어야 합니다.