--- type: guide tags: [istio, egress, gateway, multi-gateway, tls-passthrough, istio-mutual, namespace-isolation] created: 2026-06-09 --- # 이중 egress gateway — passthrough와 mTLS를 별도 ns·별도 pod로 나란히 > [!abstract] > 단일 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 스코핑](gw__note-egress-vs-scoping.html) (멀티-gateway의 전제) · [egress CRD 멘탈모델](gw__guide-egress-crd-mental-model.html) --- ## 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 스코핑](gw__note-egress-vs-scoping.html)을 먼저 잡아두면 이 문서의 `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**로 갈린다. ```mermaid flowchart TB subgraph mesh["mesh-test ns — client sleep (injection ON)"] C1["curl https://example.org"] C2["curl https://www.wikipedia.org"] end subgraph pt["ns: egress-pt"] GPT["pod egw-pt (label istio=egw-pt)
Gateway :443 → :8443
tls PASSTHROUGH
SNI route, no decrypt"] end subgraph mt["ns: egress-mtls"] GMT["pod egw-mtls (label istio=egw-mtls)
Gateway :443 → :8443
tls ISTIO_MUTUAL
terminate OUTER mTLS (verify client cert/SPIFFE)
tcp_proxy INNER app TLS"] end EX1["example.org:443"] EX2["www.wikipedia.org:443"] C1 -->|"tls/sniHosts route (sidecar no terminate)"| GPT C2 -->|"DR ISTIO_MUTUAL + sni (sidecar wraps mesh mTLS)"| GMT GPT --> EX1 GMT --> EX2 ``` 왼쪽 경로는 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 가이드](gw__guide-egress-gateway-https.html), [필드 매뉴얼](gw__src-egress-gateway.html) | [HTTPS over mTLS 구조](gw__src-egress-https-over-mtls.html), [mTLS 테스트 리포트](gw__report-2026-06-08_egress-mtls.html) | | 패턴 선택 근거 | — | [이중 TLS 없이(decision)](gw__note-egress-identity-without-mtls.html) | | 운영(모니터링·SNAT·graceful) | [egress 운영 가이드](gw__src-egress-operations.html) | 동일 | 두 패턴 모두 inner는 gateway가 못 보므로 **L7 정책은 어느 쪽도 불가**다. 차이는 *in-mesh leg*에 있다 — mTLS 경로만 SPIFFE 신원으로 호출자를 식별할 수 있고, passthrough는 gateway단에서 호출자를 못 가린다(Calico 등 외부 수단 필요). ## 3. 구성 따라하기 — 두 축을 어떻게 코드로 채우나 ### 3-1. 물리 축: gateway 2개를 별도 ns·pod로 ```bash # (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**다: ```yaml # (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 구조](gw__src-egress-https-over-mtls.html)의 CRD 해부와 첨부 manifest를 참조. 스코핑은 [전제 문서](gw__note-egress-vs-scoping.html)대로 client-VS / gateway-VS를 분리하고 각자 `exportTo: ["."]`로 닫았다 — 이게 위 멘탈모델의 LOGICAL 축을 코드로 채우는 부분이다. ## 4. 검증 (기대 vs 실제) ```bash 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 미종단"을 못 박는다: ```bash # 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](gw__note-egress-vs-scoping.html). ## 핵심 정리 - 멀티-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 분리 축을 택한다 — 프로덕션 채택 맥락은 [도입 가이드 (사내 공유본)](gw__guide-egress-adoption-passthrough-vs-mtls.html) §3 참고. --- ## 관련 파일 · 참조 - 📎 [00-namespaces.yaml](attachment/scenarios/20-egress/dual-gateway/00-namespaces.yaml) — egress-pt / egress-mtls (둘 다 injection ON) - 📎 [10-passthrough.yaml](attachment/scenarios/20-egress/dual-gateway/10-passthrough.yaml) — SE example.org + Gateway :443 PASSTHROUGH + DR subset + client/gateway-VS(`tls`) - 📎 [20-mtls.yaml](attachment/scenarios/20-egress/dual-gateway/20-mtls.yaml) — SE www.wikipedia.org + Gateway :443 ISTIO_MUTUAL + DR(`sni`) + client-VS(`tls`)/gateway-VS(`tcp`) - 📎 [values-egw-pt.yaml](attachment/install/helm/values-egw-pt.yaml) — label `istio=egw-pt`, svc 443→8443, ClusterIP - 📎 [values-egw-mtls.yaml](attachment/install/helm/values-egw-mtls.yaml) — label `istio=egw-mtls`, svc 443→8443, ClusterIP **선행/전제:** [egress route 스코핑](gw__note-egress-vs-scoping.html) · [egress CRD 멘탈모델](gw__guide-egress-crd-mental-model.html) · **패턴 상세:** [passthrough 가이드](gw__guide-egress-gateway-https.html) · [HTTPS over mTLS 구조](gw__src-egress-https-over-mtls.html) · [mTLS 테스트 리포트](gw__report-2026-06-08_egress-mtls.html)