K8s 클러스터에서 로드 밸런싱 후 요청 원본 IP를 보존하는 방법
Categories:
서론
애플리케이션 배포가 항상 간단한 설치와 실행만은 아닙니다. 때때로 네트워크 문제도 고려해야 합니다. 본 문서에서는 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로 간주합니다.
주로 두 가지 유형의 동작으로 인해 원본 정보가 손실됩니다:
- 네트워크 주소 변환(NAT): 공인 IPv4 절약, 로드 밸런싱 등을 목적으로 합니다. 서버가 보는 원본 IP가 NAT 장치의 IP가 되어 실제 원본 IP가 아닙니다.
- 프록시(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 헤더 | |||
원본 IP |
4 | 0-31 | 발신자 IP 주소 |
| 목적 IP | 4 | 32-63 | 수신자 IP 주소 |
| TCP 헤더 | |||
| 원본 포트 | 2 | 0-15 | 발신 포트 번호 |
| 목적 포트 | 2 | 16-31 | 수신 포트 번호 |
| 시퀀스 번호 | 4 | 32-63 | 발신자가 보낸 데이터의 바이트 스트림 식별 |
| 확인 번호 | 4 | 64-95 | ACK 플래그가 설정되면 다음 기대 수신 시퀀스 번호 |
| 데이터 오프셋 | 4 | 96-103 | 데이터 시작 위치가 TCP 헤더 기준 바이트 수 |
| 예약 | 4 | 104-111 | 예약 필드, 사용되지 않음, 0으로 설정 |
| 플래그 비트 | 2 | 112-127 | SYN, ACK, FIN 등의 제어 플래그 |
| 윈도우 크기 | 2 | 128-143 | 수신자가 수신할 수 있는 데이터 양 |
| 체크섬 | 2 | 144-159 | 전송 중 데이터 오류 감지용 |
| 긴급 포인터 | 2 | 160-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 서비스를 추가하여 외부 액세스를 노출할 수 있습니다.
NodePort는 NodeIP: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>
Ingress로 NodePort 서비스를 리버스 프록시하면 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-controller의 configmap에서 use-forwarded-headers를 true로 설정하여 Ingress Controller가 X-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를 보존하면서 로드 밸런싱 능력을 유지합니다.