homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-06-08egressmtlsspiffegatewayenvoy

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 리포트, 운영 정본은 Egress 운영, 개념 정본은 Egress Gateway 정본, 이 신원 위에 올라가는 통제(AuthorizationPolicy·테스트 매트릭스)는 Egress 신원 기반 통제 가이드 참조.

대상 환경: 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 라우팅(정본 §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라 호출자가 누군지 암호학적으로 모른다. 상세 정본, 가이드 HTTPS passthrough 가이드.
  • TLS origination: gateway가 외부로 TLS를 시작하므로 앱은 평문 HTTP를 메시에 보내야 한다 → gateway가 L7(method/path/status)을 보고 정책·감사·중앙 cert 관리가 가능하지만, end-to-end 암호화가 깨진다(payload가 gateway 메모리에 평문으로 존재). 비교 HTTP vs HTTPS.

여기서 빈 칸이 하나 남는다: “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중앙  |
              +----------------+----------------+
flowchart TD
  Q1{"앱 payload를 gateway가<br/>복호화해도 되는가?"}
  Q1 -->|"안 됨 (end-to-end 유지)"| Q2{"egress에서 호출 워크로드<br/>신원을 식별해야 하는가?"}
  Q1 -->|"됨 (gw가 L7 보고 정책/감사)"| ORIG["TLS origination<br/>앱은 평문 HTTP, gw가 새 TLS 시작"]
  Q2 -->|"필요 없음"| PASS["PASSTHROUGH<br/>단일 TLS, SNI 라우팅"]
  Q2 -->|"필요함 (SPIFFE)"| MTLS["HTTPS over mTLS (ISTIO_MUTUAL)<br/>이중 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_MUTUALclient 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)

2.1 CRD 4종 — 각 객체가 “봉투 메커니즘의 어느 부분"을 책임지나

scenarios/20-egress/*-cnn-mtls.yaml. 4개 객체는 위 데이터 경로의 서로 다른 부분을 맡는다. PASSTHROUGH 대비 **무엇이 달라지는지(델타)**만 좁혀 본다 — 차이가 곧 이 패턴의 본질이다.

CRD 답하는 질문 (봉투 경로의 역할) passthrough 대비 델타
ServiceEntry “이 외부 호스트를 메시가 알아도 되나?” 차이 없음 (외부 등록은 패턴 무관)
Gateway “egress pod가 outer를 어떻게 받나?” tls.mode: PASSTHROUGHISTIO_MUTUAL, 포트 443 → 15443
DestinationRule “sidecar가 outer를 어떻게 만드나?” trafficPolicy.tls 신규(passthrough는 subset만)
VirtualService “각 leg를 어떻게 라우팅하나?” leg-2가 tls/sniHosts → tcp

ServiceEntry — 외부 호스트 등록 (passthrough와 동일)

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 봉투를 여는 곳)

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: host(SNI)가 겹치는 상태로 같은 포트(443)에 PASSTHROUGH server와 ISTIO_MUTUAL server를 두면 머지 단계에서 한쪽이 드롭될 위험이 있다(→ filter_chain_not_found, 본 실측 §3에서 실제로 밟은 함정). 다만 Istio 1.30 실측(T08)에서는 host가 서로 다르면 Envoy가 SNI 기반 filterChainMatch로 두 서버를 정상 분리해 공존시킨다는 것도 확인됐다 — “같은 포트=무조건 충돌"은 과장이고, 실제 조건은 host/SNI 매칭이 겹치는지 여부다. 그리고 15443 자체는 egress Service가 “노출하는 표준 tls 포트"가 아니다 — Istio 1.10부터 istio-egressgateway 기본 Helm 차트에서 15443/tls 포트 항목이 제거되어, 1.30.0 기준 기본 egress gateway Service는 80/443만 노출한다(15443은 오늘날 오히려 east-west/cross-network 게이트웨이의 관용 포트). 쓰려면 Helm values/IstioOperator로 Service·Deployment에 수동으로 분리해 추가해야 한다. 그럼에도 종단 모드별로 전용 포트를 갈라 쓰는 포트 위생 자체는 안전한 방어적 관행으로 유효하다.

DestinationRule — leg-1을 메시 mTLS로 래핑 (★ sidecar가 outer 봉투를 만드는 트리거)

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)

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_proxytcp 라우트. (§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 관계 한눈에

flowchart LR
  SE["ServiceEntry cnn-ext<br/>edition.cnn.com:443 TLS"]
  GW["Gateway egress-cnn<br/>:15443 ISTIO_MUTUAL (종단+SPIFFE)"]
  DR["DestinationRule egressgateway-cnn<br/>subset cnn: ISTIO_MUTUAL+sni"]
  VS["VirtualService egress-cnn<br/>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 리포트)

TEST 1 — end-to-end로 통하는가 + gateway가 inner를 안 건드렸나

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)

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)

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. (리포트 §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 멘탈모델)
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/paymentpartner-bank.example.com으로 나갈 수 있다"를 spoof 가능한 IP가 아니라 SPIFFE principal로 강제. gateway에 AuthorizationPolicy(principals)를 걸어 구현. (authz, SPIFFE)

  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 기반)를 걸어 인가로 닫아야 비용이 정당화된다. 식별만 하면 비싼 로깅에 그친다. (리포트 §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 우회로 새어 나간다. (정본 §02 강제 계층 — Cilium 언급은 Calico로 대치해 읽을 것)
  • 포트 위생. 종단 모드(ISTIO_MUTUAL) host-group마다 전용 포트 할당을 기본 방어 전략으로 유지(15443은 관용적으로 쓰이나 Istio 1.10+부터 egress Service 기본 노출 포트는 아니므로 Service/Deployment에 수동 추가 필요). 한 포트에 passthrough + 종단을 혼재시키면 host/SNI가 겹칠 때 머지 충돌 위험이 있다 — 단, host가 다르면 Envoy가 SNI로 정상 분리함을 1.30 실측(T08)에서 확인했으므로 “무조건 금지"는 아니다. 종단 호스트가 늘면 포트/listener 설계를 먼저.
  • L7 관측 공백은 앱 sidecar에서 메운다. gateway는 L4만 보므로, per-request egress 지표가 필요하면 호출 워크로드 sidecar의 L7 텔레메트리로 보완.
  • HA. egress gateway는 SPOF. 본 검증 values는 replicaCount: 1 → 사내 적용 시 replica↑ + PDB + 노드 분산 필수. (운영 정본)
  • 신뢰 도메인 경계. 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). host/SNI가 겹치는 상태로 같은 포트(443)에 PASSTHROUGH server와 ISTIO_MUTUAL server가 공존하면 머지 충돌로 한쪽이 드롭될 수 있다(host가 다르면 SNI로 정상 분리됨을 1.30 실측(T08)에서 확인 — 무조건 충돌은 아님) → 안전하게는 종단 모드별 전용 포트로 분리. 단 15443은 Istio 1.10+부터 egress Service 기본 노출 포트가 아니라 수동 추가가 필요하다.
  • 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리소스)
  • 이중 TLS는 관측 도구를 헷갈리게 한다. Kiali/Prometheus에서 이 트래픽은 istio_tcp_*로만 잡히고 L7 그래프엔 안 뜬다 — “트래픽이 안 보인다"가 아니라 L4로 보고 있는 것. passthrough와 동일한 가시성 한계.
  • tcp 라우트의 호스트 다중화 한계. leg-2가 포트 매칭만으로 동작하는 건 그 listener에 호스트가 1개여서다. 한 종단 포트로 여러 외부 호스트를 라우팅하려면 SNI가 이미 소비된 종단 listener에선 까다롭다 — 호스트별 포트 분리가 현실적.
  • resolution: DNS의 노드 DNS 의존. 외부 해석은 istiod/노드 DNS에 달려 있어 DNS 장애가 egress 장애로 직결. (DNS resolution 리포트)

7. 참조

아카이브 내부

관련 IaC (실제 manifest)

외부 (조사 출처)

반대 선택의 근거이중 TLS 없이 egress 신원 — passthrough + Calico


검증 기록 (2026-07-05 · Istio 1.30.0 / k8s 1.30.6)

검증 방법: 공식 Istio 레퍼런스/태스크 문서 대조 + homelab 클러스터(k8s 1.30.6, Istio 1.30.0) 실측(istio-verify 네임스페이스).

주장 판정 근거
C1. outer(sidecar↔gw)는 ISTIO_MUTUAL 메시 mTLS로 종단·SPIFFE 검증, inner(app↔외부)는 gw가 복호화 없이 tcp_proxy로 blind relay ✅ 실측 확인 istio.io/…/networking/gateway · T07 실측
C2. Gateway tls.mode: ISTIO_MUTUAL은 client cert 강제+mesh CA 검증+종단을 한 줄로 유도 ✅ 문헌 확인 istio.io/…/networking/gateway
C3. DestinationRule tls.mode: ISTIO_MUTUAL(portLevelSettings+sni)은 sidecar가 SPIFFE client cert로 outer mTLS를 originate ✅ 문헌 확인 istio.io/…/networking/destination-rule
C4. 종단된 listener에 tls/sniHosts를 걸면 0 chains로 listener가 omit되어 leg-2는 반드시 tcp ✅ 실측 확인 istio.io/…/networking/virtual-service · T07 실측
C5. PASSTHROUGH는 SNI가 끝까지 살아 양쪽 leg 모두 tls/sniHosts, in-mesh leg는 별도 mTLS 없인 평문 TCP ✅ 문헌 확인 istio.io/…/egress/egress-gateway
C6. 같은 포트에 PASSTHROUGH+ISTIO_MUTUAL server가 공존하면 머지 단계에서 한쪽이 항상 드롭된다 🔬 실측 반증 — 본문 교정 github.com/istio/istio#37293 · T08 실측(host가 다르면 SNI 기반 filterChainMatch로 2개 체인이 정상 공존, 드롭 없음)
C7. 포트 15443은 egress Service가 노출하는 “표준 tls 포트” ⚠️ 구버전 서술 — 갱신 raw.githubusercontent.com/istio/[email protected]/…/istio-egress/values.yaml(1.10부터 기본 차트에서 제거, 1.30.0 기준 기본은 80/443만 노출)
C8. ServiceEntry 포트 protocol은 HTTP/HTTPS가 아니라 TLS로 등록 ✅ 문헌 확인 istio.io/…/networking/service-entry
C9. egress gateway AuthorizationPolicy.principals(<trust-domain>/ns/<ns>/sa/<sa>)로 신원 기반 인가 가능 ✅ 문헌 확인 istio.io/…/security/authorization-policy
C10. 기본 TCP access log엔 client SPIFFE ID가 없어 %DOWNSTREAM_PEER_URI_SAN%을 명시 추가해야 함 ✅ 문헌 확인 istio.io/…/logs/access-log
C11. ISTIO_MUTUAL(DR/Gateway)과 PeerAuthentication STRICT는 서로 다른 레이어 ✅ 문헌 확인 istio.io/…/concepts/security
C12. 이중 TLS 경로는 istio_tcp_*(L4)로만 잡히고 istio_requests_total 등 L7 지표는 생성 안 됨 ✅ 문헌 확인 istio.io/…/config/metrics
C13. 메시 기본 mTLS는 PERMISSIVE지만 DR/Gateway의 명시적 ISTIO_MUTUAL은 mesh-wide 설정과 무관하게 해당 leg를 강제 ✅ 문헌 확인 istio.io/…/security/peer_authentication
C14. Istio 라우팅은 네트워크 강제가 아니며 sidecar 우회 시 새어나감 — Calico NetworkPolicy로 강제해야 함 ✅ 문헌 확인 istio.io/…/best-practices/security
C15. Envoy 클러스터 이름 형식은 direction|port|subset|fqdn ✅ 문헌 확인 istio.io/…/diagnostic-tools/proxy-cmd

Files