Skip to content
Published on

Ingress와 ExternalDNS·클라우드 LB 통합 — 도메인부터 L4까지

Authors

들어가며

쿠버네티스에서 애플리케이션을 외부에 노출하는 일은 단순히 Ingress 리소스 하나를 작성하는 것으로 끝나지 않습니다. 사용자가 브라우저에 도메인을 입력하는 순간부터 Pod에 패킷이 도달하기까지는 여러 계층이 사슬처럼 연결되어 있습니다. 도메인 DNS 레코드가 클라우드 로드밸런서의 주소를 가리켜야 하고, 그 로드밸런서가 클러스터 노드로 트래픽을 전달해야 하며, 노드의 Ingress 컨트롤러가 호스트와 경로를 보고 올바른 Service로 라우팅해야 합니다.

이 전체 경로 중 어느 한 곳이라도 잘못 설정되면 증상은 비슷하게 나타납니다. "도메인에 접속이 안 됩니다"라는 한 줄짜리 신고 뒤에는 DNS 전파 문제, LoadBalancer가 pending 상태로 멈춘 문제, 소스 IP가 사라져 접근 제어가 깨진 문제 등 전혀 다른 원인들이 숨어 있을 수 있습니다.

이 글에서는 그 사슬 전체를 따라가 보겠습니다. Ingress 컨트롤러를 외부에 노출하는 방식부터 시작해, AWS·GCP·Azure 클라우드 로드밸런서 연동, ExternalDNS를 이용한 DNS 레코드 자동화, 온프레미스 환경의 MetalLB, 소스 IP 보존을 위한 Proxy Protocol과 externalTrafficPolicy, 멀티 리전 DNS 라우팅, 비용 고려사항, 그리고 현장에서 마주치는 트러블슈팅까지 순서대로 다룹니다.

2026년의 맥락: API freeze와 후계 표준

본격적인 내용에 앞서 현재 생태계의 흐름을 짚고 넘어가겠습니다. Ingress API는 안정화된 이후 사실상 동결(frozen) 상태입니다. 더 이상 새로운 기능이 추가되지 않으며, 쿠버네티스 네트워킹의 후계 표준은 Gateway API로 자리 잡았습니다. 동시에 가장 널리 쓰이던 ingress-nginx 프로젝트는 유지보수 모드로 전환되면서 신규 기능보다 보안 패치 중심으로 운영되고 있습니다.

그렇다고 해서 이 글의 내용이 무의미해지는 것은 아닙니다. 도메인에서 로드밸런서를 거쳐 클러스터로 들어오는 트래픽 경로의 원리는 Ingress든 Gateway API든 동일하게 적용됩니다. ExternalDNS 역시 이미 Gateway API의 HTTPRoute를 소스로 지원하기 시작했고, 클라우드 로드밸런서 연동 어노테이션의 대부분은 Service 리소스 수준에서 작동하므로 상위 추상화가 무엇이든 그대로 쓸 수 있습니다. 따라서 여기서 익히는 개념은 Gateway API로 전환하더라도 그대로 유효합니다.

전체 트래픽 경로 한눈에 보기

먼저 사용자 요청이 Pod에 도달하기까지의 경로를 그림으로 정리해 보겠습니다.

[사용자 브라우저]
      |  (1) app.example.com 조회
      v
[DNS 리졸버] ----> [권한 있는 DNS: Route53/Cloud DNS/Azure DNS]
      |                  ^
      |                  | ExternalDNS가 레코드 자동 생성/갱신
      |  (2) A/CNAME 응답: LB 주소
      v
[클라우드 로드밸런서 (NLB/ALB/GLB)]
      |  (3) L4 또는 L7 전달
      v
[클러스터 노드 (NodePort) 또는 직접 Pod]
      |  (4) kube-proxy / 직접 라우팅
      v
[Ingress 컨트롤러 Pod (nginx/envoy 등)]
      |  (5) Host/Path 기반 라우팅
      v
[애플리케이션 Service ----> Pod]

이 다섯 단계 각각이 별도의 설정 지점이며, 이 글의 각 절은 이 경로의 한 구간씩에 대응합니다. (1)과 (2)는 DNS·ExternalDNS, (3)은 클라우드 LB 연동, (4)~(5)는 Ingress 컨트롤러 노출 방식과 소스 IP 보존에 해당합니다.

Ingress 컨트롤러를 외부에 노출하는 방식

Ingress 리소스 자체는 "어떻게 라우팅할지"를 선언할 뿐, 실제 트래픽을 받는 진입점은 Ingress 컨트롤러 Pod입니다. 이 컨트롤러를 외부에 노출하는 방식에는 세 가지가 있고, 각각 트레이드오프가 다릅니다.

Service type 비교

노출 방식동작장점단점적합한 환경
NodePort모든 노드의 고정 포트(30000~32767)로 노출추가 인프라 불필요, 단순비표준 포트, 노드 IP 직접 노출, 별도 LB 필요개발/테스트, 외부 LB 앞단
LoadBalancer클라우드 LB를 자동 프로비저닝표준 포트, 자동화, 헬스체크클라우드 비용, 서비스마다 LB 1개퍼블릭 클라우드 프로덕션
hostNetworkPod가 노드 네트워크 직접 사용최소 홉, 최고 성능노드당 포트 충돌, 스케줄링 제약베어메탈, 고성능 요구

NodePort 방식

가장 단순한 노출 방식입니다. Service를 NodePort 타입으로 만들면 모든 노드의 특정 포트가 열리고, 외부 로드밸런서가 그 포트로 트래픽을 보냅니다.

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
spec:
  type: NodePort
  selector:
    app.kubernetes.io/name: ingress-nginx
  ports:
    - name: http
      port: 80
      targetPort: 80
      nodePort: 30080
    - name: https
      port: 443
      targetPort: 443
      nodePort: 30443

NodePort는 외부에 직접 노출하기보다는, 별도로 관리하는 로드밸런서(예: 회사 표준 LB나 온프레미스 어플라이언스) 뒤에 두는 백엔드로 흔히 사용합니다.

LoadBalancer 방식

퍼블릭 클라우드에서 가장 일반적인 방식입니다. Service를 LoadBalancer 타입으로 만들면 클라우드 공급자의 컨트롤러가 실제 로드밸런서를 자동으로 프로비저닝하고 외부 IP 또는 DNS 이름을 할당합니다.

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

여기서 externalTrafficPolicy는 소스 IP 보존과 직결되는 중요한 필드인데, 뒤의 별도 절에서 자세히 다루겠습니다.

hostNetwork 방식

성능을 극한으로 끌어올리거나 베어메탈에서 LB 추상화 없이 직접 노출하고 싶을 때 사용합니다. Pod가 노드의 네트워크 네임스페이스를 직접 사용하므로 추가적인 네트워크 홉이 없습니다.

spec:
  template:
    spec:
      hostNetwork: true
      dnsPolicy: ClusterFirstWithHostNet
      containers:
        - name: controller
          ports:
            - name: http
              containerPort: 80
              hostPort: 80
            - name: https
              containerPort: 443
              hostPort: 443

다만 노드당 80/443 포트를 점유하므로 한 노드에 하나의 컨트롤러 Pod만 스케줄링할 수 있고, 노드 IP가 그대로 노출되므로 노드 교체 시 DNS 갱신이 필요합니다.

클라우드 로드밸런서 연동

LoadBalancer 타입 Service를 만들 때, 클라우드별로 어떤 종류의 로드밸런서를 어떤 옵션으로 만들지는 Service에 붙이는 어노테이션으로 제어합니다. 클라우드마다 어노테이션 키가 다르므로 주요 클라우드 세 곳을 정리합니다.

AWS: NLB vs ALB

AWS에는 크게 두 종류의 로드밸런서가 있습니다. NLB(Network Load Balancer)는 L4에서 동작하고, ALB(Application Load Balancer)는 L7에서 동작합니다. Ingress 컨트롤러 앞단에는 보통 NLB를 두어 TLS 종료와 L7 라우팅을 컨트롤러에 맡기는 구성을 많이 씁니다.

항목NLBALB
동작 계층L4 (TCP/UDP)L7 (HTTP/HTTPS)
소스 IP 보존기본 보존 가능X-Forwarded-For 헤더로 전달
TLS 종료선택 (보통 컨트롤러에서)LB에서 종료
연동 리소스Service type=LoadBalancerIngress (AWS LB Controller)
비용 특성시간당 + LCU시간당 + LCU

NLB를 Service 어노테이션으로 프로비저닝하는 예시입니다. AWS Load Balancer Controller가 설치되어 있어야 합니다.

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: external
    service.beta.kubernetes.io/aws-load-balancer-nlb-target-type: instance
    service.beta.kubernetes.io/aws-load-balancer-scheme: internet-facing
    service.beta.kubernetes.io/aws-load-balancer-cross-zone-load-balancing-enabled: "true"
    service.beta.kubernetes.io/aws-load-balancer-proxy-protocol: "*"
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local
  ports:
    - name: http
      port: 80
      targetPort: 80
    - name: https
      port: 443
      targetPort: 443

여기서 aws-load-balancer-proxy-protocol 어노테이션은 NLB가 Proxy Protocol v2를 통해 원본 클라이언트 정보를 전달하도록 합니다. 이를 켰다면 Ingress 컨트롤러 쪽에서도 Proxy Protocol을 수신하도록 설정해야 하는데, 이 부분은 뒤에서 다룹니다.

ALB를 쓰는 경우에는 Service가 아니라 Ingress 리소스에 어노테이션을 붙이고, AWS Load Balancer Controller가 그 Ingress를 보고 ALB를 만듭니다.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: app
  annotations:
    kubernetes.io/ingress.class: alb
    alb.ingress.kubernetes.io/scheme: internet-facing
    alb.ingress.kubernetes.io/target-type: ip
    alb.ingress.kubernetes.io/listen-ports: '[{"HTTP": 80}, {"HTTPS": 443}]'
    alb.ingress.kubernetes.io/ssl-redirect: '443'
spec:
  rules:
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: app-svc
                port:
                  number: 80

GCP

GCP에서는 Service type=LoadBalancer가 기본적으로 외부 패스스루 네트워크 로드밸런서를 만듭니다. 내부 LB나 백엔드 설정은 어노테이션과 BackendConfig CRD로 제어합니다.

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
  annotations:
    cloud.google.com/load-balancer-type: "External"
    networking.gke.io/load-balancer-type: "External"
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local
  ports:
    - name: http
      port: 80
      targetPort: 80
    - name: https
      port: 443
      targetPort: 443

내부 전용 LB가 필요하면 cloud.google.com/load-balancer-type 값을 Internal로 바꿉니다. GKE의 Ingress를 쓰면 GCE L7 로드밸런서가 생성되며, 이때는 BackendConfig로 헬스체크, Cloud CDN, IAP 등을 세밀하게 설정할 수 있습니다.

Azure

Azure에서는 AKS가 Standard Load Balancer를 프로비저닝합니다. 내부 LB 여부, 헬스 프로브 경로 등을 어노테이션으로 지정합니다.

apiVersion: v1
kind: Service
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
  annotations:
    service.beta.kubernetes.io/azure-load-balancer-internal: "false"
    service.beta.kubernetes.io/azure-load-balancer-health-probe-request-path: /healthz
spec:
  type: LoadBalancer
  externalTrafficPolicy: Local
  ports:
    - name: http
      port: 80
      targetPort: 80
    - name: https
      port: 443
      targetPort: 443

내부 LB가 필요하면 azure-load-balancer-internal 값을 true로 설정합니다. 헬스 프로브 경로를 명시하지 않으면 Azure가 기본 프로브를 사용하는데, externalTrafficPolicy가 Local일 때는 노드의 헬스체크 포트를 정확히 가리키도록 주의해야 합니다.

ExternalDNS로 DNS 레코드 자동화

여기까지 하면 로드밸런서에 외부 주소가 할당됩니다. 하지만 사용자는 그 주소를 직접 입력하지 않고 도메인을 입력합니다. 따라서 도메인 레코드가 항상 최신 LB 주소를 가리키도록 유지해야 하는데, 이를 수동으로 관리하면 LB 교체나 IP 변경 때마다 실수가 발생합니다. ExternalDNS는 이 작업을 자동화합니다.

ExternalDNS의 동작 원리

ExternalDNS는 클러스터 내에서 Service와 Ingress(그리고 Gateway API HTTPRoute) 리소스를 감시합니다. 호스트 이름과 LB 주소 정보를 읽어, 설정된 DNS 공급자(Route53, Cloud DNS, Azure DNS 등)에 해당 레코드를 자동으로 생성·갱신·삭제합니다.

[Service/Ingress 리소스]
   - host: app.example.com
   - LB 주소: a1b2.elb.amazonaws.com
        |
        v
   [ExternalDNS 컨트롤러]
        |  주기적 reconcile
        v
   [DNS 공급자 API]
   - app.example.com  CNAME  a1b2.elb.amazonaws.com
   - 소유권 TXT 레코드 동시 생성

배포 예시

다음은 Route53을 공급자로 사용하는 ExternalDNS Deployment 예시입니다.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: external-dns
  namespace: external-dns
spec:
  replicas: 1
  selector:
    matchLabels:
      app: external-dns
  template:
    metadata:
      labels:
        app: external-dns
    spec:
      serviceAccountName: external-dns
      containers:
        - name: external-dns
          image: registry.k8s.io/external-dns/external-dns:v0.15.0
          args:
            - --source=service
            - --source=ingress
            - --provider=aws
            - --aws-zone-type=public
            - --registry=txt
            - --txt-owner-id=my-cluster-prod
            - --policy=sync
            - --domain-filter=example.com

핵심 인자 설명

  • sources: ExternalDNS가 감시할 리소스 종류입니다. service와 ingress를 모두 지정하면 두 종류 모두에서 레코드를 만듭니다. Gateway API를 쓴다면 gateway-httproute 등을 추가할 수 있습니다.
  • provider: DNS 공급자입니다. aws(Route53), google(Cloud DNS), azure 등을 지정합니다.
  • txt-owner-id: 이 ExternalDNS 인스턴스의 고유 식별자입니다. 매우 중요한 값으로, 뒤에서 설명합니다.
  • policy: sync는 생성·갱신·삭제를 모두 수행하고, upsert-only는 삭제는 하지 않습니다. 운영 초기에는 upsert-only로 시작해 안전을 확인한 뒤 sync로 전환하는 것이 안전합니다.
  • domain-filter: 관리 대상 도메인을 제한해 실수로 다른 존을 건드리지 않도록 합니다.

txtOwnerId의 중요성

ExternalDNS는 자신이 만든 레코드를 표시하기 위해 별도의 TXT 레코드를 함께 생성합니다. 이 TXT 레코드에는 txt-owner-id가 담기고, ExternalDNS는 이 식별자가 일치하는 레코드만 자신의 관리 대상으로 인식합니다.

여러 클러스터가 같은 DNS 존을 공유할 때 모든 인스턴스의 txt-owner-id가 같으면, 한 클러스터가 다른 클러스터가 만든 레코드를 자기 것으로 오인해 삭제할 수 있습니다. 따라서 클러스터마다, 또는 환경(prod/staging)마다 고유한 txt-owner-id를 반드시 부여해야 합니다. 이 한 줄을 빠뜨려서 운영 도메인이 통째로 사라지는 사고가 드물지 않게 발생합니다.

서비스에 어노테이션으로 호스트 이름을 지정하는 예시입니다.

apiVersion: v1
kind: Service
metadata:
  name: app-svc
  annotations:
    external-dns.alpha.kubernetes.io/hostname: app.example.com
    external-dns.alpha.kubernetes.io/ttl: "60"
spec:
  type: LoadBalancer
  ports:
    - port: 80
      targetPort: 8080

MetalLB: 온프레미스 LoadBalancer

퍼블릭 클라우드가 아니라 베어메탈이나 온프레미스에서는 LoadBalancer 타입 Service를 만들어도 자동으로 외부 IP를 할당해 줄 클라우드 컨트롤러가 없습니다. 그 결과 Service가 영원히 pending 상태에 머무릅니다. MetalLB는 이 공백을 메워 온프레미스에서도 LoadBalancer 타입을 사용할 수 있게 해 줍니다.

L2 모드와 BGP 모드

MetalLB는 두 가지 모드로 동작합니다.

모드동작장점단점
L2 (ARP/NDP)한 노드가 VIP를 소유하고 ARP로 응답라우터 설정 불필요, 단순단일 노드 병목, 페일오버 지연
BGP라우터와 BGP 피어링해 ECMP 분산진정한 부하 분산, 빠른 수렴BGP 가능한 라우터 필요, 설정 복잡

IP 풀과 광고 설정

다음은 IP 주소 풀을 정의하고 L2 모드로 광고하는 예시입니다.

apiVersion: metallb.io/v1beta1
kind: IPAddressPool
metadata:
  name: prod-pool
  namespace: metallb-system
spec:
  addresses:
    - 192.168.10.100-192.168.10.150
---
apiVersion: metallb.io/v1beta1
kind: L2Advertisement
metadata:
  name: prod-l2
  namespace: metallb-system
spec:
  ipAddressPools:
    - prod-pool

BGP 모드를 쓰는 경우에는 라우터와의 피어링을 정의합니다.

apiVersion: metallb.io/v1beta1
kind: BGPPeer
metadata:
  name: top-of-rack
  namespace: metallb-system
spec:
  myASN: 64500
  peerASN: 64501
  peerAddress: 192.168.10.1
---
apiVersion: metallb.io/v1beta1
kind: BGPAdvertisement
metadata:
  name: prod-bgp
  namespace: metallb-system
spec:
  ipAddressPools:
    - prod-pool

L2 모드는 설정이 단순하지만 특정 VIP의 트래픽이 항상 한 노드를 통과하므로 그 노드가 병목이 됩니다. 처리량이 중요하다면 BGP 모드로 ECMP 기반 분산을 구성하는 편이 좋습니다.

Proxy Protocol과 소스 IP 보존

클라우드 LB나 MetalLB를 거치면 백엔드 입장에서 보이는 소스 IP가 클라이언트의 실제 IP가 아니라 중간 노드의 IP로 바뀌는 일이 흔합니다. 이는 접근 제어(IP 화이트리스트), 레이트 리밋, 감사 로그, 지역 기반 처리 등에서 문제를 일으킵니다. 소스 IP를 보존하는 방법은 크게 두 갈래입니다.

externalTrafficPolicy: Local vs Cluster

항목Cluster (기본)Local
소스 IPSNAT로 손실보존
부하 분산모든 노드로 재분배받은 노드의 Pod만
추가 홉노드 간 홉 발생 가능없음
헬스체크모든 노드 통과Pod 있는 노드만 통과

externalTrafficPolicy가 Cluster이면 트래픽을 받은 노드가 다른 노드의 Pod로 다시 분배할 수 있고, 이 과정에서 SNAT가 일어나 소스 IP가 노드 IP로 바뀝니다. Local로 설정하면 트래픽을 받은 노드는 자기 노드에 있는 Pod로만 전달하므로 SNAT가 없어 소스 IP가 보존됩니다.

대신 Local 모드에서는 해당 노드에 백엔드 Pod가 없으면 트래픽이 버려질 수 있으므로, LB가 Pod가 실제로 있는 노드로만 트래픽을 보내도록 노드 헬스체크가 정확히 동작해야 합니다.

Proxy Protocol과 X-Forwarded-For

L4 LB(예: AWS NLB)에서는 패킷 페이로드를 건드리지 않으므로 소스 IP를 헤더로 넣을 수 없습니다. 대신 Proxy Protocol이라는 프로토콜로 연결 시작 시 원본 주소 정보를 전달합니다. LB와 Ingress 컨트롤러 양쪽 모두에서 Proxy Protocol을 켜야 합니다.

ingress-nginx에서 Proxy Protocol을 수신하도록 설정하는 예시입니다.

apiVersion: v1
kind: ConfigMap
metadata:
  name: ingress-nginx-controller
  namespace: ingress-nginx
data:
  use-proxy-protocol: "true"
  real-ip-header: "proxy_protocol"

반면 L7 LB(예: AWS ALB)는 HTTP 헤더를 다룰 수 있으므로 X-Forwarded-For 헤더에 클라이언트 IP를 넣어 전달합니다. 이 경우 컨트롤러는 그 헤더를 신뢰하도록 신뢰 프록시 대역을 설정해야 합니다.

data:
  use-forwarded-headers: "true"
  proxy-real-ip-cidr: "10.0.0.0/8"

주의할 점은, LB에서 Proxy Protocol을 켰는데 컨트롤러에서 끄면(또는 그 반대면) 컨트롤러가 첫 바이트를 HTTP로 해석하려다 깨지면서 모든 요청이 실패합니다. 양쪽 설정은 반드시 일치시켜야 합니다.

멀티 클러스터·멀티 리전 DNS

하나의 도메인을 여러 리전이나 여러 클러스터에 분산할 때는 DNS 수준의 라우팅 정책이 필요합니다. Route53을 예로 들면 가중치 기반, 지연 시간 기반, 지리 기반 라우팅을 지원합니다.

라우팅 정책 비교

정책동작용도
가중치 기반비율대로 트래픽 분배점진적 롤아웃, 블루/그린
지연 시간 기반가장 빠른 리전으로글로벌 성능 최적화
지리 기반사용자 위치별 리전데이터 주권, 규정 준수
페일오버헬스체크 실패 시 백업으로재해 복구

ExternalDNS는 어노테이션을 통해 이러한 정책을 표현할 수 있습니다. 가중치 기반 라우팅 예시입니다.

apiVersion: v1
kind: Service
metadata:
  name: app-region-a
  annotations:
    external-dns.alpha.kubernetes.io/hostname: app.example.com
    external-dns.alpha.kubernetes.io/aws-weight: "80"
    external-dns.alpha.kubernetes.io/set-identifier: region-a
spec:
  type: LoadBalancer
  ports:
    - port: 80

다른 리전 클러스터에서는 같은 호스트 이름에 다른 set-identifier와 가중치를 부여합니다. 두 클러스터의 ExternalDNS가 동일한 존을 관리하므로, 앞서 강조한 txt-owner-id를 클러스터별로 구분하고 set-identifier를 일관되게 부여하는 것이 충돌을 막는 핵심입니다.

비용 고려사항

로드밸런서와 DNS는 운영비에 직접 영향을 줍니다. 설계 단계에서 비용 모델을 이해해야 나중에 청구서를 보고 놀라지 않습니다.

  • Service마다 LoadBalancer를 만들면 LB 개수만큼 시간당 요금이 곱해집니다. Ingress 컨트롤러 하나 뒤에 여러 서비스를 호스트/경로로 라우팅하면 LB는 하나만 있으면 되므로 비용이 크게 줄어듭니다. 이것이 LoadBalancer 타입 Service를 서비스마다 만들지 않고 Ingress를 쓰는 주된 경제적 이유입니다.
  • AWS의 LCU(Load balancer Capacity Unit)처럼 처리량·연결 수·규칙 수에 따라 과금되는 모델은 트래픽이 늘면 시간당 고정비보다 더 큰 비중을 차지할 수 있습니다.
  • 크로스 존 부하 분산을 켜면 가용성은 올라가지만 가용 영역 간 데이터 전송 요금이 발생합니다. 트래픽이 많다면 이 요금이 무시할 수 없습니다.
  • DNS 쿼리 자체도 백만 쿼리 단위로 과금됩니다. TTL을 너무 짧게 잡으면 쿼리 수가 늘어 비용과 부하가 함께 증가합니다. 다만 페일오버나 잦은 변경이 필요한 레코드는 짧은 TTL이 필요하므로 균형을 잡아야 합니다.

트러블슈팅

현장에서 자주 마주치는 세 가지 증상과 진단 흐름을 정리합니다.

LoadBalancer가 pending 상태

Service가 EXTERNAL-IP 칸에 pending만 표시하며 멈추는 경우입니다.

kubectl get svc -n ingress-nginx
kubectl describe svc ingress-nginx-controller -n ingress-nginx
kubectl get events -n ingress-nginx --sort-by=.lastTimestamp

원인 진단 흐름입니다.

LoadBalancer pending
   |
   +-- 온프레미스인가? --예--> 클라우드 컨트롤러 없음. MetalLB 등 설치 필요
   |
   +-- 클라우드인가?
         |
         +-- cloud-controller-manager 동작? --아니오--> 컴포넌트 점검
         +-- LB Controller(AWS 등) 설치됨? --아니오--> 설치/IAM 권한 확인
         +-- 서브넷 태그/쿼터 문제? --예--> 서브넷 태그, LB 쿼터 확인

DNS 전파 지연

레코드는 만들어졌는데 도메인이 아직 옛 주소를 가리키는 경우입니다.

dig +short app.example.com
dig app.example.com @8.8.8.8
kubectl logs -n external-dns deploy/external-dns | tail -50

ExternalDNS 로그에서 레코드가 실제로 업서트되었는지 먼저 확인하고, 그다음 권한 있는 네임서버에 직접 질의해 봅니다. 권한 네임서버는 이미 갱신되었는데 클라이언트가 옛 값을 본다면 이는 TTL 만큼의 캐시 잔존이며 시간이 지나면 해소됩니다. 권한 네임서버 자체가 옛 값이면 ExternalDNS 권한이나 존 설정 문제입니다.

소스 IP가 손실됨

백엔드 로그에 클라이언트 IP 대신 노드 IP나 LB IP만 찍히는 경우입니다.

소스 IP 손실
   |
   +-- externalTrafficPolicy=Cluster 인가? --예--> Local로 변경 검토
   |
   +-- L4 LB(NLB)인데 Proxy Protocol 한쪽만 켰나?
   |       --예--> LB와 컨트롤러 양쪽 일치시킴
   |
   +-- L7 LB(ALB)인데 X-Forwarded-For 미신뢰? --예--> 신뢰 프록시 CIDR 설정

가장 흔한 함정은 Proxy Protocol을 LB에서만 켜거나 컨트롤러에서만 켠 경우입니다. 이때는 소스 IP 손실을 넘어 연결 자체가 깨지므로, 위 흐름에서 가장 먼저 양쪽 일치 여부를 확인해야 합니다.

운영 체크리스트

  • Ingress 컨트롤러 노출 방식(NodePort/LoadBalancer/hostNetwork)을 환경에 맞게 선택했는가.
  • 클라우드별 LB 어노테이션이 의도한 LB 종류(NLB/ALB 등)와 스킴(internet-facing/internal)을 정확히 가리키는가.
  • ExternalDNS의 txt-owner-id가 클러스터·환경별로 고유한가.
  • ExternalDNS policy를 초기에는 upsert-only로 두고 검증 후 sync로 전환했는가.
  • domain-filter로 관리 대상 존을 제한했는가.
  • externalTrafficPolicy 설정과 소스 IP 보존 요구사항이 일치하는가.
  • Proxy Protocol을 쓴다면 LB와 컨트롤러 양쪽 설정이 일치하는가.
  • 멀티 리전이면 set-identifier와 라우팅 정책이 일관적인가.
  • LB 개수와 크로스 존 전송 비용을 검토했는가.
  • DNS TTL이 페일오버 요구와 쿼리 비용 사이에서 균형 잡혀 있는가.

마치며

도메인에서 시작해 클라우드 로드밸런서, Ingress 컨트롤러를 거쳐 Pod까지 이어지는 경로는 길고 각 구간마다 별도의 설정 지점이 있습니다. 이 글에서는 노출 방식 선택, 클라우드별 LB 연동, ExternalDNS 자동화, MetalLB, 소스 IP 보존, 멀티 리전 라우팅, 비용, 트러블슈팅까지 그 경로 전체를 따라가 보았습니다.

핵심은 각 구간을 독립적으로 이해하되, 구간 사이의 약속(예: Proxy Protocol의 양쪽 일치, txt-owner-id의 고유성, externalTrafficPolicy와 헬스체크의 정합성)을 놓치지 않는 것입니다. 또한 2026년 현재 Ingress API는 동결되었고 Gateway API가 후계 표준이지만, 여기서 다룬 트래픽 경로의 원리와 ExternalDNS·LB 연동은 Gateway API로 전환하더라도 그대로 통용됩니다. 지금 이 경로를 탄탄히 이해해 두면 추상화가 바뀌어도 흔들리지 않을 것입니다.

참고 자료