--- type: src tags: [istio, egress, passthrough, mtls, tls-origination, service-mesh] created: 2026-06-07 --- # Egress Gateway 필드 매뉴얼 — HTTPS passthrough 중심 (출처: LLM 답변 + Istio 1.30 공식 문서) > [!abstract] 이 문서가 다루는 것 > **머릿속에 둘 단 하나의 그림(ANCHOR):** egress gateway는 외부 트래픽을 *유도하는 라우팅 수렴점*일 뿐 강제 장치가 아니며, 외부 구간의 **TLS를 누가 종단하느냐**가 게이트웨이의 가시성·앱 변경·암호화 범위를 한꺼번에 결정한다. 앱이 끝까지 TLS를 쥐면(`https`) 게이트웨이는 SNI만 보는 **passthrough**(주류), 앱이 평문을 보내면(`http`) 게이트웨이가 TLS를 대신 맺는 **origination**(특수) — 이 한 갈래에서 나머지(가시성·앱 변경·암호화·인증서 위치)가 전부 따라 나온다. > - **Sidecar 모드** (Ambient 아님) · CRD는 Istio 1.30 / `networking.istio.io/v1` 기준 (공식 문서 검증, 출처는 문서 끝) > - **주류 경로** = 앱이 `https://`로 직접 호출 → 게이트웨이 `PASSTHROUGH` (섹션 04, 가장 비중 큼) > - **특수 경로** = 앱을 `http://`로 바꿔 게이트웨이가 TLS/mTLS를 대신 맺는 origination (섹션 06, 필요할 때만) **대상환경:** Istio 1.30 sidecar mesh · on-prem/홈랩(Calico CNI 전제) · **대상독자:** egress 경로를 설계·운영하는 DevOps/SRE · **범위:** 설계·TLS 모델 정본(운영 deep-dive는 [egress 운영 정본](ref__src-operations.html)에 위임) · **선행개념:** 아래 박스. > [!note] 선행 개념 — 이 7개만 손에 쥐면 본문이 풀린다 > 본문은 이 단어들 위에서 돈다. 모르는 것만 집어 읽고 넘어가라. > - **TLS / 종단(termination)**: 주고받는 내용을 암호화하는 규약(`https`의 's'). 암호를 *푸는 지점*이 종단인데, **한 연결에서 종단은 단 한 번뿐**이다 — 이 제약이 passthrough vs origination을 가르는 물리 법칙이다. > - **SNI**: TLS를 열 때 *평문으로* 같이 실리는 목적지 도메인(예: api.partner.example.com). 암호를 못 풀어도 "어느 호스트로 가는 연결인지"는 보인다 → passthrough 라우팅의 유일한 단서. > - **passthrough vs origination**: 전자는 암호를 안 풀고 SNI만 보고 통과, 후자는 게이트웨이/사이드카가 외부행 TLS를 *직접 새로 맺음*. > - **L4 / L7**: L4 = 연결(TCP) 수준("어디로 얼마나"), L7 = 요청(HTTP) 수준("어떤 요청이 200이었나"). 게이트웨이가 L7을 보려면 암호를 풀어야 한다. > - **사이드카 vs egress gateway**: 사이드카 = 앱마다 붙는 Envoy(모든 트래픽 통과). egress gateway = 외부행만 모으는 전용 Envoy. > - **메시 자동 암호화 (mesh mTLS, ISTIO_MUTUAL)**: 클러스터 안 사이드카끼리를 Istio가 자동 양방향 암호화하는 것. 외부 파트너와의 mTLS와는 *다른 구간, 다른 인증서*다(혼동이 모든 오해의 출발점). > - **설정 리소스 4종**: **ServiceEntry**(어떤 외부를 허용·등록) · **Gateway**(게이트웨이가 받는 포트) · **VirtualService**(어디로 보낼지 규칙) · **DestinationRule**(보낸 뒤 TLS·묶음 정책). --- ## 01. 배경 — 왜 egress gateway가 존재하나 (해결하는 문제) 사이드카를 주입하면 기본값(`outboundTrafficPolicy: ALLOW_ANY`)에서 각 Pod는 자기 사이드카를 거쳐 외부로 *직접* 나간다 — 출구가 노드 수만큼 흩어지고, 누가 어디로 나갔는지 한 곳에 안 남으며, 파트너에게 등록할 출구 IP도 고정되지 않는다. egress gateway가 푸는 문제는 이 **흩어진 외부행을 하나의 제어점(choke point)으로 수렴**시키는 것이고, 거기서 네 가지가 파생된다. | 동기 | 무엇을 얻는가 | |---|---| | **노드 고정** (가장 흔한 목표) | 외부 NAT·방화벽 통로를 *특정 노드에만* 부여. 앱 노드는 외부 라우트가 없고 egress 노드만 인터넷에 도달. 출구 IP를 고정해 파트너 IP allowlist에 등록 가능 | | **관측·감사** | 외부 접근이 단일 지점을 통과 → 누가·어디로·얼마나 나갔는지 한 곳에서 수집. PCI-DSS 감사 추적에 직접 연결 | | **인증서 집중** | 파트너가 client 인증서(mTLS)를 요구할 때, 모든 앱이 각자 들고 있는 대신 게이트웨이 한 곳에 모음. *단, 이건 origination(섹션 06)에서만* | | **공격면 축소** | 침해된 Pod의 데이터 유출 경로를 게이트웨이로 한정. *단, 강제 계층이 따로 받쳐줄 때만 — 섹션 02* | 대부분의 도입 목적은 첫 줄 — **"외부행을 특정 노드로만"** — 이다. 그리고 그 경우 앱은 보통 이미 `https://`로 외부를 부르고 있어서, 앱을 건드리지 않고 게이트웨이만 얹는 **passthrough**가 자연스러운 선택이 된다. > [!info] What you might be missing > **egress gateway는 ingress gateway의 거울상이 아니다.** Ingress는 외부 트래픽이 *물리적으로 그 LoadBalancer IP를 거칠 수밖에 없어* 자연히 강제된다. Egress엔 그런 물리적 강제가 없다 — Pod는 여전히 자기 사이드카에서 임의 목적지로 나갈 수 있다. 공식 문서도 명시한다: *"egress Gateway를 정의하는 것 자체는 그 게이트웨이가 도는 노드에 어떤 특별한 취급도 부여하지 않는다."* 이 비대칭성이 egress 운영 난이도의 근원이다. --- ## 02. 전제 교정 — 게이트웨이는 강제 장치가 아니다 도입 사고가 가장 많이 나는 지점이라 먼저 못을 박는다. **"트래픽을 게이트웨이로 라우팅"** 과 **"트래픽이 게이트웨이를 거치도록 강제"** 는 완전히 다른 레이어의 일이다. VirtualService로 "이 호스트는 egress gateway를 거쳐라"라고 쓰면, *그 규칙을 따르는* 사이드카는 게이트웨이로 보낸다. 하지만 규칙에 안 걸리는 호스트, 사이드카가 없는 Pod, 사이드카를 우회하도록 조작된 컨테이너는 그냥 직접 나간다. 기본값 `ALLOW_ANY`에서는 규칙에 없는 목적지를 **PassthroughCluster로 조용히 통과**시켜 버린다. 즉 "조용한 우회"가 기본 동작이다. 진짜로 "특정 노드로만"을 보장하려면 **세 계층**이 함께 서야 한다. | 계층 | 메커니즘 | 역할 / 한계 | |---|---|---| | **① L7/SNI 라우팅** | `VirtualService` mesh→gateway | 외부행을 게이트웨이로 *유도*. 규칙을 따르는 사이드카에만 적용. **우회 차단 불가** | | **② 레지스트리 통제** | `outboundTrafficPolicy: REGISTRY_ONLY` | 등록(`ServiceEntry`)되지 않은 목적지를 **BlackHoleCluster**로 차단. "알려진 외부만". **등록된 외부로는 여전히 직접 나갈 수 있음** | | **③ 네트워크 강제** | NetworkPolicy(CNI의 egress 정책) + 노드 라우팅 | 앱 노드의 `0.0.0.0/0` 아웃바운드 차단, 외부 NAT는 egress 노드에만. **여기서 비로소 물리적 강제** | > [!tip] 강제의 핵심 등식 > **실질적 강제 = (REGISTRY_ONLY로 미등록 차단) × (NetworkPolicy로 직접 아웃바운드 차단) × (egress 노드만 NAT 보유).** 게이트웨이 라우팅은 그 위에 얹는 "정상 경로"일 뿐. 셋 중 하나라도 빠지면 우회 구멍이 남는다. Istio 단독으로는 ③을 못 만든다 — CNI의 egress NetworkPolicy(Cilium은 `CiliumNetworkPolicy`, Calico는 `GlobalNetworkPolicy`)가 ③을 담당한다. ⚠️ 단, **Calico는 egress 정책 표현력에 제약**이 있어(도메인 기반 egress 등 일부 기능 미지원·제한) 노드 라우팅·외부 NAT 한정에 더 의존하게 된다 — 홈랩 CNI가 Calico임을 전제로 검증할 것. > [!info] What you might be missing > **REGISTRY_ONLY를 메시 전역에 켜는 순간, ALLOW_ANY에 기대 조용히 나가던 외부 호출이 한꺼번에 막힌다.** 로그 수집기, 메트릭 푸시, 외부 시크릿 매니저, OCSP/CRL 인증서 검증, 컨테이너 레지스트리 — 평소 안 보이던 의존성이 전부 ServiceEntry를 요구한다. air-gapped라도 내부 미러·프록시로 가는 트래픽까지 레지스트리에 있어야 한다. 그래서 전역 적용은 "켜고 본다"가 아니라, `Sidecar` 리소스로 네임스페이스 단위 audit → 트래픽 인벤토리 확보 → 점진 확대가 정석이다. --- ## 03. 핵심 아키텍처 — 갈래는 단 하나, "TLS를 누가 종단하나" 여기가 이 문서의 심장이다. 선행 박스의 물리 법칙 — **한 연결에서 TLS 종단은 단 한 번** — 을 egress 경로에 적용하면 모든 설계가 한 갈래에서 갈라진다: 외부행 TLS를 **앱이 쥐느냐(passthrough), 게이트웨이가 쥐느냐(origination)**. 이 하나를 정하는 순간 게이트웨이의 가시성·앱 변경·암호화 범위·인증서 위치가 *자동으로* 결정된다 — 따로 고를 자유도가 아니라, TLS 종단점을 정하면 따라 나오는 **종속 변수**들이다. 그래서 설정·모니터링을 외우기 전에 이 한 줄부터 정해야 한다. | | 주류 · HTTPS + passthrough | 특수 · HTTP + origination | |---|---|---| | **앱 호출** | `https://api.partner...` — 앱이 직접 TLS 종단 | `http://api.partner...` — 앱은 평문 전송 | | **게이트웨이** | 복호화 **안 함**. SNI만 읽어 라우팅 | TLS 종단 후 새 TLS를 **대신 맺음**(client cert 제시) | | **암호화** | 앱 ↔ 외부 end-to-end 그대로 유지 | 앱↔GW(메시 mTLS) + GW↔외부(새 mTLS), 2구간 | | **앱 변경** | **없음** — 그대로 두고 게이트웨이만 얹음 | **필요** — `https`→`http`로 호출 변경(침습적) | | **게이트웨이 L7 가시성** | **없음** (SNI·L4만) | **있음** (method·path·status) | | **주 용도** | "외부행을 특정 노드로", 출구 IP 고정, 감사 | 파트너 mTLS 중앙관리, 게이트웨이 L7 관측 | | **자세히** | '주류 경로' 섹션 (04) | '특수 경로' 섹션 (06) | > [!tip] 결정 규칙 > **앱이 이미 `https://`로 외부를 호출하고 있다면 → 주류(passthrough, 섹션 04).** 앱을 그대로 두고 게이트웨이만 얹어 "특정 노드로" 목표를 달성한다. 게이트웨이가 *인증서를 대신 관리*해야 하거나(파트너 mTLS) 게이트웨이에서 *HTTP 레벨 메트릭*이 꼭 필요할 때만 → 특수(origination, 섹션 06). 둘은 배타적이지 않아서, 파트너별로 호스트 단위로 섞어 쓸 수 있다. --- ## 04. 주류 경로 — HTTPS 직접 호출 + passthrough 앱이 `https://api.partner.example.com`으로 직접 호출하고, 게이트웨이는 그 TLS를 **풀지 않고** 통과시키는 형태다. 게이트웨이가 읽는 건 TLS ClientHello에 평문으로 실린 **SNI**(목적지 호스트명)뿐이다. TLS는 처음부터 끝까지 **앱과 외부 사이에서만** 종단된다. ```mermaid flowchart LR subgraph APPNODE["APP NODE (no external route)"] APP["App
https :443"] --> SC["Sidecar
reads SNI only
payload opaque"] end subgraph EGNODE["EGRESS NODE (has NAT)"] GW["Egress Gateway
PASSTHROUGH
SNI 라우팅 · 복호화 X"] end PARTNER["Partner API
TLS 종단 (서버)"] SC == "end-to-end TLS (암호문)" ==> GW == "암호문 그대로" ==> PARTNER ``` > TLS 터널은 앱에서 외부까지 한 번에 이어진다. 게이트웨이는 그 터널을 끊지 않고 SNI만 보고 길을 안내한다. 게이트웨이가 보는 것: SNI + bytes + duration. HTTP method·path·status·header = 안 보임(암호문). ### HTTPS passthrough 요청 6단계 | 단계 | 무엇이 일어나나 | 관장 리소스 | 흔한 실패 | |---|---|---|---| | 1 | 앱이 HTTPS로 외부 호출 (SNI 포함 ClientHello) | (앱 코드) | SNI를 안 싣는 클라이언트면 SNI 기반 라우팅 불가 | | 2 | 사이드카 가로채기 — iptables REDIRECT :15001, SNI만 평문으로 읽힘 | (istio-init) | `excludeOutboundIPRanges` 어노테이션이면 우회 | | 3 | SNI로 게이트웨이 라우팅 (mesh leg) | VirtualService + DestinationRule | **NR** — `http:`로 잘못 작성(tls:+sniHosts여야), sniHosts/host mismatch | | 4 | 게이트웨이 PASSTHROUGH 수신 — 복호화 없이 SNI 매칭 | Gateway | server protocol이 TLS 아니거나 mode 누락 | | 5 | 게이트웨이 → 외부 forward (egress leg + ServiceEntry resolution) | VirtualService + ServiceEntry | **UH** — DNS 미해석 / resolution 설정 오류 | | 6 | 앱 ↔ 외부 end-to-end TLS 완성 (게이트웨이는 중간 도관) | (도관) | 파트너 인증서/SNI 검증 실패는 앱에서 발생 | ### 설정 — CRD 4종 (passthrough) 핵심 차이: ServiceEntry 포트가 `protocol: TLS`, 게이트웨이가 `tls.mode: PASSTHROUGH`, 그리고 VirtualService가 **`http:`가 아니라 `tls:` + `sniHosts`** 로 라우팅한다(SNI 기반 L4 라우팅이라). 전부 `istio-system`에 둔다. ```yaml # ① ServiceEntry — 외부 등록 (protocol: TLS) apiVersion: networking.istio.io/v1 kind: ServiceEntry metadata: name: partner-api namespace: istio-system spec: hosts: - api.partner.example.com ports: - number: 443 name: tls protocol: TLS # HTTPS 아님! SNI 기반 passthrough resolution: DNS # DNS / STATIC / NONE — 아래 DNS 절 참고 location: MESH_EXTERNAL ``` ```yaml # ② Gateway — PASSTHROUGH 리스너 apiVersion: networking.istio.io/v1 kind: Gateway metadata: name: egress-partner namespace: istio-system spec: selector: istio: egressgateway servers: - port: number: 443 name: tls protocol: TLS hosts: - api.partner.example.com tls: mode: PASSTHROUGH # 복호화하지 않고 SNI로만 통과 ``` ```yaml # ③ DestinationRule — subset 정의만 (TLS 설정 없음) apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: name: egressgateway-partner namespace: istio-system spec: host: istio-egressgateway.istio-system.svc.cluster.local subsets: - name: partner # passthrough에선 mTLS 래핑 없음 — subset 이름만 ``` ```yaml # ④ VirtualService — SNI 라우팅 (tls + sniHosts, 2-leg) apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: route-partner namespace: istio-system spec: hosts: - api.partner.example.com gateways: - mesh # leg 1: 사이드카에서 - egress-partner # leg 2: 게이트웨이에서 (Gateway 이름과 일치!) tls: # http: 아님 — SNI 기반이므로 tls: - match: # leg 1 — 메시 → 게이트웨이 - gateways: [mesh] port: 443 sniHosts: [api.partner.example.com] route: - destination: host: istio-egressgateway.istio-system.svc.cluster.local subset: partner port: number: 443 - match: # leg 2 — 게이트웨이 → 외부 - gateways: [egress-partner] port: 443 sniHosts: [api.partner.example.com] route: - destination: host: api.partner.example.com port: number: 443 ``` 앱은 평소처럼 `https://api.partner.example.com`을 부르면 된다. 인증서·TLS는 앱이 끝까지 책임지고, 게이트웨이는 경로만 강제한다. **앱 코드 0 변경** — 이게 passthrough가 주류인 이유다. ### 변형 — proxy와 게이트웨이 사이를 mTLS로 한 겹 더 감싸기 (권장) 위 기본형은 사이드카↔게이트웨이 구간의 TLS를 명시하지 않았다. 실무에서, 특히 PCI-DSS처럼 "내부 구간도 암호화·인증됐음을 증명"해야 하는 환경에서는, 그 구간을 **메시 자동 mTLS(ISTIO_MUTUAL)로 명시해서 한 겹 더 감싼다.** 이때도 게이트웨이는 앱의 외부 TLS를 **여전히 풀지 않는다** — TLS가 두 겹으로 겹칠 뿐이다. ```mermaid flowchart LR APP["App"] --> SC["Sidecar"] SC --> GW["Egress GW
passthrough"] GW --> PARTNER["Partner"] APP == "outer: end-to-end TLS (GW가 못 풂, App→Partner 관통)" ==> PARTNER SC -. "inner: ISTIO_MUTUAL (Sidecar↔GW 구간만 덮음)
메시 인증서·워크로드 신원·내부 암호화" .-> GW ``` 여기엔 **서로 독립된 두 개의 스위치**가 있다는 게 핵심이다. 하나는 "게이트웨이가 외부 TLS를 푸느냐"(Gateway server 쪽), 다른 하나는 "proxy→게이트웨이 구간을 mTLS로 감싸느냐"(DestinationRule 쪽). 우리가 원하는 건 **앞은 안 풀고(PASSTHROUGH) 뒤는 감싸는(ISTIO_MUTUAL)** 조합이다. | 설정할 것 | 필드 (리소스) | 값 | |---|---|---| | 바깥 봉투(외부 TLS)를 **안 푼다** | Gateway `servers[].tls.mode` | `PASSTHROUGH` | | 안쪽 봉투(proxy→egress)를 **mTLS로 감싼다** | DestinationRule `trafficPolicy.tls.mode` | `ISTIO_MUTUAL` | | 라우팅 (양쪽 leg) | VirtualService | `tls` + `sniHosts` | 기본형과 비교해 **DestinationRule 한 곳만** 바뀐다(ServiceEntry·Gateway·VirtualService는 위 기본형 그대로). subset에 `trafficPolicy`를 붙여 `ISTIO_MUTUAL`을 명시한다. ```yaml # ③ DestinationRule (변형) — proxy→egress를 ISTIO_MUTUAL로 명시 apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: name: egressgateway-partner namespace: istio-system spec: host: istio-egressgateway.istio-system.svc.cluster.local subsets: - name: partner trafficPolicy: portLevelSettings: - port: number: 443 tls: mode: ISTIO_MUTUAL # proxy(사이드카) -> egress 게이트웨이 mTLS sni: api.partner.example.com # 이 sni는 "게이트웨이로 가는 연결"의 것 ``` > [!warning] 중요 — 이 sni와 ISTIO_MUTUAL의 위치 > 이 `ISTIO_MUTUAL`은 **DestinationRule(proxy→egress hop)** 에 들어간다. **Gateway server**의 `tls.mode`를 ISTIO_MUTUAL로 바꾸면 그건 "게이트웨이가 메시 TLS를 *종단*한다"는 뜻이 되어 origination(특수 경로)으로 넘어간다. 반드시 **Gateway server = PASSTHROUGH 고정, DestinationRule = ISTIO_MUTUAL** 조합이어야 한다. 또 여기 `sni`는 게이트웨이로 가는 안쪽 연결의 SNI이지, 외부 파트너로 가는 SNI가 아니다 — 외부행 SNI는 앱이 만든 바깥 봉투에 들어있고 게이트웨이는 그대로 전달한다. > [!note] Auto mTLS와의 관계 — 무엇이 실제로 달라지나 > Istio에는 "Auto mTLS"가 있어서, DestinationRule에 TLS를 명시하지 않은 기본형에서도 **proxy→게이트웨이 구간은 이미 메시 mTLS일 가능성이 높다.** 그러니 `ISTIO_MUTUAL`을 명시하는 실익은 "없던 암호화를 새로 만드는 것"이 아니라, **자동 추론에 맡기지 않고 mTLS와 SNI를 못 박아 확정**하는 데 있다 — air-gapped·PCI 환경에서 "이 구간은 확실히 mTLS다"를 증명·고정해야 할 때 권장된다. 현재 상태는 `istioctl proxy-config cluster deploy/istio-egressgateway -n istio-system --fqdn api.partner.example.com -o json`에서 `transportSocket`이 mTLS인지로 확인한다. **이 변형으로 얻는 것:** 내부 구간(proxy→게이트웨이)의 암호화 + 워크로드 신원(SPIFFE) 기반 인증·정책. **얻지 못하는 것:** 게이트웨이에서의 요청 수준(L7) 가시성 — 바깥 봉투를 안 풀었으니 여전히 0이다. 즉 이 구성은 "앱이 https를 그대로 들고 가되, 내부 hop까지 확실히 암호화·인증된 passthrough"다. ### DNS — 어떻게 되어야 하는가 passthrough에서 가장 자주 막히는 지점이라 따로 본다. 호스트명이 IP로 바뀌는 일이 **두 군데**에서 일어나고, 그 둘은 독립적이다. ``` app --DNS query--> CoreDNS --> IP (app opens TLS, SNI in ClientHello) | sidecar --route by SNI--> egress gateway | gateway --ServiceEntry.resolution--> real destination IP DNS : gateway resolves the hostname itself (background, 30s) NONE : gateway reuses the ORIGINAL dest IP (the one app resolved) STATIC : gateway uses the endpoints[] IPs you declared ``` 여기서 결정적인 함정 하나: **Istio 프록시는 요청 시점에 동기 DNS를 하지 않는다.** `resolution: DNS`면 프록시가 **백그라운드 주기로** 재해석한 결과를 모든 요청에 쓴다. 기본 주기는 **약 30초**인데, 이건 고정값이 아니라 `meshConfig.dnsRefreshRate`(전역) 또는 cluster의 DNS 설정에서 조정되는 값이다 — 즉 IP가 자주 바뀌는 외부엔 이 주기를 줄여 대응할 수 있다. 그래서 파트너 IP가 바뀌어도 최대 (재해석 주기 + DNS TTL)만큼 지연된다. 실제 적용된 주기·DNS 모드는 cluster config_dump로 확인한다. ```bash istioctl proxy-config cluster deploy/istio-egressgateway -n istio-system \ --fqdn api.partner.example.com -o json \ | grep -E 'dnsRefreshRate|dnsLookupFamily|respectDnsTtl|type' # 기대 출력(예): "type": "STRICT_DNS", "dnsRefreshRate": "30s", "respectDnsTtl": true # - type=STRICT_DNS → 프록시가 hostname을 직접 주기 해석(resolution: DNS) # - dnsRefreshRate → 백그라운드 재해석 주기(기본 30s, meshConfig.dnsRefreshRate로 조정) # - respectDnsTtl → DNS TTL을 함께 존중(주기 vs TTL 중 짧은 쪽 영향) ``` | resolution | 동작 | 언제 | |---|---|---| | `DNS` | 게이트웨이가 hostname을 30초 주기로 직접 해석. **게이트웨이 노드가 외부 DNS에 도달 가능해야** 함 | 단일 호스트, 게이트웨이가 외부 DNS를 쓸 수 있는 환경 | | `NONE` | 해석 안 함. 앱이 이미 해석한 **원래 목적지 IP**를 그대로 사용. 게이트웨이는 DNS 불필요 | 와일드카드(`*.partner.com`), 앱 해석을 신뢰하는 일반 passthrough | | `STATIC` | ServiceEntry의 `endpoints[].address`에 IP 명시. **endpoints 빠지면 동작 안 함** | 고정 IP, DNS 불안정, **air-gapped** | air-gapped 환경에서 외부 DNS가 없다면 `STATIC` + 명시 IP가 가장 단순하고, 내부 forwarding resolver(CoreDNS forward 존)를 두면 `DNS`도 가능하다. 한편 **Istio DNS proxy**(`ISTIO_META_DNS_CAPTURE: "true"`)를 켜면 사이드카가 앱의 DNS 질의를 가로채 Istio 레지스트리로 응답하는데, 여기에 **auto-allocate**(`ISTIO_META_DNS_AUTO_ALLOCATE`)를 더하면 ServiceEntry 호스트마다 `240.240.0.0/16` 대역의 가상 IP를 배정해 준다. 기본은 비활성이고, DNS 응답을 바꾸므로 일부 앱과 호환성 이슈가 있을 수 있다. ### 모니터링 — 무엇이 보이고 무엇이 안 보이나 passthrough의 관측은 "안 되는" 게 아니라 **L4까지만 되고 L7이 0**이다. 게이트웨이는 암호문을 못 풀기 때문이다. | 게이트웨이에서 보이는 것 (L4 + SNI) | 안 보이는 것 (L7) | |---|---| | ✅ 목적지 **SNI**(어느 외부 호스트) | ❌ HTTP method · path · query | | ✅ 송수신 바이트, 연결 시간 | ❌ status code · 응답 크기 | | ✅ TCP 성공/실패 + `response_flags` | ❌ request/response header | | ✅ 출발 워크로드 identity | ❌ 요청 단위 latency(p50/p99), tracing span | 메트릭으로 말하면, 게이트웨이에는 `istio_tcp_connections_opened_total`·`istio_tcp_sent_bytes_total` 같은 **L4 시리즈만** 잡히고, `istio_requests_total`·`istio_request_duration_milliseconds` 같은 **L7 시리즈는 없다.** access log도 HTTP가 아니라 TCP 포맷(SNI·바이트·duration·flags)이다. > [!note] 모니터링 실무 결론 > **게이트웨이 = "누가·어느 외부로·얼마나·TCP 성공했나"**(통제·감사·이상 탐지엔 대개 충분). **앱 계측 = "그 호출이 200이었나, 얼마나 느렸나"**(L7 디테일). 게이트웨이에서 *중앙집중 L7 메트릭*이 꼭 필요하면, 그건 passthrough로는 안 되고, 게이트웨이를 TLS 끝점으로 만들거나 앱을 직접 계측해야 한다 — 그 선택지 전체를 바로 다음 '가시성 옵션' 섹션에서 정리한다. ### 설정 체크리스트 - ServiceEntry `ports.protocol: TLS` (HTTPS 아님), `location: MESH_EXTERNAL` - Gateway `tls.mode: PASSTHROUGH`, server `protocol: TLS` - VirtualService는 `tls:` + `sniHosts` (`http:` 쓰면 안 됨), leg 2개, `gateways:`에 Gateway **이름** 정확히 - DestinationRule subset 이름이 VirtualService의 `subset`과 일치 - `resolution`을 환경에 맞게 — 단일 호스트 `DNS` / 와일드카드 `NONE` / air-gap `STATIC` - egress 노드가 외부 NAT 보유 + (DNS 모드면) 외부 DNS 도달 가능 - 강제가 필요하면 `REGISTRY_ONLY` + CNI egress NetworkPolicy (강제 섹션) - egress 노드 핀닝 (노드 핀닝 섹션) > [!info] What you might be missing > - **SNI는 클라이언트가 평문으로 채워 넣는 값이라 위조 가능**하다 — SNI 기반 라우팅·정책은 신뢰 경계가 약하고, 진짜 강제는 강제 섹션의 네트워크 계층(목적지 IP allowlist)에 기대야 한다. > - `resolution: DNS`의 **30초 주기 + TTL** 때문에 IP가 자주 바뀌는 외부(CDN 뒤의 API 등)는 간헐 실패가 날 수 있다 — 이럴 땐 `NONE`(앱 해석 신뢰)이 더 안정적이다. > - 와일드카드 호스트(`*.partner.com`)는 `resolution: NONE`과 함께 써야 하고, SNI가 없거나 일치하지 않는 클라이언트는 라우팅이 실패한다. > - 미래 리스크 — **Encrypted Client Hello(ECH)** 로 SNI 자체가 암호화되면 SNI 기반 passthrough 라우팅이 깨진다. "게이트웨이는 SNI를 본다"는 전제가 영구하지 않다. --- ## 05. 앱이 HTTPS를 보낼 때, 가시성을 얻는 방법 앞 섹션의 상황을 그대로 둔다 — 앱이 `https://api.partner.example.com`으로 직접 호출한다. 이때 게이트웨이에서 "무엇을 볼 수 있느냐"가 자주 막히는 지점이라, 선택지를 전부 펼쳐 비교한다. > [!note] 먼저: 가시성에는 두 층이 있다 > **연결 수준(흔히 L4라고 부름)** — TCP 연결 하나하나에 대한 정보. 어느 워크로드가, 어느 외부 호스트(SNI)로, 몇 바이트를, 몇 번·얼마 동안 연결했고, 성공/실패했는지. SNI는 암호를 못 풀어도 보인다. > > **요청 수준(흔히 L7이라고 부름)** — HTTP 요청 하나하나에 대한 정보. method, 경로, 응답 코드, 응답 시간, 헤더. **이건 암호문을 풀어야만 보인다.** 핵심은 단순하다. **요청 수준(L7)을 보려면 누군가 암호를 풀어야 하고, 암호를 풀 수 있는 건 TLS 연결의 양 끝점(앱, 또는 상대 서버)뿐**이다. 앱이 HTTPS로 끝까지 직접 들고 가면 게이트웨이는 끝점이 아니라 중간이라 기본적으로 암호를 못 푼다. 그래서 게이트웨이에서 요청 수준을 보려면 둘 중 하나다 — (가) 게이트웨이를 억지로 TLS 끝점으로 만들어 중간에서 풀거나, (나) 애초에 앱이 암호를 안 쥐게 만들거나. ### 선택지 한눈에 보기 | 선택지 | 앱 호출 | 게이트웨이가 암호 푸나 | 게이트웨이 L7 | 끝까지 암호화 | 앱 변경 | client 인증서 위치 | |---|---|---|---|---|---|---| | **1. passthrough 그대로** | `https` | 아니오 | 없음 | 유지 | 없음 | 앱(필요 시) | | **2. origination (앱→http)** | `http` | 해당 없음(앱이 평문) | 전부 | 2구간 분리 | 필요 | 게이트웨이 | | **3. 가로채기(bridging)** | `https` | 예 | 전부 | 깨짐 | 신뢰목록만 | 게이트웨이 | | **4. 앱 계측** | `https` | 아니오 | 앱에서만 | 유지 | 계측 추가 | 앱(필요 시) | > ※ "끝까지 암호화" = 앱과 외부 서버 사이가 한 번도 풀리지 않고 암호화된 채 이어지는 것. 선택지 2·3은 중간에 게이트웨이가 끼어 한 번 풀거나 다시 맺으므로 한 줄 암호화는 아님. ### 선택지 1 — passthrough 그대로 두기 (연결 수준만) 게이트웨이가 암호를 안 풀고 SNI만 읽어 길을 안내한다. 설정은 주류 경로 섹션의 passthrough 그대로. 추가로 할 일은 **접속 기록(access log)을 켜고** 거기 남는 SNI·바이트·성공여부로 대시보드를 만드는 것뿐이다. - **보이는 것** — 출발 워크로드, 목적지 SNI, 송수신 바이트, 연결 수·시간, TCP 성공/실패. 메트릭은 `istio_tcp_*` 연결 단위. - **안 보이는 것** — 요청 수준 전부(응답 코드, 경로, 지연 시간). - **암호화** — 앱↔외부 끝까지 유지(가장 안전하고 규제 친화적). ```yaml # 접속 기록 켜기 (전역 meshConfig) apiVersion: install.istio.io/v1alpha1 kind: IstioOperator spec: meshConfig: accessLogFile: /dev/stdout # 게이트웨이/사이드카 접속 기록 출력 # 기본 포맷에 SNI(requested_server_name) 바이트 실패사유가 포함됨 ``` ### 선택지 2 — 앱을 HTTP로 바꾸고 게이트웨이가 TLS를 대신 맺기 (게이트웨이에서 요청 수준 전부) 앱이 `http://`로 보내면, 사이드카와 게이트웨이 사이 구간은 메시 자동 암호화로 보호되고, 게이트웨이가 그 HTTP를 본 다음 새 TLS를 외부에 맺는다. 게이트웨이가 끝점이 되니 요청 수준이 전부 보인다. 전체 설정은 특수 경로 섹션에 있다. - **보이는 것** — 게이트웨이에서 method/path/status/지연 전부. 메트릭 `istio_requests_total`, 분산 추적까지. - **비용** — **앱 호출을 `https`에서 `http`로 바꿔야 함**(가장 침습적). 게이트웨이가 파트너용 client 인증서를 보유하므로, 게이트웨이가 뚫리면 그 자격증명도 함께 노출. - **암호화** — 앱↔게이트웨이(메시 자동 암호화) + 게이트웨이↔외부(새 TLS), **두 구간으로 나뉨**. 한 줄로 끝까지는 아님. > [!note] 팀이 "HTTPS인데 게이트웨이에서 보인다"고 하면 십중팔구 이것 > 앱은 사실 `http://`로 부르는데, **사이드카↔게이트웨이 구간이 메시 자동 암호화(ISTIO_MUTUAL, 일명 mesh mTLS)로 자동 암호화**돼서 "전 구간이 HTTPS인 것처럼" 보이는 것. 여기서 "mTLS"는 외부 파트너와의 mTLS가 아니라 **메시 내부 자동 암호화**를 가리킨다. 이 둘을 섞어 부르면 "HTTPS 직접 + mTLS origination"이라는, 한 연결에서는 성립할 수 없는 표현이 나온다. ### 선택지 3 — 게이트웨이가 앱의 TLS를 중간에서 풀고 다시 맺기 (가로채기 / bridging) 앱은 `https://` 그대로 둔다. 대신 게이트웨이가 그 TLS를 **종단(풀고)** 해서 HTTP를 본 뒤, 외부로 새 TLS를 맺는다. 흔히 말하는 트래픽 가로채기·검사 프록시와 같은 원리다. 여기엔 까다로운 전제가 있다. 게이트웨이가 앱의 TLS를 풀려면 앱에게 "내가 api.partner.example.com이다"라고 내세울 **server 인증서**를 제시해야 하고, **앱이 그 인증서를 신뢰**해야 한다. 즉 사내 CA로 그 외부 도메인용 인증서를 발급하고, 앱의 신뢰 목록에 그 사내 CA를 심어야 한다. 이건 사실상 의도된 중간자(MITM) 구성이다. - **보이는 것** — 게이트웨이에서 요청 수준 전부. 앱 코드는 `https` 그대로(신뢰 목록만 바뀜). - **비용** — **끝까지 암호화가 깨짐**: 게이트웨이가 평문을 보므로, 카드정보 등 페이로드를 보면 안 되는 규제에선 금지. 외부 도메인 인증서를 사내에서 발급·신뢰시키는 운영 부담이 크고, Istio 기본 패턴이 아니라 의도적 구성이 필요. - **암호화** — 앱↔게이트웨이(사내 cert) + 게이트웨이↔외부(새 TLS). 중간에서 한 번 풀림. ```yaml # 구조 — Gateway가 외부 도메인 cert로 TLS 종단 (개념) apiVersion: networking.istio.io/v1 kind: Gateway metadata: { name: egress-partner, namespace: istio-system } spec: selector: { istio: egressgateway } servers: - port: { number: 443, name: https, protocol: HTTPS } hosts: [api.partner.example.com] tls: mode: SIMPLE # PASSTHROUGH 아님 — 여기서 종단(복호화) credentialName: partner-domain-cert # 사내 CA가 발급한 그 도메인용 server cert # 이후 DestinationRule(MUTUAL/SIMPLE)로 외부에 다시 TLS origination # 그리고 앱 신뢰 목록에 사내 CA를 심어야 앱이 연결을 수락함 ``` > [!warning] 알려진 함정 > 게이트웨이에서 TLS를 종단해도 **요청 수준(L7) 메트릭이 자동으로 안 잡히는** 경우가 있다 — Envoy 리스너가 여전히 TLS 통과용으로 동작해 HTTP로 인식하지 못하기 때문. L7 메트릭을 실제로 얻으려면 종단 후 트래픽을 HTTP로 라우팅하도록 추가 구성(VirtualService를 `tls`가 아니라 `http`로)이 필요하고, 이 부분은 환경마다 달라 검증이 필수다. ### 선택지 4 — 앱에서 직접 계측하기 (앱 쪽에서 요청 수준) 게이트웨이는 passthrough 그대로 두고, **앱이 자기 HTTP 클라이언트의 메트릭·분산 추적(OpenTelemetry 등)을 직접 남기는** 방법. 앱이 TLS의 끝점이라 응답 코드·지연을 이미 알고 있다. 암호화를 전혀 깨지 않고 요청 수준을 얻는다 — 단, 게이트웨이 한 곳이 아니라 앱마다 계측이 들어간다. - **보이는 것** — 요청 수준 전부, 단 **게이트웨이가 아니라 앱에서.** - **비용** — 앱에 계측 코드/SDK 추가. "게이트웨이 한 곳에서 모든 외부 호출을 본다"는 그림은 아님. - **암호화** — 끝까지 유지(안 깸). 끝까지 암호화는 지켜야 하고 요청 수준은 필요한데 "게이트웨이 집중"이 필수는 아닐 때, 실무에서 가장 흔한 절충이다. 게이트웨이의 연결 수준(선택지 1) + 앱의 요청 수준(선택지 4)을 합치면 암호화를 안 깨면서도 "어디로 얼마나(게이트웨이)" + "그 호출이 200이었나(앱)"를 모두 얻는다. > [!tip] 결정 가이드 > - **끝까지 암호화를 깨면 안 되고 게이트웨이 요청수준이 꼭 필요한 건 아니다** → 선택지 1(passthrough) + 선택지 4(앱 계측). 가장 흔하고 안전한 조합. > - **게이트웨이에서 요청수준이 필요하고 앱을 바꿔도 된다** → 선택지 2(origination). 가장 깔끔하게 게이트웨이 L7을 얻음. > - **게이트웨이에서 요청수준이 필요한데 앱은 https를 유지해야 하고 복호화가 허용된다** → 선택지 3(가로채기). 사내 CA·신뢰 관리가 전제라 가장 무거움. > [!info] What you might be missing > - **"게이트웨이에서 요청 수준(L7) 가시성"과 "끝까지 암호화"는 동시에 가질 수 없다.** 둘 중 하나는 반드시 포기한다 — 이게 모든 선택을 가르는 근본 트레이드오프다. > - "암호화돼 보인다"가 두 가지를 뜻한다 — **메시 내부 자동 암호화(ISTIO_MUTUAL)** 와 **외부 파트너와의 TLS** 는 다른 구간, 다른 인증서다. 선택지 2에서 "HTTPS처럼 보이는" 건 앞쪽(내부)이지 끝까지가 아니다. > - 선택지 3은 외부 도메인 인증서를 사내에서 발급해 앱이 신뢰하게 만드는 것이라 **감사·규제 관점에서 "우리가 우리 트래픽을 중간에서 깐다"는 사실**이 문제가 될 수 있다 — 기술적으로 가능하다고 정책적으로 허용되는 건 아니다. > - 가시성을 위해 굳이 게이트웨이를 끝점으로 만들 필요가 없는 경우가 많다 — 선택지 4(앱 계측)는 암호화를 안 깨면서 요청 수준을 주는 가장 비침습적인 길이라, "게이트웨이 집중"이 진짜 요구사항인지부터 따져보는 게 좋다. --- ## 06. 특수 경로 — HTTP + mTLS origination > [!note] 이게 필요한 경우만 > ① 파트너 API가 **client 인증서(mTLS)** 를 요구하고, 그걸 앱마다가 아니라 **게이트웨이 한 곳에서 중앙관리**하고 싶을 때. ② 게이트웨이에서 **HTTP 레벨 메트릭·로그**가 꼭 필요할 때. 대가는 **앱을 `https`→`http`로 바꿔야 함**(침습적). 그 외엔 passthrough(주류 경로 섹션)가 낫다. 구조는 passthrough와 정반대다. 앱이 평문 HTTP를 보내면, 게이트웨이가 메시 mTLS 연결을 **종단**해서 L7 평문을 본 뒤(여기서 관측이 생김), 자기 client 인증서로 **새 TLS 연결을 외부에 맺는다.** TLS가 두 번, 별개 연결로 일어난다. ```mermaid flowchart LR subgraph APPNODE["APP NODE"] APP["App
http :80"] --> SC["Sidecar
Envoy"] end subgraph EGNODE["EGRESS NODE"] GW["Egress Gateway
terminate → re-originate"] end PARTNER["Partner API
requires mTLS"] SC == "conn ① mesh mTLS" ==> GW == "conn ② mTLS (client cert)" ==> PARTNER ``` > conn①과 conn②는 게이트웨이에서 끊겼다 다시 맺어지는 별개 연결. 그 사이에 L7 평문이 노출되어 관측이 가능해진다. 게이트웨이가 client cert 보유(blast radius 주의). ### 설정 — passthrough와 다른 점 차이: ServiceEntry가 HTTP/HTTPS 포트를 갖고, VirtualService는 `http:`로 라우팅, DestinationRule이 **두 개**(메시 leg ISTIO_MUTUAL + 외부 leg MUTUAL origination), 그리고 client 인증서 **secret**이 필요하다. ```bash # step 0 — client 인증서 secret (SDS) # tls.crt/tls.key = 우리가 파트너에게 제시할 client 인증서 # ca.crt = 파트너의 SERVER 인증서를 검증할 CA (혼동 주의!) kubectl create secret generic partner-client-cert -n istio-system \ --from-file=tls.crt=client.pem \ --from-file=tls.key=client.key \ --from-file=ca.crt=partner-ca.pem ``` ```yaml # ① ServiceEntry — 포트 80(HTTP)+443(HTTPS) apiVersion: networking.istio.io/v1 kind: ServiceEntry metadata: { name: partner-api, namespace: istio-system } spec: hosts: [api.partner.example.com] ports: - { number: 80, name: http, protocol: HTTP } # 앱이 평문으로 거는 포트 - { number: 443, name: https, protocol: HTTPS } # 게이트웨이가 originate 하는 포트 resolution: DNS location: MESH_EXTERNAL ``` ```yaml # ② Gateway — 메시 mTLS 종단 리스너 apiVersion: networking.istio.io/v1 kind: Gateway metadata: { name: egress-partner, namespace: istio-system } spec: selector: { istio: egressgateway } servers: - port: { number: 443, name: https, protocol: HTTPS } hosts: [api.partner.example.com] tls: { mode: ISTIO_MUTUAL } # 사이드카→게이트웨이 leg 종단 ``` ```yaml # ③ DestinationRule ① — 사이드카→게이트웨이 (ISTIO_MUTUAL) apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: { name: egressgateway-partner, namespace: istio-system } spec: host: istio-egressgateway.istio-system.svc.cluster.local subsets: - name: partner trafficPolicy: portLevelSettings: - port: { number: 443 } tls: { mode: ISTIO_MUTUAL, sni: api.partner.example.com } ``` ```yaml # ④ VirtualService — http 라우팅 2-leg apiVersion: networking.istio.io/v1 kind: VirtualService metadata: { name: route-partner, namespace: istio-system } spec: hosts: [api.partner.example.com] gateways: [mesh, egress-partner] http: - match: [{ gateways: [mesh], port: 80 }] route: [{ destination: { host: istio-egressgateway.istio-system.svc.cluster.local, subset: partner, port: { number: 443 } } }] - match: [{ gateways: [egress-partner], port: 443 }] route: [{ destination: { host: api.partner.example.com, port: { number: 443 } } }] ``` ```yaml # ⑤ DestinationRule ② — 게이트웨이→외부 (MUTUAL origination) apiVersion: networking.istio.io/v1 kind: DestinationRule metadata: { name: originate-mtls-partner, namespace: istio-system } spec: host: api.partner.example.com trafficPolicy: portLevelSettings: - port: { number: 443 } tls: mode: MUTUAL # ← mTLS origination credentialName: partner-client-cert # step 0 의 secret sni: api.partner.example.com ``` > [!note] 검증 3종 > ① `istioctl proxy-config secret deploy/istio-egressgateway -n istio-system` → 인증서가 SDS로 로드됐는지(**cert 문제 80%**가 여기). ② `istioctl proxy-config cluster deploy/istio-egressgateway -n istio-system --fqdn api.partner.example.com -o json | grep -A5 transportSocket` → MUTUAL/SNI 확인. ③ 앱에서 `curl -v http://api.partner.example.com` → 200 + 게이트웨이 access log에 출현. > [!info] What you might be missing > - origination은 **게이트웨이가 client 개인키를 보유**하므로 게이트웨이 Pod 침해 = 파트너 자격증명 침해다 — 키를 앱에서 게이트웨이로 옮긴다는 건 blast radius를 옮긴다는 뜻이다. > - 앱→게이트웨이 구간에 평문 HTTP가 존재하므로 **메시 STRICT mTLS가 사실상 전제**다(PERMISSIVE면 내부 평문 노출 구멍). > - VirtualService `gateways:`에 **Gateway CRD 이름**을 정확히 — Deployment 이름이나 기본 `istio-egressgateway` Gateway를 잘못 적으면 leg 2가 조용히 무시된다. > - SDS secret은 **게이트웨이와 같은 네임스페이스**(istio-system)에 있어야 origination DR이 읽는다. --- ## 07. TLS 모드 정밀 비교 (레퍼런스) 주류 경로와 특수 경로가 두 실무 경로였다면, 여기는 그 바탕에 깔린 TLS 모드 자체를 정리한다. 외부 구간 TLS를 다루는 방식은 본질적으로 세 가지다. > [!warning] 전제 교정 > **"앱이 HTTPS로 직접 종단하면서 동시에 게이트웨이가 mTLS origination을 한다"는 같은 hop에서 성립할 수 없다.** TLS는 point-to-point — 한 연결에서 종단은 한 번뿐이다. 앱이 외부와 직접 TLS를 맺으면(end-to-end HTTPS) 게이트웨이는 암호문만 흘리는 **passthrough**밖에 못 한다. 게이트웨이가 인증서를 새로 제시하려면(origination) 앱이 TLS를 *맺지 않아야* 하고(평문 HTTP) 게이트웨이가 TLS 클라이언트 종단이 돼야 한다. | 모드 | 앱이 보내는 것 | 게이트웨이 역할 | |---|---|---| | **PASSTHROUGH** (주류) | HTTPS — 앱이 TLS 종단 | 복호화 안 함. SNI만 읽어 라우팅, 암호문 forward. L4만 보임 | | **MUTUAL** (mTLS origination, 특수) | 평문 HTTP | conn① 종단 → L7 가시 → conn② 신규 mTLS, client cert 제시 + server 검증 | | **SIMPLE** (TLS origination) | 평문 HTTP | MUTUAL과 같으나 client cert 없이 **server cert만 검증**(one-way) | ### 모드별 게이트웨이 가시성 | 관측 항목 | 레이어 | PASSTHROUGH | MUTUAL | SIMPLE | |---|---|---|---|---| | Source IP · Dest SNI | L4 | ✅ | ✅ | ✅ | | 송수신 바이트 · 연결 시간 | L4 | ✅ | ✅ | ✅ | | HTTP method · path | L7 | ❌ | ✅ | ✅ | | status code (`istio_requests_total`) | L7 | ❌ | ✅ | ✅ | | request / response header | L7 | ❌ | ✅ | ✅ | | distributed tracing | L7 | ❌ | ✅ | ✅ | | 게이트웨이가 client 인증서 제시(외부) | — | ❌ | ✅ | ❌ | > [!info] What you might be missing > 세 모드는 **호스트별로 섞어** 쓸 수 있다 — 가시성이 필요한 파트너는 `MUTUAL`, end-to-end 암호화를 깨면 안 되는 컴플라이언스 파트너는 `PASSTHROUGH`로 DestinationRule을 따로 두면 된다. 하나로 통일할 이유가 없다. 또 `auto_sni`/`auto_san_validation`이 기본 활성이라, origination DR에 `sni`를 명시하지 않으면 downstream Host 헤더에서 SNI를 자동 추론하고 upstream 인증서를 그 호스트로 검증한다 — 의도와 다른 SNI가 나갈 수 있으니 origination에선 `sni`를 명시하는 게 안전하다. --- ## 08. 노드 핀닝과 가용성의 트레이드오프 강제 섹션의 ③ 네트워크 강제를 완성하려면 외부 NAT를 **소수의 전용 노드에만** 부여하고 게이트웨이 Pod를 그 노드로 끌어당겨야 한다. 세 가지를 함께 쓴다: `nodeSelector`(그 노드를 선택), taint/toleration(다른 워크로드는 못 들어오게 + 게이트웨이는 용인), podAntiAffinity(복제본을 서로 다른 노드로 분산). ```bash # 전용 노드에 라벨 + 테인트 kubectl label node egress-1 egress-2 node-role=egress kubectl taint node egress-1 egress-2 dedicated=egress:NoSchedule ``` ```yaml # IstioOperator — egress 게이트웨이 배치 apiVersion: install.istio.io/v1alpha1 kind: IstioOperator spec: components: egressGateways: - name: istio-egressgateway enabled: true k8s: replicaCount: 2 nodeSelector: node-role: egress # egress 노드로 끌어당김 tolerations: - key: dedicated operator: Equal value: egress effect: NoSchedule # 테인트 용인 → 그 노드에 뜰 수 있음 affinity: podAntiAffinity: # 복제본을 다른 노드로 분산 requiredDuringSchedulingIgnoredDuringExecution: - labelSelector: matchLabels: istio: egressgateway topologyKey: kubernetes.io/hostname podDisruptionBudget: minAvailable: 1 # 드레인/업그레이드 중 최소 1 유지 ``` ### 숨은 비용: 핀닝은 가용성을 깎는다 모든 외부 트래픽을 소수 노드로 모으는 순간, 그 노드들이 **전체 외부 통신의 단일 장애점(SPOF)** 이 된다. 핀닝을 강하게 할수록(노드 수 $N$을 줄일수록) 보안·IP 고정은 좋아지지만 가용성은 떨어진다. egress 경로가 살아있을 확률은, 노드 장애가 독립이라 가정하면 "최소 한 노드라도 살아있을 확률"이다. 한 노드가 죽을 확률이 $(1-p)$이고 $N$개가 동시에 다 죽어야 경로가 끊기므로: $$A_{\text{sys}} = 1 - (1-p)^{N}$$ 곱 $(1-p)^N$은 "$N$개가 전부 죽는" 사건의 확률(독립이라 곱)이고, 경로가 살려면 그 여사건이면 되니 $1$에서 뺀다. 연간 다운타임은 가용성의 여집합에 1년(분)을 곱한 값: $$D_{\text{year}} = (1 - A_{\text{sys}}) \times 525{,}600 \ \text{min}$$ | 노드 수 $N$ (노드당 $p=0.99$) | $A_{\text{sys}}$ | 연간 다운타임 | nines | |---|---|---|---| | 1 | 99.0000% | 약 3.65 일/년 | 2.00 | | 2 | 99.9900% | 약 52.56 분/년 | 4.00 | | 3 | 99.999900% | 약 31.5 초/년 | 6.00 | $N=1$로 내리면 가용성이 노드당 가용성과 정확히 같아지고(여분이 없으니), $N=2$로만 올려도 nine이 두 배 가까이 뛴다. **실무 결론: 전용 egress 노드는 최소 2개(가급적 3개, AZ 분산), `minAvailable: 1` PDB, podAntiAffinity 필수.** 노드 1개 핀닝은 "특정 노드로만"을 가장 엄격히 만족하지만 운영적으로는 금지에 가깝다. > [!info] What you might be missing > 이 모델은 **독립 장애**를 가정한다. 현실에선 egress 노드들이 같은 ToR 스위치, 같은 NAT 게이트웨이, 같은 방화벽 정책을 공유하면 그 공통 요소가 진짜 SPOF다 — 노드를 3개로 늘려도 NAT가 하나면 $A_{\text{sys}}$ 계산은 거짓 위안이 된다. 둘째, 노드 장애만이 아니라 **게이트웨이 재시작 시 connection draining**도 다운타임의 큰 부분이다(자세한 drain/grace 타임라인·preStop·기본 5초 drain은 [egress 운영 deep-dive](ref__src-operations.html) 참조). 셋째, egress를 소수 노드로 모으면 그 노드의 **conntrack 테이블·소스 포트 고갈(SNAT port exhaustion)** 이 새 병목으로 등장한다 — 한 출구 IP당 약 64K 포트, 외부 목적지가 적으면 동시연결 한계에 먼저 부딪힌다. --- ## 09. 운영·장애 진단 — 무엇이 깨졌는지 읽기 egress 경로는 hop이 많아(앱 → 사이드카 → 게이트웨이 → 파트너), 장애 시 "어디서" 깨졌는지부터 좁혀야 한다. 세 질문이 렌즈다 — **Routing**(가야 할 곳으로 가는가), **Transform**(TLS·인증서·헤더가 의도대로 바뀌는가), **Record**(무슨 일이 기록에 남는가). > [!note] 이 문서 vs 운영 정본 > 본 문서는 **설계·TLS 모델 정본**이라 여기선 response_flags → 진단 명령 매핑(트래픽이 깨졌을 때 1차 단서 읽기)까지만 다룬다. **종료/drain 타임라인·grace tuning·롤링 업그레이드 중 무중단·모니터링 대시보드 구성** 같은 운영 deep-dive는 [egress 운영 정본](ref__src-operations.html)에 위임한다. ### Record — Envoy response flags가 1차 단서 | flag | 의미 | egress에서의 전형적 원인 | |---|---|---| | **NR** | no route | **오설정 1순위.** VirtualService `gateways`/host/port/sniHosts 매칭 실패. passthrough에서 `http:`로 잘못 쓴 경우 포함 | | **UH** | no healthy upstream | route는 됐으나 endpoint 0. ServiceEntry host DNS 미해석, resolution 설정 문제 | | **UF** | upstream conn failure | TCP 연결 실패. 노드 NAT/라우트 부재, 방화벽 차단. config 밖(커널) 문제 | | **UC** | upstream conn termination | 붙었다 끊김. origination 핸드셰이크 실패(파트너가 cert reject) 또는 파트너 reset | | **NC** | no cluster | route는 cluster를 가리키는데 부재 — subset/DR 누락 | ### Routing — 미등록 트래픽은 어디로 가나 Envoy엔 두 합성 cluster가 있다. **BlackHoleCluster**는 `REGISTRY_ONLY`일 때 미등록 목적지가 보내지는 곳으로 즉시 실패하며 로그에 명시적으로 남는다("막혔다"가 보임). **PassthroughCluster**는 기본값 `ALLOW_ANY`일 때 미등록 목적지를 그대로 통과시키는 곳이라 — **게이트웨이를 안 거치고 조용히 나간다.** "왜 이 트래픽만 게이트웨이를 안 거치지?"의 절반은 PassthroughCluster, 나머지 절반은 강제 섹션에서 본 `excludeOutboundIPRanges` 어노테이션이다. | 축 | 질문 | 명령 | |---|---|---| | 공통 | 프록시가 컨트롤플레인과 sync 됐나 | `istioctl proxy-status` | | Routing | listener→route→cluster→endpoint 체인 | `istioctl proxy-config listener\|route\|cluster\|endpoint deploy/istio-egressgateway -n istio-system` | | Transform | 인증서가 게이트웨이에 로드됐나 | `istioctl proxy-config secret deploy/istio-egressgateway -n istio-system` | | Record | 설정 정합성 정적 점검 | `istioctl analyze -n istio-system` | ### 증상 → 원인 → 진단 명령 ```bash # UF — Connection refused (TCP 연결 자체 실패, config 밖 문제) istioctl proxy-config endpoint deploy/istio-egressgateway -n istio-system | grep partner openssl s_client -connect api.partner.example.com:443 -servername api.partner.example.com ip route get 203.0.113.10 # UC — TLS handshake failure (origination, 핸드셰이크 실패) istioctl proxy-config secret deploy/istio-egressgateway -n istio-system openssl s_client -connect api.partner.example.com:443 -servername api.partner.example.com -CAfile partner-ca.pem kubectl -n istio-system get destinationrule originate-mtls-partner -o yaml # 503 NR — no route (passthrough인데 http:로 작성, gateways 이름 mismatch, leg 누락) istioctl proxy-config route deploy/istio-egressgateway -n istio-system istioctl analyze -n istio-system # 503 UH — no healthy upstream (DNS 미해석, resolution 오류) istioctl proxy-config cluster deploy/istio-egressgateway -n istio-system --fqdn api.partner.example.com kubectl -n istio-system get serviceentry partner-api -o yaml # bypass — 게이트웨이 안 거치고 그냥 나감 (ALLOW_ANY / excludeOutboundIPRanges) istioctl proxy-config cluster deploy/myapp -n | grep -i passthrough kubectl get pod -l app=myapp -n -o jsonpath="{.items[*].metadata.annotations}" ``` > [!info] What you might be missing > - `response_flags`를 보려면 **access log가 켜져 있어야** 한다 — `meshConfig.accessLogFile: /dev/stdout`이 꺼져 있으면 1차 단서가 통째로 없다. passthrough는 TCP access log라 SNI·바이트·flags만 남는다. > - `istioctl proxy-config`는 **Envoy가 컨트롤플레인에서 받은 설정**을 보여줄 뿐 커널 소켓·conntrack 상태가 아니다 — config는 완벽한데 `UF`면 문제는 Envoy 밖(노드·방화벽)이고, egress 노드에서 `openssl s_client`·`ss`·`conntrack`으로 봐야 한다. > - response flag는 철저히 **Envoy 시점**이다 — 우리 `ca.crt`가 틀려서 우리가 파트너 cert를 거부하면 그건 upstream이 끊은 게 아니라 우리쪽 핸드셰이크 에러로 나와서 방향을 헷갈린다. --- ## 10. 멘탈모델 정착 — 실무 매핑 마지막으로 anchor를 한 문장으로 되감으면: **egress gateway는 외부행을 모으는 라우팅 수렴점일 뿐이고, "TLS를 누가 종단하나" 하나가 가시성·앱 변경·암호화·인증서 위치를 전부 결정하며, 진짜 강제는 게이트웨이가 아니라 그 아래 네트워크 3계층에서 나온다.** 이 한 문장에서 설정·모니터링·핀닝·진단이 전부 따라 나온다. 아래는 그 모델을 실제 환경에 떨어뜨린 지도다. | 개념 | 프로덕션 적용 | 홈랩 검증 (RTX 3090 / KVM) | |---|---|---| | 주류: HTTPS + passthrough | 앱 그대로 두고 ServiceEntry(TLS)+Gateway(PASSTHROUGH)+VS(sniHosts)로 파트너별 경로 강제, 출구 IP 고정 | self-signed로 mock 파트너 HTTPS 서버 띄우고 passthrough 라우팅 + SNI 메트릭 관찰 | | DNS resolution | 단일 호스트 `DNS`, air-gap 파트너는 `STATIC`+IP, 와일드카드는 `NONE` | `DNS`↔`STATIC`↔`NONE` 전환하며 재해석 주기(기본 30s, `dnsRefreshRate`)·라우팅 차이를 config_dump로 관찰 | | 모니터링 분리 | 게이트웨이 L4+SNI + 앱 OpenTelemetry로 L7 보완 | `istio_tcp_*` vs 앱 메트릭 비교, passthrough L7 부재 확인 | | 특수: mTLS origination | 파트너 mTLS 필요 호스트만 origination, SDS+cert-manager 로테이션 | mock 파트너에 client cert 요구 걸고 MUTUAL DR로 핸드셰이크 재현 | | 3계층 강제 | `REGISTRY_ONLY` + CNI egress NetworkPolicy drop + egress 노드만 NAT | `ALLOW_ANY`↔`REGISTRY_ONLY`로 Passthrough vs BlackHole 로그 비교 (홈랩 CNI=Calico → `GlobalNetworkPolicy` egress drop, 노드 라우팅 한정으로 보강) | | 노드 핀닝 + 진단 | 전용 egress 노드 2~3개, `response_flags` 런북, proxy-config 체인 점검 | 의도적으로 route/cert 깨뜨리고 NR/UC/UH·flag 관찰 | --- ## 핵심 정리 - **egress gateway ≠ 강제 장치.** 외부행을 *유도*하는 라우팅 수렴점일 뿐. 진짜 강제 = `REGISTRY_ONLY`(미등록 차단) × CNI egress NetworkPolicy(직접 아웃바운드 차단) × egress 노드만 NAT — **셋 다** 서야 우회 구멍이 닫힌다. Istio 단독으로는 ③(네트워크 강제)을 못 만든다. - **단 하나의 갈래는 "외부 TLS를 누가 종단하나".** 앱이 쥐면(`https`) 게이트웨이는 SNI만 보는 **PASSTHROUGH**(주류), 앱이 평문이면(`http`) 게이트웨이가 **origination**(특수). 가시성·앱 변경·암호화·인증서 위치는 여기서 *따라 나오는 종속 변수*다. - **주류(passthrough) 설정 4종:** ServiceEntry `protocol: TLS` · Gateway `tls.mode: PASSTHROUGH` · VirtualService `tls`+`sniHosts`(2-leg, `http:` 아님) · DestinationRule subset. 앱 코드 0 변경. 내부 hop을 더 조이려면 DR 한 곳에 `ISTIO_MUTUAL` 추가(Gateway는 PASSTHROUGH 고정). - **게이트웨이 L7 가시성 ⊥ end-to-end 암호화 — 동시에 못 가진다.** passthrough는 L4+SNI만(`istio_tcp_*`, L7 시리즈 없음). L7이 게이트웨이에서 필요하면 origination(앱→http)·bridging(복호화)·앱 계측 중 택일. - **DNS는 요청 시점이 아니라 백그라운드(기본 30s, `dnsRefreshRate`)로 해석.** `resolution`: 단일 호스트 `DNS` / 와일드카드 `NONE`(앱 해석 신뢰) / air-gap·고정IP `STATIC`(+endpoints). - **핀닝은 가용성을 깎는다.** $A_{sys}=1-(1-p)^N$ — 노드 1개는 SPOF, 최소 2~3개 + `minAvailable:1` PDB + podAntiAffinity. 단 공통 NAT/ToR가 있으면 N을 늘려도 거짓 위안. - **장애는 response_flags로 1차 좁히기:** NR(no route, 오설정 1순위) · UH(DNS/resolution) · UF(노드 NAT·방화벽, config 밖) · UC(핸드셰이크/cert) · NC(subset/DR 누락). ## What you might be missing - **egress는 ingress의 거울상이 아니다.** Ingress는 LoadBalancer IP를 *물리적으로* 거치므로 강제가 공짜지만, egress엔 그 물리적 강제가 없다("egress Gateway 정의 자체는 노드에 특별 취급을 주지 않는다"). 이 비대칭이 egress 운영 난이도의 근원이고, 위 3계층이 필요한 이유다. - **"암호화돼 보인다"는 두 가지를 가린다.** 메시 내부 자동 암호화(ISTIO_MUTUAL)와 외부 파트너 TLS는 **다른 구간·다른 인증서**다. 이 둘을 섞어 부르면 "HTTPS 직접 + mTLS origination" 같은, 한 연결에서 성립 불가능한 표현이 나온다(한 hop에서 종단은 한 번뿐). - **SNI는 클라이언트가 평문으로 채워 위조 가능**하다 — SNI 기반 라우팅·정책은 신뢰 경계가 약하고, 진짜 강제는 목적지 IP allowlist(네트워크 계층)에 기대야 한다. 미래엔 **ECH(Encrypted Client Hello)** 로 SNI 자체가 암호화되면 passthrough 라우팅 전제가 깨진다. - **REGISTRY_ONLY를 전역에 켜면 조용히 나가던 의존성이 한꺼번에 막힌다** — 로그/메트릭 푸시, 외부 시크릿, OCSP/CRL, 레지스트리까지. `Sidecar`로 네임스페이스 단위 audit → 인벤토리 → 점진 확대가 정석. - **`istioctl proxy-config`는 Envoy가 받은 설정만 보여준다 — 커널 소켓·conntrack·SNAT 포트가 아니다.** config는 완벽한데 `UF`면 문제는 Envoy 밖(노드·방화벽)이고, egress 노드에서 `openssl s_client`·`ss`·`conntrack`으로 봐야 한다. response flag는 철저히 *우리 Envoy 시점*이라(우리가 파트너 cert를 거부해도 우리쪽 에러로 나옴) 방향을 헷갈리기 쉽다. - **origination은 blast radius를 옮기는 것**이다 — 게이트웨이가 client 개인키를 보유하므로 게이트웨이 침해 = 파트너 자격증명 침해. 또 앱→게이트웨이 평문 HTTP 구간 때문에 메시 **STRICT mTLS가 사실상 전제**다. --- ## See also - [Egress HTTP vs HTTPS](ref__src-http-vs-https.html) — 외부 endpoint가 HTTP/HTTPS일 때 설정 차이 (후속) - [Egress 운영 정본](ref__src-operations.html) — graceful shutdown·drain·모니터링 deep-dive (섹션 08/09가 위임) - [Egress Gateway HTTPS 설치 가이드](cfg__guide-gateway-https.html) — passthrough 구성 설치 절차 - [Sidecar scope](mm__src-sidecar-scope.html) — `Sidecar` 리소스로 egress 트래픽 인벤토리 audit (섹션 02 REGISTRY_ONLY 점진 적용) - [DNS resolution 실측](rpt__report-2026-06-07_dns-resolution.html) — `resolution` 모드별 재해석 동작 검증 결과 - [Ingress/Egress 통합 리포트](rpt__report-2026-06-07_ingress-egress.html) — 홈랩 적용 실측 --- > [!quote] 출처 (Istio 공식 문서, 검증 기준 Istio 1.30 / `networking.istio.io/v1`) > - *Egress Gateways* (HTTPS/TLS passthrough 섹션: ServiceEntry `protocol: TLS`, Gateway `tls.mode: PASSTHROUGH`, VirtualService `tls`+`sniHosts`), *Egress Gateways with TLS Origination*(`MUTUAL`/`credentialName`/`sni`, SDS secret), *DNS Proxying* 및 *Understanding DNS*(`resolution` DNS/STATIC/NONE, 프록시 30초 주기 백그라운드 해석, `ISTIO_META_DNS_AUTO_ALLOCATE` 240.240.0.0/16) — istio.io/latest/docs. > - 가로채기(bridging) 패턴 및 TLS 종단 후 L7 메트릭 미수집 함정 — Istio egress TLS 종단 운영 사례 및 관련 이슈. > - ※ 최신 문서는 egress 예시를 Gateway API로 이전 중이나, 본 문서는 on-prem 표준인 classic CRD 패턴(Gateway+VirtualService+DestinationRule)을 사용함. 두 패턴 모두 1.30에서 유효함. --- ## 관련 파일 · 참조 **실제 매니페스트(이 클러스터에서 사용)** - 📎 [gateway-egress.yaml](../istio/attachment/scenarios/20-egress/gateway-egress.yaml) · 📎 [serviceentry-httpbin-ext.yaml](../istio/attachment/scenarios/20-egress/serviceentry-httpbin-ext.yaml) - 📎 [destinationrule-egress.yaml](../istio/attachment/scenarios/20-egress/destinationrule-egress.yaml) · 📎 [virtualservice-egress.yaml](../istio/attachment/scenarios/20-egress/virtualservice-egress.yaml) - 📎 [20-egress/README.md](../istio/attachment/scenarios/20-egress/README.md) · 📎 [traffic.sh](../istio/attachment/scripts/traffic.sh) · 📎 [proxy-dump.sh](../istio/attachment/scripts/proxy-dump.sh) **공식 문서** - ↗ [Istio: Egress Gateways](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway/) · ↗ [Egress Gateway for HTTPS (SNI passthrough)](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway/#egress-gateway-for-https-traffic) · ↗ [Egress TLS Origination](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-tls-origination/) **선행**: [Istio 1.30 Helm 재설치 런북](../istio/arch__runbook-helm-reinstall.html) **관련 검증** → [Egress mTLS 리포트 — HTTPS over mTLS (ISTIO_MUTUAL)](rpt__report-2026-06-08_egress-mtls.html) · [구조 정본 — CRD·장단점·활용·운영](id__src-https-over-mtls.html)