이중 egress gateway — passthrough와 mTLS를 별도 ns·별도 pod로 나란히
단일 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의 graceful drain·재시작·crashloop이 모든 외부 트래픽을 흔든다.
- SNAT 포트 고갈 공유 — 한 워크로드가 source port를 다 써버리면 무관한 다른 트래픽도 외부 연결을 못 연다.
- 정책 충돌 — passthrough(SNI만 보고 통과)와 mTLS 종단(client cert 검증)은 listener의 TLS 동작이 정반대다. 한 pod에 얹으면 포트를 갈라야 하고, 한쪽 정책 변경이 다른 쪽 listener를 건드릴 위험이 상존한다.
그래서 패턴/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를 못 얼리게"
- PHYSICAL — 각 ns의
Gateway리소스가 자기 label만 selector로 잡으므로 한쪽 변경이 다른 pod로 새지 않는다. ns는 소유권/RBAC 경계, pod는 장애·SNAT 경계.PILOT_SCOPE_GATEWAY_TO_NAMESPACE=true환경에서도 Gateway 리소스와 workload가 같은 ns라 안전하다. - LOGICAL —
exportTo: ["."]로 SE/VS의 가시성을 자기 ns에 닫아, 전역 push에 끼어 남의 config를 얼리지 않게 한다 (함정 ②).sourceLabels는 "어떤 client가 어느 gateway로 가나"를 명시한다(현재 랩은 host로 분기, §다음 작업 참고).
이 한 장면에서 나머지가 따라 나온다. 아래 토폴로지가 그 물리 축을 그림으로 보인 것이다 — 같은 client(sleep)가
두 외부 host를 호출하지만, host에 따라 다른 ns의 다른 gateway pod로 갈린다.
왼쪽 경로는 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). tls→tcp(포트 매칭)로 바꾸자 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.
핵심 정리
- 멀티-gateway egress의 격리는 물리 축 × 논리 축의 곱이다: label 분리(selector) + ns 분리(소유권) + exportTo(가시성) + sourceLabels(적용대상). 한 축만으론 "분리된 척"에 그친다.
- 종단 여부가 2단 route 타입을 결정한다: PASSTHROUGH=
tls(SNI 살아있음), ISTIO_MUTUAL 종단 후=tcp(SNI 소비됨). 거꾸로 쓰면 0-chains로 listener가 안 뜬다(함정 ①). - 별도 ns·pod로 나누면 장애·정책·소스 IP가 tier별 격리되지만, 같은 메시인 이상 전역 리소스(
exportTo누락 SE) 하나가 모든 gateway의 config를 동시에 얼릴 수 있다(함정 ②, xDS 트랜잭션성). - 검증의 핵심 증거는 "200" 자체가 아니라 각자의 gateway pod access log에 찍힌 cluster 이름(경로가 실제 갈렸는지)
- listener의
requireClientCertificate/transportSocket(종단/미종단)이다.
다음 작업
- tier별 client를
sourceLabels로 각 gateway에 강제 매핑(현재는 host로 분기) → LOGICAL 축의 sourceLabels 절반을 채워 egress 거버넌스 강화.
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 참고.
관련 파일 · 참조
- 📎 00-namespaces.yaml — egress-pt / egress-mtls (둘 다 injection ON)
- 📎 10-passthrough.yaml — SE example.org + Gateway :443 PASSTHROUGH + DR subset + client/gateway-VS(
tls) - 📎 20-mtls.yaml — SE www.wikipedia.org + Gateway :443 ISTIO_MUTUAL + DR(
sni) + client-VS(tls)/gateway-VS(tcp) - 📎 values-egw-pt.yaml — label
istio=egw-pt, svc 443→8443, ClusterIP - 📎 values-egw-mtls.yaml — label
istio=egw-mtls, svc 443→8443, ClusterIP
선행/전제: egress route 스코핑 · egress CRD 멘탈모델 · 패턴 상세: passthrough 가이드 · HTTPS over mTLS 구조 · mTLS 테스트 리포트