튜토리얼

Edit This Page

소스 IP 주소 이용하기

쿠버네티스 클러스터에서 실행 중인 애플리케이션은 서로 간에 외부 세계와 서비스 추상화를 통해 찾고 통신한다. 이 문서는 다른 종류의 서비스로 보내진 패킷의 소스 IP 주소에 어떤 일이 벌어지는지와 이 동작을 요구에 따라 토글할 수 있는지 설명한다.

목적

  • 간단한 애플리케이션을 다양한 서비스 종류로 노출하기
  • 각 서비스 유형에 따른 소스 IP NAT 의 동작 이해하기
  • 소스 IP 주소 보존에 관한 절충 사항 이해

시작하기 전에

쿠버네티스 클러스터가 필요하고, kubectl 커맨드-라인 툴이 클러스터와 통신할 수 있도록 설정되어 있어야 합니다. 만약, 클러스터를 이미 가지고 있지 않다면, Minikube를 사용해서 만들거나, 다음의 쿠버네티스 플레이그라운드 중 하나를 사용할 수 있습니다:

버전 확인을 위해서, 다음 커맨드를 실행 kubectl version.

용어

이 문서는 다음 용어를 사용한다.

  • NAT: 네트워크 주소 변환
  • 소스 NAT: 패킷 상의 소스 IP 주소를 변경함, 보통 노드의 IP 주소
  • 대상 NAT: 패킷 상의 대상 IP 주소를 변경함, 보통 파드의 IP 주소
  • VIP: 가상 IP 주소, 모든 쿠버네티스 서비스에 할당된 것 같은
  • Kube-proxy: 네트워크 데몬으로 모든 노드에서 서비스 VIP 관리를 관리한다.

전제 조건

이 문서의 예시를 실행하기 위해서 쿠버네티스 1.5 이상의 동작하는 클러스터가 필요하다. 이 예시는 HTTP 헤더로 수신한 요청의 소스 IP 주소를 회신하는 작은 nginx 웹 서버를 이용한다. 다음과 같이 생성할 수 있다.

kubectl run source-ip-app --image=k8s.gcr.io/echoserver:1.4

출력은 다음과 같다.

deployment.apps/source-ip-app created

Type=ClusterIP인 서비스에서 소스 IP

쿠버네티스 1.2부터 기본으로 제공하는 iptables 모드로 운영하는 경우 클러스터 내에서 클러스터 IP로 패킷을 보내면 소스 NAT를 통과하지 않는다. Kube-proxy는 이 모드를 proxyMode 엔드포인트를 통해 노출한다.

kubectl get nodes

출력은 다음과 유사하다

NAME                           STATUS     ROLES    AGE     VERSION
kubernetes-node-6jst   Ready      <none>   2h      v1.13.0
kubernetes-node-cx31   Ready      <none>   2h      v1.13.0
kubernetes-node-jj1t   Ready      <none>   2h      v1.13.0

한 노드의 프록시 모드를 확인한다.

kubernetes-node-6jst $ curl localhost:10249/proxyMode

출력은 다음과 같다.

iptables

소스 IP 애플리케이션을 통해 서비스를 생성하여 소스 IP 주소 보존 여부를 테스트할 수 있다.

$ kubectl expose deployment source-ip-app --name=clusterip --port=80 --target-port=8080
service/clusterip exposed

$ kubectl get svc clusterip
NAME         TYPE        CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
clusterip    ClusterIP   10.0.170.92   <none>        80/TCP    51s

그리고 동일한 클러스터의 파드에서 클러스터IP를 치면:

$ kubectl run busybox -it --image=busybox --restart=Never --rm
Waiting for pod default/busybox to be running, status is Pending, pod ready: false
If you don't see a command prompt, try pressing enter.

# ip addr
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
3: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1460 qdisc noqueue
    link/ether 0a:58:0a:f4:03:08 brd ff:ff:ff:ff:ff:ff
    inet 10.244.3.8/24 scope global eth0
       valid_lft forever preferred_lft forever
    inet6 fe80::188a:84ff:feb0:26a5/64 scope link
       valid_lft forever preferred_lft forever

# wget -qO - 10.0.170.92
CLIENT VALUES:
client_address=10.244.3.8
command=GET
...

client_address는 클라이언트 파드와 서버 파드가 같은 노드 또는 다른 노드에 있는지 여부에 관계없이 항상 클라이언트 파드의 IP 주소이다.

Type=NodePort인 서비스에서 소스 IP

쿠버네티스 1.5부터 Type=NodePort인 서비스로 보내진 패킷은 소스 NAT가 기본으로 적용된다. NodePort 서비스를 생성하여 이것을 테스트할 수 있다.

$ kubectl expose deployment source-ip-app --name=nodeport --port=80 --target-port=8080 --type=NodePort
service/nodeport exposed

$ NODEPORT=$(kubectl get -o jsonpath="{.spec.ports[0].nodePort}" services nodeport)
$ NODES=$(kubectl get nodes -o jsonpath='{ $.items[*].status.addresses[?(@.type=="IPAddress")].address }')

클라우드 공급자 상에서 실행한다면, 위에 보고된 nodes:nodeport를 위한 방화벽 규칙을 열어주어야 한다. 이제 위에 노드 포트로 할당받은 포트를 통해 클러스터 외부에서 서비스에 도달할 수 있다.

$ for node in $NODES; do curl -s $node:$NODEPORT | grep -i client_address; done
client_address=10.180.1.1
client_address=10.240.0.5
client_address=10.240.0.3

명심할 것은 정확한 클라이언트 IP 주소가 아니고, 클러스터 내부 IP 주소이다. 왜 이런 일이 발생했는지 설명한다.

  • 클라이언트는 node2:nodePort로 패킷을 보낸다.
  • node2는 소스 IP 주소(SNAT)를 패킷 상에서 자신의 IP 주소로 교체한다.
  • noee2는 대상 IP를 패킷 상에서 파드의 IP로 교체한다.
  • 패킷은 node 1로 라우팅 된 다음 엔드포인트로 라우팅 된다.
  • 파드의 응답은 node2로 다시 라우팅된다.
  • 파드의 응답은 클라이언트로 다시 전송된다.

시각적으로

          client
             \ ^
              \ \
               v \
   node 1 <--- node 2
    | ^   SNAT
    | |   --->
    v |
 endpoint

이를 피하기 위해 쿠버네티스는 클라이언트 소스 IP 주소를 보존하는 기능이 있다. (기능별 가용성은 여기에). service.spec.externalTrafficPolicyLocal로 하면 오직 로컬 엔드포인트로만 프록시 요청하고 다른 노드로 트래픽 전달하지 않으므로, 원본 소스 IP 주소를 보존한다. 만약 로컬 엔드 포인트가 없다면, 그 노드로 보내진 패킷은 버려지므로 패킷 처리 규칙에서 정확한 소스 IP 임을 신뢰할 수 있으므로, 패킷을 엔드포인트까지 전달할 수 있다.

다음과 같이 service.spec.externalTrafficPolicy 필드를 설정하자.

$ kubectl patch svc nodeport -p '{"spec":{"externalTrafficPolicy":"Local"}}'
service/nodeport patched

이제 다시 테스트를 실행해보자.

$ for node in $NODES; do curl --connect-timeout 1 -s $node:$NODEPORT | grep -i client_address; done
client_address=104.132.1.79

엔드포인트 파드가 실행 중인 노드에서 올바른 클라이언트 IP 주소인 딱 한 종류의 응답만 수신한다.

어떻게 이렇게 되었는가:

  • 클라이언트는 패킷을 엔드포인트가 없는 node2:nodePort 보낸다.
  • 패킷은 버려진다.
  • 클라이언트는 패킷을 엔드포인트를 가진 node1:nodePort 보낸다.
  • node1은 패킷을 올바른 소스 IP 주소로 엔드포인트로 라우팅 한다.

시각적으로

        client
       ^ /   \
      / /     \
     / v       X
   node 1     node 2
    ^ |
    | |
    | v
 endpoint

Type=LoadBalancer인 서비스에서 소스 IP

쿠버네티스 1.5 부터 Type=LoadBalancer인 서비스로 보낸 패킷은 소스 NAT를 기본으로 하는데, Ready 상태로 모든 스케줄된 모든 쿠버네티스 노드는 로드 밸런싱 트래픽에 적합하다. 따라서 엔드포인트가 없는 노드에 패킷이 도착하면 시스템은 엔드포인트를 포함한 노드에 프록시를 수행하고 패킷 상에서 노드의 IP 주소로 소스 IP 주소를 변경한다 (이전 섹션에서 기술한 것처럼).

로드밸런서를 통해 source-ip-app을 노출하여 테스트할 수 있다.

$ kubectl expose deployment source-ip-app --name=loadbalancer --port=80 --target-port=8080 --type=LoadBalancer
service/loadbalancer exposed

$ kubectl get svc loadbalancer
NAME           TYPE           CLUSTER-IP    EXTERNAL-IP       PORT(S)   AGE
loadbalancer   LoadBalancer   10.0.65.118   104.198.149.140   80/TCP    5m

$ curl 104.198.149.140
CLIENT VALUES:
client_address=10.240.0.5
...

그러나 구글 클라우드 엔진/GCE 에서 실행 중이라면 동일한 service.spec.externalTrafficPolicy 필드를 Local로 설정하면 서비스 엔드포인트가 없는 노드는 고의적으로 헬스 체크에 실패하여 강제로 로드밸런싱 트래픽을 받을 수 있는 노드 목록에서 자신을 스스로 제거한다.

시각적으로:

                      client
                        |
                      lb VIP
                     / ^
                    v /
health check --->   node 1   node 2 <--- health check
        200  <---   ^ |             ---> 500
                    | V
                 endpoint

이것은 어노테이션을 설정하여 테스트할 수 있다.

$ kubectl patch svc loadbalancer -p '{"spec":{"externalTrafficPolicy":"Local"}}'

쿠버네티스에 의해 service.spec.healthCheckNodePort 필드가 즉각적으로 할당되는 것을 봐야 한다.

$ kubectl get svc loadbalancer -o yaml | grep -i healthCheckNodePort
  healthCheckNodePort: 32122

service.spec.healthCheckNodePort 필드는 /healthz에서 헬스 체크를 제공하는 모든 노드의 포트를 가르킨다. 이것을 테스트할 수 있다.

$ kubectl get pod -o wide -l run=source-ip-app
NAME                            READY     STATUS    RESTARTS   AGE       IP             NODE
source-ip-app-826191075-qehz4   1/1       Running   0          20h       10.180.1.136   kubernetes-node-6jst

kubernetes-node-6jst $ curl localhost:32122/healthz
1 Service Endpoints found

kubernetes-node-jj1t $ curl localhost:32122/healthz
No Service Endpoints Found

마스터에서 실행 중인 서비스 컨트롤러는 필요시에 클라우드 로드밸런서를 할당할 책임이 있다. 또한, 각 노드에 HTTP 헬스 체크를 이 포트와 경로로 할당한다. 헬스체크가 실패한 엔드포인트를 포함하지 않은 2개 노드에서 10초를 기다리고 로드밸런서 IP 주소로 curl 하자.

$ curl 104.198.149.140
CLIENT VALUES:
client_address=104.132.1.79
...

크로스 플랫폼 지원

쿠버네티스 1.5부터 Type=LoadBalancer 서비스를 통한 소스 IP 주소 보존을 지원하지만, 이는 클라우드 공급자(GCE, Azure)의 하위 집합으로 구현되어 있다. 실행중인 클라우드 공급자에서 몇 가지 다른 방법으로 로드밸런서를 요청하자.

  1. 클라이언트 연결을 종료하고 새 연결을 여는 프록시를 이용한다. 이 경우 소스 IP 주소는 클라이언트 IP 주소가 아니고 항상 클라우드 로드밸런서의 IP 주소이다.

  2. 로드밸런서의 VIP에 전달된 클라이언트가 보낸 요청을 중간 프록시가 아닌 클라이언트 소스 IP 주소가 있는 노드로 끝나는 패킷 전달자를 이용한다.

첫 번째 범주의 로드밸런서는 진짜 클라이언트 IP를 통신하기 위해 HTTP X-FORWARDED-FOR 헤더나 프록시 프로토콜같이 로드밸런서와 백엔드 간에 합의된 프로토콜을 사용해야 한다. 두 번째 범주의 로드밸런서는 서비스의 service.spec.healthCheckNodePort 필드의 저장된 포트를 가르키는 간단한 HTTP 헬스 체크를 생성하여 위에서 설명한 기능을 활용할 수 있다.

정리하기

서비스를 삭제하자.

$ kubectl delete svc -l run=source-ip-app

디플로이먼트와 리플리카 셋과 파드를 삭제하자.

$ kubectl delete deployment source-ip-app

다음 내용

피드백