본문으로 건너뛰기
2.6 kube-proxy와 애드온

2.6 kube-proxy와 애드온

노드 컴포넌트가 하나 더 남았다. kubelet이 Pod를 실행하는 동안, 같은 노드에서 kube-proxy는 그 Pod에 도달하는 트래픽 경로를 만든다. Service를 하나 만들면 클러스터 안 어디서든 그 가상 IP로 접속이 되지만, apiserver에 저장된 객체가 패킷을 전달하는 것은 아니다. 전달은 각 노드의 커널이 하고, 커널에 그 규칙을 넣는 것이 kube-proxy다. 이 챕터는 kube-proxy가 어느 층에서 무엇을 하는지 확인하고, CoreDNS나 CNI 플러그인처럼 클러스터에 반드시 필요하면서 Kubernetes 본체 밖에 있는 애드온의 자리를 정리한 뒤, Part II 전체를 되짚는다.

선언과 데이터 경로는 다른 층에 있다

Service는 요구사항을 적은 문서에 가깝다. “app=web 라벨이 붙은 Pod들에게 이 가상 IP의 80번 포트로 온 트래픽을 나눠 달라"는 선언이며, 그 자체로는 패킷 하나도 옮기지 않는다. 선언을 동작하는 경로로 바꾸는 일은 두 단계로 나뉜다. control plane의 EndpointSlice controller가 selector에 맞고 준비 상태인 Pod의 IP 목록을 EndpointSlice 객체로 유지하고, 각 노드의 kube-proxy가 Service와 EndpointSlice를 함께 읽어 자기 노드의 규칙을 만든다.

kube-proxy의 동작 방식은 앞선 컴포넌트들과 같다. apiserver를 watch하다가 Service나 EndpointSlice가 바뀌면 노드의 규칙을 선언에 맞게 다시 쓴다. 2.4의 reconciliation loop가 대상만 바뀐 채 반복되는 것이다. 관찰 대상은 API 객체이고, 조치 대상은 커널의 규칙 테이블이다. 변경이 몰릴 때는 매번 규칙을 다시 쓰지 않고 최소 동기화 간격 안의 변경을 묶어 한 번에 반영한다.

    flowchart TD
  SVC["Service 선언"] --> API["kube-apiserver"]
  EPC["EndpointSlice<br/>controller"] -->|"Pod IP 목록"| API
  API -->|"watch"| KP["kube-proxy"]
  KP -->|"규칙 갱신"| K["노드 커널 규칙"]
  POD["Pod 트래픽"] --> K
  K -->|"DNAT"| TGT["대상 Pod IP"]
  

이 분리에서 몇 가지 성질이 따라온다. 먼저 kube-proxy 프로세스는 데이터 경로에 없다. 규칙을 설치한 뒤 패킷을 처리하는 주체는 커널이므로, kube-proxy가 죽어도 이미 설치된 규칙으로 통신은 이어진다. 대신 그 시점부터 규칙 갱신이 멈춘다. 새로 만든 Service는 연결되지 않고, 사라진 Pod의 주소가 대상 목록에 남는다. 장애 증상이 전면 불통이 아니라 최근 변경의 미반영으로 나타나는 이유다.

규칙이 노드마다 독립적이라는 점도 중요하다. 모든 노드가 같은 선언을 읽어 각자 같은 내용의 규칙을 만들며, 클러스터 전체를 담당하는 중앙 로드밸런서는 없다. 그래서 Pod가 Service의 가상 IP로 보낸 패킷은 자기 노드를 떠나기 전에 이미 목적지가 실제 Pod IP로 바뀐다. Service의 타입별 노출 방식과 트래픽 정책은 Part V. 네트워킹에서 다룬다.

iptables 모드와 IPVS 모드

kube-proxy가 커널 규칙을 만드는 방식은 모드로 갈린다. 기본은 iptables 모드다.

iptables 모드에서 kube-proxy는 nat 테이블에 자기 체인 묶음을 만든다. 진입점은 KUBE-SERVICES 체인이고 그 안에 Service마다 규칙 하나가 놓인다. 목적지가 어느 Service의 가상 IP와 포트인 패킷은 그 Service 전용 체인(KUBE-SVC- 접두어)으로 넘어가고, 거기서 대상 Pod 하나가 선택되면 endpoint 전용 체인(KUBE-SEP- 접두어)이 목적지를 그 Pod의 IP와 포트로 DNAT한다. 분배는 확률 매칭으로 이뤄진다. endpoint가 셋이면 첫 규칙에 1/3, 다음에 1/2, 마지막에 나머지 전부를 배정해 결과가 균등해지게 만드는 방식이다.

이 구조에는 규모에 따른 성질이 있다. 체인과 규칙의 수가 Service 수와 endpoint 수에 비례해 늘어난다. Service가 수천 개인 클러스터에서는 규칙이 수만 줄이 되고, 진입 체인의 Service 규칙은 순차 평가이므로 매칭 비용도 목록 길이에 영향을 받는다. 더 부담이 되는 쪽은 갱신이다. 초기 구현은 변경이 생길 때마다 규칙 전체를 다시 적용했고, 그래서 Pod 하나의 교체가 대규모 클러스터에서는 동기화 지연으로 나타났다. 이후 버전에서 바뀐 부분만 갱신하는 개선이 반영되어 이 비용은 줄었다.

IPVS 모드는 커널에 이미 들어 있는 L4 로드밸런서 IPVS(IP Virtual Server)를 사용한다. kube-proxy는 iptables 규칙을 쓰는 대신 netlink로 IPVS의 가상 서버와 실제 서버 목록을 설정하고, Service의 가상 IP는 노드에 생성되는 더미 인터페이스 kube-ipvs0에 붙는다. IPVS는 대상 조회를 해시 테이블로 처리하므로 Service 수가 늘어도 조회 비용이 목록 길이를 따라 늘지 않고, 규칙 갱신도 항목 단위로 끝난다. 대규모 클러스터에서 IPVS를 선택하는 이유가 이 두 가지다.

분배 방식도 선택 대상이 된다. iptables 모드의 확률 분배와 달리 IPVS는 여러 스케줄링 알고리즘을 제공하고, kube-proxy의 --ipvs-scheduler 옵션으로 지정한다.

알고리즘
rr라운드 로빈 (기본값)
lc최소 연결 수
sh출발지 주소 해시
dh목적지 주소 해시
sed예상 지연 최소

IPVS 모드에서도 iptables가 완전히 사라지지는 않는다. 패킷 마킹과 masquerade, 일부 필터링에는 여전히 iptables와 ipset이 쓰인다. 또 노드에 ip_vs 계열 커널 모듈이 적재되어 있어야 하며, 모듈을 사용할 수 없으면 kube-proxy는 경고를 남기고 iptables 모드로 되돌아간다. 이름만 보고 IPVS로 동작한다고 단정하지 말고 실제 모드를 확인하는 편이 안전하다.

# kube-proxy의 모드 설정 확인 (빈 값이면 기본값인 iptables)
kubectl -n kube-system get configmap kube-proxy -o yaml | grep "mode:"

# 노드에서 Service 진입 체인 확인 (iptables 모드)
sudo iptables -t nat -L KUBE-SERVICES -n | head -20

세 번째로 nftables 모드가 있다. iptables의 후속 프레임워크인 nftables를 직접 사용해 규칙 갱신 비용과 표현의 제약을 개선하려는 모드다. 성숙 단계가 버전에 따라 다르므로 도입 전에 사용 중인 버전의 문서에서 상태를 확인한다. kube-proxy를 아예 설치하지 않고 CNI 플러그인이 eBPF로 같은 역할을 대신하는 구성도 있다.

모드를 바꿀 때는 이전 모드가 설치한 규칙이 노드에 남는다. kube-proxy는 새 모드의 규칙만 관리하므로, 남은 규칙이 트래픽을 예전 대상으로 보내는 상태가 이어질 수 있다. 모드 전환은 kube-proxy --cleanup으로 기존 규칙을 정리하거나 노드를 재시작한 뒤 진행한다.

애드온: 필수 구성이면서 본체 밖

지금까지 본 컴포넌트만 있으면 Pod는 기동된다. 그런데 그 Pod에서 다른 Service의 이름을 조회하면 실패하고, Pod에 IP를 부여할 방법도 없다. 클러스터가 실제로 쓸 만해지려면 Kubernetes 배포본에 포함되지 않는 구성 요소가 더 필요하다. 이것을 애드온이라 부른다.

CoreDNS는 클러스터 DNS의 표준 구현이다. kube-system namespace의 Deployment로 배치되고 그 앞에 kube-dns라는 이름의 ClusterIP Service가 놓인다. kubelet은 Pod를 만들 때 이 Service의 IP를 컨테이너의 /etc/resolv.conf에 nameserver로 기록하므로, Pod 안에서 일어나는 이름 조회는 모두 CoreDNS로 향한다. 여기서 층위가 겹친다. CoreDNS 자신도 Service로 노출된 평범한 워크로드이므로, Pod가 CoreDNS에 도달하는 경로 역시 kube-proxy가 만든 규칙이다.

CNI 플러그인은 Pod에 네트워크 인터페이스와 IP를 부여한다. 호출 경로는 2.5에서 본 계층을 한 단계 더 내려간다. kubelet이 CRI로 런타임에 Pod 샌드박스 생성을 요청하고, 런타임이 network namespace를 만든 뒤 CNI 플러그인 바이너리를 실행해 인터페이스 생성과 IP 할당을 위임한다. kubelet은 CNI를 직접 호출하지 않는다. 플러그인 자체는 보통 DaemonSet으로 배포되어 각 노드에 바이너리와 설정 파일을 설치한다.

노드에 CNI 설정이 없으면 kubelet은 네트워크가 준비되지 않았다고 보고하고 노드는 NotReady에 머문다. kubeadm init 직후 노드가 NotReady인 것이 정상인 이유이며, CNI 플러그인을 설치하면 해소된다. 이 상태에서는 host network를 쓰지 않는 Pod가 기동되지 못하므로 CoreDNS Pod도 함께 Pending에 남는다.

metrics-server는 리소스 메트릭 API를 제공한다. 2.5에서 본 kubelet의 사용량 수치를 주기적으로 수집해 metrics.k8s.io API로 노출하고, kubectl top과 HPA가 그 API를 소비한다. 이쪽은 없어도 클러스터가 동작하지만, 없으면 사용량 조회와 자동 확장이 함께 멈춘다.

kubectl get pods -n kube-system -o wide

애드온이 본체 밖에 있는 이유는 Kubernetes가 구현 대신 인터페이스를 정의하는 방향을 택했기 때문이다. 네트워크는 CNI, 런타임은 CRI, 스토리지는 CSI로 계약만 정하고 구현은 고르게 두면, 환경마다 다른 요구를 만족시키는 선택이 프로젝트 밖에서 이뤄진다. 애드온이 Deployment나 DaemonSet 같은 보통의 워크로드로 배포되는 것도 같은 방향이다. 애드온만을 위한 별도 관리 체계 없이, 애드온이 지원하는 그 control plane이 애드온 자신도 관리한다.

다시 보는 컴포넌트 지도

2.1에서 세운 규칙 하나가 Part 전체를 관통했다. 어떤 컴포넌트도 서로 직접 호출하지 않고 전부 apiserver를 경유한다는 규칙이다. apiserver는 인증과 인가와 어드미션을 거쳐 요청을 검증하고 etcd에 기록하는 유일한 관문이었고(2.2), etcd는 Raft 합의로 그 기록을 여러 멤버에 복제하는 상태 저장소였다(2.3). 결정은 비어 있는 nodeName을 채우는 스케줄러와 관찰, 비교, 조치를 반복하는 컨트롤러들이 담당하고(2.4), 실행은 PodSpec을 컨테이너로 만드는 kubelet(2.5)과 트래픽 경로를 만드는 kube-proxy가 담당한다.

컴포넌트 이름을 외우는 것보다 남는 것은 이 대응이다. 능동 컴포넌트는 모두 apiserver에서 원하는 상태를 읽고 자기 영역의 현실을 그 상태에 맞춘다. 대상만 다르다. 컨트롤러의 대상은 API 객체이고, kubelet의 대상은 노드의 컨테이너이고, kube-proxy의 대상은 커널의 규칙이다. 이후 Part에서 다루는 리소스들도 결국 이 구조 위에서 동작하므로, 새 기능을 만날 때 “이 상태를 누가 읽고 무엇을 맞추는가"를 물으면 대개 자리가 잡힌다.

정리

  • kube-proxy는 Service와 EndpointSlice를 watch해 노드의 커널 규칙을 만든다. 패킷 처리는 커널이 하므로 프로세스가 죽으면 통신이 아니라 규칙 갱신이 멈춘다.
  • iptables 모드는 체인과 규칙이 Service 수에 비례해 늘어나고, IPVS 모드는 커널 로드밸런서의 해시 테이블과 스케줄링 알고리즘을 사용한다.
  • CoreDNS, CNI 플러그인, metrics-server는 운영에 필수지만 인터페이스 밖의 구현이라 애드온으로 배포되며, 그 자신도 control plane이 관리하는 워크로드다.