Kubernetes 서비스란?
Kubernetes에서 서비스란 아래와 같이 정의된다.
In Kubernetes, a Service is a method for exposing a network application that is running as one or more
Pods in your cluster.
The Service API, part of Kubernetes, is an abstraction to help you expose groups of Pods over a network. Each Service object defines a logical set of endpoints (usually these endpoints are Pods) along with a policy about how to make those pods accessible.
https://kubernetes.io/docs/concepts/services-networking/service/
Kubernetes 서비스 종류
일반적으로 사용하는 서비스라는 단어와 조금 다르게 Kubernetes에서의 특정 리소스를 의미하며,
서비스 리소스는 주로 내부/외부 네트워크의 트래픽을 Kubernetes Cluster의 Pod에 연결하는 역할을 수행한다.
- ClusterIP
- Pod가 클러스터 내부의 특정 리소스와 접근할 수 있게 해주는 유형이다.
- 해당 ClusterIP는 클러스터 내부에서만 접근 가능하다.
- 기본 서비스 유형으로 서비스에 별도 유형을 설정하지 않으면 자동으로 ClusterIP로 설정된다.
- NodePort : 외부에서 노드 IP와 Port를 통해 연결된 Pod로 접근할 수 있게 해주는 유형이다.
- LoadBalancer : 클라우드 등의 환경처럼 별도의 외부 로드밸런서를 통해 외부에서 접근할 수 있게 해주는 유형이다.
- ExternalName
- 클러스터 내에서 외부의 리소스에 접근할 때 주로 사용되는 유형이다.
- 외부의 DNS 주소와 클러스터 내의 서비스 주로를 연결해 DNS 이름으로 참조가 가능하게 해주는 유형이다.
- Headless
- 따라서 부하 분산이나 클러스터 IP 주소를 제공하지 않는 특수한 유형의 서비스이다.
- 각 Pod에 직접 접근하는 방식으로 DNS를 통해 개별 Pod를 식별할 수 있게 해주는 유형이다.
서비스를 왜 쓰는 것인가?
이전 글에서 컨테이너부터 시작해서 Pod의 통신을 간략하게 알아봤는데, 왜 서비스가 필요한 걸까?
아래의 상황을 생각해보자.
내부의 FrontEnd Pod가 BackEnd Pod로 웹서비스를 제공하기 위해 접근한다고 가정해보자.
기존의 10.10.2.1 IP로 접근해서 웹서비스를 제공하고 있었는데, 기존 Pod에 문제가 생겨서 재기동을 하게될 경우 BackEnd New 서버가 기동하면서 새로운 IP를 할당받게된다.
하지만 기존 10.10.2.1로 접근하던 서비스 호출은 10.10.2.2를 알지 못하기 때문에 웹서비스 장애가 발생할 것이다.
ClusterIP 서비스 구성 예시
그럼 여기서 ClusterIP 서비스를 사용하도록 다시 구성해보자.
FrontEnd Pod에서는 ClusterIP 유형의 서비스의 IP인 10.200.1.1을 계속해서 바라보고 있다.
해당 서비스는 Selector를 통해 특정 label이 설정된 Pod를 계속 가르키도록 되어있고 10.10.2.1 Pod에 문제가 생겨 재시작해도 label이 동일하게 설정된 Pod를 다시 가르키기 때문에 BackEnd Pod에 계속 정상접근이 가능하다.
참고) Selector를 쓰지 않는 경우도 있다 : https://kubernetes.io/docs/concepts/services-networking/service/#services-without-selectors
이런 ClusterIP 유형의 서비스는 자동으로 label이 설정된 Pod를 가르키기 때문에 아래와 같은 식으로 Node 자체가 문제가 생겨도 서비스가 되도록 로드밸런싱 기능을 통해 가용성을 제공해줄 수도 있다.
NodePort 서비스 구성 예시
처음 설명에서 ClusterIP는 클러스터 외부에서 접근이 안되고 내부 IP에서만 접근이 가능하다고 설명했다.
그러면 클러스터 외부에서는 어떻게 Pod에 접근할 수 있을까?
여러가지 방법이 있지만, 서비스의 NodePort 유형을 통해 아래와 같이 구성할 수 있다.
NodePort 서비스에서 설정한 Port를 각 Node에서 접속할 수 있도록 설정하고, 해당 서비스에서 내부 ClusterIP를 통해 Pod로 연결해준다.
- 172.18.0.2:9000 으로 접속 시 10.10.1.2:8080 또는 10.10.2.2:8080 으로 접속하도록 연결한다.
- 해당 NodePort는 모든 Node들을 통해 연결 가능하도록 설정해준다. (172.18.0.4:9000로도 접속이 가능)
- 예전 K8S버전에서는 각 Node에 실제 Listen Port(여기선 9000번)가 있었으나, 최근에는 kube-proxy의 역할이 점점 축소되면서 iptables rule에 의해 바로 처리되기 때문에 Listen Port가 확인되지 않는다.
NodePort 서비스는 내부적으로 ClusterIP(서비스가 아님)를 자동으로 생성한다. 해당 ClusterIP는 기존 ClusterIP 서비스와 동일하게 Pod에 대한 접근을 지원한다.
LoadBalancer 서비스 구성 예시
NodePort 서비스를 통해 외부에서 접속이 가능하게 되었지만, 한 Node의 IP:Port로 계속 접속하다가 해당 Node가 문제가 생길 경우 기존에 잘 접속하던 클러스터 외부 사용자는 접속 장애가 발생한다.
이런 상황을 해결하기 위해 LoadBalancer 서비스를 사용할 수 있다.
해당 LoadBalancer 서비스는 동작 자체는 NodePort 서비스와 크게 다르지 않다.
다른점은 외부 로드밸런서 장치(클라우드 업체에서 제공하는 로드밸런서나 온프레미스에 설치한 MetalLB 등)에 Node의 IP와 NodePort를 전달하고 외부 로드밸런서에서 해당 정보를 등록해 서비스하도록 지원하는 점이다.
https://kubernetes.io/docs/tasks/access-application-cluster/create-external-load-balancer/
만약 외부 로드밸런서에서 이런 LoadBalancer 서비스의 요청을 제대로 인식하지 못한다면, NodePort와 동일한 역할만을 수행하게 된다.
K8S 서비스에 대한 개념은 어느정도 확인했으므로, 실제 내부 네트워크 동작을 좀 더 자세히 분석해보자.
ClusterIP 서비스 분석을 위한 환경 구성
이전과 동일하게 WSL을 사용한 kind 환경으로 테스트 환경을 구성하였다.
Kind를 통한 Kubernetes 클러스터 구성
이전과 동일한 방법으로 kind를 구성하고 WSL에 접속해서 kind 및 필요한 패키지들을 설치한다.
(⎈|kind-myk8s:N/A) root@Ersia:~# docker ps -q | xargs docker inspect --format '{{.Name}} {{.NetworkSettings.Networks.kind.IPAddress}}'
/mypc 172.18.0.100
/myk8s-worker 172.18.0.2
/myk8s-worker2 172.18.0.4
/myk8s-control-plane 172.18.0.3
/myk8s-worker3 172.18.0.5
(⎈|kind-myk8s:N/A) root@Ersia:~# kubectl get nodes
NAME STATUS ROLES AGE VERSION
myk8s-control-plane Ready control-plane 39m v1.31.0
myk8s-worker Ready <none> 39m v1.31.0
myk8s-worker2 Ready <none> 39m v1.31.0
myk8s-worker3 Ready <none> 39m v1.31.0
(⎈|kind-myk8s:N/A) root@Ersia:~# kubectl get cm -n kube-system kubeadm-config -oyaml | grep -i subnet
podSubnet: 10.10.0.0/16
serviceSubnet: 10.200.1.0/24
(⎈|kind-myk8s:N/A) root@Ersia:~# kubectl get servicecidr
NAME CIDRS AGE
kubernetes 10.200.1.0/24 40m
구성된 환경은 아래와 같다.
ClusterIP 서비스와 테스트용 Pod 배포
출처 : https://gasidaseo.notion.site/K8S-Service-1-f095388c48a84841b09a13b582f374c8
cat <<EOT> 3pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: webpod1
labels:
app: webpod
spec:
nodeName: myk8s-worker
containers:
- name: container
image: traefik/whoami
terminationGracePeriodSeconds: 0
---
apiVersion: v1
kind: Pod
metadata:
name: webpod2
labels:
app: webpod
spec:
nodeName: myk8s-worker2
containers:
- name: container
image: traefik/whoami
terminationGracePeriodSeconds: 0
---
apiVersion: v1
kind: Pod
metadata:
name: webpod3
labels:
app: webpod
spec:
nodeName: myk8s-worker3
containers:
- name: container
image: traefik/whoami
terminationGracePeriodSeconds: 0
EOT
cat <<EOT> netpod.yaml
apiVersion: v1
kind: Pod
metadata:
name: net-pod
spec:
nodeName: myk8s-control-plane
containers:
- name: netshoot-pod
image: nicolaka/netshoot
command: ["tail"]
args: ["-f", "/dev/null"]
terminationGracePeriodSeconds: 0
EOT
위의 코드는 3개의 웹 서비스 Pod와 네트워크 명령어가 사전에 설치되어있는 테스트용 Pod 1개를 생성하는 코드다.
기존과 다른 점은 3개의 웹 서비스 Pod에 app:webpod라는 label이 추가된 점과 특정 Node에 배포되도록 지정해준 점이다.
cat <<EOT> svc-clusterip.yaml
apiVersion: v1
kind: Service
metadata:
name: svc-clusterip
spec:
ports:
- name: svc-webport
port: 9000 # 서비스 IP 에 접속 시 사용하는 포트 port 를 의미
targetPort: 80 # 타킷 targetPort 는 서비스를 통해서 목적지 파드로 접속 시 해당 파드로 접속하는 포트를 의미
selector:
app: webpod # 셀렉터 아래 app:webpod 레이블이 설정되어 있는 파드들은 해당 서비스에 연동됨
type: ClusterIP # 서비스 타입
EOT
위의 코드가 실제 ClusterIP 서비스를 생성하는 코드이다.
위의 yaml을 기반으로 Pod를 생성하면 아래와 같은 정보를 확인할 수 있다.
ClusterIP 서비스 통신 분석
자 그러면 실제 ClusterIP를 통해서 통신이 되는지 확인해보자.
ClusterIP를 통한 통신 테스트
root@myk8s-control-plane:~# kubectl exec -it net-pod -- zsh -c "curl -s 10.200.1.148:9000"
Hostname: webpod2
IP: 127.0.0.1
IP: ::1
IP: 10.10.2.2
IP: fe80::7882:3fff:feda:9087
RemoteAddr: 10.10.0.5:57334
GET / HTTP/1.1
Host: 10.200.1.148:9000
User-Agent: curl/8.7.1
Accept: */*
net-pod에서 ClusterIP를 통해 2번 webpod의 80 Port로 정상 접속되는 것을 확인할 수 있다.
root@myk8s-control-plane:~# kubectl exec -it net-pod -- zsh -c "for i in {1..1000}; do curl -s 10.200.1.148:9000 | grep Hostname; done | sort | uniq -c | sort -nr"
358 Hostname: webpod1
343 Hostname: webpod2
299 Hostname: webpod3
net-pod에서 ClusterIP로 1000번의 접속을 수행하니 각 webpod로 로드밸런싱이 되어 분산 접속되는 것을 확인할 수 있다.
ClusterIP 서비스의 통신 원리
어떻게 ClusterIP로 접속했는데 Pod로 접속이 되는걸까?
kube-proxy는 이런 통신을 지원하기 위해 서비스에서 Pod로 패킷을 전달할 때 proxying을 사용한다.
해당 proxying은 구성에 따라 다양한 모드가 있는데, 별다른 설정을 하지 않았다면 리눅스에서는 현재 iptables모드를 사용하고 해당 모드는 iptables 정책을 통해 패킷을 전달한다.
https://kubernetes.io/docs/reference/networking/virtual-ips/#proxy-modes
root@myk8s-control-plane:/# iptables -t nat -S | grep 10.200.1.148
-A KUBE-SERVICES -d 10.200.1.148/32 -p tcp -m comment --comment "default/svc-clusterip:svc-webport cluster IP" -m tcp --dport 9000 -j KUBE-SVC-KBDEBIL6IU6WL7RF
-A KUBE-SVC-KBDEBIL6IU6WL7RF ! -s 10.10.0.0/16 -d 10.200.1.148/32 -p tcp -m comment --comment "default/svc-clusterip:svc-webport cluster IP" -m tcp --dport 9000 -j KUBE-MARK-MASQ
ClusterIP 서비스가 생성되면 아래의 과정을 거친다.
- Kubernetes API를 통해 새로운 ClusterIP 서비스 생성
- ClusterIP 서비스 관련 정보를 etcd에 저장
- kube-proxy는 모니터링하다가 ClusterIP 서비스 생성을 감지하고 KUBE-SERVICES, KUBE-SVC-, KUBE-SEP- 등 이와 관련된 iptables 정책을 생성하고 추가
그럼 control-plane에 접속해서 iptables 정책을 확인해보자.
root@myk8s-control-plane:/# iptables -t nat -nvL | wc -l
141
기본 구성에 Pod 4개와 서비스 1개만 생성했는데도 이 정도로 많은 규칙이 존재한다.
조금만 추려서 봐보자.
# PREROUTING 체인
-A PREROUTING -j KUBE-SERVICES
# KUBE-SERVICES 체인
-A KUBE-SERVICES -d 10.200.1.148/32 -p tcp -m tcp --dport 9000 -j KUBE-SVC-KBDEBIL6IU6WL7RF
-A KUBE-SERVICES -d 10.200.1.10/32 -p udp -m udp --dport 53 -j KUBE-SVC-TCOU7JCQXEZGVUNU
-A KUBE-SERVICES -d 10.200.1.10/32 -p tcp -m tcp --dport 53 -j KUBE-SVC-ERIFXISQEP7F7OF4
-A KUBE-SERVICES -d 10.200.1.1/32 -p tcp -m tcp --dport 443 -j KUBE-SVC-NPX46M4PTMTKRN6Y
# KUBE-SVC-KBDEBIL6IU6WL7RF 체인 (svc-clusterip)
-A KUBE-SVC-KBDEBIL6IU6WL7RF -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-TBW2IYJKUCAC7GB3
-A KUBE-SVC-KBDEBIL6IU6WL7RF -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-DOIEFYKPESCDTYCH
-A KUBE-SVC-KBDEBIL6IU6WL7RF -j KUBE-SEP-2TLZC6QOUTI37HEJ
# KUBE-SEP-TBW2IYJKUCAC7GB3 체인 (svc-clusterip의 첫 번째 endpoint)
-A KUBE-SEP-TBW2IYJKUCAC7GB3 -s 10.10.1.2/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-TBW2IYJKUCAC7GB3 -p tcp -m tcp -j DNAT --to-destination 10.10.1.2:80
# KUBE-SEP-DOIEFYKPESCDTYCH 체인 (svc-clusterip의 두 번째 endpoint)
-A KUBE-SEP-DOIEFYKPESCDTYCH -s 10.10.2.2/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-DOIEFYKPESCDTYCH -p tcp -m tcp -j DNAT --to-destination 10.10.2.2:80
# KUBE-SEP-2TLZC6QOUTI37HEJ 체인 (svc-clusterip의 세 번째 endpoint)
-A KUBE-SEP-2TLZC6QOUTI37HEJ -s 10.10.3.2/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-2TLZC6QOUTI37HEJ -p tcp -m tcp -j DNAT --to-destination 10.10.3.2:80
# KUBE-SVC-TCOU7JCQXEZGVUNU 체인 (kube-dns UDP)
-A KUBE-SVC-TCOU7JCQXEZGVUNU -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-2XZJVPRY2PQVE3B3
-A KUBE-SVC-TCOU7JCQXEZGVUNU -j KUBE-SEP-OEOVYBFUDTUCKBZR
# KUBE-SEP-2XZJVPRY2PQVE3B3 체인 (kube-dns의 첫 번째 endpoint - UDP)
-A KUBE-SEP-2XZJVPRY2PQVE3B3 -s 10.10.0.2/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-2XZJVPRY2PQVE3B3 -p udp -m udp -j DNAT --to-destination 10.10.0.2:53
# KUBE-SVC-ERIFXISQEP7F7OF4 체인 (kube-dns TCP)
-A KUBE-SVC-ERIFXISQEP7F7OF4 -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-XVHB3NIW2NQLTFP3
-A KUBE-SVC-ERIFXISQEP7F7OF4 -j KUBE-SEP-F2ZDTMFKATSD3GWE
# KUBE-SEP-XVHB3NIW2NQLTFP3 체인 (kube-dns의 첫 번째 endpoint - TCP)
-A KUBE-SEP-XVHB3NIW2NQLTFP3 -s 10.10.0.2/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-XVHB3NIW2NQLTFP3 -p tcp -m tcp -j DNAT --to-destination 10.10.0.2:53
# KUBE-SVC-NPX46M4PTMTKRN6Y 체인 (kubernetes API 서버)
-A KUBE-SVC-NPX46M4PTMTKRN6Y -j KUBE-SEP-QKX4QX54UKWK6JIY
# KUBE-SEP-QKX4QX54UKWK6JIY 체인 (kubernetes API 서버 endpoint)
-A KUBE-SEP-QKX4QX54UKWK6JIY -s 172.18.0.3/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-QKX4QX54UKWK6JIY -p tcp -m tcp -j DNAT --to-destination 172.18.0.3:6443
# POSTROUTING 체인
-A POSTROUTING -j KUBE-POSTROUTING
# KUBE-POSTROUTING 체인
-A KUBE-POSTROUTING -m mark ! --mark 0x4000/0x4000 -j RETURN
-A KUBE-POSTROUTING -j MARK --xor-mark 0x4000
-A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -j MASQUERADE --random-fully
# KIND-MASQ-AGENT 체인 (kind 클러스터 특화 규칙)
-A KIND-MASQ-AGENT -d 10.10.0.0/16 -j RETURN
-A KIND-MASQ-AGENT -j MASQUERADE
위의 규칙을 이해하려면 아래의 그림을 이해해야한다.
netfilter는 리눅스 커널 레벨에서 패킷을 처리하는 모듈이다.
iptables는 이런 netfilter를 사용해 네트워크 패킷을 필터링하고 컨트롤한다.
(추가) 정리하다가 설명이 잘되어있는 글을 찾아서 추가하였다.
https://velog.io/@qlgks1/리눅스-방화벽-시스템-방화벽-netfilter-iptables-ufw-nftables
ClusterIP와 연관된 iptables
많이 복잡하기 때문에 net-pod (10.10.0.5)에서 ClusterIP 10.200.1.148:9000을 통해 10.10.1.2:80으로 패킷이 전달되는 과정만 확인해보자.
1. 10.10.0.5에서 10.200.1.148:9000으로 패킷을 생성해 보낸다.
2. iptables의 PREROUTING을 확인하고 KUBE-SERVICES 체인으로 jump한다.
-A PREROUTING -j KUBE-SERVICES
root@myk8s-control-plane:/# iptables -t nat -L PREROUTING | column -t
Chain PREROUTING (policy ACCEPT)
target prot opt source destination
KUBE-SERVICES all -- anywhere anywhere /* kubernetes service portals */
DOCKER_OUTPUT all -- anywhere 172.18.0.1
KUBE-SERVICES는 kube-proxy가 만든 커스텀 체인으로, 이를 통해 kubernetes 에서 발생하는 패킷을 검사하고 원하는 대상으로 SNAT/DNAT 할 수 있다.
3.KUBE-SERVICES 체인에서 목적지 IP와 Port를 확인한다.
-A KUBE-SERVICES -d 10.200.1.148/32 -p tcp -m tcp --dport 9000 -j KUBE-SVC-KBDEBIL6IU6WL7RF
일치하므로 KUBE-SVC-KBDEBIL6IU6WL7RF 체인으로 jump한다.
4. KUBE-SVC-KBDEBIL6IU6WL7RF 체인에서 세 개의 KUBE-SEP 규칙 중 하나가 선택된다.
# KUBE-SVC-KBDEBIL6IU6WL7RF 체인 (svc-clusterip)
-A KUBE-SVC-KBDEBIL6IU6WL7RF -m statistic --mode random --probability 0.33333333349 -j KUBE-SEP-TBW2IYJKUCAC7GB3
-A KUBE-SVC-KBDEBIL6IU6WL7RF -m statistic --mode random --probability 0.50000000000 -j KUBE-SEP-DOIEFYKPESCDTYCH
-A KUBE-SVC-KBDEBIL6IU6WL7RF -j KUBE-SEP-2TLZC6QOUTI37HEJ
여기서 확률에 따라 로드밸런싱이 이루어진다.
10.10.1.2:80으로 패킷이 전달되었으므로 여기서는 첫번째 규칙이 선택된 것으로 해석한다.
KUBE-SEP-TBW2IYJKUCAC7GB3 체인으로 jump한다.
5. KUBE-SEP-TBW2IYJKUCAC7GB3 체인에서 패킷에 대해 마스커레이드 표시 여부를 확인한다.
# KUBE-SEP-TBW2IYJKUCAC7GB3 체인 (svc-clusterip의 첫 번째 endpoint)
-A KUBE-SEP-TBW2IYJKUCAC7GB3 -s 10.10.1.2/32 -j KUBE-MARK-MASQ
-A KUBE-SEP-TBW2IYJKUCAC7GB3 -p tcp -m tcp -j DNAT --to-destination 10.10.1.2:80
출발지 IP가 10.10.1.2로 설정된 트래픽에 대해 마스커레이드(Masquerade) 표시를 추가한다.
해당 규칙은 패킷이 Pod 내부에서 외부로 나갈 때 IP를 변경하는 NAT 작업을 진행한다. 하지만 출발지가 10.10.1.2가 아니므로, 다음 규칙으로 넘어간다.
해당 규칙에서 DNAT를 수행하고 패킷의 목적지 IP와 포트가 10.10.1.2:80으로 변경된다.
6. POSTROUTING 체인을 거쳐 KUBE-POSTROUTING 체인을 확인하고 별도로 marking 된게 없으므로 return하고 패킷이 10.10.1.2:80으로 전달된다.
# POSTROUTING 체인
-A POSTROUTING -j KUBE-POSTROUTING
# KUBE-POSTROUTING 체인
-A KUBE-POSTROUTING -m mark ! --mark 0x4000/0x4000 -j RETURN
-A KUBE-POSTROUTING -j MARK --xor-mark 0x4000
-A KUBE-POSTROUTING -m comment --comment "kubernetes service traffic requiring SNAT" -j MASQUERADE --random-fully
# KIND-MASQ-AGENT 체인 (kind 클러스터 특화 규칙)
-A KIND-MASQ-AGENT -d 10.10.0.0/16 -j RETURN
-A KIND-MASQ-AGENT -j MASQUERADE
POSTROUTING 체인은 클러스터 외부로 나가는 패킷에 대해 SNAT를 수행하는데 사용되지만 여기서는 사용되지 않았다.
iptables의 제약사항
당연히 pod와 ClusterIP 서비스 같은 서비스가 많아지면 해당 iptables는 어마어마하게 복잡해지고, 변경에 따른 iptables 수정에도 많은 리소스가 소모된다. 이를 개선하기 위한 iptables 최적화 옵션도 존재한다.
- EndpointSlice라는 단위로 특정 서비스가 바뀔 때 EndpointSlice에 포함된 목록만 갱신을하거나,
- 동기화 하는 시간을 조정하는 minSyncPeriod, syncPeriod 옵션을 제공한다.
https://kubernetes.io/docs/reference/networking/virtual-ips/#optimizing-iptables-mode-performance
또는 iptables가 아닌 IPVS 모드와 같은 다른 proxy-mode를 사용하는 방법도 있다.
참고자료
- K8s Headless Service, 왜 필요한가 : https://interp.blog/k8s-headless-service-why/
- [k8s] Service - ExternalName 사용하기 : https://kimjingo.tistory.com/150