--- type: src tags: [istio, egress, mtls, istio-mutual, spiffe, double-tls, gateway] created: 2026-06-08 --- # Egress "HTTPS over mTLS" (ISTIO_MUTUAL) — 구조·CRD·장단점·활용·운영 > [!abstract] > **머릿속에 넣을 한 장의 그림:** 이 패턴은 "**end-to-end 암호화 보존**"과 "**egress에서 호출자 신원 식별**"이라는 > 서로 독립인 두 축의 *교집합* 칸이다 — 둘 다 Yes일 때만 정답인 좁은 셀. 구현은 **이중 TLS**: 앱은 그대로 > `https://`(inner end-to-end TLS)로 호출하고, **sidecar↔egress 구간만 Istio 메시 mTLS(`ISTIO_MUTUAL`)로 > 한 겹 더 감싸**(outer) gateway가 그 outer를 **종단**하며 호출 워크로드의 **SPIFFE 신원을 암호학적으로 검증**하되, > 안쪽 앱 TLS는 풀지 않고 `tcp_proxy`로 외부까지 그대로 흘린다. passthrough(신원 없음)와 TLS origination(앱 평문화)의 > **사각지대를 메우는** 패턴이며, 실측 검증은 [Egress mTLS 리포트](gw__report-2026-06-08_egress-mtls.html), 운영 정본은 > [Egress 운영](gw__src-egress-operations.html), 개념 정본은 [Egress Gateway 정본](gw__src-egress-gateway.html), > 이 신원 **위에 올라가는 통제**(AuthorizationPolicy·테스트 매트릭스)는 [Egress 신원 기반 통제 가이드](sec__guide-egress-mtls-identity-control.html) 참조. **대상 환경:** homelab (kubespray bare-metal, k8s v1.30.6, CNI Calico), Istio **1.30.0** **검증 도메인:** `edition.cnn.com`(외부 HTTPS) — 기존 `httpbin.org` passthrough를 control로 보존하고 직접 비교 **전제 지식:** sidecar/Gateway/VirtualService/DestinationRule 기본 + egress 2-leg 라우팅([정본](gw__src-egress-gateway.html) §04) --- ## 1. 배경 — 왜 이 패턴이 존재하나 (passthrough와 origination 사이의 빈칸) egress gateway로 외부 HTTPS를 내보내는 방식은, 단 두 개의 독립된 질문으로 완전히 좌표가 잡힌다. 이 두 질문이 이 문서 전체의 출발점이다. 1. **gateway가 앱 payload를 복호화해도 되는가?** (= end-to-end 암호화를 포기할 수 있나) 2. **egress에서 "누가 나가는지"를 식별해야 하는가?** (= 호출 워크로드 신원이 필요한가) 표준 egress 패턴 두 개는 이 좌표의 **대각선 두 칸**만 채운다. - **PASSTHROUGH**: 가장 단순·흔함. gateway는 SNI만 보고 앱 TLS를 그대로 흘린다 → end-to-end는 지켜지지만, in-mesh leg가 평문 TCP라 **호출자가 누군지 암호학적으로 모른다**. 상세 [정본](gw__src-egress-gateway.html), 가이드 [HTTPS passthrough 가이드](gw__guide-egress-gateway-https.html). - **TLS origination**: gateway가 외부로 TLS를 *시작*하므로 앱은 평문 HTTP를 메시에 보내야 한다 → gateway가 L7(method/path/status)을 보고 정책·감사·중앙 cert 관리가 가능하지만, **end-to-end 암호화가 깨진다**(payload가 gateway 메모리에 평문으로 존재). 비교 [HTTP vs HTTPS](gw__src-egress-http-vs-https.html). 여기서 **빈 칸**이 하나 남는다: "payload는 절대 못 보게 하면서(end-to-end) + 그래도 누가 나가는지는 알아야 한다." passthrough는 신원이 없어서, origination은 복호화를 해서 둘 다 이 칸을 못 채운다. **그 빈 칸을 메우려고 등장한 게 바로 이 "HTTPS over mTLS" 패턴**이다. 이 한 문장이 이 패턴이 존재하는 유일한 이유이고, 이후 모든 설계 결정(이중 TLS, ISTIO_MUTUAL, leg-2가 tcp인 것)은 전부 이 칸을 채우기 위한 귀결이다. ``` end-to-end 암호화 보존? Yes No +----------------+----------------+ egress No | PASSTHROUGH | (의미 없음) | 신원 | 가장 흔함 | | 식별? +----------------+----------------+ Yes | HTTPS over mTLS | TLS origination| | <-- 본 문서 | gw L7+cert중앙 | +----------------+----------------+ ``` ```mermaid flowchart TD Q1{"앱 payload를 gateway가
복호화해도 되는가?"} Q1 -->|"안 됨 (end-to-end 유지)"| Q2{"egress에서 호출 워크로드
신원을 식별해야 하는가?"} Q1 -->|"됨 (gw가 L7 보고 정책/감사)"| ORIG["TLS origination
앱은 평문 HTTP, gw가 새 TLS 시작"] Q2 -->|"필요 없음"| PASS["PASSTHROUGH
단일 TLS, SNI 라우팅"] Q2 -->|"필요함 (SPIFFE)"| MTLS["HTTPS over mTLS (ISTIO_MUTUAL)
이중 TLS — 본 문서"] ``` 세 패턴을 한 표로 정렬하면 "무엇을 누가 푸느냐"가 한눈에 보인다. ``` gateway가 앱 TLS를 in-mesh leg(sidecar->gw)가 gateway가 본 패턴 복호화하는가 암호+신원검증인가 L7(method/path/status) --------------- ------------------- -------------------------- --------------------- PASSTHROUGH No (SNI만 봄) No (평문 TCP, 앱 TLS만) No (L4 only) HTTPS over mTLS No (이중 TLS) Yes (메시 mTLS 종단+SPIFFE) No (L4 only) <-- 본 문서 TLS origination Yes (gw가 종단) 선택(메시 mTLS는 별개) Yes (L7 visible) ``` 여기서 **이 패턴이 niche인 이유까지 미리 못 박아두자** — 뒤의 모든 설계 함정을 받아들이는 마음가짐이 된다. 본 패턴은 위 좌표의 한 칸일 뿐이고, 그 칸에 드는 시나리오 자체가 드물다. 대부분의 "단순 외부 HTTPS"는 passthrough로 충분하고(신원 불필요·cert 관리 없음·4객체 중 최단순), 신원·정책이 필요한 조직은 보통 origination을 택한다(egress gateway를 두는 *주된 이유*인 L7 감사/per-URL 정책/중앙 cert를 다 주므로). 본 패턴은 **payload는 절대 노출 불가 + 신원은 필요**라는 교집합에서만 최적이고, 결정타로 이 패턴을 써도 **L7 egress 정책은 여전히 불가**하다(이중 TLS라 gateway가 L7을 못 봄). 추가 복잡성으로 얻는 건 오직 "신원" 하나뿐이라, 많은 조직은 그 신원을 source IP / NetworkPolicy / namespace로 더 싸게 근사한다. > 즉 **드문 건 구조가 나빠서가 아니라, 그 효익(end-to-end + 신원)을 동시에 요구하는 시나리오가 드물어서**다. 그 시나리오에 정확히 들면 이건 **유일하게 맞는 패턴**이다(구체 사례는 §5). --- ## 2. 아키텍처 — 이중 TLS의 데이터 경로 (앵커: "outer는 풀고, inner는 흘린다") 이 패턴 전체를 한 문장으로 압축하면 이렇다: **gateway는 바깥 봉투(outer mesh mTLS)만 열어 "누가 보냈는지"를 확인하고, 안쪽 편지(inner 앱 TLS)는 봉인된 채로 외부까지 그대로 부친다.** 이 한 그림에서 나머지가 전부 따라 나온다 — outer를 종단하니까 신원을 볼 수 있고, inner를 못 푸니까 L7도 못 보고, 종단된 listener에선 SNI가 이미 소비됐으니까 leg-2가 tcp여야 한다. ``` sleep(app) sleep sidecar egress-gw (:15443) edition.cnn.com:443 ---------- ------------- ------------------ ------------------- curl https:// ──(A)──> [생성한 앱 TLS를 ───(terminate outer)───> [inner 앱 TLS를 (inner TLS 생성) outer mesh mTLS로 래핑] verify client SPIFFE 서버가 종단] ── outer: ISTIO_MUTUAL ──> re-emit inner via tcp_proxy \________ inner: app TLS (end-to-end, gw가 못 봄) ________/ outer (sidecar<->gw) : Istio 메시 mTLS. gw가 client cert 강제 + mesh CA로 SPIFFE 검증 후 *종단*. inner (app<->cnn) : 앱 HTTPS. gw는 복호화 안 함 — 종단 직후 tcp_proxy로 opaque bytes 그대로 전달. ``` 봉투가 어떻게 열리고 닫히는지 단계별로 — 각 단계가 위 앵커의 어느 부분인지 의식하며 읽으면 된다. 1. **(A) 앱 — inner 봉인**: `curl https://edition.cnn.com/`. 앱이 직접 TLS handshake를 시작한다(inner TLS). 이 시점의 ciphertext는 끝까지 누구도 풀지 않는다 — 이게 "end-to-end 보존"의 물리적 실체다. 2. **sidecar (leg-1, outer 봉투 생성)**: VirtualService leg-1이 이 트래픽을 egress gateway로 라우팅하고, DestinationRule이 그 leg를 **메시 mTLS(`ISTIO_MUTUAL`)로 래핑**한다. sidecar는 *자기 SPIFFE client cert*를 제시하며 outer TLS를 만든다. outer의 SNI는 DR이 지정한 `edition.cnn.com` — 이게 gateway가 filter chain을 고르는 키. 3. **egress gateway (outer 봉투 개봉 + 신원 확정)**: `:15443` listener가 `tls.mode: ISTIO_MUTUAL` → **client cert를 강제(`requireClientCertificate: true`)** 하고 mesh CA(`validationContext`)로 sidecar의 SPIFFE ID를 검증한 뒤 outer mTLS를 **종단**한다. 여기서 "누가 나가는가"가 암호학적으로 확정된다 — 이게 이 패턴이 passthrough 대비 유일하게 더 얻는 것. 4. **egress gateway (leg-2, inner 봉인 그대로 발송)**: outer를 벗기고 나면 손에 남는 건 **앱의 inner TLS ciphertext**다. gateway는 이걸 풀 수 없고(앱↔cnn end-to-end), VirtualService leg-2의 **`tcp` 라우트(tcp_proxy)** 가 이 opaque 바이트를 `outbound|443||edition.cnn.com`으로 그대로 흘린다. 5. **외부 — inner 봉인 개봉**: `edition.cnn.com:443`가 inner TLS를 종단. 앱이 본 `ssl_verify=0`은 **앱↔cnn end-to-end 검증** 결과 — gateway가 inner에 전혀 관여하지 않았음의 증거다. > **왜 leg-2는 `tls`가 아니라 `tcp`인가 (앵커에서 직접 따라 나오는, 이 패턴의 가장 큰 함정)** > outer를 종단하면 SNI는 그 자리에서 *소비*된다 — 더는 라우팅 키로 남아 있지 않다. 그런데 종단된 listener에 > `tls`/`sniHosts` 라우트를 걸면 Envoy는 매칭할 SNI가 없어 **network filter를 생성하지 못하고**, listener가 > 통째로 누락된다(`must have more than 0 chains`로 omit). 종단 후 남은 inner 바이트를 흘리려면 SNI를 다시 > 보지 않는 L4 `tcp_proxy`가 필요 → leg-2는 반드시 **`tcp` 라우트**. > passthrough는 *종단을 안 하므로* SNI가 끝까지 살아 있어 양쪽 leg 모두 `tls`/`sniHosts` — 정반대다. > (filter chain 생성 원리: [Envoy filter chain](xds__note-envoy-filter-chain-extension.html)) ### 2.1 CRD 4종 — 각 객체가 "봉투 메커니즘의 어느 부분"을 책임지나 `scenarios/20-egress/*-cnn-mtls.yaml`. 4개 객체는 위 데이터 경로의 서로 다른 부분을 맡는다. PASSTHROUGH 대비 **무엇이 달라지는지(델타)**만 좁혀 본다 — 차이가 곧 이 패턴의 본질이다. | CRD | 답하는 질문 (봉투 경로의 역할) | passthrough 대비 델타 | |---|---|---| | ServiceEntry | "이 외부 호스트를 메시가 알아도 되나?" | **차이 없음** (외부 등록은 패턴 무관) | | Gateway | "egress pod가 outer를 어떻게 받나?" | `tls.mode: PASSTHROUGH` → **`ISTIO_MUTUAL`**, 포트 443 → **15443** | | DestinationRule | "sidecar가 outer를 어떻게 만드나?" | `trafficPolicy.tls` **신규**(passthrough는 subset만) | | VirtualService | "각 leg를 어떻게 라우팅하나?" | leg-2가 `tls`/sniHosts → **`tcp`** | **ServiceEntry — 외부 호스트 등록 (passthrough와 동일)** ```yaml apiVersion: networking.istio.io/v1 kind: ServiceEntry metadata: { name: cnn-ext, namespace: mesh-test } spec: hosts: [edition.cnn.com] ports: - number: 443 name: tls protocol: TLS # HTTPS 아님 — gateway가 L7을 파싱하지 않으므로 L4 TLS로 등록 resolution: DNS # istiod가 DNS로 실제 IP 해석 location: MESH_EXTERNAL ``` `protocol: TLS`인 이유가 곧 앵커의 따름정리다 — 이 패턴에서 gateway는 앱 TLS를 끝까지 안 푸므로 L7 `HTTP`가 아니라 L4 `TLS`. origination이었다면 여기가 `HTTP`(앱이 평문)였을 것이다. 외부 등록 자체는 패턴 무관이라 passthrough와 동일하다. **Gateway — egress pod에 ISTIO_MUTUAL server (★ outer 봉투를 여는 곳)** ```yaml apiVersion: networking.istio.io/v1 kind: Gateway metadata: { name: egress-cnn, namespace: mesh-test } spec: selector: { istio: egressgateway } # egress pod 라벨과 일치 servers: - port: number: 15443 # ★ 443 아님 — passthrough(443)와 머지 충돌 회피 name: tls-cnn protocol: TLS hosts: [edition.cnn.com] tls: mode: ISTIO_MUTUAL # ★ PASSTHROUGH가 아니라 메시 mTLS를 *종단*+신원검증 ``` - **`tls.mode: ISTIO_MUTUAL`**: 이 한 줄이 listener를 "client cert 강제 + mesh CA 검증 + 종단" 모드로 만든다. 결과 listener: `requireClientCertificate: True`, `validationContext(SPIFFE): True`, server cert SDS `default`(gateway 자기 메시 신원). = 데이터 경로 3단계의 그 listener. - **`port.number: 15443`**: 같은 포트(443)에 PASSTHROUGH server와 ISTIO_MUTUAL server가 공존하면 머지 단계에서 한쪽이 드롭된다(→ `filter_chain_not_found`). egress Service가 노출하는 표준 tls 포트 15443(→targetPort 15443)으로 **분리**해 해결. 종단 모드별로 포트를 갈라 쓰는 게 원칙. **DestinationRule — leg-1을 메시 mTLS로 래핑 (★ sidecar가 outer 봉투를 만드는 트리거)** ```yaml apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: { name: egressgateway-cnn, namespace: mesh-test } spec: host: istio-egressgateway.istio-system.svc.cluster.local subsets: - name: cnn trafficPolicy: loadBalancer: { simple: ROUND_ROBIN } portLevelSettings: - port: { number: 15443 } tls: mode: ISTIO_MUTUAL # ★ sidecar가 client cert 제시 + outer mTLS 생성 sni: edition.cnn.com # ★ gateway가 filter chain 고르는 키 ``` - passthrough DR은 subset만 정의하고 `trafficPolicy.tls`를 **비운다**(앱 TLS를 그대로 전달). 여기서는 **`trafficPolicy`가 신규** — 이게 leg-1(sidecar→gw)을 메시 mTLS로 감싸는 트리거다(데이터 경로 2단계). - **`tls.mode: ISTIO_MUTUAL`**(DR 쪽): sidecar가 자기 SPIFFE client cert를 제시하도록 한다. Gateway의 ISTIO_MUTUAL(server)과 짝 → outer mTLS 성립. - **`tls.sni: edition.cnn.com`**: outer TLS의 SNI를 명시. gateway server의 `hosts: [edition.cnn.com]`/filter chain 매칭 키이자, 잘못 두면 SAN 불일치 SSL 오류의 원인. **DR sni ↔ Gateway hosts ↔ VS sniHosts 3자 일치** 필수(아래 정렬 지도). **VirtualService — 2-leg, leg별 라우트 타입이 다름 (★ tcp vs tls)** ```yaml apiVersion: networking.istio.io/v1 kind: VirtualService metadata: { name: egress-cnn, namespace: mesh-test } spec: hosts: [edition.cnn.com] gateways: [mesh, egress-cnn] # mesh=sidecar(leg-1), egress-cnn=gateway(leg-2) tls: # leg-1: sidecar -> egress gw (종단 안 함 -> tls/sniHosts) - match: - gateways: [mesh] port: 443 sniHosts: [edition.cnn.com] route: - destination: host: istio-egressgateway.istio-system.svc.cluster.local subset: cnn # DR subset cnn = 메시 mTLS 래핑 port: { number: 15443 } tcp: # ★ leg-2: gw -> 외부 (gw가 종단함 -> tcp 필수) - match: - gateways: [egress-cnn] port: 15443 route: - destination: host: edition.cnn.com port: { number: 443 } weight: 100 ``` - **leg-1 = `tls`/`sniHosts`**: sidecar는 outer를 *만들지만 종단하지 않는다* → SNI가 살아 있으므로 `tls` 라우트. - **leg-2 = `tcp`**: gateway가 outer를 *종단*했으므로 SNI는 이미 소비됨. inner 바이트를 흘리려면 `tcp_proxy` → `tcp` 라우트. (§2 함정 박스 참조) - gateway listener엔 호스트가 1개라 `tcp` 포트 매칭만으로 충분. 한 포트에 여러 종단 호스트를 섞으려면 포트를 더 분리하거나 라우팅 설계를 다시 해야 한다. **정렬 지도 — 같은 magic string이 4객체에 흩어진다.** 이 값들이 한 글자라도 어긋나면 filter chain 매칭이나 SAN 검증이 깨진다. ``` SNI host : DR tls.sni == Gateway hosts[] == VS sniHosts[] (모두 edition.cnn.com) 종단 포트 : DR port 15443 == Gateway port == VS leg-1 dest port == VS leg-2 match port gateway ref: Gateway name egress-cnn == VS gateways[]에 등장 egress 대상: DR host(egressgateway FQDN) == VS leg-1 dest host ``` **CRD 관계 한눈에** ```mermaid flowchart LR SE["ServiceEntry cnn-ext
edition.cnn.com:443 TLS"] GW["Gateway egress-cnn
:15443 ISTIO_MUTUAL (종단+SPIFFE)"] DR["DestinationRule egressgateway-cnn
subset cnn: ISTIO_MUTUAL+sni"] VS["VirtualService egress-cnn
leg-1 tls / leg-2 tcp"] DR -. "leg-1 outer mTLS 래핑·sni" .-> VS VS -->|"leg-1 tls: mesh -> gw:15443"| GW GW -->|"leg-2 tcp: gw -> cnn:443 (tcp_proxy)"| SE SE -. "registry whitelist" .-> VS ``` --- ## 3. 예시와 결과 — 실측으로 봉투 메커니즘 입증 위 4객체를 `mesh-test` 네임스페이스에 apply한 뒤, **앵커의 세 주장**("outer를 종단하며 신원을 본다 / inner는 풀지 않는다 / 그래서 200")을 각각 한 줄씩으로 검증한다. (전체 실측: [Egress mTLS 리포트](gw__report-2026-06-08_egress-mtls.html)) **TEST 1 — end-to-end로 통하는가 + gateway가 inner를 안 건드렸나** ```bash kubectl exec -n mesh-test deploy/sleep -c sleep -- \ curl -sS -o /dev/null -w "HTTP=%{http_code} ssl_verify=%{ssl_verify_result} remote=%{remote_ip}:%{remote_port}\n" \ https://edition.cnn.com/ # 실제: HTTP=200 ssl_verify=0 remote=151.101.195.5:443 ← 앱 TLS end-to-end 검증 OK ``` `ssl_verify=0`이 결정적이다 — 이건 **앱↔cnn 사이의 cert 검증**이 통과했다는 뜻이고, gateway가 inner에 끼어들었다면 SAN이 어긋나 0이 안 나온다. 즉 inner 봉인이 외부까지 온전했다는 증거다. **TEST 2 — gateway가 inner를 풀지 않고 tcp_proxy로 흘렸나 (egress-gw access log)** ``` [11:14:34] 0 - - ... 2422 4731478 435 ... "151.101.3.5:443" outbound|443||edition.cnn.com ``` response flag가 `-`(성공, `NR filter_chain_not_found` 없음)이고, **4.7MB(4731478 bytes)** 가 흘러갔다 — cnn 페이지 본문이 tcp_proxy로 opaque하게 통과했다는 뜻. cluster 이름 `outbound|443||edition.cnn.com`이 leg-2의 목적지(`direction|port|subset|fqdn`, subset 없음). **TEST 3 — in-mesh leg가 진짜 메시 mTLS인가 (egress-gw :15443 listener)** ```bash istioctl proxy-config listener deploy/istio-egressgateway.istio-system --port 15443 -o json \ | grep -E 'sni|requireClientCertificate|validationContext|secretName' # requireClientCertificate : True ← client cert 강제(메시 mTLS) # validationContext : True ← mesh CA로 SPIFFE 검증 ``` `requireClientCertificate: True` + `validationContext: True` = "client cert를 강제하고 mesh CA로 검증한다" = **앵커의 '신원을 본다'가 설정 레벨에서 참**. 이 두 필드가 곧 데이터 경로 3단계의 물증이다. **TEST 4 — sidecar leg-1이 outer mTLS를 래핑하고 sni를 넣었나 (sleep cluster)** ```bash istioctl proxy-config cluster deploy/sleep.mesh-test \ --fqdn istio-egressgateway.istio-system.svc.cluster.local -o json | grep -E 'sni|mode|subset' # cluster outbound|15443|cnn|...egressgateway 에 tls(mode ISTIO_MUTUAL, sni edition.cnn.com) ``` cluster 이름 `outbound|15443|cnn|...egressgateway`의 subset `cnn`이 DR의 subset과 일치하고, 거기 tls 컨텍스트가 붙어 있다 = sidecar가 데이터 경로 2단계대로 outer를 만든다는 증거. **검증 요약선**: `HTTP=200` + `ssl_verify=0`(inner end-to-end) + `requireClientCertificate=True`(신원 강제) + `validationContext=True`(SPIFFE 검증) + access log response flag `-`(종단·전달 성공). 다섯 신호가 동시에 참이면 이중 TLS가 의도대로 동작한 것이다. 기존 httpbin passthrough(control)는 무손상(간헐 200, 503/timeout은 외부 flakiness + outlier DR). > 실측은 **처음 manifest 그대로는 실패**했다 — 두 함정을 실제로 밟았다. ① 포트 443을 httpbin passthrough가 선점한 상태로 cnn ISTIO_MUTUAL server를 같은 443에 두니 머지 충돌로 cnn 서버가 드롭(`curl: Connection reset`, egw log `NR filter_chain_not_found`) → 15443 분리로 FIX. ② leg-2에 `tls`/sniHosts를 걸자 종단 listener에 network filter가 0개라 istiod가 `omitting listener ... must have more than 0 chains`로 통째 omit → `tcp`로 FIX. ([리포트](gw__report-2026-06-08_egress-mtls.html) §3) ### 3.1 장점 / 단점 — 위 실측이 곧 근거 이 패턴을 채택했을 때 실제로 얻는 것과 치르는 비용. 각 항목의 "메커니즘적 근거"는 위 데이터 경로/실측에서 곧장 나온다. **장점** | 장점 | 메커니즘적 근거 | |---|---| | **egress에서 호출자 SPIFFE 신원 식별** | gateway가 outer mTLS를 종단하며 client cert를 mesh CA로 검증(TEST 3). IP/namespace가 아닌 **암호학적 워크로드 신원**. | | **신원 기반 egress 인가 가능** | 그 신원으로 `AuthorizationPolicy`(principals=`cluster.local/ns/.../sa/...`)를 gateway에 걸어 "특정 SA만 이 외부로" 강제. ([authz 멘탈모델](sec__src-authorizationpolicy-mental-model.html)) | | **end-to-end 앱 암호화 보존** | gateway는 inner 앱 TLS를 복호화하지 않음(blind relay, TEST 1의 `ssl_verify=0`). payload가 gateway 메모리에 평문으로 없음 → PCI/PII 경계에서 gateway를 복호화 주체에서 제외. | | **mesh mTLS가 PERMISSIVE여도 leg-1 강제 암호화** | DR/Gateway의 명시적 ISTIO_MUTUAL이 mesh-wide 설정과 무관하게 in-mesh leg를 암호화+인증. | | **외부 인증서 관리 불필요** | origination/MUTUAL-to-external과 달리 gateway가 외부용 cert를 보관·갱신할 필요 없음(앱이 알아서 TLS). | **단점** | 단점 | 메커니즘적 근거 | |---|---| | **L7 사각은 그대로** | 이중 TLS라 gateway는 여전히 method/path/status를 못 봄(inner 미복호화) → **per-URL egress 정책 불가**, 관측은 `istio_tcp_*`(L4)뿐. egress gateway를 흔히 쓰는 이유(L7 감사/정책)를 못 얻는다. | | **이중 TLS 비용** | handshake 2회(sidecar↔gw mesh mTLS + 앱↔외부) + 홉 1개 추가 → 지연·CPU 증가. | | **설정 복잡성·함정** | 포트 머지 충돌(passthrough vs 종단), leg-2 tcp/tls 혼동, DR sni↔Gateway hosts↔VS sniHosts 3자 정렬. 실측에서 2개 함정을 실제로 밟음(§3 위). | | **Istio 단독으로는 강제 아님** | sidecar 우회(root/hostNetwork) 시 그냥 나감. 진짜 강제하려면 **Calico NetworkPolicy**로 egress pod 외 직접 송신 차단 필요. | | **드물게 쓰임 → 자료·예제 적음** | 커뮤니티 레퍼런스가 passthrough/origination 대비 희소 → 트러블슈팅이 self-support. | --- ## 4. 활용 사례 — 이 패턴이 "유일하게 맞는" 경우 (조사) §1에서 "교집합이 드물다"고 했다. 그 드문 교집합에 정확히 드는 구체 시나리오 — 여기선 다른 두 패턴이 *둘 다 탈락*한다. (웹 자료: Istio 공식·Tetrate·실무 가이드, §7) 1. **규제·컴플라이언스 — "복호화 없는 감사"** PCI-DSS/개인정보처럼 **gateway가 payload를 복호화하면 안 되는** 데이터를 외부 파트너로 보내면서도, "모든 외부 호출이 알려진 암호화 chokepoint를 지났고 + **어떤 워크로드(SA)가 호출했는지** 감사 로그로 입증"해야 할 때. origination은 gateway가 복호화하므로 탈락, passthrough는 신원이 없어 탈락 → **이 패턴만 둘 다 만족**. 2. **워크로드 단위 egress allow-list (인가)** "오직 `sa/payment`만 `partner-bank.example.com`으로 나갈 수 있다"를 **spoof 가능한 IP가 아니라 SPIFFE principal**로 강제. gateway에 `AuthorizationPolicy`(principals)를 걸어 구현. ([authz](sec__src-authorizationpolicy-mental-model.html), [SPIFFE](sec__note-mtls-spiffe-identity.html)) 3. **멀티테넌트 클러스터에서 IP/namespace로는 신원이 불충분할 때** 여러 팀이 한 클러스터를 공유하고 egress 권한을 팀(SA)별로 갈라야 하는데 namespace 라벨/IP가 신뢰 경계로 약할 때, mesh CA가 보증하는 SPIFFE가 더 강한 식별자. 4. **외부가 HTTPS-only / cert-pinning이라 종단이 불가능한데도 신원이 필요할 때** 외부 서버가 앱과의 end-to-end TLS(예: client cert pinning)를 요구해 gateway가 끼어들 수 없는데도 egress 신원 통제가 필요한 경계 사례. > 거꾸로, **L7 per-URL egress 정책이 목표라면 이 패턴은 틀린 선택**이다(L7 사각). 그 경우 origination. --- ## 5. 운영 시 고려할 점 (의견) > 전제: 채택 자체를 신중히 — "end-to-end 보존 + egress 신원"이 **동시에** 진짜 요구사항일 때만. 아니면 passthrough(단순) 또는 origination(L7). - **신원만 식별하고 끝내지 말 것 = 인가까지 가야 의미.** 종단+검증은 "누가 나가는지 *식별*"일 뿐. egress gateway에 `AuthorizationPolicy`(principals 기반)를 걸어 *인가*로 닫아야 비용이 정당화된다. 식별만 하면 비싼 로깅에 그친다. ([리포트](gw__report-2026-06-08_egress-mtls.html) §6 "다음 작업"이 정확히 이것) - **access log에 `%DOWNSTREAM_PEER_URI_SAN%` 추가.** 그래야 gateway가 *실제로 본* SPIFFE ID가 로그에 남아 감사 가치가 생긴다(기본 TCP access log엔 client SPIFFE ID가 안 찍힘). - **Istio 라우팅 ≠ 강제. Calico로 닫아라.** 본 homelab CNI는 Calico → 워크로드 pod의 egress를 egress gateway pod로만 허용하고 그 외 0.0.0.0/0 차단하는 `NetworkPolicy`/`GlobalNetworkPolicy`가 없으면 sidecar 우회로 새어 나간다. ([정본](gw__src-egress-gateway.html) §02 강제 계층 — Cilium 언급은 Calico로 대치해 읽을 것) - **포트 위생.** 종단 모드(ISTIO_MUTUAL) host-group마다 전용 포트(15443 등) 할당. **한 포트에 passthrough + 종단 절대 혼재 금지**(머지 충돌). 종단 호스트가 늘면 포트/listener 설계를 먼저. - **L7 관측 공백은 앱 sidecar에서 메운다.** gateway는 L4만 보므로, per-request egress 지표가 필요하면 호출 워크로드 sidecar의 L7 텔레메트리로 보완. - **HA.** egress gateway는 SPOF. 본 검증 values는 `replicaCount: 1` → 사내 적용 시 replica↑ + PDB + 노드 분산 필수. ([운영 정본](gw__src-egress-operations.html)) - **신뢰 도메인 경계.** outer leg 검증은 mesh CA(istiod) trust domain에 의존. 멀티클러스터/trust-domain federation이면 `validationContext`가 어느 CA를 신뢰하는지 재설계 필요. - **버전 정합.** egress gateway ↔ istiod 버전 정렬(본 검증은 둘 다 1.30.0). 하위 버전 메시(예: 1.27.x)로 이식할 땐 istiod부터 정렬한 뒤 이 패턴을 적용. --- ## 핵심 정리 - **앵커 = 봉투 두 겹.** outer(sidecar↔gw)는 메시 mTLS(`ISTIO_MUTUAL`)로 gateway가 **종단+SPIFFE 검증**(누가 보냈나), inner(앱↔외부)는 앱 HTTPS를 gateway가 **풀지 않고** end-to-end 보존(무엇을 보냈나는 끝까지 비밀). - **leg-1 `tls` / leg-2 `tcp` 규칙.** sidecar는 outer를 만들기만 하므로 leg-1은 `tls`/`sniHosts`(SNI 살아 있음). gateway는 outer를 *종단*해 SNI를 소비하므로 leg-2는 `tcp_proxy`(= `tcp` 라우트) 필수 — `tls`를 걸면 filter chain 0개로 listener가 통째 누락된다. - **포트 분리 15443.** 같은 포트(443)에 PASSTHROUGH server와 ISTIO_MUTUAL server가 공존하면 머지 충돌로 한쪽 드롭 → 종단 모드는 전용 포트(15443)로 분리. - **3자 SNI 정렬.** `DR tls.sni` == `Gateway hosts` == `VS sniHosts`가 일치해야 filter chain 매칭·SAN 검증이 성립. - **얻는 건 신원뿐, L7은 못 본다.** 이중 TLS라 gateway는 method/path/status를 못 봐 per-URL 정책 불가·관측은 `istio_tcp_*`(L4)만 → 본질적으로 niche 패턴. - **Istio 라우팅 ≠ 강제.** sidecar 우회(root/hostNetwork)로 새므로 진짜 강제는 Calico `NetworkPolicy`로 egress pod 외 직접 송신을 차단해야 완성된다. --- ## 6. What you might be missing - **이 패턴의 "신원"은 in-mesh leg 한정이다.** gateway가 검증하는 SPIFFE는 *sidecar(호출 워크로드)의 신원*이지, *외부 서버의 신원*이 아니다. 외부 서버 인증은 여전히 앱의 inner TLS가 책임진다(앱이 `ssl_verify`). 외부 *서버*를 gateway가 검증하게 하려면 origination(gateway가 외부와 MUTUAL/SIMPLE)으로 가야 하고, 그건 end-to-end를 포기하는 것 — **둘을 동시에는 못 가진다.** - **`ISTIO_MUTUAL`(DR/Gateway)과 `PeerAuthentication STRICT`는 다른 레이어다.** 전자는 *이 egress leg*의 전송 보안을 명시 지정, 후자는 *수신측 워크로드*가 평문을 거부하는 정책. egress mTLS를 켰다고 메시 전체 STRICT가 되는 게 아니다. ([보안 3리소스](sec__note-security-resource-trio.html)) - **이중 TLS는 관측 도구를 헷갈리게 한다.** Kiali/Prometheus에서 이 트래픽은 `istio_tcp_*`로만 잡히고 L7 그래프엔 안 뜬다 — "트래픽이 안 보인다"가 아니라 **L4로 보고 있는 것**. passthrough와 동일한 가시성 한계. - **`tcp` 라우트의 호스트 다중화 한계.** leg-2가 포트 매칭만으로 동작하는 건 그 listener에 호스트가 1개여서다. 한 종단 포트로 여러 외부 호스트를 라우팅하려면 SNI가 이미 소비된 종단 listener에선 까다롭다 — 호스트별 포트 분리가 현실적. - **`resolution: DNS`의 노드 DNS 의존.** 외부 해석은 istiod/노드 DNS에 달려 있어 DNS 장애가 egress 장애로 직결. ([DNS resolution 리포트](gw__report-2026-06-07_dns-resolution.html)) --- ## 7. 참조 **아카이브 내부** - [Egress mTLS 리포트 — 실측](gw__report-2026-06-08_egress-mtls.html) · [Egress Gateway 개념 정본](gw__src-egress-gateway.html) · [Egress 운영 정본](gw__src-egress-operations.html) - [HTTP vs HTTPS egress](gw__src-egress-http-vs-https.html) · [HTTPS passthrough 가이드](gw__guide-egress-gateway-https.html) - [SPIFFE 신원](sec__note-mtls-spiffe-identity.html) · [AuthorizationPolicy 멘탈모델](sec__src-authorizationpolicy-mental-model.html) · [보안 3리소스](sec__note-security-resource-trio.html) - [Envoy filter chain](xds__note-envoy-filter-chain-extension.html) · [sidecar scope](gw__src-sidecar-scope.html) **관련 IaC (실제 manifest)** - 📎 [gateway-egress-cnn-mtls.yaml](attachment/scenarios/20-egress/gateway-egress-cnn-mtls.yaml) · 📎 [destinationrule-egress-cnn-mtls.yaml](attachment/scenarios/20-egress/destinationrule-egress-cnn-mtls.yaml) - 📎 [virtualservice-egress-cnn-mtls.yaml](attachment/scenarios/20-egress/virtualservice-egress-cnn-mtls.yaml) · 📎 [serviceentry-cnn-ext.yaml](attachment/scenarios/20-egress/serviceentry-cnn-ext.yaml) · 📎 [20-egress/README.md](attachment/scenarios/20-egress/README.md) **외부 (조사 출처)** - ↗ [Istio: Understanding TLS Configuration](https://istio.io/latest/docs/ops/configuration/traffic-management/tls-configuration/) - ↗ [Istio: Egress Gateways with TLS Origination](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway-tls-origination/) - ↗ [Tetrate: Enforce egress traffic using Istio's authorization policies](https://tetrate.io/blog/istio-how-to-enforce-egress-traffic-using-istios-authorization-policies) - ↗ [Istio: Security Best Practices](https://istio.io/latest/docs/ops/best-practices/security/) **반대 선택의 근거** → [이중 TLS 없이 egress 신원 — passthrough + Calico](gw__note-egress-identity-without-mtls.html)