포스팅은 CloudNet@팀 서종호(Gasida)님이 진행하시는 AWS EKS Workshop Study 내용을 참고하여 작성합니다.
안녕하세요!
오늘은 EKS의 서비스에 대해서 알아보는 시간을
갖도록 하겠습니다.
01. kube-proxy 역할
저는 처음에 kube-proxy가 노드에서 DaemonSet으로 동작하면서
'Pods'로 들어오는 모든 트래픽을 관여하여 직접 받고, Pods에 전달해주는 줄 알았습니다.
실제로는 트래픽을 직접 처리하지 않고, 규칙을 쓰고 관리하는 관리자 역할을 한다고 합니다.
그렇다면, kube-proxy의 역할은 도대체 뭘까?
API server를 watch해서 서비스/엔드포인트 변화를 감지
새 서비스 생성됨 → kube-proxy 감지
해당 노드에 iptables 규칙 추가
이후 해당 ClusterIP로 오는 패킷은 자동으로 처리됨
Pod가 죽거나 새로 뜨면 엔드포인트 목록이 바뀌는데, 그걸 규칙에 반영
Pod A 죽음 → Endpoints에서 제거됨
kube-proxy 감지 → Pod A로 가는 iptables 규칙 삭제
이후 트래픽은 살아있는 Pod로만 분산됨
규칙이 꼬이거나 노드가 재시작되면 규칙을 다시 동기화
실제로 이렇게 규칙을 관리하는 관리자 역할의 kube-proxy가 죽게 된다면?
kube-proxy 프로세스 죽음
↓
이미 심어진 iptables 규칙은 커널에 살아있음
↓
기존 서비스 → 기존 Pod 통신: 정상 동작
↓
단, 새 서비스 생성 / Pod 추가·삭제 → 규칙 갱신 안 됨
↓
새로 뜬 Pod로는 트래픽이 안 감
죽은 Pod IP가 아직 규칙에 남아있으면 일부 요청이 실패
DaemonSet으로 관리되기 때문에 kubelet이 바로 재시작 시키므로 오래 죽어있는 경우는 드물다고 한다.
그래서 kube-proxy가 죽어도 기존 통신들은 정상동작 한다고 합니다. (다만, 죽은 상태가 오래 지속되면 장애)
02. kube-proxy 모드 종류
iptables mode
iptables 모드의 핵심은 kube-proxy가 트래픽을 직접 만지지 않는다는 것입니다.
위에서 언급했듯이, kube-proxy는 apiserver를 watch하다가 서비스/엔드포인트가 바뀌면 iptables 규칙을 갱신하는 역할. 실제 패킷은 커널의 netfilter가 규칙을 보고 처리합니다.
Pod에서 ClusterIP(10.100.16.17:80)로 패킷을 보내면, 패킷이 netfilter의 PREROUTING 체인을 통과하면서 iptables 규칙과 순서대로 매칭됩니다. 매칭된 규칙에 따라 목적지가 실제 Pod IP로 DNAT되고, 그 Pod로 전달됨.
위 처럼 iptables 규칙과 순서대로 매칭되는 것을 '선형 탐색'이라고 하는데 구조를 살펴보면 아래와 같습니다.
서비스가 1개면 규칙이 예를들어, 약 8개, 100개면 800개, 1000개면 8000개가 되는데요, 패킷이 올 때마다 이 규칙을 처음부터 순서대로 비교한다. -> 서비스가 늘어날수록 CPU 사용량이 같이 올라가고, 규칙 업데이트 자체도 느려지는 구조네요. 왜? 엔드포인트 하나만 바뀌어도 관련 규칙을 전체 다시 써야하기 때문에!
그렇다면 서비스가 1개일때 왜 규칙이 왜 여러개인 것인가?
실제로 k8s는 iptables 규칙을 체인(Chain) 단위로 쪼개서 관리하며, 서비스 하나 생성 시 아래 체인이 만들어진다.
KUBE-SERVICES : 모든 서비스의 진입점. 여기서 분기 KUBE-SVC-XXXXX : 특정 서비스의 로드밸런싱 담당 KUBE-SEP-YYYYY : 특정 엔드포인트(Pod)로의 DNAT 담당 KUBE-SEP-ZZZZZ : 또 다른 엔드포인트 KUBE-MARK-MASQ : SNAT 마킹용
한번 실습에서 만들어 보았던 mario 서비스에 Pod 2개가 있다고 가정해보자.
# 1. KUBE-SERVICES 체인에서 mario 서비스로 분기
-A KUBE-SERVICES -d 10.100.16.17/32 -p tcp --dport 80 -j KUBE-SVC-MARIO
# 2. KUBE-SVC-MARIO: Pod A로 50% 확률
-A KUBE-SVC-MARIO -m statistic --mode random --probability 0.5 -j KUBE-SEP-POD-A
# 3. KUBE-SVC-MARIO: 나머지는 Pod B로
-A KUBE-SVC-MARIO -j KUBE-SEP-POD-B
# 4. KUBE-SEP-POD-A: Pod A IP로 DNAT
-A KUBE-SEP-POD-A -p tcp -j DNAT --to-destination 192.168.1.10:8080
# 5. KUBE-SEP-POD-A: Pod A에서 나가는 트래픽 마킹 (헤어핀 방지)
-A KUBE-SEP-POD-A -s 192.168.1.10/32 -j KUBE-MARK-MASQ
# 6. KUBE-SEP-POD-B: Pod B IP로 DNAT
-A KUBE-SEP-POD-B -p tcp -j DNAT --to-destination 192.168.1.11:8080
# 7. KUBE-SEP-POD-B: Pod B에서 나가는 트래픽 마킹
-A KUBE-SEP-POD-B -s 192.168.1.11/32 -j KUBE-MARK-MASQ
# 8. NodePort용 규칙 (NodePort 타입이면 추가)
-A KUBE-NODEPORTS -p tcp --dport 30001 -j KUBE-SVC-MARIO
```
서비스 1개 + Pod 2개인데 규칙이 벌써 8개야. Pod가 3개면 10개, 4개면 12개가 되겠네요.
그렇다면 선형 탐색 구조의 문제점은 아래와 같습니다.
패킷이 들어오면 커널이 `KUBE-SERVICES` 체인부터 위에서 아래로 순서대로(Top-down 방식)비교합니다.
패킷 목적지: 10.100.99.99 (서비스 1000번째)
KUBE-SERVICES 체인
규칙 1: 10.100.0.1 → 불일치, 다음
규칙 2: 10.100.0.10 → 불일치, 다음
규칙 3: 10.100.1.5 → 불일치, 다음
...
규칙 999: 10.100.99.99 → 일치! → KUBE-SVC-XXX로 점프
서비스가 1000개면 최악의 경우 규칙 1000개를 다 훑어야 합니다.
패킷이 초당 수만 개씩 들어오면 이 비교가 매번 반복되는 거야.
즉, 만약 Pod 하나가 죽게되면 앤드포인트가 변경되고, 규칙을 아래와 같이 업데이트 해야한다.
iptables 업데이트 과정:
1. 현재 규칙 전체를 메모리로 읽어옴 (규칙 8000개 전부)
2. 변경할 규칙 찾아서 수정
3. 수정된 규칙 전체를 커널에 다시 씀 (8000개 전부 재작성)
IPVS mode
IPVS 모드를 알아보기에 앞서, 중요한 개념은 "Cluster IP는 가상 IP라서 어떤 인터페이스에도 바인딩 되어 있지 X"
패킷이 노드에 도착하면 커널이 "이 IP는 내 것이 아니다"라고 판단해서 그냥 버립니다. IPVS가 처리하려면 패킷이 로컬 소켓까지 올라와야 하는데, 그러려면 목적지 IP가 이 노드의 IP로 인식되어야 한다.
그래서 kube-proxy는 kube-ipvs0이라는 더미 인터페이스를 만들고, 모든 ClusterIP를 여기에 바인딩한다.
커널이 ClusterIP로 오는 패킷을 "이 노드로 온 패킷"으로 인식하게 만드는 트릭입니다. 이후 IPVS가 해시 테이블 조회로 실제 Pod IP를 찾아서 전달하죠.
그렇다면, iptables와의 결정적 차이점은? 로드밸런싱 알고리즘이다.
rr (Round Robin) : 순서대로 돌아가며 분배. 기본값
lc (Least Connection) : 현재 연결 수가 가장 적은 Pod로
sh (Source Hash) : 클라이언트 IP 기반으로 항상 같은 Pod로
dh (Destination Hash) : 목적지 기반 해시
sed (Shortest Expected Delay) : 예상 응답 시간이 짧은 Pod로
nq (Never Queue) : 유휴 Pod 있으면 바로, 없으면 sed 방식
세션 유지가 필요한 서비스라면 sh(Source Hash)를,
연결마다 처리 시간이 다른 서비스라면 lc(Least Connection)를 쓰는 게 합리적겠네요.
nftables mode
nftables의 핵심 차이는 셋(Set) 자료구조 입니다.
iptables는 각 엔드포인트마다 규칙을 하나씩 만들고 확률값을 조정하는 방식이라, Pod가 하나 바뀌면 전체 규칙을 처음부터 다시 계산해서 써야 합니다.
nftables는 "이 서비스의 엔드포인트 목록"을 셋으로 관리하고, 규칙은 그 셋을 참조하는 형태로 단 하나만 만듭니다. 이렇게 된다면 Pod가 하나 추가되거나 제거되면 셋 항목만 수정하면 끝이겠죠?
즉, 엔드포인트 1만 개인 환경에서 Pod 하나가 재시작되면 iptables는 수만 줄의 규칙을 전부 재작성하지만,
nftables는 셋에서 항목 하나만 바꾼다.
eBPF mode (Cilium)
커널 스택을 우회하는 방식 (VPC CNI Plugin 설명 참조)
앞선 세 방식과 출발점이 다른것이, 위 3가지 방식은 netfilter 프레임워크 위에서 동작함.
반면, eBPF는 패킷이 NIC에서 올라오자마자 가장 이른 시점인 XDP나 TC hook에서 가로챈다.
eBPF 프로그램이 eBPF 맵에서 서비스 IP를 해시 조회하고, 패킷의 목적지를 Pod IP로 바꾼 다음 바로 전달합니다. netfilter, IP routing, TCP/IP 스택을 대부분 건너뛴다. 이 차이가 레이턴시와 처리량에서 극명하게 드러난다.
Cilium은 이 방식으로 kube-proxy를 완전히 대체하며, kube-proxy DaemonSet 자체가 필요 없어겠네요.
즉, 4가지 kube-proxy 모드를 아래와 같이 한 눈에 비교할 수 있습니다.
03. 실제로 내 kube-proxy 확인
현재 내 kube-proxy 모드는 iptables 모드이며,
kube-proxy에서 실행되고 있는 iptables 규칙의 체인은 위 그림과 같습니다.
클러스터에서 실행되고 있는 서비스는 5개, 각 서비스당 nat 규칙은 124개, 즉 서비스 하나 당 평균 25개의 규칙이 생긴 것이라고 봐도 무방합니다.
현재 가용중인 iptables nat 테이블에서 실행되고 있는 체인의 다이어그램은 아래와 같습니다.
그럼, 위에서 봤던 iptables nat 테이블의 체인들의 상세 내용은 아래와 같습니다.
줄 1 — eks-extension-metrics-api : 10.100.244.135:443으로 오는 패킷을 KUBE-SVC-I7SKRZYQ7PWYV5X7 체인으로 보내라는 규칙
줄 2, 3 — kube-dns : CoreDNS 서비스가 줄 2개를 차지하는 이유가 보이죠? DNS는 UDP랑 TCP를 둘 다 쓰기 때문입니다. UDP 53은 일반 쿼리, TCP 53은 응답이 512바이트를 넘는 대형 쿼리용입니다. 같은 ClusterIP(10.100.0.10)인데 프로토콜이 달라서 규칙이 분리된 것으로 생각하시면 될 것 같습니다.
줄 4 — kube-dns metrics : CoreDNS가 Prometheus 메트릭을 9153 포트로 노출합니다. 그것도 서비스로 등록되어 있어서 규칙이 하나 더 생김.
줄 5 — kubernetes apiserver : Pod에서 kubernetes.default.svc.cluster.local로 apiserver를 호출하면 이 규칙을 타고 들어갑니다.
줄 6 — KUBE-NODEPORTS : 주석에 "이 규칙은 반드시 마지막이어야 한다"고 명시되어 있네요. 앞의 규칙들은 전부 목적지 IP가 ClusterIP인 경우를 처리하는데, NodePort는 목적지가 노드 IP라서 앞의 규칙들과 매칭이 안 됌. 그래서 ClusterIP 규칙을 다 탐색한 다음에 마지막으로 NodePort 체인을 확인하는 것 입니다. (만약 이게 앞에 오면 모든 패킷이 NodePort 체인을 먼저 탐색하게 되어서 불필요한 오버헤드가 생김)
kube-dns:dns 부분 보시면, 50:50으로 pod를 분배하는 것으로 확인할 수 있습니다.
kube-dns:metrics 부분 보시면, Prometheus 메트릭을 9153 포트로 노출하는 것입니다.
kubernetes:https 부분 보시면 EKS Control Plane apiserver의 ENI IP 입니다.
정리 해보자면 아래와 같으며, iptables의 규칙 관리에 대해서 알아보았습니다.
04. K8S 서비스 종류
📌 k8s에서 service란?
위에서 언급하였던 대로 Pod는 언제든 재생성되면서 IP가 바뀐다고 하였습니다.
그래서 K8s는 Service라는 추상 레이어를 아래와 같이 제공합니다.
Pod 집합 앞에 고정된 접근 지점을 만들어, 클라이언트가 Pod의 생명주기에 영향받지 않도록 한다.
Service는 selector로 대상 Pod를 찾고,
kube-proxy가 각 노드에서 iptables/IPVS 규칙을 관리해 트래픽을 라우팅합니다.
먼저 알아보기에 앞서, 아래와 같이 간단한 비교표를 참고해 주시면 되겠습니다.
Cluster IP
기본값이자 가장 많이 쓰이는 타입, kubectl create service를 하면 별도 지정 없이 ClusterIP로 생성됩니다.
동작 원리 : kube-proxy는 iptables 규칙을 통해 ClusterIP로 들어오는 트래픽을 selector에 매칭된 Pod들로 라운드로빈 분산. 이 가상 IP는 클러스터 외부에서는 라우팅되지 않습니다. (내부 라우팅에서 사용됨)
DNS 통합 : CoreDNS가 <service-name>.<namespace>.svc.cluster.local 형태로 자동 등록합니다. Pod 안에서 같은 네임스페이스라면 서비스 이름만으로 접근 가능하다.
매니 페스트 예시는 아래와 같습니다.
apiVersion: v1
kind: Service
metadata:
name: api-service
spec:
type: ClusterIP # 생략해도 기본값
selector:
app: api # 이 레이블을 가진 Pod에 트래픽 전달
ports:
- port: 8080 # Service가 노출하는 포트
targetPort: 8080 # Pod가 실제 Listen하는 포트
Endpoints 동작 : selector에 매칭되는 Pod가 생성/삭제되면 Endpoints 오브젝트가 자동 갱신됨.
NodePort
ClusterIP 위에 노드 포트를 추가로 열어 클러스터 외부에서도 접근 가능하게 합니다.
<NodeIP>:<NodePort>로 접근하면 해당 노드의 kube-proxy가 ClusterIP로 포워딩합니다.
포트 범위 : 기본적으로 30000-32767 범위에서 자동 할당되거나 수동 지정을 할 수 있습니다. 이 범위는 kube-apiserver 의 --service-node-port-range 옵션으로 변경 가능하다.
트래픽 경로: 외부 클라이언트 → 노드 IP:NodePort → iptables DNAT → ClusterIP → Pod
매니 페스트 예시는 아래와 같습니다.
apiVersion: v1
kind: Service
metadata:
name: web-service
spec:
type: NodePort
selector:
app: web
ports:
- port: 80 # ClusterIP 포트
targetPort: 8080 # Pod 포트
nodePort: 30080 # 노드에서 열리는 포트 (30000-32767, 생략 시 자동)
LoadBalancer
NodePort 위에 클라우드 프로바이더의 외부 로드밸런서를 자동 프로비저닝합니다.
kubectl apply만 하면 AWS ALB/NLB 등이 생성되고 External IP가 할당된다.
동작 원리 : cloud-controller-manager가 클라우드 API를 호출해 LB를 생성 → LB 타겟 그룹에 노드들의 NodePort가 등록 → External IP → NodePort → ClusterIP → Pod 순으로 트래픽이 전달
* LoadBalancer는 NodePort와 ClusterIP를 모두 내부적으로 생성합니다. kubectl get svc 로 보면 ClusterIP와 NodePort 정보가 함께 표시되는 이유가 이것이다.