Test Report — Egress Gateway "HTTPS over mTLS" (ISTIO_MUTUAL)
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 신원 |
비교 기준선: Ingress/Egress 검증 리포트 (PASSTHROUGH control) · 운영 주의점 Egress 운영 가이드 · 필드 매뉴얼 src-egress-gateway
1. 핵심 아키텍처 — "종단하면 SNI가 소비된다"
머릿속에 둘 그림 하나: egress-gw에서 바깥 TLS는 종단되고 안쪽 TLS는 통과한다. 바깥(mesh mTLS)을 푸는 순간 그 handshake가 싣고 온 SNI는 그 자리에서 소비된다. 그래서 그 뒤 leg-2는 더 이상 SNI로 라우팅할 수 없고, 불투명한 바이트(tcp proxy) 로만 흘려야 한다. 이 한 문장이 아래 모든 설정과 두 함정의 원천이다.
두 레이어를 각각 보면:
- 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 설정, 구조 정본은 HTTPS over mTLS 구조.
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이다.
# 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가 예고한 대로 깨졌다.
함정 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
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의 바깥 레이어)
# :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 멘탈모델 - 이중 TLS면 게이트웨이는 L7을 못 본다. 안쪽 앱 TLS를 안 푸니 관측은 L4(
istio_tcp_*)뿐. HTTP path·method 등 L7을 보려면 앱이 평문 HTTP를 보내고 게이트웨이가 외부로 TLS를 새로 거는 TLS origination이라는 다른 패턴이어야 한다(이 리포트와 정반대의 trade-off). → 운영 가이드의 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 · 📎 destinationrule-egress-cnn-mtls.yaml - 📎 virtualservice-egress-cnn-mtls.yaml · 📎 serviceentry-cnn-ext.yaml - 📎 20-egress/README.md · 📎 원본 test-report
개념 · 비교 - PASSTHROUGH control 리포트 · Egress 운영 가이드 · src-egress-gateway · Egress HTTPS 가이드 - HTTP vs HTTPS egress 설정 · mTLS/SPIFFE 신원 · AuthorizationPolicy 멘탈모델 - ↗ Istio: Egress Gateways