🏠 목록 이중 egress gateway — passthrough와 mTLS를 별도 ns·별도 pod로 나란히 📄 MD 원본 🌓 테마
istioegressgatewaymulti-gatewaytls-passthroughistio-mutualnamespace-isolation

이중 egress gateway — passthrough와 mTLS를 별도 ns·별도 pod로 나란히

NOTE

단일 egress gateway를 멀티-gateway 토폴로지로 일반화하면서, 두 egress 패턴(① TLS PASSTHROUGH, ② outer 메시 mTLS + inner 앱 TLS)을 서로 다른 namespace의 서로 다른 gateway pod로 동시에 띄워 직접 비교한 homelab 검증 랩. 결론: 진짜 격리는 물리 분리(pod/ns/label)와 논리 스코핑(exportTo/sourceLabels)을 함께 갖춰야 성립한다 — 둘 중 하나라도 빠지면 "분리된 것처럼 보이는" 상태에 그친다. 본 문서는 그 둘을 어떻게 맞물리는지에 집중한다(패턴 자체의 구조·운영은 기존 문서로 링크).

대상 환경: homelab (kubespray bare-metal, k8s v1.30.6, CNI Calico, 3노드), Istio 1.30.0 (Helm gateway chart) 대상 독자: 단일 egress gateway는 띄워봤고, 이제 패턴/tier별로 gateway를 쪼개려는 SRE/DevOps 범위: "왜 쪼개나" → 격리의 두 축(물리·논리) → 경로별 Istio 객체 → 트래픽·Envoy 검증 → 실측 함정 2건 선행 개념: egress route 스코핑 (멀티-gateway의 전제) · egress CRD 멘탈모델


1. 배경 — 왜 egress gateway를 둘로 쪼개나

단일 egress gateway면 메시의 모든 외부 호출이 한 pod를 통과한다. 처음엔 단순해서 좋지만, 운영이 커지면 그 한 pod가 여러 책임을 한 몸에 떠안는 구조가 문제가 된다:

그래서 패턴/tier별로 gateway pod를 분리한다. 그런데 "Deployment를 둘로 나눴다"만으로 격리가 끝나지 않는다는 게 이 문서의 핵심 교훈이다. Istio에서 한 메시 안의 리소스는 기본적으로 전역 가시성을 갖기 때문에, 물리적으로 떨어진 두 pod라도 잘못된 전역 리소스 하나가 양쪽 config를 동시에 망가뜨릴 수 있다(함정 ②). 진짜 격리에는 두 번째 축이 필요하다.

선행 개념인 egress route 스코핑을 먼저 잡아두면 이 문서의 exportTo/ client-VS·gateway-VS 분리가 자연스럽게 읽힌다. 멀티-gateway는 그 스코핑 규칙을 gateway 개수만큼 곱한 것에 가깝다.

2. 멘탈모델 — 격리는 두 축의 곱(物理 × 論理)

머릿속에 담을 한 장면: 두 gateway는 pod·ns·label로 물리적으로 분리돼 있지만 여전히 하나의 메시다. 그래서 격리는 다음 두 축이 둘 다 채워질 때만 성립한다 — 하나라도 비면 "분리된 척"에 그친다.

isolation = PHYSICAL  ×  LOGICAL
            (분리)        (가시성·적용대상)

  PHYSICAL axis                    LOGICAL axis
  +--------------------+           +---------------------------+
  | label (selector)   |           | exportTo  (누가 이 리소스를  |
  |   Gateway가 자기      |           |            볼 수 있나)      |
  |   pod만 잡음         |           | sourceLabels (어떤 client에 |
  | ns    (소유권 경계)   |           |            라우트가 붙나)    |
  | pod   (장애·SNAT 경계)|           +---------------------------+
  +--------------------+
        |                                   |
        v                                   v
  "딴 pod의 변경이                    "전역 리소스 하나가
   내 listener를 안 건드림"            모든 gateway를 못 얼리게"

이 한 장면에서 나머지가 따라 나온다. 아래 토폴로지가 그 물리 축을 그림으로 보인 것이다 — 같은 client(sleep)가 두 외부 host를 호출하지만, host에 따라 다른 ns의 다른 gateway pod로 갈린다.

mesh-test ns — client sleep (injection ON) curl https://example.org curl https://www.wikipedia.org tls/sniHosts route (sidecar no terminate) DR ISTIO_MUTUAL + sni (sidecar wraps mesh mTLS) ns: egress-pt egw-pt (istio=egw-pt) Gateway :443 → :8443 tls PASSTHROUGH SNI route, no decrypt ns: egress-mtls egw-mtls (istio=egw-mtls) Gateway :443 → :8443 · ISTIO_MUTUAL terminate OUTER mTLS (verify client cert) tcp_proxy INNER app TLS example.org:443 www.wikipedia.org:443
그림 1. host별로 갈리는 dual egress — example.org는 egress-pt가 SNI만 보고 복호화 없이 PASSTHROUGH, www.wikipedia.org는 egress-mtls가 OUTER mesh mTLS를 종단·검증하고 INNER 앱 TLS만 tcp_proxy로 흘려보낸다.

왼쪽 경로는 gateway가 복호화하지 않는 passthrough, 오른쪽은 gateway가 outer mesh mTLS를 종단하고 inner 앱 TLS만 흘려보내는 패턴이다. inner 앱 TLS는 두 경로 모두 client↔external 간 end-to-end로 유지된다 — passthrough는 애초에 건드리지 않고, mTLS 경로는 outer mesh 레이어만 벗기고 inner는 tcp_proxy로 통과시키므로.

두 패턴의 본질 차이 (각 상세는 링크)

측면 PASSTHROUGH outer mTLS + inner TLS
in-mesh leg 평문 TCP(앱 TLS만) 메시 mTLS(SPIFFE 신원 검증)
gateway가 보는 것 SNI만 outer 종단 후 inner는 불투명 통과
L7 정책 불가 불가(inner 미복호)
호출자 식별 gateway단 불가(→ Calico 등) SPIFFE로 가능
상세 구조·실측 passthrough 가이드, 필드 매뉴얼 HTTPS over mTLS 구조, mTLS 테스트 리포트
패턴 선택 근거 이중 TLS 없이(decision)
운영(모니터링·SNAT·graceful) egress 운영 가이드 동일

두 패턴 모두 inner는 gateway가 못 보므로 L7 정책은 어느 쪽도 불가다. 차이는 in-mesh leg에 있다 — mTLS 경로만 SPIFFE 신원으로 호출자를 식별할 수 있고, passthrough는 gateway단에서 호출자를 못 가린다(Calico 등 외부 수단 필요).

3. 구성 따라하기 — 두 축을 어떻게 코드로 채우나

3-1. 물리 축: gateway 2개를 별도 ns·pod로

# (repo) scenarios/20-egress/dual-gateway/00-namespaces.yaml — egress-pt / egress-mtls (둘 다 injection ON)
kubectl apply -f scenarios/20-egress/dual-gateway/00-namespaces.yaml

# Helm gateway chart 2 release — label로만 구분(egw-pt / egw-mtls), 둘 다 ClusterIP
helm upgrade --install egw-pt   istio/gateway -n egress-pt   --version 1.30.0 -f install/helm/values-egw-pt.yaml   --wait
helm upgrade --install egw-mtls istio/gateway -n egress-mtls --version 1.30.0 -f install/helm/values-egw-mtls.yaml --wait

두 release의 차이는 label과 ns뿐이다 — 나머지(svc 포트, ClusterIP)는 의도적으로 동일하게 둬서 "차이 = 격리의 근거"가 label/ns 단 두 곳임을 드러낸다.

# egw-pt   svc 포트: 443 -> targetPort 8443   (PASSTHROUGH listener)
# egw-mtls svc 포트: 443 -> targetPort 8443   (ISTIO_MUTUAL listener)  <- egw-pt와 동일
#   둘 다 443으로 통일: 다른 pod·다른 ns라 충돌 없음. (한 pod에 두 모드를 얹을 때만 포트 분리 필요.)

→ 포트를 같이 443으로 둘 수 있는 이유 자체가 물리 격리의 증거다. 단일 pod였다면 두 모드가 같은 listener 포트를 다툴 테니 갈라야 했다. 별도 pod라 충돌이 원천적으로 없다.

3-2. 논리 축: 경로별 Istio 객체

passthrough (scenarios/20-egress/dual-gateway/10-passthrough.yaml): SE(example.org, protocol: TLS, exportTo: [".", "egress-pt"]) + Gateway(egress-pt, :443 PASSTHROUGH) + DR(subset만) + client-VS(mesh, tls) + gateway-VS(egress-pt, tls). gateway는 복호화 안 함.

mTLS (scenarios/20-egress/dual-gateway/20-mtls.yaml): SE(www.wikipedia.org) + Gateway(egress-mtls, :443 ISTIO_MUTUAL) + DR(portLevelSettings 443 ISTIO_MUTUAL + sni: www.wikipedia.org) + client-VS(mesh, tls → gw:443) + gateway-VS(egress-mtls, tcp :443 → wikipedia:443).

여기서 route 타입(tls vs tcp)이 왜 갈리는가가 이 랩의 가장 비자명한 지점이다. 규칙은 단순하다: gateway가 종단(decrypt)하면 SNI가 이미 소비돼 사라지므로 SNI 매칭(tls/sniHosts)을 더는 쓸 수 없다 → tcp route(포트 매칭)로 가야 한다. passthrough는 종단을 안 하니 SNI가 그대로 살아 있어 tls route로 라우팅한다. gateway-VS(2단)는 이 규칙과 직결되므로 정확한 필드 구조를 보인다 — ISTIO_MUTUAL 종단 listener는 tcp route다:

# (2) egress-mtls gateway -> external (inner 앱 TLS passthrough). ISTIO_MUTUAL listener는 tcp route.
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata: { name: wiki-gateway, namespace: egress-mtls }
spec:
  exportTo: ["."]                 # 가시성을 egress-mtls ns로 닫음(함정 ② 방지) — LOGICAL 축
  hosts: [www.wikipedia.org]
  gateways: [egw-mtls-gateway]    # mesh가 아니라 egress-mtls gateway에만 적용
  tcp:                            # ← tls/sniHosts가 아님. outer mTLS가 이미 종단돼 SNI는 소비됨
    - match:
        - gateways: [egw-mtls-gateway]
          port: 443               # 포트 매칭만으로 충분 → inner 앱 TLS를 tcp_proxy로 그대로 흘림
      route:
        - destination: { host: www.wikipedia.org, port: { number: 443 } }
          weight: 100

전체 필드(client-VS의 tls.match.sniHosts, DR portLevelSettings.sni, Gateway ISTIO_MUTUAL 서버)는 HTTPS over mTLS 구조의 CRD 해부와 첨부 manifest를 참조.

스코핑은 전제 문서대로 client-VS / gateway-VS를 분리하고 각자 exportTo: ["."]로 닫았다 — 이게 위 멘탈모델의 LOGICAL 축을 코드로 채우는 부분이다.

4. 검증 (기대 vs 실제)

SLEEP=$(kubectl -n mesh-test get pod -l app=sleep -o jsonpath='{.items[0].metadata.name}')
# A) passthrough
kubectl -n mesh-test exec $SLEEP -c sleep -- curl -sS -o /dev/null -w "%{http_code} %{remote_ip}\n" https://example.org
#  -> 200 104.20.26.136     egw-pt access log: outbound|443||example.org (egw-pt pod :8443 경유)
# B) mTLS
kubectl -n mesh-test exec $SLEEP -c sleep -- curl -sS -o /dev/null -w "%{http_code} %{remote_ip}\n" https://www.wikipedia.org
#  -> 200 103.102.166.224   egw-mtls access log: outbound|443||www.wikipedia.org (egw-mtls pod :8443 경유)

cluster 이름 outbound|443||example.org(direction|port|subset|fqdn)이 각자의 gateway pod access log에 찍히는 게 경로가 실제로 갈렸다는 직접 증거다 — 둘 다 200이어도 같은 pod로 샜다면 격리가 깨진 것이다.

Envoy 증거 — listener/cluster 레벨에서 "종단 vs 미종단"을 못 박는다:

# egw-mtls 8443 listener = outer mTLS 강제됨
istioctl proxy-config listener deploy/egw-mtls.egress-mtls --port 8443 -o json | grep requireClientCertificate
#  "requireClientCertificate": true        ← client cert 요구 + ROOTCA 검증(SPIFFE)
# egw-pt 클러스터에 originate-TLS 없음 = 순수 passthrough
istioctl proxy-config cluster deploy/egw-pt.egress-pt --fqdn example.org -o json | grep -c transportSocket
#  0                                        ← TLS transport socket 없음
istioctl analyze -A                          # 충돌 0 (default-ns Info 무관)

합격 판정: 두 경로 모두 200 + 각자의 gateway pod 경유(access log) + egw-mtls는 outer mTLS 강제 (requireClientCertificate: true) + egw-pt는 미복호 passthrough(transportSocket 0개) + analyze 청정.

5. 실측 함정 2건 (검증 중 실제로 막힌 곳)

함정 ① 종단 listener에 tls route → 0 chains

mTLS gateway-VS(2단)를 처음에 tls/sniHosts로 작성했더니 egw-mtls의 listener가 아예 안 떴다. (당시 포트는 15443 — 이후 §3처럼 443→8443으로 통일했고, 아래 로그의 15443은 그 시점 값이다.) istiod 로그:

gateway egress-mtls/egw-mtls-gateway:15443 listener missed network filter
omitting listener "0.0.0.0_15443" due to: must have more than 0 chains

메커니즘: ISTIO_MUTUAL은 gateway가 outer mTLS를 종단하므로 그 시점에 SNI는 이미 소비됐다. 그런데 tls/ sniHosts route는 SNI로 filter chain을 매칭하려 한다 → 매칭할 SNI가 없어 유효 chain이 0개 → Envoy가 빈 listener를 거부(must have more than 0 chains). tlstcp(포트 매칭)로 바꾸자 listener가 SNI: www.wikipedia.org / Cluster: outbound|443||www.wikipedia.org로 떴다. 이것이 §3의 "종단하면 tcp" 규칙의 실증이다.

함정 ② exportTo 누락 SE가 mesh 전체 gateway를 NACK

전역 export된 데모 SE(example-logicaldns, LOGICAL_DNS)가 multi-IP가 된 example.com 때문에 Envoy에서 거부됐고, xDS의 트랜잭션성(한 push는 all-or-nothing) 때문에 그 push에 실린 listener까지 모든 gateway에서 드롭됐다 (기존 egress gateway 포함). 즉 물리적으로 멀쩡히 분리된 두 gateway가 전역 리소스 하나에 동반 사망한 사건이다 — 이게 §1·§2에서 말한 "물리 분리만으론 부족, LOGICAL 축이 필요"의 정확한 근거다. exportTo: ["."]로 가시성을 좁혀 해소. 메커니즘 상세는 스코핑 §5.

핵심 정리

다음 작업

What you might be missing

"별도 gateway pod"의 실익은 장애·정책·소스 IP의 격리다(한 tier의 graceful drain·SNAT 고갈·정책 변경이 다른 tier에 안 번짐). 그러나 격리는 배포만으로 완성되지 않는다 — 같은 메시 안에 사는 이상, 하나의 잘못된 전역 리소스(exportTo 누락 SE)가 모든 gateway의 config를 동시에 얼릴 수 있다(함정 ②). 즉 멀티-gateway는 물리적 분리(pod/ns)와 논리적 스코핑(exportTo/sourceLabels)을 함께 갖춰야 비로소 격리다. 한쪽만으로는 "분리된 것처럼 보이는" 상태에 그친다. 그리고 이 함정은 gateway를 늘릴수록 더 위험해진다 — blast radius가 gateway 개수만큼 커지기 때문이다(전역 push 1회가 N개 gateway를 동시에 얼린다). 그래서 멀티-gateway 환경일수록 exportTo 위생이 선택이 아니라 전제다.

덧붙여, 두 패턴의 공존에는 pod 분리가 아닌 다른 축도 있다: mode는 Deployment 속성이 아니라 Gateway CR server(=리스너/포트) 속성이라, 같은 Deployment 하나에서 8443=ISTIO_MUTUAL, 9443=PASSTHROUGH로 포트 단위 공존이 가능하다. 클라이언트·인프라 무변경으로 목적지별 점진 전환이 되는 게 이 축의 실익이고, 장애·소스IP 격리가 필요하면 이 문서의 pod 분리 축을 택한다 — 프로덕션 채택 맥락은 도입 가이드 (사내 공유본) §3 참고.


관련 파일 · 참조

선행/전제: egress route 스코핑 · egress CRD 멘탈모델 · 패턴 상세: passthrough 가이드 · HTTPS over mTLS 구조 · mTLS 테스트 리포트