--- type: report tags: [istio, egress, mtls, istio-mutual, spiffe, gateway, report] created: 2026-06-08 --- # Test Report — Egress Gateway "HTTPS over mTLS" (ISTIO_MUTUAL) > [!abstract] > egress gateway에서 **sidecar↔gw 구간만 Istio mTLS(`ISTIO_MUTUAL`)로 감싸 게이트웨이가 호출자의 SPIFFE 신원을 검증**하면서, 앱이 보낸 **HTTPS는 외부까지 end-to-end로 보존**되는 "이중 TLS" 패턴을 홈랩에서 실측 검증한 리포트. 결론: 동작하지만(200), 처음 manifest 그대로는 깨졌고 그 실패 두 개가 이 패턴의 핵심 원리를 그대로 드러낸다 — **종단하면 SNI가 소비된다**는 한 문장에서 모든 설정 결정과 두 함정이 따라 나온다. **Date:** 2026-06-08 · **Cluster:** homelab (kubespray bare-metal, k8s v1.30.6, Calico) · **Istio:** 1.30.0 (istiod + egress gateway, Helm) **Scenario:** `scenarios/20-egress/` — `*-cnn-mtls` 세트 · **NS:** mesh-test (istio-injection=enabled) / 외부: edition.cnn.com **대상독자:** egress 패턴은 한 번 봤지만 ISTIO_MUTUAL과 PASSTHROUGH가 *왜* 다른 라우트 타입을 강요하는지 궁금한 SRE · **선행:** egress gateway 개념, mesh mTLS/SPIFFE, TLS SNI --- ## 0. 배경 지식 — PASSTHROUGH는 "누가 나가는지"를 모른다 egress gateway의 기본형은 **HTTPS PASSTHROUGH**다(이 아카이브의 control: httpbin.org). 게이트웨이가 TLS를 *풀지 않고* 단일 TLS 레이어의 **SNI만 읽어** 외부로 포워딩한다. 구현은 단순하고 앱 TLS가 end-to-end로 보존되는 장점이 있지만, 보안상 빈 칸이 하나 있다: 메시 내부 leg(sidecar→gw)가 **평문 TCP**(그 위에 앱 TLS만 얹힘)다. 게이트웨이 입장에서 들어온 바이트에는 *호출 워크로드의 신원*이 없다. 즉 **"누가 외부로 나가는가"를 암호학적으로 식별하지 못한다.** egress를 보안 경계로 쓰려면 이게 치명적이다 — 신원 없이는 그 위에 인가(authorization)도 못 건다. 이 빈 칸을 메우는 변형이 "HTTPS over mTLS"다. **in-mesh leg를 메시 mTLS(`ISTIO_MUTUAL`)로 묶어** 게이트웨이가 SPIFFE 신원을 검증하게 하되, 앱은 평문 HTTP가 아니라 **HTTPS를 그대로** 보낸다. 결과는 두 개의 TLS 레이어가 중첩된 구조 — 바깥은 mesh mTLS(신원), 안쪽은 앱 HTTPS(기밀성). 전제 개념 셋만 잡고 가면 된다. | 개념 | 한 줄 정의 | 이 리포트에서의 역할 | |---|---|---| | **TLS termination vs passthrough** | 게이트웨이가 TLS를 *푸는가*(terminate) 그냥 *흘리는가*(passthrough) | 둘의 차이가 라우트 타입(`tcp` vs `tls`)을 강제 — 함정 2의 뿌리 | | **SNI** | TLS handshake가 평문으로 싣는 목적지 호스트명 | passthrough면 라우팅 키, terminate면 풀리는 순간 **소비**되어 다음 leg에선 못 씀 | | **SPIFFE / mesh mTLS** | sidecar가 제시하는 워크로드 신원 인증서(`spiffe://…/sa/…`) | egress-gw가 *누가* 호출했는지 검증하는 근거 → [mTLS/SPIFFE 신원](sec__note-mtls-spiffe-identity.html) | > 비교 기준선: [Ingress/Egress 검증 리포트 (PASSTHROUGH control)](gw__report-2026-06-07_ingress-egress.html) · 운영 주의점 [Egress 운영 가이드](gw__src-egress-operations.html) · 필드 매뉴얼 [src-egress-gateway](gw__src-egress-gateway.html) --- ## 1. 핵심 아키텍처 — "종단하면 SNI가 소비된다" **머릿속에 둘 그림 하나:** egress-gw에서 *바깥 TLS는 종단되고 안쪽 TLS는 통과한다*. 바깥(mesh mTLS)을 푸는 순간 그 handshake가 싣고 온 SNI는 **그 자리에서 소비**된다. 그래서 그 뒤 leg-2는 더 이상 SNI로 라우팅할 수 없고, **불투명한 바이트(`tcp` proxy)** 로만 흘려야 한다. 이 한 문장이 아래 모든 설정과 두 함정의 원천이다. ```mermaid flowchart LR app["sleep app
curl https://edition.cnn.com"] sc["sidecar
(envoy)"] egw["egress-gw
listener :15443"] cnn["edition.cnn.com:443"] app -->|"app TLS (inner)"| sc sc -->|"outer: mesh mTLS (ISTIO_MUTUAL)
SNI=edition.cnn.com + client cert"| egw egw -->|"terminate outer / verify SPIFFE
then tcp_proxy inner bytes"| cnn app -. "inner app TLS stays end-to-end (gw never decrypts)" .-> cnn ``` 두 레이어를 각각 보면: - **outer (sidecar↔egress)** = Istio mTLS. sidecar가 SPIFFE client cert를 제시하고, egress-gw가 그걸 **강제**(requireClientCertificate)하며 mesh CA로 검증한다. 여기서 *누가* 나가는지가 결정된다. - **inner (app↔cnn)** = 앱 HTTPS. egress-gw는 바깥 mesh TLS만 풀고 **안쪽 앱 TLS는 복호화하지 않은 채** tcp_proxy로 cnn까지 전달. 그래서 게이트웨이는 끝까지 평문 payload를 못 본다. ### 왜 PASSTHROUGH와 ISTIO_MUTUAL이 라우트 타입을 가르나 본질은 **"종단(terminate) 여부"** 한 축이다. 이게 SNI의 운명을 정하고, SNI의 운명이 leg-2의 라우트 타입을 정한다. | | outer TLS를 푸는가 | leg-1에서 SNI는 | leg-2 라우팅 키 | leg-2 라우트 타입 | |---|---|---|---|---| | **PASSTHROUGH** | 안 푼다 (그냥 흘림) | 끝까지 살아 있음 | SNI(`sniHosts`) | `tls` | | **ISTIO_MUTUAL** | **푼다** (mesh mTLS 종단·SPIFFE 검증) | **푸는 순간 소비됨** | 더 못 씀 → 포트 매칭 | **`tcp`** (tcp_proxy) | PASSTHROUGH는 게이트웨이가 SNI를 끝까지 들고 갈 수 있으니 leg-2도 `tls`/sniHosts로 받는다. ISTIO_MUTUAL은 종단하는 순간 SNI가 사라지므로, 종단된 listener에는 SNI 기반 network filter가 **하나도 안 생긴다.** Envoy listener는 filter chain이 0개면 통째로 omit된다(함정 2). 그래서 leg-2는 SNI를 포기하고 **포트 매칭 + `tcp` 라우트**로 받아 안쪽 바이트를 그대로 흘린다. — 자세한 설정 대비는 [HTTP vs HTTPS egress 설정](gw__src-egress-http-vs-https.html), 구조 정본은 [HTTPS over mTLS 구조](gw__src-egress-https-over-mtls.html). --- ## 2. CRD 구성 — 4 리소스 = 4개의 질문 + 정렬 지도 별도 도메인 `edition.cnn.com`을 써서 기존 httpbin PASSTHROUGH를 **control로 보존**하고 직접 비교한다. 리소스 4종을 "그게 답하는 질문"으로 보면 길을 안 잃는다. | 파일 (리소스) | 답하는 질문 | PASSTHROUGH 대비 델타 | |---|---|---| | `serviceentry-cnn-ext.yaml` | "이 외부 호스트를 메시가 인지하는가?" | 도메인만 다름 (protocol TLS / resolution DNS) | | `gateway-egress-cnn-mtls.yaml` | "게이트웨이는 무엇을 어느 포트로 받고, 종단하는가?" | **port 15443 / `tls.mode: ISTIO_MUTUAL`** (← PASSTHROUGH, 포트 분리) | | `destinationrule-egress-cnn-mtls.yaml` | "sidecar는 gw로 갈 때 무엇으로 감싸는가?" | **`portLevelSettings.tls {ISTIO_MUTUAL, sni}`** 신규 (passthrough DR은 subset만) | | `virtualservice-egress-cnn-mtls.yaml` | "트래픽을 어떻게 gw 경유로 강제하는가?" | leg-2가 **`tcp`** (passthrough는 `tls`/sniHosts) | ### 정렬 지도 — 같은 magic string이 어디서 어디로 묶이나 세 리소스가 두 개의 magic value를 공유한다. 하나라도 어긋나면 listener 매칭이 깨져 연결이 끊긴다. ``` 포트 15443 : Gateway.server.port.number == DR.portLevelSettings.port.number == VS leg-1 destination.port == VS leg-2 match.port SNI cnn.com : DR.tls.sni == Gateway.server.hosts[0] (sidecar가 거는 mesh-mTLS SNI == gw server가 필터체인 매칭하는 키) subset cnn : DR.subsets[].name == VS leg-1 destination.subset ``` 핵심은 **DR의 `sni`**: sidecar가 mesh mTLS handshake에 싣는 SNI를 외부 도메인으로 강제 설정하고, egress-gw의 `Gateway.server.hosts`가 *그 SNI*로 필터체인을 고른다. 이 sni가 없거나 어긋나면 게이트웨이 listener가 매칭에 실패해 그냥 끊긴다. ### 적용한 YAML 전체 (주석 = 줄마다 왜) apply 그대로의 4개 파일. apiVersion은 실제 manifest 기준 `networking.istio.io/v1beta1`이다. ```yaml # ServiceEntry — 외부 도메인을 메시 레지스트리에 등록. 등록해야 sidecar/egress-gw가 # 이 호스트로의 트래픽을 인지하고 VS/DR을 걸 수 있다. 앱이 end-to-end TLS를 유지하므로 # protocol TLS(복호화 안 함), resolution DNS. (httpbin passthrough와 충돌 없게 별도 도메인) apiVersion: networking.istio.io/v1beta1 kind: ServiceEntry metadata: { name: cnn-ext, namespace: mesh-test } spec: hosts: [edition.cnn.com] ports: - { number: 443, name: tls, protocol: TLS } resolution: DNS location: MESH_EXTERNAL --- # Gateway — egress-gw가 15443에서 받고 ISTIO_MUTUAL로 종단·SPIFFE 검증. apiVersion: networking.istio.io/v1beta1 kind: Gateway metadata: { name: egress-cnn, namespace: mesh-test } spec: selector: { istio: egressgateway } # istio-system egress gw pod 라벨 servers: - port: number: 15443 # 443(passthrough/httpbin)과 분리한 별도 tls 포트 → 함정 1 name: tls-cnn protocol: TLS hosts: [edition.cnn.com] # 이 호스트명 == DR.tls.sni (필터체인 매칭 키) tls: mode: ISTIO_MUTUAL # 메시 mTLS 종단 + client SPIFFE 검증 (vs PASSTHROUGH) --- # DestinationRule — sidecar→gw 구간에 ISTIO_MUTUAL을 거는 핵심. passthrough DR은 subset만, # 여기선 trafficPolicy가 반드시 필요하다. apiVersion: networking.istio.io/v1beta1 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 } # gw 수신 포트(Gateway server와 일치) tls: mode: ISTIO_MUTUAL # sidecar가 SPIFFE client cert 제시 sni: edition.cnn.com # gw server SNI 매칭 키 (어긋나면 연결 끊김) --- # VirtualService — 2단 라우팅으로 cnn 호출을 egress-gw 경유로 강제. apiVersion: networking.istio.io/v1beta1 kind: VirtualService metadata: { name: egress-cnn, namespace: mesh-test } spec: hosts: [edition.cnn.com] gateways: - mesh # 1단: sidecar에서 나오는 트래픽 - egress-cnn # 2단: egress gateway에서 나오는 트래픽 tls: # 1단: sidecar → egress-gw(subset cnn). 종단 안 함 → tls/sniHosts로 라우팅. # match port 443 = 앱이 cnn:443 연결. destination 15443 = gw 수신. - match: - { gateways: [mesh], port: 443, sniHosts: [edition.cnn.com] } route: - destination: host: istio-egressgateway.istio-system.svc.cluster.local subset: cnn port: { number: 15443 } tcp: # 2단: gw가 mesh mTLS 종단 후 → 안쪽 앱 TLS를 tcp_proxy로 cnn:443에 전달. # 종단 listener라 SNI는 이미 소비됨 → 포트 매칭만(listener에 호스트 1개라 충분) → 함정 2. - match: - { gateways: [egress-cnn], port: 15443 } route: - destination: { host: edition.cnn.com, port: { number: 443 } } weight: 100 ``` --- ## 3. 두 함정 (실측 → 교정) 처음 작성한 manifest는 (a) 443 그대로, (b) leg-2도 `tls`/sniHosts였다. 둘 다 §1 anchor가 예고한 대로 깨졌다. ```mermaid flowchart TD s0["manifest v1
(443, leg-2 = tls/sniHosts)"] --> p1 p1{"443 = httpbin PASSTHROUGH 점유 중"} -->|"머지 충돌
cnn 서버 드롭"| e1["curl: Connection reset
egw log: NR filter_chain_not_found"] e1 --> f1["FIX 1: 포트 15443 분리"] f1 --> p2{"ISTIO_MUTUAL = 메시 mTLS 종단"} p2 -->|"종단 listener에 tls/sniHosts
= network filter 0개"| e2["istiod: omitting listener
must have more than 0 chains"] e2 --> f2["FIX 2: leg-2를 tcp 라우트로"] f2 --> ok["listener 생성
:15443 SNI edition.cnn.com → outbound cnn:443"] ``` **함정 1 — 포트 충돌 (한 포트에 passthrough + terminate 공존 불가).** 같은 egress-gw에 이미 443(PASSTHROUGH, httpbin)이 server를 점유 중이다. 같은 443에 ISTIO_MUTUAL server를 또 얹으면 istiod가 두 server를 같은 listener로 머지하는데, "TLS를 풀지 않는다"와 "TLS를 종단한다"는 동시에 성립할 수 없어 한쪽(cnn server)이 드롭된다. 증상은 cnn 쪽 filter chain이 통째로 사라져 `NR filter_chain_not_found` + `Connection reset`. → **egress-gw Service가 노출하는 별도 tls 포트 15443**(→targetPort 15443)으로 server를 분리해 공존시킨다. **함정 2 — 종단 listener엔 SNI 라우트가 안 먹는다.** `ISTIO_MUTUAL`은 mesh mTLS를 **종단**한다(§1). 종단되면 SNI가 소비되므로 leg-2의 `tls`/sniHosts 매칭은 만들 network filter가 없어, 그 listener가 통째로 omit된다: ``` warn gateway mesh-test/egress-cnn:15443 listener missed network filter info gateway omitting listener "0.0.0.0_15443" due to: must have more than 0 chains ``` → 종단 후 안쪽(앱 TLS) 바이트를 흘리려면 leg-2를 **`tcp` 라우트(tcp_proxy)** 로 받는다. 이게 §1 표의 ISTIO_MUTUAL 행 그대로다. --- ## 4. 예시와 결과 — 4개 테스트로 두 레이어를 각각 입증 검증 전략: 200 한 줄로는 부족하다. **바깥 leg가 정말 mTLS인가**, **안쪽 leg가 정말 end-to-end인가**를 각각 따로 봐야 "이중 TLS"가 입증된다. TEST 1·2는 결과(통신·종단), TEST 3·4는 두 레이어의 설정 증거. ### TEST 1 — 외부 호출 성공 + 앱 TLS end-to-end ```bash kubectl -n mesh-test exec 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`이 핵심이다 — curl(앱)이 cnn의 인증서를 직접 검증해서 통과했다는 뜻. 게이트웨이가 안쪽 TLS를 풀었다면 cnn 인증서를 curl이 못 봤을 것이다. 즉 안쪽 레이어가 정말 end-to-end다. ### TEST 2 — egress 경유 + 종단 성공 (egress-gw access log) ``` [11:14:34] 0 - - ... 2422 4731478 435 ... "151.101.3.5:443" outbound|443||edition.cnn.com 10.255.126.47:55322 10.255.126.47:15443 ... edition.cnn.com ``` `NR filter_chain_not_found` 사라짐, response flag `-`(성공), 4.7MB(4731478B) 수신 — cnn 페이지가 tcp_proxy를 통해 그대로 흘러왔다는 증거. 목적지 cluster는 `outbound|443||edition.cnn.com`. ### TEST 3 — in-mesh leg가 mTLS인가 (egress-gw 15443 listener의 바깥 레이어) ```bash # :15443 listener의 filter chain TLS 컨텍스트에서 아래 필드 확인 istioctl proxy-config listener deploy/istio-egressgateway.istio-system --port 15443 -o json \ | grep -E 'sni|requireClientCertificate|validationContext|secretName' ``` ``` SNI match : ['edition.cnn.com'] requireClientCertificate : True ← client cert 강제(메시 mTLS) validationContext(SPIFFE) : True ← mesh CA로 client 신원 검증 server cert SDS : ['default'] ← 게이트웨이도 자기 메시 신원 제시 ``` `requireClientCertificate=True` + `validationContext=True` = 게이트웨이가 *누가* 호출했는지 SPIFFE로 검증하도록 켜져 있다 = 바깥 레이어가 정말 mTLS다. ### TEST 4 — sidecar leg-1이 mTLS 래핑 + sni 적용 (sleep cluster) ``` cluster outbound|15443|cnn|...egressgateway transportSocket: envoy.transport_sockets.tls sni: edition.cnn.com ← DR의 sni 적용됨(게이트웨이 SNI 매칭 키) clientCertSDS: ['default'] ← sleep이 자기 메시 신원 client cert 제시 ``` cluster 이름 `outbound|15443|cnn|...egressgateway`(direction|port|subset|fqdn)가 DR이 의도한 대로 15443·subset cnn으로 떴고, `sni: edition.cnn.com`이 §2 정렬 지도의 `DR.tls.sni == Gateway.server.hosts[0]` 매칭을 sidecar 쪽에서 입증한다. ### 합격 판정 | 합격선 | 결과 | 무엇을 증명 | |---|---|---| | 외부 호출 200 | ✅ HTTP=200, ssl_verify=0 | 경로 전체 동작 | | egress gateway 경유 | ✅ :15443 수신 → outbound cnn | gw 강제 경유 | | in-mesh leg = 메시 mTLS(client cert 강제+검증) | ✅ requireClientCertificate + validationContext | 바깥 레이어 = 신원 | | 외부 leg = 앱 TLS end-to-end(게이트웨이 미복호화) | ✅ tcp_proxy, ssl_verify=0 | 안쪽 레이어 = 기밀성 | | 기존 httpbin passthrough(control) 무손상 | ✅ 간헐 200(503/timeout은 외부 flakiness + outlier DR) | 회귀 없음 | --- ## 핵심 정리 - **모든 게 "종단 여부" 한 축에서 갈린다.** PASSTHROUGH=안 푼다(SNI 끝까지 살아 라우팅·L4 관측만). ISTIO_MUTUAL=바깥 mesh TLS를 종단·SPIFFE 검증 후 안쪽을 다시 흘린다. 종단하면 SNI가 소비되므로 leg-2는 `tls`(SNI)가 아니라 **`tcp`(tcp_proxy)** 로 받아야 한다. - **"HTTPS over mTLS"는 이중 TLS다.** 바깥 = mesh mTLS(*누가*=신원), 안쪽 = 앱 HTTPS(*무엇*=기밀성, gw 미복호화). 게이트웨이는 안쪽을 끝까지 못 본다. - **한 egress-gw에서 PASSTHROUGH와 TLS-terminate를 섞으려면 포트를 분리**한다 — 같은 포트 머지는 한쪽을 드롭(`filter_chain_not_found`). PASSTHROUGH=443, ISTIO_MUTUAL=15443. - **정렬 지도가 깨지면 조용히 끊긴다.** `15443`(Gateway·DR·VS 양 leg), `sni: edition.cnn.com`(DR==Gateway.hosts), `subset cnn`(DR==VS leg-1)이 모두 일치해야 listener가 매칭된다. - **검증선 = 두 레이어를 따로:** `requireClientCertificate=True`+`validationContext=True`(바깥=신원) · `ssl_verify=0`+`tcp_proxy`(안쪽=end-to-end) · access log response flag `-`(종단·전달 성공). --- ## What you might be missing - **이 패턴은 "식별"이지 "인가"가 아니다.** egress-gw는 *누가* 나가는지 SPIFFE로 식별만 한다 — *허용 여부*는 강제하지 않는다. 신원이 검증돼도 cnn으로 나가는 건 막히지 않는다. "특정 SA만 cnn으로"라는 인가는 egress-gw에 `AuthorizationPolicy`(principals 기반)를 따로 걸어야 한다. → [AuthorizationPolicy 멘탈모델](sec__src-authorizationpolicy-mental-model.html) - **이중 TLS면 게이트웨이는 L7을 못 본다.** 안쪽 앱 TLS를 안 푸니 관측은 L4(`istio_tcp_*`)뿐. HTTP path·method 등 L7을 보려면 앱이 평문 HTTP를 보내고 게이트웨이가 외부로 TLS를 새로 거는 **TLS origination**이라는 다른 패턴이어야 한다(이 리포트와 정반대의 trade-off). → [운영 가이드](gw__src-egress-operations.html)의 L4 관측 제약과 동일. - **신원이 access log에 안 찍힌다.** TEST 3는 *설정상* SPIFFE 검증이 켜졌음을 보지만, 게이트웨이가 *실제로 본* client SPIFFE ID는 기본 로그에 없다. access log 포맷에 `%DOWNSTREAM_PEER_URI_SAN%`를 추가하면 직접 로깅된다. - **PASSTHROUGH가 살아 있는지 함께 봐야 한다.** outbound를 REGISTRY_ONLY로 전환하면 미등록 외부가 차단되며 본 mTLS 경로가 강제되는데, 이때 기존 httpbin control 경로의 거동도 같이 확인해야 회귀를 잡는다. --- ## 관련 파일 · 참조 **manifest (ISTIO_MUTUAL 세트)** - 📎 [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) · 📎 [원본 test-report](attachment/docs/test-reports/2026-06-08_egress-mtls-istio-mutual.md) **개념 · 비교** - [PASSTHROUGH control 리포트](gw__report-2026-06-07_ingress-egress.html) · [Egress 운영 가이드](gw__src-egress-operations.html) · [src-egress-gateway](gw__src-egress-gateway.html) · [Egress HTTPS 가이드](gw__guide-egress-gateway-https.html) - [HTTP vs HTTPS egress 설정](gw__src-egress-http-vs-https.html) · [mTLS/SPIFFE 신원](sec__note-mtls-spiffe-identity.html) · [AuthorizationPolicy 멘탈모델](sec__src-authorizationpolicy-mental-model.html) - ↗ [Istio: Egress Gateways](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway-tls-origination/) **구조 정본** → [HTTPS over mTLS 구조 — CRD 해부·장단점·활용·운영](gw__src-egress-https-over-mtls.html)