Preserving Source IP in Kubernetes: A Complete Guide

Maintaining client IP addresses through Services and Ingress for logging, security, and access control

Preserving Source IP in Kubernetes: A Complete Guide



Overview

In Kubernetes clusters, accurately identifying the Source IP (client IP) of incoming external requests is crucial for various operational and security purposes including logging, security analysis, auditing, and access control.

However, in Kubernetes’ default network configuration, depending on the service type, requests pass through kube-proxy, NAT, ingress proxies, etc., causing the Source IP to be changed or lost.

This article explains why Source IP disappears in Kubernetes environments and what configuration to apply to preserve it, organized by Service type and Ingress method.

We’ll also cover practical labs using a Flask app with MetalLB + Ingress-NGINX to see how IPs appear in actual requests and how to preserve them.



Source IP Preservation by Service Type


Comparison Table

Service Type Configuration Required Request Flow Source IP Preserved
ClusterIP Not possible Internal cluster via kube-proxy ❌ No
NodePort / LoadBalancer Requires externalTrafficPolicy: Local Client → Node IP:Port → Direct Pod ✅ Yes
Ingress Requires X-Forwarded-For header Client → Ingress Controller → Pod ✅ Yes (header-based)
MetalLB (LoadBalancer) Requires externalTrafficPolicy: Local External IP → Direct node routing (L2/BGP) ✅ Yes


Important Note: With Ingress, the actual IP doesn’t reach the pod directly but is transmitted via headers (X-Forwarded-For), requiring header parsing in the application.



Why Does Source IP Disappear in Cluster Mode?


Understanding SNAT

Cluster Mode (default):

Local Mode:


Traffic Flow Comparison

Cluster Mode (externalTrafficPolicy: Cluster)
────────────────────────────────────────────
Client (1.2.3.4)
    ↓
LoadBalancer/NodePort
    ↓
Node-1 (kube-proxy SNAT)(Source becomes Node-1 IP)
Pod on Node-2
    ↓
Application sees: Node-1 IP ❌


Local Mode (externalTrafficPolicy: Local)
──────────────────────────────────────────
Client (1.2.3.4)
    ↓
LoadBalancer/NodePort
    ↓
Node-1 (NO SNAT)(Source remains 1.2.3.4)
Pod on Node-1 (must exist!)
    ↓
Application sees: 1.2.3.4 ✅



Using externalTrafficPolicy: Local


Configuration

For LoadBalancer or NodePort services, configure as follows to maintain original client IP:

apiVersion: v1
kind: Service
metadata:
  name: my-service
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local  # Key setting
  selector:
    app: my-app
  ports:
    - port: 80
      targetPort: 8080


Behavior Differences

Local:

Cluster (default):


Best Practices with Local Mode

# Use DaemonSet to ensure pod on every node
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: my-app
spec:
  selector:
    matchLabels:
      app: my-app
  template:
    metadata:
      labels:
        app: my-app
    spec:
      containers:
      - name: my-app
        image: my-app:latest



Preserving Source IP with Ingress (NGINX)


Required Configuration

Two essential settings are needed:

Component Setting Description
Service externalTrafficPolicy: Local Prevents client IP from being SNATed
ConfigMap use-forwarded-headers: "true"
proxy-real-ip-cidr: "0.0.0.0/0"
Trust X-Forwarded-For and X-Real-IP headers for logs and backend


1. ConfigMap Configuration

Ingress controllers (like NGINX) operate as L7 proxies by default, changing the Source IP. Preserve it with this configuration:

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-ingress-controller
  namespace: ingress-nginx
data:
  use-forwarded-headers: "true"
  proxy-real-ip-cidr: "0.0.0.0/0"


Configuration Keys Explained

Key Description
use-forwarded-headers: "true" Trust and use X-Forwarded-For, X-Real-IP HTTP headers
proxy-real-ip-cidr: "0.0.0.0/0" Trust any client IP. For security, limit to internal network ranges like 10.0.0.0/8, 192.168.0.0/16

This allows NGINX to correctly parse $proxy_protocol_addr, $remote_addr, etc.


2. Service Configuration

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
spec:
  type: LoadBalancer  # or NodePort
  externalTrafficPolicy: Local  # Critical!
  ports:
    - port: 80
      targetPort: http
    - port: 443
      targetPort: https
  selector:
    app.kubernetes.io/name: ingress-nginx
    app.kubernetes.io/component: controller



Lab 1: Verifying Source IP


Architecture

External Client
    ↓
NodePort Service (externalTrafficPolicy: Local)
    ↓
Flask Pod (logs request headers)


1. Flask Application ConfigMap

apiVersion: v1
kind: ConfigMap
metadata:
  name: flask-app-config
data:
  app.py: |
    from flask import Flask, request
    import os

    app = Flask(__name__)

    @app.route('/')
    def hello():
        client_ip = request.remote_addr
        x_forwarded_for = request.headers.get('X-Forwarded-For', '')
        x_real_ip = request.headers.get('X-Real-IP', '')
        all_headers = dict(request.headers)
        
        response = f"""
    Hello from Flask!
    
    Client Info:
    ------------
    Remote Addr: {client_ip}
    X-Forwarded-For: {x_forwarded_for}
    X-Real-IP: {x_real_ip}
    
    All Request Headers:
    -------------------
    {all_headers}
    """
        return response

    if __name__ == '__main__':
        app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))


2. Flask Pod Definition

apiVersion: v1
kind: Pod
metadata:
  name: flask-app
  labels:
    app: flask-app
spec:
  containers:
    - name: flask-app
      image: python:3.9-slim
      ports:
        - containerPort: 8080
      env:
        - name: PORT
          value: "8080"
      command: ["/bin/sh", "-c"]
      args:
        - |
          pip install flask
          mkdir -p /app
          cp /config/app.py /app/
          cd /app
          python app.py
      volumeMounts:
        - name: flask-app-config
          mountPath: /config
  volumes:
    - name: flask-app-config
      configMap:
        name: flask-app-config


3. Service Configuration

apiVersion: v1
kind: Service
metadata:
  name: flask-app-service
spec:
  type: NodePort
  externalTrafficPolicy: Local  # Preserves source IP
  selector:
    app: flask-app
  ports:
    - port: 8080
      targetPort: 8080


4. Complete Manifest


5. Testing from External Client

curl http://<NodeIP>:<NodePort>


6. Lab Results

Same Network Segment:

curl 10.10.10.51:30378

Hello from Flask!

Client Info:
------------
Remote Addr: 10.10.10.50
X-Forwarded-For: 
X-Real-IP: 

All Request Headers:
-------------------
{'Host': '10.10.10.51:30378', 'User-Agent': 'curl/7.81.0', 'Accept': '*/*'}

✅ Source IP preserved correctly


Different Network Segment:

curl 10.10.10.51:30378

Hello from Flask!

Client Info:
------------
Remote Addr: 10.10.10.251  # Gateway IP, not actual client!
X-Forwarded-For: 
X-Real-IP: 

All Request Headers:
-------------------
{'Host': '10.10.10.51:30378', 'User-Agent': 'curl/7.81.0', 'Accept': '*/*'}

❌ Shows gateway IP instead of actual external IP


Understanding the Results

Current Situation:


Root Cause: kube-proxy + NAT + Gateway

When requests traverse physical routers or virtual gateways, the Flask pod sees the gateway/NAT IP instead of the actual external client IP:

[Your MacBook] 
     ↓ (request)
[Gateway/NAT (10.10.10.251)](SNAT applied)
[Worker Node (10.10.10.51):32440]
     ↓
[Flask Pod]
     
Result: Flask receives SNATed IP (gateway IP), not direct client IP



Lab 2: MetalLB + Ingress-NGINX with Source IP


Architecture

External Client
    ↓
MetalLB LoadBalancer (10.10.10.63)
    ↓
Ingress-NGINX Controller (externalTrafficPolicy: Local)
    ↓
NGINX Pod (configured with real_ip_header)


1. MetalLB Setup

First, install MetalLB. For installation guide, see: What is MetalLB?

kubectl get ipaddresspools.metallb.io -n metallb 

NAME      AUTO ASSIGN   AVOID BUGGY IPS   ADDRESSES
ip-pool   true          false             ["10.10.10.55-10.10.10.58","10.10.10.62-10.10.10.65"]


2. Ingress-NGINX Controller Configuration

Create test Ingress NGINX Controller with Helm:

# values/mgmt-test.yaml
global:
  image:
    registry: registry.k8s.io

controller:
  ingressClassResource:
    name: nginx-test  # Unique name for distinction
    enabled: true
    default: false  # Not the default controller
    controllerValue: "k8s.io/ingress-nginx-test"
    
  service:
    enabled: true
    type: LoadBalancer
    loadBalancerIP: "10.10.10.63"
    externalTrafficPolicy: "Local"  # Critical!
    
  config:
    use-forwarded-headers: "true"
    real-ip-header: "X-Forwarded-For"
    set-real-ip-from: "0.0.0.0/0"
    real-ip-recursive: "true"


Install

helm install ingress-nginx-test . -n ingress-nginx -f ./values/mgmt-test.yaml


Verify Configuration

# Check service
kubectl get svc -n ingress-nginx ingress-nginx-test-controller -o yaml | grep external
  externalTrafficPolicy: Local

# Check configmap
kubectl get cm -n ingress-nginx ingress-nginx-test-controller -o yaml
apiVersion: v1
data:
  proxy-real-ip-cidr: 0.0.0.0/0
  use-forwarded-headers: "true"
  real-ip-header: X-Forwarded-For
  real-ip-recursive: "true"


3. NGINX Test Application

apiVersion: v1
kind: ConfigMap
metadata:
  name: nginx-config
  namespace: default
data:
  nginx.conf: |
    events {}
    http {
      log_format main '$http_x_forwarded_for - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
      access_log /var/log/nginx/access.log main;

      set_real_ip_from 0.0.0.0/0;
      real_ip_header X-Forwarded-For;
      real_ip_recursive on;

      server {
        listen 80;
        location / {
          return 200 'Hello from NGINX! Remote Addr: $remote_addr X-Forwarded-For: $http_x_forwarded_for';
          add_header Content-Type text/plain;
        }
      }
    }
---
apiVersion: v1
kind: Pod
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  containers:
    - name: nginx
      image: nginx:latest
      ports:
        - containerPort: 80
      volumeMounts:
        - name: config
          mountPath: /etc/nginx/nginx.conf
          subPath: nginx.conf
  volumes:
    - name: config
      configMap:
        name: nginx-config
---
apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  selector:
    app: nginx
  ports:
    - port: 80
      targetPort: 80
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nginx-ingress
spec:
  ingressClassName: nginx-test
  rules:
    - host: test.local
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nginx-service
                port:
                  number: 80


4. Testing

Add to /etc/hosts (for local environment):

echo "10.10.10.63 test.local" | sudo tee -a /etc/hosts


Test from External Network:

curl http://test.local

Hello from Nginx!
Remote Addr: 10.233.65.165
X-Forwarded-For: 10.10.10.251


Test from Internal Network:

curl http://test.local

Hello from Nginx!
Remote Addr: 10.233.65.165
X-Forwarded-For: 10.10.10.60


Test with Custom X-Forwarded-For:

curl -H "Host: test.local" -H "X-Forwarded-For: 1.2.3.4" http://10.10.10.63

If NGINX logs show 1.2.3.4, it confirms NGINX pod is correctly reading real_ip_header.



Advanced Source IP Scenarios


Scenario 1: Multi-Tier Architecture

Internet
    ↓
Cloud Load Balancer
    ↓ (X-Forwarded-For added)
MetalLB LoadBalancer
    ↓ (preserves X-Forwarded-For)
Ingress-NGINX
    ↓ (parses X-Forwarded-For)
Application Pod

Configuration:

# Ingress NGINX ConfigMap
data:
  use-forwarded-headers: "true"
  compute-full-forwarded-for: "true"  # Include all proxy IPs
  forwarded-for-header: "X-Forwarded-For"
  proxy-real-ip-cidr: "10.0.0.0/8"  # Trust internal network


Scenario 2: CDN + Kubernetes

When using CDN (CloudFlare, Akamai):

# Trust CDN IP ranges
data:
  proxy-real-ip-cidr: "173.245.48.0/20,103.21.244.0/22,..."
  real-ip-header: "CF-Connecting-IP"  # CloudFlare header


Scenario 3: Multiple Ingress Controllers

# Public ingress (strict checking)
controller-public:
  config:
    proxy-real-ip-cidr: "0.0.0.0/0"
    use-forwarded-headers: "true"

# Internal ingress (trust internal)  
controller-internal:
  config:
    proxy-real-ip-cidr: "10.0.0.0/8,172.16.0.0/12"
    use-forwarded-headers: "false"



Troubleshooting Guide


Issue 1: Source IP Still Shows Node IP

Symptoms:

Diagnosis:

# Check service configuration
kubectl get svc my-service -o yaml | grep externalTrafficPolicy

# Check pod distribution
kubectl get pods -o wide

# Check if pod exists on target node
kubectl get pods -l app=my-app -o wide | grep <node-name>

Solutions:

  1. Ensure pods exist on nodes receiving traffic
  2. Use DaemonSet for guaranteed pod placement
  3. Verify no additional proxy layers


Issue 2: X-Forwarded-For Header Empty

Symptoms:

Diagnosis:

# Check Ingress NGINX ConfigMap
kubectl get cm -n ingress-nginx ingress-nginx-controller -o yaml

# Check Ingress controller logs
kubectl logs -n ingress-nginx deployment/ingress-nginx-controller

# Test with curl verbose
curl -v -H "X-Forwarded-For: 1.2.3.4" http://test.local

Solutions:

# Ensure these settings in ConfigMap
data:
  use-forwarded-headers: "true"
  compute-full-forwarded-for: "true"
  forwarded-for-header: "X-Forwarded-For"


Issue 3: Gateway IP Instead of Client IP

Symptoms:

Root Cause:

Solutions:

  1. Use Ingress with X-Forwarded-For: Application must parse headers
  2. Configure upstream proxy: If possible, configure upstream to preserve IP
  3. Use Proxy Protocol: Enable at load balancer level
# Enable Proxy Protocol on ingress
controller:
  config:
    use-proxy-protocol: "true"
  service:
    annotations:
      service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: "*"


Issue 4: Inconsistent IP Logging

Symptoms:

Diagnosis:

# Check pod distribution
kubectl get pods -o wide

# Check service endpoints
kubectl get endpoints my-service

# Check node labels and taints
kubectl get nodes -o wide

Solutions:

# Use DaemonSet for consistent placement
apiVersion: apps/v1
kind: DaemonSet
metadata:
  name: my-app
spec:
  selector:
    matchLabels:
      app: my-app
  template:
    spec:
      nodeSelector:
        node-role.kubernetes.io/worker: ""
      containers:
      - name: my-app
        image: my-app:latest



Security Considerations


1. Trust Boundaries

# Don't trust all IPs in production
proxy-real-ip-cidr: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"  # Internal only

# For public ingress, be more restrictive
proxy-real-ip-cidr: "<known-lb-cidrs>"


2. Header Validation

# Application-level validation
def get_client_ip(request):
    # Check X-Forwarded-For
    forwarded_for = request.headers.get('X-Forwarded-For', '')
    if forwarded_for:
        # Get first IP (original client)
        client_ip = forwarded_for.split(',')[0].strip()
        
        # Validate IP format
        try:
            ipaddress.ip_address(client_ip)
            return client_ip
        except ValueError:
            pass
    
    # Fallback to remote_addr
    return request.remote_addr


3. Rate Limiting by IP

# NGINX Ingress rate limiting by real IP
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: rate-limited-ingress
  annotations:
    nginx.ingress.kubernetes.io/limit-rps: "10"
    nginx.ingress.kubernetes.io/limit-rpm: "100"
spec:
  rules:
  - host: api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: api-service
            port:
              number: 80



Best Practices


✅ Production Checklist

1. Service Configuration

2. Ingress Configuration

3. Application Configuration

4. Monitoring

5. Security



Monitoring and Logging


Structured Logging Example

import logging
import json

def log_request(request):
    log_data = {
        'timestamp': datetime.now().isoformat(),
        'remote_addr': request.remote_addr,
        'x_forwarded_for': request.headers.get('X-Forwarded-For', ''),
        'x_real_ip': request.headers.get('X-Real-IP', ''),
        'method': request.method,
        'path': request.path,
        'user_agent': request.headers.get('User-Agent', ''),
    }
    
    logging.info(json.dumps(log_data))


Prometheus Metrics

# Monitor requests by source IP
- record: http_requests_by_source_ip
  expr: |
    sum(rate(http_requests_total[5m])) by (source_ip)



Conclusion

Preserving Source IP in Kubernetes goes beyond simple client location identification—it’s essential for security policy establishment, attacker tracking, service optimization, access control, and traffic analysis.


Key Takeaways:

externalTrafficPolicy: Local is the most critical setting for avoiding SNAT and preserving original IP in Kubernetes services

In Ingress environments, original IP is transmitted indirectly through X-Forwarded-For and real_ip_header configuration

Infrastructure like MetalLB, Cilium should be configured to accurately transmit Source IP at L2/L3 level


Production Considerations:

In actual production environments, you should design:


The ability to accurately identify and log client source IPs is fundamental to operating secure, auditable, and compliant Kubernetes services.



References