실습환경 구성
cat <<EOT> kind-svc-2w.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
featureGates:
"InPlacePodVerticalScaling": true #실행 중인 파드의 리소스 요청 및 제한을 변경할 수 있게 합니다.
"MultiCIDRServiceAllocator": true #서비스에 대해 여러 CIDR 블록을 사용할 수 있게 합니다.
nodes:
- role: control-plane
labels:
mynode: control-plane
topology.kubernetes.io/zone: ap-northeast-2a
extraPortMappings: #컨테이너 포트를 호스트 포트에 매핑하여 클러스터 외부에서 서비스에 접근할 수 있도록 합니다.
- containerPort: 30000
hostPort: 30000
- containerPort: 30001
hostPort: 30001
- containerPort: 30002
hostPort: 30002
- containerPort: 30003
hostPort: 30003
- containerPort: 30004
hostPort: 30004
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs: #API 서버에 추가 인수를 제공
runtime-config: api/all=true #모든 API 버전을 활성화
controllerManager:
extraArgs:
bind-address: 0.0.0.0
etcd:
local:
extraArgs:
listen-metrics-urls: http://0.0.0.0:2381
scheduler:
extraArgs:
bind-address: 0.0.0.0
- |
kind: KubeProxyConfiguration
metricsBindAddress: 0.0.0.0
- role: worker
labels:
mynode: worker1
topology.kubernetes.io/zone: ap-northeast-2a
- role: worker
labels:
mynode: worker2
topology.kubernetes.io/zone: ap-northeast-2b
- role: worker
labels:
mynode: worker3
topology.kubernetes.io/zone: ap-northeast-2c
networking:
podSubnet: 10.10.0.0/16 #파드 IP를 위한 CIDR 범위를 정의합니다. 파드는 이 범위에서 IP를 할당받습니다.
serviceSubnet: 10.200.1.0/24 #서비스 IP를 위한 CIDR 범위를 정의합니다. 서비스는 이 범위에서 IP를 할당받습니다.
EOT
kind를 활용해 클러스터를 생성합니다.
kind create cluster --config kind-svc-2w.yaml --name myk8s --image kindest/node:v1.31.0
아래와 같이 클러스터 정보 확인 시 정상 배포된 것입니다.
MetalLB
MetalLB의 Metal은 BareMetal(베어메탈)을 의미하는데, 주로 온프레미스 환경에서 서버를 지칭할 때, BareMetal이라는 표현을 사용합니다. 같은 의미로 온프레미스 환경에서 사용할 수 있는 여러 LoadBalancer 중 MetalLB 소프트웨어 LoadBalancer를 소개합니다. 온프레미스 환경에서 사용 가능한 하드웨어 제품으로는 Cirtrix, F5 등이 있고, 소프트웨어 제품은 MetalLB 이외에도 OpenELB, PubeLB 등이 있습니다.
벤더 마다 동작의 차이가 있지만, 소프트웨어 제품들은 동작이 거의 유사한데, MetalLB의 경우 Layer2 모드와 BGP 모드 두 가지가 존재합니다. Layer2 모드를 사용하는 경우, DaemonSet으로 Speaker Pod를 생성해 External IP를 전파하고, 이 IP를 통해 노드의 IP 대신 외부에서 접속할 수 있는 엔트포인트를 제공할 수 있습니다. (NodePort를 사용하는 것과 같이 노드의 IP를 외부에 노출할 필요가 없어 보안성을 높일 수 있다는 장점이 있습니다.) Speaker Pod는 External IP를 전파하기 위해 ARP, BGP를 사용합니다. (ARP를 사용하기 때문에 MetalLB는 CSP 환경에서는 동작하지 않습니다. CSP에서는 ARP, Broadcast 등이 동작하지 않고, Mapping Server라는 개념을 사용하기 때문입니다.) 또한, Calico와 같은 일부 CNI에서 IPIP 모드에서 BGP를 사용하면서, MetalLB BGP 모드를 사용할 경우, BGP 충돌이 발생해 문제가 발생할 가능성이 있습니다.
아래 이미지는 Layer2 모드의 동작 방식에 대한 그림으로 WireShark를 통해 패킷 캡처 결과를 살펴보면, Leader(Speaker) Pod가 선출되고 IP 충돌을 막고, 자신의 IP를 공유하기 위해 GARP를 사용하는 것을 보여주고 있습니다. 외부로부터 요청이 들어오면, MetalLB에서 실제 요청을 처리할 각 Pod로 DNAT 됩니다. (Leader Pod가 생성된 노드로만 트래픽이 들어오고, 해당 노드의 iptables 규칙에 의해 여러 Pod로 분산됩니다.) 단점으로는 리더 역할을 하는 Speaker Pod에 부하가 생긴다는 것인데, Failover를 진행하는 경우, ARP의 전파, 갱신으로 인해 Downtime이 약 10-20초 발생할 수 있습니다.
BGP 모드를 사용하면, Speaker Pod에서 BGP를 통해 External IP를 전파한 후 외부에서 라우터를 통해 ECMP 라우팅으로 부하 분산합니다. 이때, 전파하는 IP는 32비트를 기본으로 하며, bgp-advertisements에 aggregation-length 설정을 통해 축약된 네트워크 정보를 전파할 수도 있습니다. Layer2 모드에서 Failover 시 발생하는 Downtime을 고려하면 BGP 모드가 더 매력적으로 보일 순 있는데, BGP라는 라우팅 프로토콜 자체가 까다롭기도 하고, 여러 설정을 건드려야 해서 네트워크를 운영하는 팀과의 협업이 필수라고 생각됩니다.
MetalLB를 테스트하기 위한 구성으로 서로 다른 노드에 webpod를 배포하고, 외부에서 접속하기 위한 LoadBalancer 유형의 Service를배포합니다. 이후 클러스터 외부에서 MetalLB를 통해 접속하는 구조입니다.
MetalLB 설치를 위한 코드입니다. 코드를 배포하는 방법은 Manifests, Helm 등 여러가지 방법이 존재하지만, 테스트를 위한 코드 배포이므로 가장 간단한 Manifests 방식을 사용했습니다.
kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/refs/heads/main/config/manifests/metallb-native-prometheus.yaml
DaemonSet으로 배포된 Pod 내 kube-rbac-proxy 컨테이너는 Prometheus Exporter 역할을 위한 컨테이너 입니다.
MetalLB는 Service 오브젝트를 위한 외부 IP 주소를 관리하고, Service가 생성될 때 해당 IP 주소를 동적으로 할당할 수 있습니다. 이를 위해 IPAddressPool이라는 Custom Resource를 통해 사용할 IP 대역을 정의하고, L2Advertisement를 통해 미리 설정한 IP Pool을 기반으로 Layer2 모드로 LoadBalancer 유형의 Service가 IP를 사용할 수 있도록 허용합니다. (Kubernetes 클러스터 내의 Service가 외부 네트워크에 IP 주소를 광고하는 방식을 정의)
cat <<EOF | kubectl apply -f -
apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
name: my-ippool
namespace: metallb-system
spec:
addresses:
- 172.18.255.200-172.18.255.250
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
name: my-l2-advertise
namespace: metallb-system
spec:
ipAddressPools:
- my-ippool
EOF
유형이 LoadBalancer인 Service와 Pod를 배포합니다.
cat <<EOF | kubectl apply -f -
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: Service
metadata:
name: svc1
spec:
ports:
- name: svc1-webport
port: 80
targetPort: 80
selector:
app: webpod
type: LoadBalancer # 서비스 타입이 LoadBalancer
---
apiVersion: v1
kind: Service
metadata:
name: svc2
spec:
ports:
- name: svc2-webport
port: 80
targetPort: 80
selector:
app: webpod
type: LoadBalancer
---
apiVersion: v1
kind: Service
metadata:
name: svc3
spec:
ports:
- name: svc3-webport
port: 80
targetPort: 80
selector:
app: webpod
type: LoadBalancer
EOF
위 구성도의 클라이언트 역할을 하는 mypc 컨테이너를 생성합니다.
docker run -d --rm --name mypc --network kind --ip 172.18.0.100 nicolaka/netshoot sleep infinity
테스트를 위해 변수를 정의합니다.
SVC1EXIP=$(kubectl get svc svc1 -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
SVC2EXIP=$(kubectl get svc svc2 -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
SVC3EXIP=$(kubectl get svc svc3 -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
echo $SVC1EXIP $SVC2EXIP $SVC3EXIP
클러스터 외부에서 IP를 arping으로 호출하면 Service에서 사용하는 External IP로 정상 응답이 옵니다. (arping이 아닌 ping을 할 경우, ICMP가 허용되어 있지 않아 차단됩니다.)
mypc 컨테이너에서 ARP 테이블 정보를 확인하면, Service IP가 Leader Pod 역할의 노드인 것을 알 수 있습니다.
Service(Type: LoadBalancer) 호출 테스트
for i in $SVC1EXIP $SVC2EXIP $SVC3EXIP; do docker exec -it mypc curl -s $i | grep Hostname ; done
for i in $SVC1EXIP $SVC2EXIP $SVC3EXIP; do echo ">> Access Service External-IP : $i <<" ; docker exec -it mypc curl -s $i | grep Hostname ; echo ; done
Remote IP의 경우, 노드 IP로 찍히는 것을 알 수 있는데, LoadBalacner 유형은 NodePort 유형을 포함하고 있어서 노드의 인터페이스로 SNAT 되어 동작하기 때문입니다.
for i in $SVC1EXIP $SVC2EXIP $SVC3EXIP; do echo ">> Access Service External-IP : $i <<" ;docker exec -it mypc curl -s $i | egrep 'Hostname|RemoteAddr|Host:' ; echo ; done
부하분산을 테스트 해보면 100% 고르진 않지만, 적절하게 부하가 분산되는 것을 알 수 있습니다.
Layer2 모드의 Failover 테스트
Failover 테스트를 위해 Speaker Pod가 존재하는 노드를 중단시켜 장애를 재현합니다. curl 명령어를 통해 서비스를 지속적으로 호출하면, 약 20초까지는 요청이 불안정하게 접속이 된 것을 알 수 있습니다. (실제로는 다른 노드의 Speaker Pod가 리더가 되고, 이후 다시 노드가 정상화되면, 다시 Leader가 됨)
BGP 모드는 실습이 어려워 생략했습니다.
IPVS
kubeproxy의 IPVS 모드를 테스트 하기 위한 kind 클러스터 설정입니다.
cat <<EOT> kind-svc-2w-ipvs.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
featureGates:
"InPlacePodVerticalScaling": true
"MultiCIDRServiceAllocator": true
nodes:
- role: control-plane
labels:
mynode: control-plane
topology.kubernetes.io/zone: ap-northeast-2a
extraPortMappings:
- containerPort: 30000
hostPort: 30000
- containerPort: 30001
hostPort: 30001
- containerPort: 30002
hostPort: 30002
- containerPort: 30003
hostPort: 30003
- containerPort: 30004
hostPort: 30004
kubeadmConfigPatches:
- |
kind: ClusterConfiguration
apiServer:
extraArgs:
runtime-config: api/all=true
controllerManager:
extraArgs:
bind-address: 0.0.0.0
etcd:
local:
extraArgs:
listen-metrics-urls: http://0.0.0.0:2381
scheduler:
extraArgs:
bind-address: 0.0.0.0
- |
kind: KubeProxyConfiguration
metricsBindAddress: 0.0.0.0
ipvs:
strictARP: true
- role: worker
labels:
mynode: worker1
topology.kubernetes.io/zone: ap-northeast-2a
- role: worker
labels:
mynode: worker2
topology.kubernetes.io/zone: ap-northeast-2b
- role: worker
labels:
mynode: worker3
topology.kubernetes.io/zone: ap-northeast-2c
networking:
podSubnet: 10.10.0.0/16
serviceSubnet: 10.200.1.0/24
kubeProxyMode: "ipvs"
EOT
설치된 클러스터의 kubeproxy 모드 확인합니다. (기본값은 iptables 입니다.)
kubectl describe cm -n kube-system kube-proxy
IPVS 모드를 사용하면, 노드에 kube-ipvs0라는 인터페이스가 생성됩니다.
for i in control-plane worker worker2 worker3; do echo ">> node myk8s-$i <<"; docker exec -it myk8s-$i ip -c addr; echo; done
for i in control-plane worker worker2 worker3; do echo ">> node myk8s-$i <<"; docker exec -it myk8s-$i ip -br -c addr show kube-ipvs0; echo; done
기본으로 랜덤 알고리즘을 사용하는 iptables와 달리, IPVS는 RoundRobin을 기본으로 사용합니다.
부하 분산을 테스트 하기 위해 샘플을 배포합니다.
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
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
kubectl apply -f 3pod.yaml,netpod.yaml,svc-clusterip.yaml
Service를 생성하면, 할당된 IP가 kube-ipvs0 인터페이스에 추가된 것을 알 수 있습니다.
100번 호출해보면, 매우 고르게 부하분산이 되는 것을 알 수 있습니다.
노드에서 Count 값도 증가된 것을 알 수 있습니다.
알고리즘 방식을 RoundRobin에서 Souring Hashing으로 변경해봤습니다.
변경 이후 kube-proxy를 재시작 했습니다.
재시작 완료됐습니다.
알고리즘 변경 후 동일하게 100번 요청한 결과 모두 동일한 Pod로 요청이 가는 것을 알 수 있습니다.