-
Kubernetes 서비스와 네트워킹 - ②DevOps/Kubernetes 2023. 5. 23. 18:17
들어가기 전에
이전 포스팅에서 클라이언트 Pod 에 서버 Pod 의 IP 를 알고 있는 상황을 가정해 살펴보았고 Pod 끼리 통신이 어떻게 동작하는지 확인해보았다. Pod 가 새로 생성되었을 때 IP 가 고정적이지 않기 때문에 서비스 앞단에 reverse-proxy 를 위치시켜 현재 살아있는 서버에게 트래픽을 전달해야 한다. 또한 proxy 서버는 장애애 대응할 수 있어야 하고 트래픽을 전달할 서버 리스트를 가지고 있어야 하며 해당 서버가 정상적인지 확인할 수 있어야 한다. k8s 설계자들은 이 문제를 service 리소스 타입으로 정의했다.
이제부터 k8s service 의 대해 알아보자.
Services
아래 예시를 바탕으로 k8s service 가 클라이언트와는 상관 없이 어떻게 여러 Pod 에 걸쳐 로드밸런싱을 하는지 살펴보자. 서버 Pod 를 생성하기 위해서 deployment object 를 다음과 같이 작성하면 된다.
kind: Deployment apiVersion: extensions/v1beta1 metadata: name: service-test spec: replicas: 2 selector: matchLabels: app: service_test_pod template: metadata: labels: app: service_test_pod spec: containers: - name: simple-http image: python:2.7 imagePullPolicy: IfNotPresent command: ["/bin/bash"] args: ["-c", "echo \"<p>Hello from $(hostname)</p>\" > index.html; python -m SimpleHTTPServer 8080"] ports: - name: http containerPort: 8080
위 deployment 는 두개의 간단한 http 서버 Pod 를 생성하고 8080 포트를 통해 각 Pod 의 hostname 을 리턴한다.
kubectl apply 를 통해 deployment 를 생성한 이후, Pod 가 클러스터에 돌고 있는 것을 확인할 수 있다.
$ kubectl apply -f test-deployment.yaml deployment "service-test" created $ kubectl get pods service-test-6ffd9ddbbf-kf4j2 1/1 Running 0 15s service-test-6ffd9ddbbf-qs2j6 1/1 Running 0 15s** # Pod 네트워크 주소 $ kubectl get pods --selector=app=service_test_pod -o jsonpath='{.items[*].status.podIP}' 10.0.1.2 10.0.2.2**
이제 서버 Pod 가 제대로 동작하는 것을 확인해 보기 위해 서버에 요청을 날리고 결과를 output 하는 클라이언트 Pod 를 하나 생성하자.
apiVersion: v1 kind: Pod metadata: name: service-test-client1 spec: restartPolicy: Never containers: - name: test-client1 image: alpine command: ["/bin/sh"] args: ["-c", "echo 'GET / HTTP/1.1\r\n\r\n' | nc 10.0.2.2 8080"]
클라이언트의 Pod 를 하나 실행하고 난 뒤 Pod 를 살펴보면 completed 상태를 확인할 수 있고 kubectl logs 명령을 통해 실제 결과값을 확인할 수 있다.
$ kubectl logs service-test-client1 HTTP/1.0 200 OK <!-- blah --> <p>Hello from service-test-6ffd9ddbbf-kf4j2</p>
해당 예시에서 클라이언트 Pod 가 어느 노드에서 실행되었는지 알려주지 않지만 그것과는 상관 없이 클라이언트 Pod 가 서버 Pod 로 요청을 날려서 response 를 받았다는 것을 알 수 있다. 이것은 Pod 네트워크 매커니즘 덕분이다.
하지만 서버 Pod 가 죽거나 재시작하거나, 혹은 다른 노드로 재배치된다면 서버 Pod 의 IP 가 아마도 바뀌게 될 것이고 클라이언트 Pod 에서는 이것을 알지 못하여 문제가 발생한다. 이러한 문제를 해결하기 위해 Service 라는 것을 이용한다.
Service 란 k8s 리소스 타입 중 하나로 각 Pod 로 트래픽을 포워딩해주는 Proxy 역할을 한다.
이때 selector 라는 것을 이용하여 트래픽 전달을 받을 Pod 들을 결정한다. 이것은 Pod 가 생성될 때 label 을 부여하여 선택할 수 있게한다. service 를 하나 생성하게 되면 해당 서비스에 IP 주소가 부여된 것을 알 수 있고 80 포트를 통해 요청 받는 것을 알 수 있다.
$ kubectl get service service-test** NAME CLUSTER-IP EXTERNAL-IP PORT(S) AGE service-test 10.3.241.152 <none> 80/TCP 11s
service 에 IP 주소로 직접 요청을 할 수 있지만 DNS 이름을 이용하여 요청할 수 있으면 더 좋다. k8s 에서는 service 이름을 이용하여 DNS 이름으로 사용할 수 있게 내부 클러스터 DNS 를 제공한다.
클라이언트 Pod 를 아래와 같이 조금 바꿔본다면?
apiVersion: v1 kind: Pod metadata: name: service-test-client2 spec: restartPolicy: Never containers: - name: test-client2 image: alpine command: ["/bin/sh"] args: ["-c", "echo 'GET / HTTP/1.1\r\n\r\n' | nc service-test 80"]
해당 Pod 를 실행하고 output 을 보면 service 가 클라이언트 요청을 현재 http Pod 중 하나로 전달해 준 것을 확인할 수 있다.
$ kubectl logs service-test-client2 HTTP/1.0 200 OK <!-- blah --> <p>Hello from service-test-6ffd9ddbbf-kf4j2</p>
계속해서 클라이언트 Pod 를 실행하면 약 50% 비율로 각 http Pod 가 response 를 리턴하는 것을 확인할 수 있다.
대체 어떠한 방법으로 service 가 동작하는지 궁금하다면 service IP 가 할당된 방법에 대해서 먼저 알아봐야 한다.
Service 네트워크
test service 에 할당된 IP 는 네트워크에 있는 주소인 것을 확인할 수 있다. 하지만 그 IP 대역이 Pod 들과는 조금 다르다.
종류 IP Network pod1 10.0.1.2 10.0.0.0/14 pod2 10.0.2.2 10.0.0.0/14 service 10.3.241.152 10.3.240.0/20 실제 Pod Network 대역대와 service Network 대역대를 확인하려면 단순히 kubectl 을 이용하여 알 수는 없고 구축한 클러스터 방법마다 조금씩 상이하다.
- 자체 구축시: kubelet 에서 -pod-cidr 값 확인 kube-apiserver 에서 -service-cluster-ip-range 값 확인
- GCP 를 이용하였을 때,
$ gcloud container clusters describe test | grep servicesIpv4Cidr
- EKS 를 이용하였을 때 10.100.x.x/16 혹은 172.20.x.x/16 대역대만 가능 (정책이 바뀔 수 있으니 공식 홈을 확인하자)
여기에 정의된 네트워크 주소 공간을 k8s 에서는 service network 라고 한다.
모든 service 는 이러한 주소를 할당 받게 된다. service 에는 여러 타입의 service 가 존재하고 ClusterIP 가 가장 기본이 되는 타입이다. ClusterIP 의 뜻은 클러스터 내의 모든 Pod 가 해당 Cluster IP 주소로 접근할 수 있다는 뜻이다.
kubectl describe service 라는 명령을 통해 더 자세한 정보를 확인할 수 있다.
$ kubectl describe services service-test Name: service-test Namespace: default Labels: <none> Selector: app=service_test_pod Type: ClusterIP IP: 10.3.241.152 Port: http 80/TCP Endpoints: 10.0.1.2:8080,10.0.2.2:8080 Session Affinity: None Events: <none>
Pod 네트워크와 동일하게 service 네트워크 또한 가상 IP 주소이다. 하지만 Pod 네트워크와는 조금 다르게 동작한다. 먼저 Pod 네트워크가 10.0.0.0/14 의 대역대를 가진다고 생각해보자. 만약 실제 host에 가서 직접 bridge 와 interface 를 확인하면 device 들이 존재하는 것을 확인할 수 있다. 이것은 가상 ethernet interface 들이 Pod 끼리 통신하기 위해 bridge 와 연결된 device 이다.
이제 service network 를 확인해보자. 예시에서 service 네트워크의 대역대가 10.3.240.0/20 이다. 직접 노드에서 ifconfig 명령을 내리더라도 이와 관련된 아무런 네트워크 device 도 나오지 않는걸 볼 수 있다.
각 노드들을 전부 연결하는 게이트웨이의 routing 테이블을 확인해 보아도 아무런 service 네트워크에 대한 라우팅 정보가 없는 것을 확인할 수 있다.
service 네트워크는 적어도 이런 방식을 통해서 구성되어 있지 않을 것임을 알 수 있다. 그럼에도 불구하고 위 예시에서 service IP 로 요청을 하게 되면 어떠한 방법을 통해서인지는 몰라도 그 요청이 Pod 네트워크에 존재하는 Pod 로 전달되는 것을 확인할 수 있었다. 이제부터 어떤 방법이 이것을 가능하게 했는지 확인해보려한다.
먼저 다음과 같은 Pod 네트워크가 구성되어 있다고 생각하자.
위의 그림을 보시면 두개의 노드가 있고 게이트웨이를 통해 서로 연결되어 있다.
게이트웨이의 라우팅 테이블에는 Pod 네트워크를 위한 정보가 적혀져 있다. 총 3개의 Pod 가 있는데 1개의 클라이언트 Pod와 1개의 서버 Pod 가 한쪽 노드에, 다른 노드에서는 1개의 서버 Pod 가 존재한다.
클라이언트 http request 를 service-test 라는 DNS 이름으로 요청한다. 클러스터 DNS 서버가 해당 이름을 service IP(예시로 10.3.241.152)로 매핑 시켜준다. http 클라이언트는 DNS 로부터 IP를 이용하여 최종적으로 요청을 보내게 된다.
IP 네트워크는 보통 자신의 host 에서 목적지를 찾지 못하게 되면 상위 게이트웨이로 전달하도록 동작한다.
예시에서 보면 Pod 안에 들어있는 첫번째 가상 ethernet interface 에서 IP 를 보게 되고 10.3.241.152 라는 주소에 대해 전혀 알지 못하기 때문에 다음 게이트웨이(bridge cbr0)로 패킷을 넘기게 된다. Bridge 의 역할을 꽤나 단순하다. bridge 로 오고 가는 패킷을 단순히 다음 network node 의 interface 로 전달하는 역할만을 한다.
연속해 아래 예시에서, 노드의 ethernet interface 대역대는 10.100.0.0/24 이다. 마찬가지로 노드에서도 10.3.241.152 의 주소에 대해서 알지 못하기 때문에 보통이라면 최상위에 존재하는 게이트웨이로 전달될 것이다.
하지만 여기서는 특별하게 패킷의 주소가 변경되어 server Pod 중 하나로 패킷이 전달되게 된다. (굳이 같은 host 인 server Pod(10.0.1.2) 뿐만 아니라 다른 host 에 존재하는 Pod(10.0.2.2)로도 전달이 가능하다.)
k8s 네트워킹 방식을 꽤나 신기하다. 대체 어떤 방법인지는 몰라도 클라이언트 Pod 가 service IP 에 대한 interface device 가 없음에도 불구하고(물리든 가상이든) server Pod 와 정확하게 통신할 수 있었다. 이러한 방식을 가능하게 했던 것은 바로 k8s의 컴포넌트 중 하나인 kube-proxy 라는 녀석 때문이라는 것을 알 수 있다.
kube-proxy
k8s의 다른 모든 것과 마찬가지로 service 또한 하나의 k8s resource 에 불과하다.
사실 service 를 하나 등록하게 되면 k8s의 여러 컴포넌트들에 영향을 미치게 되는데 여기서는 위의 예시를 가능하게 했던 kube-proxy 에 대해서만 살펴보려고 한다.
kube-proxy 는 haproxy 나 linkerd 와 같이 보통이 reverse-proxy 와는 조금 다른 부분들이 있다.
proxy 의 일반적인 역할은 서도 열린 connection 을 통해 클라이언트와 서버의 트래픽을 전달하는데 있다. 클라이언트는 service port 로 inbound 연결을 하게 되고 proxy 에서 서버로 outbound 연결을 한다. 이런 종류의 proxy 서버들은 전부 user space 에서 동작하기 때문에 모든 패킷들은 user space 를 지나 다시 kernel space 를 거쳐서 proxy 된다.
kube-proxy 도 마찬가지로 user space proxy 로 구현되어 있는데 기존의 proxy 와는 약간 다른 방법으로 구현되어 있다.
proxy 는 기본적으로 interface device 가 필요하다. 클라이언트와 서버 모두 연결을 맺을 때 필요하다. 이때 우리가 사용할 수 있는 interface 는 host 에 존재하는 ethernet interface 이거나 Pod 내에 존재하는 가상 ethernet interface 두개 뿐이다.
그 두가지 네트워크 중 하나를 이용하는 건 어떨까?
초기 프로젝트에서 이러한 방식으로 네트워크를 구성하는 것은 나중에 라우팅 규칙을 굉장히 복잡하게 만든다는 것을 깨달았기 때문이다. 왜냐하면, 이러한 네트워크 구조는 Pod 나 Node 처럼 쉽게 대체될 수 있는 개체를 위해 설계되었기 때문이다. Service 는 이와 다르게 더 독립적이고 안정적이며 기존의 네트워크 주소 공간과는 겹치지 않는 네트워크 구조가 필요했고 가상 IP 구조가 가장 적합했다. 하지만 앞서 보았듯 실제 device 들이 존재하지 않았다.
우리는 가상의 device 를 이용하여 포트를 열고 connection 을 맺을 수 있습니다만 아예 존재하지 않는 device 를 이용할 수는 없다.
k8s 는 리눅스 커널의 기능 중 하나인 netfilter 와 user space 에 존재하는 interface 인 iptables 라는 녀석들을 이용하여 해결한다. 여기서는 이 두가지를 다루지 않을 것이고 깊게 알아보고 싶다면 netfilter page 확인해보자.
짧게 요약하자면, netfilter 란 Rule-based 패킷 처리 엔진이다. kernel space 에 위치하며 모든 오고 가는 패킷의 생명주기를 관찰한다. 그리고 규칙에 매칭되는 패킷을 발견하면 미리 정의된 action 을 수행한다. 많은 action 들 중에 특별히 destination 의 주소를 변경할 수 있는 action 도 있다. 다시말해, netfilter 란 kernel space 에 존재하는 proxy 이다.
아래 그림은 kube-proxy 가 user space proxy 로 실행될 때 netfilter 의 역할에 대해서 설명한다.
kube-proxy 가 user space mode 로 동작할 때,- kube-proxy 가 localhost interface 에서 service 의 요청을 받아내기 위해 10400 포트를 연다.(예시 기준)
- netfilter 로 하여금 service IP 로 들어오는 패킷을 kube-proxy 자신에게 라우팅 되도록 설정을 한다.
- kube-proxy 로 들어온 요청을 실제 server Pod 의 IP:Port 로 요청을 전달한다. (예시에서는 10.0.2.2:8080)
이러한 방법을 통해 service IP 10.3.241.152:80 로 들어온 요청을 마법처럼 실제 server Pod 가 위치한 10.0.2.2:8080 로 전달할 수 있다.
netfilter 의 능력은, 이 모든 것을 하기 위해서는 단지 kube-proxy 가 자신의 포트를 열고 마스터 api 서버로 부터 전달 받은 service 정보를 netfilter 에 알맞는 규칙으로 입력하는 것 외엔 다른 것이 필요없다.
한가지 조금 더 설명드릴 것이 있다. 앞서 설명 드렸듯이 user space 에서 proxying 을 하는 것은 모든 패킷을 user space 에서 kernel space 로 변환을 해야하기 때문에 그만큼 비용이 든다.
k8s 1.2 kube-proxy 에서는 이것을 해결하기 위해 iptables mode 가 생겼다. 이 모드에서는 kube-proxy 가 직접 proxy 의 역할을 수행하지 않고 그 역할을 전부 netfilter 에게 맡겼다. 이를 통해 service IP 를 발견하고 그것을 실제 Pod 로 전달하는 것은 모두 netfilter 가 담당하게 되었고 kube-proxy 는 단순히 이 netfilter 의 규칙을 알맞게 수정하는 것을 담당할 뿐이다.
마무리하기 전, 이러한 방식이 맨 처음 언급했던 요구사항인 reliable한 proxy 인지 생각해 보자.
kube-proxy 가 내구성 있는 시스템 일까?
kube-proxy 는 기본적으로 systemd unit 으로 동작하거나 daemonset 으로 설치가 된다.
그렇기 때문에 프로세스가 죽어도 다시 살아날 수 있다. kube-proxy 가 user space 모드로 동작할 때는 단일 지점 장애점이 될 수 있다. 하지만 iptables 모드로 동작할 때는 꽤나 안정적으로 동작할 수 있다. 왜냐하면, 이는 netfilter 를 통해 동작하고 서버가 살아있는 한 netfilter 도 동작하는 것을 보장 받을 수 있기 때문이다.
service proxy 가 healthy server pod 를 감지할 수 있을까?
위에서 언급했듯 kube-proxy 는 마스터 api 서버의 정보를 수신하기 때문에 클러스터의 변화를 감지한다.
이를 통해 지속적으로 iptables 을 업데이트하여 netfilter 의 규칙을 최신화한다.
새로운 service 가 생성되면 kube-proxy 는 알림을 받게 되고 그에 맞는 규칙을 생성한다. 반대로 service 가 삭제되면 이와 비슷한 방법으로 규칙을 삭제한다. 서버의 health check 는 kubelet 을 통하여 수행한다.
kubelet 은 또 다른 서버에 설치되는 k8s 컴포넌트 중 하나이다. 이 kubelet 이 서버의 health check 을 수행하여 문제를 발견시 마스터 api 서버를 통해 kube-proxy 에게 알려 unhealthy Pod 의 endpoint 를 제거한다.
이러한 방법을 통해 각 Pod 들이 proxy 를 통해 서로 통신할 때 고가용한 시스템을 구축할 수 있다. 반대로 이러한 시스템의 단점은 클러스터 안의 Pod 에서 요청한 request 만 위와 같은 방식으로 동작한다.
다음으로는 netfilter 를 사용하는 방식 때문에 외부에서 들어온 요청에 대해서는 원 요청자의 origin IP 가 수정되어 들어오게 된다.
'DevOps > Kubernetes' 카테고리의 다른 글
Kubernetes 서비스와 네트워킹 - ③ (0) 2023.05.24 Kubernetes 서비스와 네트워킹 - ① (0) 2023.05.16