K8s 클러스터에서 로드 밸런싱 후 요청 원본 IP를 보존하는 방법

서론

애플리케이션 배포가 항상 간단한 설치실행만은 아닙니다. 때때로 네트워크 문제도 고려해야 합니다. 본 문서에서는 K8s 클러스터에서 서비스가 요청의 원본 IP를 획득할 수 있도록 하는 방법을 소개합니다.

애플리케이션이 서비스를 제공할 때 입력 정보에 의존합니다. 입력 정보가 5-튜플(원본 IP, 원본 포트, 목적 IP, 목적 포트, 프로토콜)에 의존하지 않으면 해당 서비스는 네트워크 결합도가 낮아 네트워크 세부 사항을 신경 쓸 필요가 없습니다.

따라서 대부분의 사람들에게는 본 문서를 읽을 필요가 없습니다. 네트워크에 관심이 있거나 시야를 넓히고 싶다면 계속 읽어보시고, 더 많은 서비스 시나리오를 이해하세요.

본 문서는 K8s v1.29.4를 기반으로 하며, 문서에서 pod와 endpoint를 일부 혼용하여 설명합니다. 본 시나리오에서는 이를 동등하게 간주할 수 있습니다.

오류가 있으면 지적 부탁드리며, 즉시 수정하겠습니다.

왜 원본 IP 정보가 손실될까?

먼저 원본 IP가 무엇인지 명확히 하겠습니다. A가 B에게 요청을 보내고 B가 이를 C에게 전달할 때, C가 보는 IP 프로토콜의 원본 IP는 B의 IP이지만, 본 문서에서는 A의 IP를 원본 IP로 간주합니다.

주로 두 가지 유형의 동작으로 인해 원본 정보가 손실됩니다:

  1. 네트워크 주소 변환(NAT): 공인 IPv4 절약, 로드 밸런싱 등을 목적으로 합니다. 서버가 보는 원본 IP가 NAT 장치의 IP가 되어 실제 원본 IP가 아닙니다.
  2. 프록시(Proxy): **리버스 프록시(RP, Reverse Proxy)**와 **로드 밸런서(LB, Load Balancer)**가 여기에 속하며, 아래에서 프록시 서버로 통칭합니다. 이 프록시 서비스는 요청을 백엔드 서비스로 전달하지만 원본 IP를 자신의 IP로 교체합니다.
  • NAT는 간단히 말해 포트 공간으로 IP 공간을 교환하는 것입니다. IPv4 주소가 제한적이기 때문에 하나의 IP 주소가 65535개의 포트를 매핑할 수 있으며, 대부분의 경우 포트가 다 소진되지 않습니다. 따라서 여러 서브넷 IP가 하나의 공인 IP를 공유하고 포트로 서비스를 구분합니다. 사용 형식: 공인 IP:공인 포트 -> 사설 IP_1:사설 포트. 더 자세한 내용은 네트워크 주소 변환을 참조하세요.
  • 프록시 서비스는 숨기기 또는 노출을 목적으로 합니다. 프록시 서비스는 요청을 백엔드 서비스로 전달하면서 원본 IP를 자신의 IP로 교체하여 백엔드 서비스의 실제 IP를 숨기고 보안을 보호합니다. 사용 형식: 클라이언트 IP -> 프록시 IP -> 서버 IP. 더 자세한 내용은 프록시를 참조하세요.

NAT프록시 서버는 매우 일반적이며, 대부분의 서비스가 요청의 원본 IP를 획득할 수 없습니다.

이것은 원본 IP를 수정하는 일반적인 두 가지 경로입니다. 다른 방법이 있으면 보완 부탁드립니다.

원본 IP를 어떻게 보존할까?

다음은 HTTP 요청 예시입니다:

필드길이(바이트)비트 오프셋설명
IP 헤더
원본 IP40-31발신자 IP 주소
목적 IP432-63수신자 IP 주소
TCP 헤더
원본 포트20-15발신 포트 번호
목적 포트216-31수신 포트 번호
시퀀스 번호432-63발신자가 보낸 데이터의 바이트 스트림 식별
확인 번호464-95ACK 플래그가 설정되면 다음 기대 수신 시퀀스 번호
데이터 오프셋496-103데이터 시작 위치가 TCP 헤더 기준 바이트 수
예약4104-111예약 필드, 사용되지 않음, 0으로 설정
플래그 비트2112-127SYN, ACK, FIN 등의 제어 플래그
윈도우 크기2128-143수신자가 수신할 수 있는 데이터 양
체크섬2144-159전송 중 데이터 오류 감지용
긴급 포인터2160-175발신자가 수신자가 우선 처리하길 바라는 긴급 데이터 위치
옵션가변176-…타임스탬프, 최대 세그먼트 길이 등
HTTP 헤더
요청 라인가변…-…요청 메서드, URI, HTTP 버전 포함
헤더 필드가변…-…Host, User-Agent 등의 헤더 필드
빈 줄2…-…헤더와 본문 구분용
본문가변…-…선택적 요청 또는 응답 본문

위 HTTP 요청 구조를 살펴보면, TCP 옵션, 요청 라인, 헤더 필드, 본문이 가변적입니다. TCP 옵션 공간이 제한적이라 원본 IP 전달에 사용되지 않고, 요청 라인은 고정 정보로 확장 불가, HTTP 본문은 암호화 후 수정 불가하므로 HTTP 헤더 필드만 원본 IP 확장에 적합합니다.

HTTP 헤더에 X-REAL-IP 필드를 추가하여 원본 IP를 전달할 수 있으며, 이 작업은 보통 프록시 서버에서 수행됩니다. 그런 다음 프록시 서버가 요청을 백엔드 서비스로 전달하면 백엔드 서비스가 이 필드를 통해 원본 IP 정보를 획득할 수 있습니다.

주의: 프록시 서버NAT 장치 전에 위치해야 실제 요청의 원본 whoami를 획득할 수 있습니다. 알리 클라우드 제품에서 로드 밸런서를 별도 카테고리로 볼 수 있으며, 네트워크 위치가 일반 애플리케이션 서버와 다릅니다.

K8S 운영 가이드

whoami 프로젝트를 예로 배포합니다.

Deployment 생성

먼저 서비스 생성:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: whoami-deployment
spec:
  replicas: 3
  selector:
    matchLabels:
      app: whoami
  template:
    metadata:
      labels:
        app: whoami
    spec:
      containers:
        - name: whoami
          image: docker.io/traefik/whoami:latest
          ports:
            - containerPort: 8080

이 단계에서 Deployment를 생성하며, 3개의 Pod를 포함하고 각 pod는 하나의 컨테이너를 포함하며, 해당 컨테이너는 whoami 서비스를 실행합니다.

Service 생성

NodePort 또는 LoadBalancer 유형의 서비스를 생성하여 외부 액세스를 지원하거나 ClusterIP 유형의 서비스를 생성하여 클러스터 내부 액세스만 지원하고 Ingress 서비스를 추가하여 외부 액세스를 노출할 수 있습니다.

NodePortNodeIP:NodePort 또는 Ingress 서비스를 통해 액세스할 수 있어 테스트에 편리하며, 본 섹션에서는 NodePort 서비스를 사용합니다.

apiVersion: v1
kind: Service
metadata:
  name: whoami-service
spec:
  type: NodePort
  selector:
    app: whoami
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
      nodePort: 30002

서비스 생성 후 curl whoami.example.com:30002로 액세스하면 반환 IP가 NodeIP이며 요청 원본 whoami가 아닙니다.

주의: 이것은 올바른 클라이언트 IP가 아닙니다. 클러스터 내부 IP입니다. 발생하는 일:

  • 클라이언트가 node2:nodePort로 데이터 패킷 전송
  • node2가 데이터 패킷의 원본 IP 주소를 자신의 IP로 교체(SNAT)
  • node2가 데이터 패킷의 목적 IP를 Pod IP로 교체
  • 데이터 패킷이 node1로 라우팅된 후 엔드포인트로
  • Pod 응답이 node2로 라우팅됨
  • Pod 응답이 클라이언트로 전송됨

그림으로 표현:

externalTrafficPolicy: Local 설정

이런 상황을 피하기 위해 Kubernetes에는 클라이언트 원본 IP를 보존하는 기능이 있습니다. service.spec.externalTrafficPolicy를 Local로 설정하면 kube-proxy가 로컬 엔드포인트로만 요청을 프록시하고 다른 노드로 트래픽을 전달하지 않습니다.

apiVersion: v1
kind: Service
metadata:
  name: whoami-service
spec:
  type: NodePort
  externalTrafficPolicy: Local
  selector:
    app: whoami
  ports:
    - protocol: TCP
      port: 80
      targetPort: 8080
      nodePort: 30002

curl whoami.example.com:30002로 테스트하면 whoami.example.com이 클러스터 여러 노드 IP로 매핑될 때 일정 비율로 액세스 불가합니다. 도메인 레코드가 endpoint(pod) 위치 노드 IP만 포함하는지 확인하세요.

이 설정에는 대가가 있으며, 클러스터 내 로드 밸런싱 능력을 잃습니다. 클라이언트가 endpoint 배포 노드에만 액세스해야 응답을 받습니다.

액세스 경로 제한

클라이언트가 Node 2에 액세스하면 응답이 없습니다.

Ingress 생성

대부분의 서비스는 사용자에게 http/https를 제공하며 https://ip:port 형식은 사용자에게 낯설 수 있습니다. 일반적으로 Ingress를 사용하여 위 NodePort 서비스를 도메인의 80/443 포트로 로드 밸런싱합니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: whoami-ingress
  namespace: default
spec:
  ingressClassName: external-lb-default
  rules:
    - host: whoami.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: whoami-service
                port:
                  number: 80

적용 후 curl whoami.example.com으로 테스트하면 ClientIP가 항상 endpoint 노드의 Ingress Controller Pod IP입니다.

root@client:~# curl whoami.example.com
...
RemoteAddr: 10.42.1.10:56482
...

root@worker:~# kubectl get -n ingress-nginx pod -o wide
NAME                                       READY   STATUS    RESTARTS   AGE    IP           NODE          NOMINATED NODE   READINESS GATES
ingress-nginx-controller-c8f499cfc-xdrg7   1/1     Running   0          3d2h   10.42.1.10   k3s-agent-1   <none>           <none>

IngressNodePort 서비스를 리버스 프록시하면 endpoint 전에 두 층의 service가 추가됩니다. 아래 그림은 둘의 차이를 보여줍니다.

graph LR
    A[Client] -->|whoami.example.com:80| B(Ingress)
    B -->|10.43.38.129:32123| C[Service]
    C -->|10.42.1.1:8080| D[Endpoint]
graph LR
    A[Client] -->|whoami.example.com:30001| B(Service)
    B -->|10.42.1.1:8080| C[Endpoint]

경로 1에서 외부가 Ingress에 액세스하면 트래픽이 먼저 Ingress Controller endpoint에 도달한 후 whoami endpoint에 도달합니다.
Ingress Controller는 본질적으로 LoadBalancer 서비스입니다.

kubectl -n ingress-nginx get svc

NAMESPACE   NAME             CLASS   HOSTS                       ADDRESS                                              PORTS   AGE
default     echoip-ingress   nginx   ip.example.com       172.16.0.57,2408:4005:3de:8500:4da1:169e:dc47:1707   80      18h
default     whoami-ingress   nginx   whoami.example.com   172.16.0.57,2408:4005:3de:8500:4da1:169e:dc47:1707   80      16h

따라서 앞서 언급한 externalTrafficPolicy를 Ingress Controller에 설정하여 원본 IP를 보존할 수 있습니다.

동시에 ingress-nginx-controllerconfigmap에서 use-forwarded-headerstrue로 설정하여 Ingress ControllerX-Forwarded-For 또는 X-REAL-IP 필드를 인식할 수 있게 합니다.

apiVersion: v1
data:
  allow-snippet-annotations: "false"
  compute-full-forwarded-for: "true"
  use-forwarded-headers: "true"
  enable-real-ip: "true"
  forwarded-for-header: "X-Real-IP" # X-Real-IP or X-Forwarded-For
kind: ConfigMap
metadata:
  labels:
    app.kubernetes.io/component: controller
    app.kubernetes.io/instance: ingress-nginx
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/part-of: ingress-nginx
    app.kubernetes.io/version: 1.10.1
  name: ingress-nginx-controller
  namespace: ingress-nginx

NodePort 서비스와 ingress-nginx-controller 서비스의 차이는 NodePort 백엔드가 모든 노드에 배포되지 않는 반면 ingress-nginx-controller 백엔드가 모든 외부 노출 노드에 배포된다는 점입니다.

NodePort 서비스에서 externalTrafficPolicy 설정으로 노드 간 요청이 응답되지 않는 것과 달리 Ingress는 요청에 먼저 HEADER를 설정한 후 프록시 전달하여 원본 IP 보존로드 밸런싱 두 능력을 모두 구현합니다.

요약

  • 주소 변환(NAT), 프록시(Proxy), 리버스 프록시(Reverse Proxy), **로드 밸런싱(Load Balance)**으로 원본 IP가 손실됩니다.
  • 원본 IP 손실 방지를 위해 프록시 서버 전달 시 실제 IP를 HTTP 헤더 필드 X-REAL-IP에 설정하여 프록시 서비스로 전달합니다. 다층 프록시 사용 시 X-Forwarded-For 필드를 사용하며, 이 필드는 스택 형태로 원본 IP와 프록시 경로의 IP 목록을 기록합니다.
  • 클러스터 NodePort 서비스에서 externalTrafficPolicy: Local 설정으로 원본 IP 보존 가능하지만 로드 밸런싱 능력을 잃습니다.
  • ingress-nginx-controller가 모든 loadbalancer 역할 노드에 daemonset 형태로 배포된 전제하에 externalTrafficPolicy: Local 설정으로 원본 IP를 보존하면서 로드 밸런싱 능력을 유지합니다.

참고