본문 바로가기

개발/docker + kubernetes

Kubernetes Networking : Part 2 - services

첫번째 글에선 kubernetes가 송신자가 수신자의 파드 네트워크 IP 주소를 아는 한 어떻게 가상 네트워크 장치와 라우팅 규칙을 결합하여 한 클러스터 노드에서 실행되는 파드가 다른 노드에서 실행되는 파드와 통신할 수 있도록 있도록 하는지를 살펴봤다.

파드가 어떻게 통신하는지 잘 모르시면 이 포스팅을 읽기 전 이전 포스팅을 한번 읽어보시길 ㅊㅊ드린다.

 

* 으로 표시된 단어는 포스팅 하단에 간단한 설명이 첨부되어 있습니다.

 

클러스터의 파드 네트워킹은 깔끔하지만, 그 자체로는 내구성있는 시스템을 만들 수 없다.

왜냐, kubernets의 파드는 일회성이기 때문이다.

파드 IP 주소를 endpoint로 사용할 수 있지만 파드를 다시 만들 때 주소가 변경되지 않을 것이라는 보장이 없다.

아마도 여러분은 이 오래된 문제를 아마 알고있을 것이고, 이 문제의 표준 솔루션은 reverse-proxy/load balancer를 통해 트래픽을 실행하는 것이다.

클라이언트는 *프록시에 연결하고 프록시는 요청을 전달할 정상적인 서버 목록을 유지관리한다.

이건 프록시에게 몇 가지 요구사항을 의미하는데 즉, 1) 자체적으로 내구성이 있으며 장애가 발생하지 않아야하고 2)전달할 수 있는 서버목록이 있어야하며 3) 특정 서버가 정상적으로 작동하고 요청에 응답할 수 있는지를 알 수 있는 방법이 있어야 한다.

kubernetes 설계자는 플랫폼의 기본 기능을 기반으로 세가지 요구 사항을 모두 제공하는 훌륭한 방식으로 이 문제를 해결했고, 이건 service라는 리소스 타입으로 시작한다.

 

 

 

Services

 

 

첫번째 게시물에선 두 개의 서버 파드가 있는 가상의 클러스터를 보여주고 노드를 통해 통신할 수 있는 방법을 설명했다.

여기서는 kubernetes 서비스가 어떻게 서버 파드의 한 세트에서 클라이언트 파드가 독립적으로 내구성있게 작동하면서  로드 밸런싱을 수행할 수 있는 지에 대해 설명한다.

서버 파드를 생성하기 우린 다음과 같은 deployment를 사용할 수 있다.

 

 

 

 

 

이 deployment는 8080포트를 실행중인 파드의 호스트 이름으로 응답하는 두 개의 간단한 http 서버 파드를 생성한다.

"kubectl apply"를 사용해 이 deployment를 생성한 후 파드가 클러스터에서 실행 중인 걸 볼 수 있고, 우리는 자신의 파드 네트워크 주소가 무엇인지 확인할 수 있다.

 

 

$ 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

$kubectl get pods --selector=app=service_test_pod -o jsonpath='{.items[*].status.podIP}'
10.0.1.2 10.0.2.2

 

 

우리는 간단한 클라이언트 파드를 만들어 요청을 한 다음 출력을 보면서 파드 네트워크가 작동하고 있음을 입증할 수 있다.

 

 

 

 

이 파드가 생성되면 명령이 완료될 때까지 실행되고 그 후 파드는 "완료"상태가 되고 "kubectl logs"를 사용해 출력을 검색할 수 있다.

 

 

$ kubectl logs service-test-client1
HTTP/1.0 200 OK
<!-- blah -->

<p>Hello from service-test-6ffd9ddbbf-kf4j2</p>

 

 

이 예제에선 어떤 것도 클라이언트 파드가 생성된 노드를 보여주진 않지만 파드 네트워크 덕분에 클러스터에서 실행된 위치와 관계없이 서버 파드에 도달할 수 있다.

그러나 서버 파드가 종료되고 다시 시작되거나 다른 노드로 일정이 변경되면 IP가 거의 확실하게 변경되기 때문에 클라이언트가 중단된다.

이러한 상황을 service를 생성함으로써 피할 수 있다.

 

 

 

 

service는 요청을 파드 세트(서버)로 전달하도록 프록시를 설정하는 kubernetes 리소스 타입이다.

트래픽을 수신할 파드 세트는 파드 세트를 생성했을 때 파드에 지정된 label과 일치하는 selector에 의해 결정된다.

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로 직접 보낼 수 있지만 IP 주소로 확인되는 호스트 이름을 사용하는 것이 좋다.

다행히 kubernetes는 서비스 이름을 확인하는 내부 클러스터 DNS를 제공하기 때문에 클라이언트 파드를 약간만 변경하면 사용할 수 있다.

 

 

 

 

이 파드가 끝나면 서비스가 서버 파드 중 하나에 요청을 전달했다는 것이 출력으로 나타난다.

 

 

$ kubectl logs service-test-client2
HTTP/1.0 200 OK
<!-- blah -->
<p>Hello from service-test-6ffd9ddbbf-kf4j2</p>

 

 

클라이언트 파드를 계속 실행할 수 있으며 각각 대략 50%의 요청을 받는 두 서버 파드의 응답을 볼 수 있다.

이것이 실제로 어떻게 작동 하는지를 이해하는 것이 목표라면 service가 할당된 IP 주소에서 시작하는 것이 좋다.

 

프록시 : 직접 통신할 수 없는 두 점(클라이언트, 서버)사이에서 통신할 경우 그 사이 중계기로서 대리로 통신을 수행하는 기능 (클라이언트 -> 프록시 -> 서버)

 

 

 

Service Network

 

 

test service가 할당된 IP는 네트워크의 주소를 나타내며 파드가 있는 네트워크와 같지 않다는 것을 알았을 수도 있다.

 

 

thing        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

 

 

또한 노드가 있는 사설망과도 같지 않다는 것이 아래 출력에서 더 명확해진다.

첫번째 포스팅에서 파드 네트워크 주소 범위는 "kubectl"을 통해 확인할 수 없다는 걸 강조했기 때문에 클러스터 속성을 검색하려면 provider-specific 명령어를 사용해야한다.

service 네트워크 주소 범위또한 마찬가지다.

Google Container Engine에서 실행중인 경우 다음의 명령을 수행할 수 있다.

 

 

$ gcloud container clusters describe test | grep servicesIpv4Cidr
servicesIpv4Cidr: 10.3.240.0/20

 

 

이 주소 공간으로 지정된 네트워크를 "service network"라고 한다.

유형이 "ClusterIP"인 모든 서비스에 이 네트워크 IP 주소가 할당된다.

다른 종류의 service도 있는데, 다음 포스팅에서 몇 가지 service에 대해 얘기하겠지만 ClusterIP가 기본값이며 이 말은 즉, "service는 클러스터내 모든 파드에서 연결할 수 있는 IP 주소가 할당된다."는 것을 의미한다.

 

 

$ 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>

 

 

파드 네트워크와 마찬가지로 service 네트워크도 가상이지만 파드 네트워크와는 약간 다르다.

파드 네트워크 주소 범위 10.0.0.0/14를 고려하자.

클러스터 내 노드를 구성하는 호스트를 살펴보고 브릿지 및 인터페이스를 나열하면 네트워크의 주소로 구성된 실제 장치를 볼 수 있다.

그것들은 파드를 위한 가상 이더넷 인터페이스와 각각의 파드를 서로 연결하고 외부 세계를 연결하는 브릿지다.

 

이제 service network 10.3.240.0/20을 살펴보자.

ifconfig를 사용하면 이 네트워크에서 주소로 구성된 장치를 찾을 수 없다.

모든 노드를 연결하는 게이트웨이에서 라우팅 규칙을 검사할 수 있는데 이 네트워크에 대한 어떠한 라우트도 찾을 수 없다.

service network는 존재하지 않으므로 적어도 연결된 인터페이스가 아닌 듯한데, 

위의 이미지에서도 봤듯이 네트워크의 IP에 요청을 보냈을 때 어떻게든 그 요청이 파드 네트워크에서 실행되는 서버 파드로 전송되었다는 것이다.

어떻게 된걸까? 패킷을 따라가 보자.

위에서 실행한 명령이 테스트 클러스터내 다음의 파드를 생성했다고 가정해보자.

 

 

 

 

 

 

여기에 노드를 연결하는 게이트웨이(파드 네트워크를 위한 라우팅 규칙도 있음)와 세 개의 파드(node 1의 클라이언트 및 서버 파드, node 2의 다른 서버 파드)가 있다.

클라이언트는 DNS name "service-test"를 사용하여 서비스에 http 요청을 보낸다.

클러스터 DNS 시스템은 해당 name을 service 클러스터 IP 10.3.241.152로 해석하고 클라이언트 파드는 목적지 필드내 해당 IP와 함께 일부 패킷이 전송되도록 하는 http요청을 생성한다.

 

IP 네트워크는 일반적으로 지정된 주소를 가진 장치가 로컬에 존재하지 않기 때문에 인터페이스가 목적지에 패킷을 전달할 수 없는 경우 패킷을 업스트림 게이트웨이로 전달하도록 하는 라우트로 구성된다.

따라서 이 예제에서 패킷을 보는 첫번째 인터페이스는 클라이언트 파드 내부의 가상 이더넷 인터페이스(veth1)다.

이 인터페이스는 파드 네트워크 10.0.0.0/14에 있어 주소가 10.3.241.152인 장치를 알지 못하므로 패킷을 브릿지 cbr0인 게이트웨이로 전달한다.

브릿지는 귀가 꽤나 안들려서 트래픽을 앞뒤로 전달하므로 브릿지가 패킷을 host/node 이더넷 인터페이스(eth0)로 보낸다.

 

 

 

 

 

 

이 예제에서 host/node 이더넷 인터페이스(eth0)는 네트워크 10.100.0.0/24에 있어, 이 또한 주소가 10.3.241.152를 가진 장치를 알지 못하므로 다시 일반적으로 일어나는 일은 이 패킷이 이 인터페이스의 게이트웨이인 최상위 라우터로 전달된다는 것이다.

대신 실제로 일어나는 것은 패킷이 비행 중 걸려서 살아있는 서버 파드 중 하나로 리다이렉션 된다는 것이다.

 

 

 

 

 

 

위의 다이어그램에서 일어나는 일들은 어쩌면 마법 같아 보였다.

어떻게든 나는 연결된 인터페이스가 없는 주소와 클러스터 내 올바른 위치에 나와있는 패킷에 연결할 수 있었고, 

이게 어떻게 이뤄지는 걸까 보니, kube-proxy 때문이었다는 것을 알게됐다.

 

 

 

kube-proxy

 

 

kubernetes의 모든 것과 마찬가지로 서비스는 하나의 리소스이며, 중앙 데이터베이스의 레코드이고, 어떤 작업을 수행하기 위해 일부 소프트웨어를 어떻게 구성할 건지를 설명한다.

실제로 서비스는 클러스터 내 여러 구성요소의 구성 및 동작에 영향을 미치지만

여기에서 중요한 점은, 위에서 설명한 마법을 일으키는 것이 바로 kube-proxy라는 것이다.

많은 사람들이 proxy라는 이름 때문에 이 구성요소가 수행하는 것에 관해 일반적인 기능을 갖고 있겠거니 생각할 수 있지만, kube-proxy에 대한 몇가지 기능이 haproxy나 linkerd와 같은 전형적인 reverse-proxy와는 상당히 다르게 만들어준다.

 

프록시의 일반적인 기능은 두개의 열린 연결을 통해 클라이언트와 서버 사이에 트래픽을 전달하는 것이다.

클라이언트는 인바운드를 서비스 포트에 연결하고 프록시는 아웃바운드를 서버에 연결한다.

이런 종류의 모든 프록시는 사용자 공간에서 실행되기 때문에 패킷이 사용자 공간으로 통제되고 프록시를 통해 모든 모든 비행(패킷)이 커널 공간으로 되돌아간다. 

초기에는 kube-proxy가 그런 사용자 공간 프록시로 구현되었지만 약간 달라졌습니다.(꼬아졌다.)

하나의 프록시는 클라이언트 연결을 listen하고 백엔드 서버에 연결하는데 이용하는 하나의 인터페이스를 필요로 한다.

하나의 노드에서 사용할 수 있는 유일한 인터페이스는 a) 호스트의 이더넷 인터페이스 또는 b) 파드 네트워크의 가상 이더넷 인터페이스이다. 

 

왜 그러한 네트워크 중 하나에서라도 한 주소만 사용하지 않는가?

나는 내부 지식이 없지만 프로젝트 초기에 클러스터의 임시 개체인 파드와 노드의 요구사항을 충족시키도록 설계된 네트워크의 라우팅 규칙이 복잡해졌음이 분명해졌다고 가정하자.

서비스는 자신의 안정적이고 충돌하지 않는 네트워크 주소 공간을 분명히 필요로 했고, 가상 IP 시스템이 가장 적합했다.

하지만 우리가 알듯이 이 네트워크에는 실제 장치가 없다.

라우팅 규칙, 방화벽 필터 등에서 가상 네트워크를 사용할 수는 있지만 실제로 존재하지 않는 인터페이스를 통해 포트에서 listen하거나 연결을 열 순 없다.

 

Kubernetes는 netfilter라는 리눅스 커널 기능과 iptables라는 사용자 공간 인터페이스를 사용하여 이 문제를 해결한다.

이게 어떻게 작동하는 지에 대해선 이미 이 포스팅이 길기 때문에 쓸 자리가 충분하지 않을 것 같다.

좀 더 자세하게 알기 원하면 netfilter 페이지를 먼저 읽는 것으로 시작하는 게 좋다.

*netfilter은 rule-based 패킷 프로세싱 엔진이고, 커널 공간에서 실행되며 라이프 사이클의 여러 시점에서 모든 패킷을 살펴본다.

패킷을 규칙과 비교하고 일치하는 규칙을 발견하면 지정된 작업을 수행하고, 이것이 취할 수 있는 많은 작업 중엔 패킷을 다른 목적지로 리다이렉션하는 작업이 있다.

그렇다, netfilter는 커널 공간 프록시다.

다음 이미지는 kube-proxy가 사용자 공간 프록시로서 실행될 때 netfilter가 담당하는 역할에 대해 보여줄 것이다.

  

 

 

 

 

 

이 모드에서 kube-proxy는 로컬 호스트 인터페이스의 포트(위의 예에서는 10400)를 열어 test-service에 대한 요청을 수신하고 netfilter 규칙을 삽입하여 서비스 IP로 향하는 패킷을 자체 포트로 리라우팅하고, 해당 요청을 포트 8080의 파드로 전달한다. 

그것이 10.3.241.152:80에 대한 요청이 마법처럼 10.0.2.2:8080에 대한 요청이 되는 방법이다.

netfilter의 기능을 생각했을 때 어떤 서비스든 이 모든 작업을 수행하는 데 필요한 것은 kube-proxy가 포트를 열고 해당 서비스에 대한 적절한 netfilter 규칙을 삽입하는 것이고, 이 작업은 마스터 api server에서 클러스터 내 변경 사항에 대한 알림에 응답한다

좀 더 여기에 대해 얘기할 게 있는데, 위에서 언급한 것처럼 사용자 공간 프록시는 패킷을 통제하는 것에 비용이 많이 든다.

kubernetes 1.2에서 kube-proxy는 iptables 모드로 실행하는 기능이 추가됐다.

이 모드에서 kube-proxy는 대개 클러스터 간 연결을 위한 프록시가 되지 않고, 대신 netfilter에게 서비스 IP에 바인딩된 패킷을 탐지하고 이를 파드로 리디렉션하는 작업을 위임한다. 이 작업은 모두 커널 공간에서 발생한다.

이 모드에서 kube-proxy의 작업은 netfilter 규칙을 동기화 상태로 유지하는 것으로 어느 정도 제한된다.

 

 

 

 

 

 

결론을 위해 위에서 설명한 모든 내용을 포스팅 시작 부분에 신뢰할 수 있는 프록시에 필요한 요구 사항과 비교해보자.

서비스 프록시 시스템은 내구성이 있을?

기본적으로 kube-proxy는 systemd 단위로 실행되므로, 실패하면 다시 시작된다.

Google Container Engine에서는 daemonset에 의해 제어되는 파드로 실행되고, 쿠버네티스 1.9 버전의 미래 기본값이 될 것이라고 본다.

사용자 공간 프록시인 kube-proxy는 여전히 연결 실패의 단일 지점을 나타낸다.

iptables 모드에서 실행될 때, 시스템은 연결을 시도하는 로컬 파드의 관점에서 잘 견딜 수 있는데, 그 이유는 노드가 올라간다면 netfilter이기 때문이다.

 

서비스 프록시가 요청을 처리할 수 있는 정상적인 서버 파드를 인식할까?

위에서 언급했듯이 kube-proxy는 서비스와 endpoint에 대한 변경 사항을 포함하여 클러스터 내 변경 사항에 대해 마스터 api server를 listen한다.

업데이트를 받으면 iptables를 사용하여 netfilter 규칙을 동기화 상태를 유지한다.

새로운 서비스가 만들어지고 그 endpoint가 채워지면 kube-proxy는 알림을 받고 필요한 규칙을 생성한다.

마찬가지로 서비스가 삭제될 때는 규칙을 제거한다.

endpoint에 대한 상태 체크는 모든 노드에서 실행되는 kubelet에 의해 수행되고 비정상적인 endpoint가 발견되면 kubelet은 api server를 통해 kube-proxy에 통지하고 netfilter 규칙을 편집하여 이 endpoint가 다시 정상이 될 때까지 제거한다.

 

이 모든 것이 파드 사이의 프록시 요청에 대한 고가용성적인 클러스터 전반적인 기능을 추가하는 반면 클러스터 변경 사항의 요구에 따라 파드 자체가 오고 가는 것을 허용한다. 하지만 시스템엔 단점이 없다.

가장 기본적인 것 중 하나는 클러스터 내부에서 발생하는 요청 (즉, 한 파드에서 다른 파드로 가는 요청)에 대해서만 설명된대로 작동한다는 것이다.

또다른 것은 netfilter 규칙이 작용되는 방식에 따른 결과이다 : 클러스터 외부에서 도착하는 요청의 경우 원래의 IP를 난독화하는 규칙이 적용된다. 이것에 대해선 몇몇의 논쟁이 발생했었고 솔루션이 적극적으로 고려되었다.

이 시리즈의 마지막인 세번째 포스팅에서 진입에 대해 논의할 때 이 두가지 문제를 더 자세히 살펴볼 것이다.

 

 

 

* netfilter : 기본 방화벽으로 사용되는 iptables의 경우 Linux 커널의 네트워크 스택에 있는 패킷 필터링 Hook과 연동되어 동작하는데, 이 Hook을 netfilter framework라고 한다.

자세한 내용은 이 페이지를 참고하는 것이 좋다.

 

 

 

참고

 

https://medium.com/google-cloud/understanding-kubernetes-networking-services-f0cb48e4cc82

https://www.digitalocean.com/community/tutorials/a-deep-dive-into-iptables-and-netfilter-architecture

'개발 > docker + kubernetes' 카테고리의 다른 글

Kubernetes Networking : Part 1 - pods  (0) 2019.06.20