소스 IP 주소 이용하기

쿠버네티스 클러스터에서 실행 중인 애플리케이션은 서비스 추상화를 통해서 서로를, 그리고 외부 세계를 찾고 통신한다. 이 문서는 다른 종류의 서비스로 전송된 패킷의 소스 IP에 어떤 일이 벌어지는지와 이 동작을 필요에 따라 어떻게 전환할 수 있는지 설명한다.

시작하기 전에

용어

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

NAT
네트워크 주소 변환
소스 NAT
패킷 상의 소스 IP 주소를 변경하는 것. 이 페이지에서는 일반적으로 노드 IP 주소로의 변경을 의미함.
대상 NAT
패킷 상의 대상 IP 주소를 변경하는 것. 이 페이지에서는 일반적으로 파드 IP 주소로의 변경을 의미함.
VIP
쿠버네티스의 모든 서비스에 할당되어 있는 것과 같은, 가상 IP 주소.
Kube-proxy
모든 노드에서 서비스 VIP 관리를 조율하는 네트워크 데몬.

전제 조건

쿠버네티스 클러스터가 필요하고, kubectl 커맨드-라인 툴이 클러스터와 통신할 수 있도록 설정되어 있어야 한다. 이 튜토리얼은 컨트롤 플레인 호스트가 아닌 노드가 적어도 2개 포함된 클러스터에서 실행하는 것을 추천한다. 만약, 아직 클러스터를 가지고 있지 않다면, minikube를 사용해서 생성하거나 다음 쿠버네티스 플레이그라운드 중 하나를 사용할 수 있다.

이 예시는 HTTP 헤더로 수신한 요청의 소스 IP 주소를 회신하는 작은 nginx 웹 서버를 이용한다. 다음과 같이 생성할 수 있다.

kubectl create deployment source-ip-app --image=registry.k8s.io/echoserver:1.4

출력은 다음과 같다.

deployment.apps/source-ip-app created

목적

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

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

iptables 모드 (기본값)에서 kube-proxy를 운영하는 경우 클러스터 내에서 클러스터IP로 패킷을 보내면 소스 NAT를 통과하지 않는다. kube-proxy가 실행중인 노드에서 http://localhost:10249/proxyMode 를 입력해서 kube-proxy 모드를 조회할 수 있다.

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

한 노드의 프록시 모드를 확인한다. (kube-proxy는 포트 10249에서 수신대기한다.)

# 질의 할 노드의 쉘에서 이것을 실행한다.
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:1.28 --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.

그런 다음 해당 파드 내에서 명령을 실행할 수 있다.

# "kubectl run" 으로 터미널 내에서 이것을 실행한다.
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 을 사용해서 로컬 웹 서버에 쿼리한다.

# "10.0.170.92"를 "clusterip"라는 이름의 서비스의 IPv4 주소로 변경한다.
wget -qO - 10.0.170.92
CLIENT VALUES:
client_address=10.244.3.8
command=GET
...

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

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

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=="InternalIP")].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로 다시 라우팅된다.
  • 파드의 응답은 클라이언트로 다시 전송된다.

이를 그림으로 표현하면 다음과 같다.

source IP nodeport figure 01

그림. Source IP Type=NodePort using SNAT

이를 피하기 위해 쿠버네티스는 클라이언트 소스 IP 주소를 보존하는 기능이 있다. service.spec.externalTrafficPolicy 의 값을 Local 로 하면 오직 로컬 엔드포인트로만 프록시 요청하고 다른 노드로 트래픽 전달하지 않는다. 이 방법은 원본 소스 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 주소로 엔드포인트로 라우팅 한다.

이를 시각적으로 표현하면 다음과 같다.

source IP nodeport figure 02

그림. Source IP Type=NodePort preserves client source IP address

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

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

서비스의 IP 주소를 출력한다.

kubectl get svc loadbalancer

다음과 유사하게 출력된다.

NAME           TYPE           CLUSTER-IP    EXTERNAL-IP       PORT(S)   AGE
loadbalancer   LoadBalancer   10.0.65.118   203.0.113.140     80/TCP    5m

다음으로 이 서비스의 외부 IP에 요청을 전송한다.

curl 203.0.113.140

다음과 유사하게 출력된다.

CLIENT VALUES:
client_address=10.240.0.5
...

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

이를 그림으로 표현하면 다음과 같다.

Source IP with externalTrafficPolicy

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

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 app=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

다양한 노드에서 /healthz 엔드포인트를 가져오려면 curl 을 사용한다.

# 선택한 노드에서 로컬로 이것을 실행한다.
curl localhost:32122/healthz
1 Service Endpoints found

다른 노드에서는 다른 결과를 얻을 수 있다.

# 선택한 노드에서 로컬로 이것을 실행한다.
curl localhost:32122/healthz
No Service Endpoints Found

컨트롤 플레인에서 실행중인 컨트롤러는 클라우드 로드 밸런서를 할당한다. 또한 같은 컨트롤러는 각 노드에서 포트/경로(port/path)를 가르키는 HTTP 상태 확인도 할당한다. 엔드포인트가 없는 2개의 노드가 상태 확인에 실패할 때까지 약 10초간 대기한 다음, curl 을 사용해서 로드밸런서의 IPv4 주소를 쿼리한다.

curl 203.0.113.140

출력은 다음과 유사하다.

CLIENT VALUES:
client_address=198.51.100.79
...

크로스-플랫폼 지원

일부 클라우드 공급자만 Type=LoadBalancer 를 사용하는 서비스를 통해 소스 IP 보존을 지원한다. 실행 중인 클라우드 공급자에서 몇 가지 다른 방법으로 로드밸런서를 요청한다.

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

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

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

정리하기

서비스를 삭제한다.

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

디플로이먼트, 레플리카셋 그리고 파드를 삭제한다.

kubectl delete deployment source-ip-app

다음 내용