태그 보관물: Apache

Preserving the Real Client IP Behind Traefik: externalTrafficPolicy and mod_remoteip

개요

Kubernetes로 이전한 WordPress에서 방문자 IP가 실제 접속자가 아니라 리버스 프록시의 주소로 기록되는 문제를 처리한 기록입니다. 트래픽 통계 플러그인에 찍히는 방문자 IP가 모두 동일한 값이었고, 확인해 보니 Traefik 파드의 IP였습니다.

리버스 프록시 뒤에서 클라이언트 IP가 사라지는 것은 흔한 증상이라 mod_remoteip 설정만 점검하면 될 것으로 예상했지만, 실제로는 서로 다른 계층에서 두 가지 원인이 겹쳐 있었습니다. 하나는 Kubernetes Service의 SNAT 동작이고, 다른 하나는 mod_remoteip가 사설 IP 대역을 신뢰하지 않는 하드코딩된 동작이었습니다. 후자는 설정으로 우회할 수 없어 애플리케이션 계층에서 보정해야 했습니다.

환경

구성 요소 내용
Ingress Traefik v3.7.5 (LoadBalancer, MetalLB 할당)
애플리케이션 WordPress (Apache + PHP 8.4), 파드 2개
앞단 프록시 HAProxy → Traefik (내부망) / CloudFront (외부)
내부망 대역 RFC1918 사설 대역
파드 네트워크 Calico, 172.16.0.0/16

증상

트래픽 통계 플러그인에 기록된 방문자 IP가 전부 같은 값이었고, 그 값은 Traefik 파드의 IP였습니다. 특이한 점은 모든 방문자가 아니라 내부망에서 접속한 방문자만 해당된다는 것이었습니다. CloudFront를 거쳐 들어오는 외부 인터넷 방문자의 IP는 정상적으로 기록되고 있었습니다.

이 차이가 원인을 좁히는 단서가 되었습니다. 프록시 설정이 전면적으로 잘못되었다면 외부 방문자도 같이 깨져야 하는데 그렇지 않았기 때문입니다. 내부망 방문자와 외부 방문자를 가르는 차이는 실제 클라이언트 IP가 사설 대역인지 공인 대역인지였습니다.

원인 ① — externalTrafficPolicy의 SNAT

먼저 Kubernetes 계층을 확인했습니다. Traefik의 Service가 기본값인 externalTrafficPolicy: Cluster로 동작하고 있었습니다.

이 정책에서는 트래픽을 받은 노드가 반드시 자기 노드의 파드로 전달하지 않습니다. 다른 노드의 파드로 라우팅할 수 있고, 이때 kube-proxy가 SNAT을 수행하면서 원본 클라이언트 IP를 자기 노드 주소로 바꿉니다. Apache가 요청을 받기도 전에 출발지 IP가 이미 손실되는 것입니다.

# helm/traefik-values.yaml
service:
  annotations:
    metallb.universe.tf/loadBalancerIPs: "192.168.x.x"
  spec:
    externalTrafficPolicy: Local

Local로 바꾸면 각 노드가 자기 노드의 파드로만 전달하므로 SNAT이 발생하지 않고 원본 IP가 보존됩니다. 다만 로컬 엔드포인트가 없는 노드로 트래픽이 가면 응답이 끊기는데, MetalLB가 로컬 엔드포인트가 없는 노드를 광고 대상에서 자동으로 제외하므로 이 구성에서는 안전합니다. Traefik 파드를 2개로 운영하고 있어 가용성 측면의 여유도 확보된 상태였습니다.

같은 이유로 mariadb-galera-primary Service에도 이미 동일한 설정을 적용해 둔 상태였습니다. MetalLB와 kube-proxy가 얽힌 같은 원인이므로, LoadBalancer로 노출하면서 출발지 IP가 의미 있는 서비스라면 함께 점검할 항목입니다.

원인 ② — mod_remoteip가 사설 IP를 무시함

Service 정책을 고친 뒤에도 내부망 방문자의 IP는 여전히 Traefik 파드 IP로 기록되었습니다. Apache 계층에 두 번째 원인이 있었습니다.

mod_remoteip 설정 자체는 정상이었습니다.

# remoteip.conf
RemoteIPHeader X-Forwarded-For
RemoteIPTrustedProxy 172.16.0.0/16

모듈의 디버그 로그를 켜서 실제 동작을 확인했습니다.

# Apache 설정에 추가
LogLevel remoteip:trace8

# 로그 출력
"appears to be a private IP or nonsensical. Ignored"

mod_remoteipX-Forwarded-For 값이 사설 IP로 보이면 이를 신뢰하지 않고 버립니다. 스푸핑 방지를 위한 안전장치인데, 문제는 이 판단이 모듈에 하드코딩되어 있어 RemoteIPTrustedProxyRemoteIPInternalProxy로 우회할 수 없다는 점입니다. 두 지시자는 어떤 프록시를 신뢰할지 지정할 뿐, 전달받은 값이 사설 대역일 때의 거부 동작에는 관여하지 않습니다.

이 환경은 LAN과 파드 네트워크가 모두 RFC1918 사설 대역입니다. 따라서 내부망에서 접속하는 방문자의 실제 IP는 항상 사설 대역이고, mod_remoteip는 그 값을 예외 없이 버린 뒤 REMOTE_ADDR을 Traefik 파드 IP로 남겨둡니다. 외부 인터넷 방문자의 IP는 공인 대역이라 정상적으로 수용되므로, 앞서 관찰한 내부/외부 차이가 여기서 설명됩니다.

해결 — PHP 계층에서 보정

모듈 동작을 설정으로 바꿀 수 없으므로 애플리케이션 계층에서 처리했습니다. wp-config.php에서 REMOTE_ADDR이 신뢰하는 프록시 대역 안에 있고 X-Forwarded-For 헤더가 존재할 때만 직접 값을 덮어쓰는 방식입니다.

// mod_remoteip가 사설 IP로 보이는 X-Forwarded-For를 버리므로 PHP 레벨에서 보정
if ( isset( $_SERVER['HTTP_X_FORWARDED_FOR'] ) && isset( $_SERVER['REMOTE_ADDR'] ) ) {
    $trusted_proxy_long = ip2long( '172.16.0.0' );
    $trusted_proxy_mask = -1 << ( 32 - 16 ); // /16
    $remote_addr_long   = ip2long( $_SERVER['REMOTE_ADDR'] );

    if ( false !== $remote_addr_long
        && ( $remote_addr_long & $trusted_proxy_mask ) === ( $trusted_proxy_long & $trusted_proxy_mask ) ) {
        $forwarded_ips = array_map( 'trim', explode( ',', $_SERVER['HTTP_X_FORWARDED_FOR'] ) );
        $client_ip     = $forwarded_ips[0];

        if ( filter_var( $client_ip, FILTER_VALIDATE_IP ) ) {
            $_SERVER['REMOTE_ADDR'] = $client_ip;
        }
    }
}

조건을 두 개 건 이유가 있습니다. REMOTE_ADDR이 파드 네트워크 대역일 때만 덮어쓰므로, 프록시를 거치지 않은 직접 요청이 X-Forwarded-For를 위조해 보내더라도 값이 반영되지 않습니다. 또한 FILTER_VALIDATE_IP로 형식을 검증해 잘못된 값이 들어가는 것을 막습니다.

이 방식은 같은 파일에 이미 적용되어 있던 HTTP_X_FORWARDED_PROTO$_SERVER['HTTPS'] 보정과 동일한 패턴입니다. 리버스 프록시 뒤에서 PHP가 요청 맥락을 잘못 인식하는 문제를 애플리케이션 진입점에서 한 번에 정리하는 구조입니다.

주의 — 이 보정이 적용되지 않는 경우

보정 코드가 wp-config.php에 있다는 점에서 오는 제약이 있습니다. wp-load.php를 거치지 않고 직접 호출되는 순수 PHP 스크립트는 이 보정을 보지 못합니다. Apache나 php.ini 레벨이 아니라 WordPress 부트스트랩 과정에서만 실행되기 때문입니다.

검증할 때 이 점 때문에 혼란을 겪을 수 있습니다. 테스트용 PHP 파일을 만들어 $_SERVER['REMOTE_ADDR']을 출력하면 보정 전 값이 나오므로 수정이 반영되지 않은 것처럼 보입니다. WordPress를 실제로 부트스트랩하는 경로에서 확인해야 정확합니다.

결과 확인

두 가지를 모두 적용한 뒤 내부망에서 접속해 통계 플러그인의 기록을 확인했습니다. 방문자 IP가 Traefik 파드 IP가 아니라 실제 접속 단말의 주소로 기록되는 것을 확인했습니다. 외부 방문자 기록은 기존과 동일하게 유지되었습니다.

# Service 정책 확인
kubectl get svc -n traefik traefik -o jsonpath='{.spec.externalTrafficPolicy}'
# 출력: Local

# Traefik 파드가 각 노드에 분산되어 있는지 확인
kubectl get pods -n traefik -o wide

# MetalLB 광고 상태 확인 (로컬 엔드포인트 없는 노드 제외 여부)
kubectl get svc -n traefik traefik -o wide

정리

리버스 프록시 뒤에서 클라이언트 IP가 사라질 때는 계층을 나눠서 확인하는 편이 빠릅니다. 이번 사례에서는 Kubernetes Service의 SNAT과 Apache 모듈의 거부 동작이 겹쳐 있었고, 한쪽만 고쳐서는 증상이 사라지지 않았습니다.

특히 mod_remoteip가 사설 IP를 버리는 동작은 사내망이나 VPN 환경처럼 클라이언트 IP 자체가 사설 대역인 구성에서 문제가 됩니다. 인터넷에 공개된 서비스에서는 클라이언트 IP가 공인 대역이라 드러나지 않으므로, 같은 구성이라도 접속 경로에 따라 증상이 갈립니다. 내부망 접속만 IP가 이상하다면 이 동작을 의심해 볼 만합니다.

참고

관련 포스트:

참고 문서: Apache — mod_remoteip · Kubernetes — External Traffic Policy · MetalLB — Traffic Policies