homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-06-07istioegressgatewaytls-passthroughsni

Istio 1.30 Egress Gateway — 외부 HTTPS 통신 설치·구성·테스트 가이드

ABSTRACT

homelab(kubespray bare-metal, k8s v1.30.6, CNI Calico, Istio 1.30.0)에서 egress gateway를 Helm으로 구성하고, 앱이 직접 https://를 호출하는 TLS Passthrough(SNI 라우팅) 시나리오를 끝까지 구성·검증한다. 머릿속에 담을 한 장의 그림: 메시의 모든 외부 송신을 egress gateway라는 단일 choke point로 모으되, TLS는 끝까지 암호화된 채로 두고 gateway는 SNI만 보고 라우팅한다(2-홉: mesh→gateway, gateway→external). 핵심 결론: egress의 “완료"는 200이 아니라 트래픽이 egress gateway를 실제로 경유했음을 증명하는 것이며, 호출 결과 / proxy-config / access log 세 가지를 교차 확인한다.

대상 환경: homelab (kubespray bare-metal, k8s v1.30.6, CNI Calico, 3노드), Istio 1.30.0 (Helm chart) 범위: egress gateway Helm 설치/구성 → 외부 HTTPS 테스트 앱 구성 → 필요한 Istio 객체 → 테스트·검증 절차 난이도 전제: Istio sidecar/Gateway/VirtualService 기본 개념을 알고 있음. egress 특유의 동작에 초점.


0. 배경 지식 — 왜 egress gateway를 거치게 만드는가

기본 상태의 메시는 외부 송신을 막지 않는다. sidecar의 outboundTrafficPolicyALLOW_ANY이면 각 워크로드의 Envoy가 모르는 목적지를 PassthroughCluster로 그냥 흘려보낸다 — pod마다 인터넷으로 나가는 구멍이 하나씩 생기는 셈이다. 이게 운영에서 곤란한 이유는 세 가지다:

  • 방화벽 화이트리스트가 불가능 — 송신 출발 IP가 노드 수만큼, pod 수만큼 흩어진다. “이 IP에서만 나간다"를 외부 방화벽에 적을 수가 없다.
  • 송신 감사가 불가능 — 누가 어디로 나갔는지 한 곳에 로그가 없다.
  • egress 정책 강제 지점이 없다 — “결제 워크로드만 PG사로 나갈 수 있다” 같은 규칙을 걸 단일 지점이 없다.

egress gateway는 이 흩어진 송신을 단일 지점(choke point) 으로 모으는 전용 Envoy다. 메시의 외부 송신이 모두 이 pod 한 종류를 통과하면 — 출발 IP가 하나로 고정(방화벽 화이트리스트 가능), 송신 로그가 한 곳에 모이고 (감사 가능), 정책을 이 한 지점에 걸 수 있다(강제 지점 확보). 이 문서는 그 choke point를 homelab에 실제로 세우고, 앱이 https://를 직접 호출하는 가장 흔한 케이스를 통과시킨 뒤, 트래픽이 정말 그 지점을 경유했는지까지 증명한다.

선행 개념 한 줄씩 (모르면 먼저 채울 것):

개념 이 문서에서 왜 필요한가
sidecar 트래픽 캡처(:15001 outbound) 앱이 보낸 패킷을 Envoy가 가로채야 egress로 우회시킬 수 있다
outboundTrafficPolicy (ALLOW_ANY / REGISTRY_ONLY) 통제를 “강제"로 바꾸는 스위치 — §7.4의 차단 검증 핵심
TLS handshake의 SNI passthrough에서 gateway가 라우팅에 쓸 수 있는 유일한 평문 키
Gateway / VirtualService / DestinationRule / ServiceEntry 2-홉을 잇는 4객체 (§5에서 관계, §6에서 YAML)

왜/모드결정/2-leg 라우팅의 개념 정본egress gateway 개념 정본 §01·§02·§04에 있다. 본 가이드는 그 개념을 homelab에서 실제로 구성·검증하는 절차에 집중하므로 이론은 정본에 위임하고 여기서는 “왜 이 객체가 이 모양인지"만 짚는다.


1. 핵심 아키텍처 — 한 장의 그림과 그로부터 따라오는 모든 것

머릿속 앵커 한 문장: choke point로 모으되 TLS는 절대 풀지 않는다. 이 한 가지 제약이 이후 모든 설계를 결정한다.

flowchart TD
  subgraph mesh["namespace: mesh-test (injection ON)"]
    sleep["sleep (curl)"]
    sc["sleep sidecar (Envoy)\n:15001 outbound capture"]
    sleep -->|"(0) 15001 outbound capture\nSNI only readable, payload encrypted"| sc
  end
  subgraph sys["namespace: istio-system"]
    egw["istio-egressgateway (Envoy, ClusterIP)\nsingle egress choke point"]
  end
  ext["httpbin.org:443 (external)"]
  sc -->|"hop1: mesh -> egress (SNI)\nend-to-end TLS (ciphertext kept)"| egw
  egw -->|"hop2: egress -> external (PASSTHROUGH)\nend-to-end TLS (ciphertext kept)"| ext

그림이 말하는 핵심은 2-홉이다. 외부 호출이 sidecar에서 외부로 직접 가지 않고, 일부러 한 번 더 꺾여 egress gateway를 경유한다(hop1: mesh→gateway, hop2: gateway→external). 이 “일부러 꺾기"가 choke point를 만든다. 그리고 양쪽 홉 모두 암호문이 그대로 유지된다(end-to-end TLS) — gateway는 봉투를 뜯지 않는다.

여기서 핵심 긴장이 나온다. choke point로 모으면 보통은 “거기서 트래픽을 들여다보겠다"가 따라오는데, 앱이 이미 https://종단간 암호화를 걸어 보냈으므로 gateway가 봉투를 뜯으면 그 암호화가 깨진다. 그래서 봉투를 안 뜯는다 — 이게 TLS Passthrough다. 봉투를 안 뜯으니 gateway가 라우팅에 쓸 수 있는 정보는 평문 HTTP 헤더/경로가 아니라, TLS handshake 때 평문으로 노출되는 목적지 호스트명 — SNI 하나뿐이다.

이 SNI 제약이 §6의 모든 객체 모양을 한 줄로 설명한다. 왜 이 모양인가를 미리 깔아두면 §6 YAML이 전부 “당연"해진다:

설계 선택 SNI 제약에서 따라오는 이유
ServiceEntry protocol: TLS (HTTP 아님) Envoy가 평문 헤더를 못 보니 L7 HTTP로 등록할 수 없다 → L4 TLS
Gateway server tls.mode: PASSTHROUGH 봉투를 뜯지 않고 그대로 통과 → 종단간 암호화 유지
VirtualService tls: 라우팅 (http: 아님) 라우팅 키가 경로/헤더가 아니라 sniHosts
access log가 L4 포맷 (status/path 없음) gateway가 L7을 못 보니 SNI·bytes·duration만 기록
미등록 차단 신호가 000(L4 reset) passthrough는 L7 응답을 만들 수 없어 연결 자체를 끊음

TLS Passthrough(SNI 기반)는 가장 흔한 “외부 HTTPS” 케이스이며 인증서 관리가 필요 없다(봉투를 안 뜯으니 gateway가 인증서를 가질 이유가 없다). TLS를 일부러 풀어 L7 가시성을 얻는 반대 선택지(TLS origination)는 §8에서 다룬다 — 거기서는 이 표의 모든 “이유"가 정반대로 뒤집힌다.


2. 사전 조건 (현재 상태 확인)

이 가이드는 Istio 1.30.0이 Helm으로 이미 설치되어 있고 egress gateway deployment가 떠 있는 상태를 전제한다(repo docs/runbooks/2026-06-01_istio-1.30-helm-reinstall.md에서 완료됨).

⚠️ 버전 skew 주의: 이 환경 istiod는 1.30.0이지만 로컬 istioctl 클라이언트는 1.27.0이다. proxy-status/proxy-config가 버전 불일치 경고나 일부 필드 누락을 보일 수 있다 — 다만 이게 “client 표시 문제일 뿐"이라고 공식적으로 보장되지는 않는다. 공식 권장은 istioctl을 컨트롤플레인 버전에 맞추는 것이며, 오래된 client는 신규 필드/명령을 지원하지 못해 결과가 실제로 불완전할 수 있다. §6.5·§7.3처럼 proxy-config 결과가 이 가이드의 판정 근거로 쓰이는 지점에서는 가능하면 매칭되는 istioctl 1.30.x로 재확인할 것(상세: ingress·egress 리포트 §0).

먼저 확인:

CTX=homelab; NS=istio-system

# control plane + gateway가 모두 deployed / Running 인지
helm --kube-context $CTX -n $NS list
#  istio-base / istiod / istio-ingressgateway / istio-egressgateway  모두 1.30.0 deployed

kubectl --context $CTX -n $NS get deploy istio-egressgateway
#  istio-egressgateway   1/1

kubectl --context $CTX -n $NS get svc istio-egressgateway
#  ClusterIP  포트 15021,80,443,15443

만약 egress gateway가 없다면 §3부터, 이미 있다면 §3은 “구성 확인"용으로 읽고 §4로 진행한다.


3. Egress Gateway Helm 설치·구성

3.1 chart 구조 — gateway 차트 하나, values만 다름

Istio는 ingress/egress를 동일한 istio/gateway 차트로 만든다. 차이는 values뿐이다. egress의 핵심 선택:

  • service.type: ClusterIP — egress는 외부에서 들어오는 트래픽이 없다. 메시 내부 트래픽만 받아 외부로 내보내므로 NodePort/LoadBalancer로 노출할 이유가 없다. (ingress는 외부 인입이므로 NodePort)
  • 포트 15443 (tls) 포함 — 단, 이건 이 시나리오(단일 클러스터 HTTPS egress passthrough)의 “표준 TLS 포트"가 아니다. 15443은 멀티클러스터/멀티네트워크 환경에서 east-west gateway가 클러스터 간 트래픽을 mTLS+SNI로 라우팅할 때 쓰는 포트이며, 현재 통합 istio/gateway 차트의 기본 values.yaml에는 들어있지 않고 networkGateway를 명시적으로 켤 때만 추가된다(§3.2 values 주석 참조). 이 가이드 메인 시나리오는 443에 TLS PASSTHROUGH server를 직접 정의하므로 15443은 이 시나리오와 무관하지만, 열어둬도 무해하니 참고용으로만 유지한다.

3.2 values 파일 (values-egress-gateway.yaml)

repo 경로: install/helm/values-egress-gateway.yaml — 이미 존재. 외부 참조용으로 전문 수록.

# egress gateway — chart: istio/gateway 1.30.0
# 메시 -> 외부 송신을 단일 지점으로 모아 통제. ClusterIP(외부 노출 불필요).
name: istio-egressgateway

labels:
  app: istio-egressgateway
  istio: egressgateway        # <-- Gateway 리소스의 selector(istio: egressgateway)와 반드시 일치

service:
  type: ClusterIP             # egress는 외부 노출 안 함. 메시 내부 트래픽만 경유.
  ports:
    - name: status-port
      port: 15021
      targetPort: 15021
    - name: http2
      port: 80
      targetPort: 8080
    - name: https
      port: 443
      targetPort: 8443
    - name: tls
      port: 15443            # east-west/network gateway용 포트(멀티클러스터 mTLS+SNI). 기본 gateway 차트
      targetPort: 15443       # values엔 없고 networkGateway 활성화 시에만 추가됨 — 이 문서의 단일 클러스터
                              # passthrough(443) 시나리오엔 불필요하지만 무해하므로 참고용으로 유지.

autoscaling:
  enabled: false
replicaCount: 1

resources:
  requests:
    cpu: 50m
    memory: 128Mi

가장 중요한 한 줄: labels.istio: egressgateway. 이후 만들 Gateway 리소스가 selector: { istio: egressgateway }로 이 pod들을 찾는다. 라벨이 어긋나면 Gateway가 어떤 Envoy도 프로그래밍하지 못하고 트래픽이 흐르지 않는다(에러도 안 나서 디버깅이 까다롭다).

3.3 설치 / 갱신

CTX=homelab; NS=istio-system; VER=1.30.0

# (최초 1회) repo 등록
helm --kube-context $CTX repo add istio https://istio-release.storage.googleapis.com/charts
helm --kube-context $CTX repo update

# egress gateway 설치/갱신 (idempotent)
helm --kube-context $CTX upgrade --install istio-egressgateway istio/gateway \
  -n $NS --version $VER -f install/helm/values-egress-gateway.yaml --wait --timeout 3m

repo 루트라면 make install-gateways가 ingress/egress를 함께 처리한다.

3.4 설치 검증

# pod 1/1, deployment 정상
kubectl --context $CTX -n $NS get deploy,pod -l istio=egressgateway

# Envoy가 istiod와 xDS sync 됐는지 (CDS/LDS/EDS/RDS SYNCED)
istioctl --context $CTX proxy-status | grep egressgateway
#  istio-egressgateway-xxxx.istio-system   ...   SYNCED   SYNCED   SYNCED   SYNCED   istiod-...
#  (열 순서 = CDS LDS EDS RDS — 4개 모두 SYNCED 여야 합격)

# 경고 0
istioctl --context $CTX analyze -A

합격선: istio-egressgateway 1/1 Running, proxy-status에서 CDS/LDS/EDS/RDS 모두 SYNCED, analyze 경고 0.

이 시점에서 egress gateway는 떠 있지만 아무 트래픽도 받지 않는다. Gateway/VirtualService를 붙이기 전까지는 빈 Envoy다. 다음 단계부터 트래픽을 흘린다.


4. 테스트 앱 구성 (트래픽 소스)

외부 HTTPS를 호출할 클라이언트가 필요하다. repo의 mesh-test 네임스페이스 + sleep(curl 컨테이너)을 쓴다. egress는 “나가는” 트래픽 검증이므로 서버(httpbin) 없이 클라이언트만 있으면 된다.

4.1 네임스페이스 + sleep 배포

# namespace.yaml — sidecar 자동 주입 활성화 (이게 있어야 egress 통제가 가능)
apiVersion: v1
kind: Namespace
metadata:
  name: mesh-test
  labels:
    istio-injection: enabled
# sleep.yaml — 트래픽 소스(클라이언트). curl 이미지로 외부 호출.
apiVersion: v1
kind: ServiceAccount
metadata:
  name: sleep
  namespace: mesh-test
---
apiVersion: v1
kind: Service
metadata:
  name: sleep
  namespace: mesh-test
  labels: { app: sleep, service: sleep }
spec:
  ports:
    - name: http
      port: 80
  selector: { app: sleep }
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: sleep
  namespace: mesh-test
spec:
  replicas: 1
  selector:
    matchLabels: { app: sleep }
  template:
    metadata:
      labels: { app: sleep }
    spec:
      serviceAccountName: sleep
      containers:
        - name: sleep
          image: curlimages/curl
          command: ["/bin/sleep", "infinity"]
          imagePullPolicy: IfNotPresent
          resources:
            requests: { cpu: 10m, memory: 32Mi }
kubectl --context homelab apply -f namespace.yaml -f sleep.yaml
# repo라면: make apps  (또는 kubectl apply -f scenarios/00-sample-apps/)

4.2 주입 확인 (가장 흔한 함정)

kubectl --context homelab -n mesh-test get pod
#  sleep-xxxx   2/2   Running     <-- 반드시 2/2 (app + istio-proxy)

READY가 1/2가 아니라 2/2여야 한다. 1/1이면 sidecar가 안 붙은 것 → egress gateway 통제 자체가 불가능(sidecar가 트래픽을 가로채지 못함). 네임스페이스 라벨 istio-injection=enabled을 확인하고 pod를 재생성(kubectl rollout restart deploy/sleep -n mesh-test).

4.3 baseline 호출 (현재는 sidecar가 직접 나감)

아직 egress 객체가 없으므로, 기본 ALLOW_ANY 상태에서는 외부 호출이 그냥 된다(egress gateway 경유 X). 이게 baseline이다.

kubectl --context homelab -n mesh-test exec deploy/sleep -c sleep -- \
  curl -sS -o /dev/null -w "%{http_code}\n" https://httpbin.org/get
#  200   <-- 단, 지금은 egress gateway를 안 거치고 sidecar가 직접 송신

→ 목표는 이 트래픽을 egress gateway로 강제 경유시키고, 나아가 미등록 외부를 차단하는 것.


5. 외부 HTTPS를 위한 Istio 객체 — 개념 맵

TLS Passthrough(SNI) 시나리오에 필요한 객체는 4개. §1에서 “각 객체가 왜 이 모양인지"는 SNI 제약으로 이미 깔았다. 여기서는 **4객체가 2-홉을 어떻게 잇는지(관계)**만 본다. 각 객체의 역할 한 줄 설명은 중복을 피해 §6의 YAML 주석으로 일원화한다. 부품을 “그게 답하는 질문"으로 읽으면 직관적이다:

객체 답하는 질문
ServiceEntry 이 외부 호스트가 메시 레지스트리에 존재하는가(화이트리스트)
Gateway (egress) egress pod의 어느 포트에 어떤 server(여기선 443 PASSTHROUGH)를 여는가
DestinationRule hop1의 목적지(egress 서비스)를 어떤 subset으로 부를 것인가
VirtualService hop1·hop2를 어디로 라우팅하는가 (sniHosts 매칭)
flowchart LR
  SE["ServiceEntry\nhttpbin-ext"]
  GW["Gateway (egress)\nselector istio=egressgateway"]
  DR["DestinationRule\negressgateway subset"]
  VS["VirtualService\n2-hop tls routing"]
  SE -. "registry (whitelist)" .-> VS
  VS -->|"hop1: mesh -> egress (SNI)"| GW
  VS -->|"hop2: egress -> httpbin.org:443"| SE
  DR -. "hop1 destination subset" .-> VS

추가(차단 검증용):

  • outboundTrafficPolicy: REGISTRY_ONLY (mesh 전역 또는 Sidecar 리소스) — ServiceEntry 없는 외부를 막아 “egress 통제가 실제로 강제되는가"를 증명한다.

6. Istio 객체 설정 (TLS Passthrough / SNI) — 전체 YAML

아래 5개 파일은 repo scenarios/20-egress/에 두는 것을 권장(파일명은 repo 컨벤션 kind-목적.yaml). 외부 참조용으로 전문 수록. 등록 외부 호스트는 프로젝트 컨벤션대로 httpbin.org 사용.

네임스페이스 배치 주의: 이 가이드는 시나리오 격리를 위해 4종 객체(ServiceEntry/Gateway/DestinationRule/ VirtualService)를 트래픽 소스와 같은 mesh-test에 둔다. Gateway selector는 네임스페이스를 가로질러 istio-system의 egress pod(라벨 istio=egressgateway)를 찾으므로 동작한다(객체 ns ≠ pod ns여도 무방). 정본 egress gateway 개념 정본 §04는 운영 표준으로 이 4종을 전부 istio-system에 집중 배치하길 권장한다 — 둘 다 동작하나, 운영 일관성·RBAC 경계 면에서 정본 쪽이 기준이다.

이름 주의 (인라인 vs 첨부): 아래 인라인 YAML의 리소스 이름은 본문 설명용이며 내부적으로 일관된다 (Gateway/VS istio-egressgateway·direct-httpbin-through-egress, DR egressgateway-for-httpbin, §7 검증·§10 cleanup과 일치). 문서 말미 「관련 파일」의 📎 첨부 파일은 repo 컨벤션상 다른 이름(egress-httpbin / egressgateway-httpbin)을 쓴다. 둘을 섞지 말고 적용한 쪽 이름으로 §7 검증·§10 cleanup을 맞출 것 — 첨부를 그대로 apply했다면 cleanup의 리소스 이름도 첨부 이름으로 바꿔야 한다.

6.1 ServiceEntry — 외부 호스트 등록

# serviceentry-httpbin-ext.yaml
# 외부 도메인을 메시 레지스트리에 등록. 이게 있어야 REGISTRY_ONLY에서도 통과(화이트리스트).
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
  name: httpbin-ext
  namespace: mesh-test
spec:
  hosts:
    - httpbin.org
  ports:
    - number: 443
      name: tls
      protocol: TLS        # 앱이 직접 TLS -> protocol은 TLS (HTTPS가 아니라 TLS)
  resolution: DNS          # 실제 이름 해석은 istiod가 아니라 각 Envoy 프록시가 비동기로 수행
  location: MESH_EXTERNAL  # 메시 밖 목적지

포인트: passthrough에서는 Envoy가 평문 HTTP 헤더를 못 본다(이미 암호화됨). 그래서 L7 HTTP가 아니라 L4 TLS 프로토콜로 등록하고, 라우팅 키는 TLS handshake의 SNI가 된다.

resolution: DNS를 실제로 수행하는 주체는 istiod(컨트롤 플레인)가 아니라 각 워크로드/게이트웨이의 Envoy(데이터 플레인) 다. istiod는 해당 클러스터를 STRICT_DNS 타입으로 프로그래밍만 하고, 실제 DNS 조회는 프록시마다 비동기로 수행된다 — 그래서 노드/파드의 DNS 설정(resolv.conf, CoreDNS 도달성)에 따라 결과가 갈릴 수 있다.

6.2 Gateway — egress gateway에 TLS PASSTHROUGH server

# gateway-egress.yaml
# egress gateway pod(selector istio=egressgateway)의 443 포트에 PASSTHROUGH server를 연다.
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
  name: istio-egressgateway
  namespace: mesh-test
spec:
  selector:
    istio: egressgateway     # <-- values의 labels.istio 와 일치해야 함
  servers:
    - port:
        number: 443
        name: tls
        protocol: TLS
      hosts:
        - httpbin.org
      tls:
        mode: PASSTHROUGH    # TLS를 풀지 않고 그대로 통과(종단간 암호화 유지)

6.3 DestinationRule — egress gateway subset

# destinationrule-egress.yaml
# hop 1의 목적지(egress gateway 서비스)에 대한 subset 정의. passthrough라 TLS 설정은 비움.
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
  name: egressgateway-for-httpbin
  namespace: mesh-test
spec:
  host: istio-egressgateway.istio-system.svc.cluster.local
  subsets:
    - name: httpbin
      # PASSTHROUGH 모드에서는 여기서 TLS를 다시 만지지 않는다(앱 TLS를 그대로 전달).

6.4 VirtualService — 2-홉 SNI 라우팅 (핵심)

# virtualservice-egress.yaml
# hop 1(mesh -> egress gateway) + hop 2(egress gateway -> external) 를 한 파일에.
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
  name: direct-httpbin-through-egress
  namespace: mesh-test
spec:
  hosts:
    - httpbin.org
  gateways:
    - mesh                  # sidecar (= 메시 내부 모든 워크로드)
    - istio-egressgateway   # 위 Gateway 리소스 이름
  tls:
    # --- hop 1: sleep sidecar -> egress gateway ---
    - match:
        - gateways: [mesh]
          port: 443
          sniHosts: [httpbin.org]
      route:
        - destination:
            host: istio-egressgateway.istio-system.svc.cluster.local
            subset: httpbin
            port:
              number: 443
    # --- hop 2: egress gateway -> 실제 외부 ---
    - match:
        - gateways: [istio-egressgateway]
          port: 443
          sniHosts: [httpbin.org]
      route:
        - destination:
            host: httpbin.org   # 진짜 외부 (ServiceEntry로 등록됨)
            port:
              number: 443
          weight: 100

tls 라우팅을 쓰는 이유: passthrough에서 Envoy가 볼 수 있는 건 TLS handshake의 SNI뿐이다. HTTP 경로/헤더 기반 라우팅(http:)은 불가능. sniHosts가 라우팅 키다.

6.5 적용 + 검증(분석)

CTX=homelab; NS=mesh-test

# 적용 전 서버측 dry-run + 분석
kubectl --context $CTX apply --dry-run=server -f serviceentry-httpbin-ext.yaml \
  -f gateway-egress.yaml -f destinationrule-egress.yaml -f virtualservice-egress.yaml

kubectl --context $CTX apply -f serviceentry-httpbin-ext.yaml \
  -f gateway-egress.yaml -f destinationrule-egress.yaml -f virtualservice-egress.yaml

istioctl --context $CTX analyze -n $NS    # 경고 0 기대

7. 테스트 진행 방법 (검증)

egress의 “완료 정의"는 호출이 200이 아니라 트래픽이 egress gateway를 실제로 경유했는가다. 세 가지를 교차 확인한다: ① 호출 결과 ② Envoy 설정 반영 ③ egress gateway access log.

7.1 ① 호출 — 200 확인

kubectl --context homelab -n mesh-test exec deploy/sleep -c sleep -- \
  curl -sS -o /dev/null -w "%{http_code}\n" https://httpbin.org/get
#  200

7.2 ② 경유 증명 — egress gateway access log

가장 직접적인 증거. 호출 직전 로그를 follow 해두고 호출한다.

# 터미널 A: egress gateway 로그 follow
kubectl --context homelab -n istio-system logs -f deploy/istio-egressgateway

# 터미널 B: 호출 몇 번
bash scripts/traffic.sh https://httpbin.org/get 5    # repo 스크립트
#   또는 위 curl 반복

# 터미널 A 로그에 httpbin.org:443 향 라인이 찍히면 = egress gateway 경유 성공
#   "... httpbin.org:443 ... outbound|443||httpbin.org ..."

passthrough 로그는 TCP 포맷이다. TLS를 풀지 않으므로 egress gateway는 L7을 못 본다. 이 access log 라인은 SNI(requested_server_namebytes_sent/received·duration·response_flags(예: -, UF, UH) 같은 L4 필드만 있고, HTTP method/path/status없다(암호문). 로그에서 HTTP status를 찾다가 “L7이 안 보인다"에서 막히는 게 정상 — L7 가시성이 필요하면 TLS origination(§8)으로 바꿔야 하며, 모드별 가시성은 정본 egress gateway 개념 정본 §07(TLS 모드 가시성) 참조.

로그에 아무것도 안 찍히면 트래픽이 gateway를 안 거치고 sidecar가 직접 나간 것(=라우팅 미스). §9 참조.

7.3 ② 경유 증명 — proxy-config (Envoy에 실제 반영됐는지)

# sleep sidecar가 httpbin.org:443 을 egress gateway 클러스터로 보내도록 프로그래밍됐는지
istioctl --context homelab proxy-config routes deploy/sleep.mesh-test | grep -i httpbin
istioctl --context homelab proxy-config clusters deploy/sleep.mesh-test | grep -i egress

# egress gateway 쪽에 httpbin.org 향 cluster가 생겼는지
istioctl --context homelab proxy-config clusters deploy/istio-egressgateway.istio-system | grep -i httpbin

# 일괄 덤프 (repo 스크립트)
bash scripts/proxy-dump.sh sleep.mesh-test

7.4 ③ 차단 검증 — REGISTRY_ONLY

여기까지는 ALLOW_ANY라 “경유는 하지만, 안 거쳐도 나가긴 한다.” egress 통제를 강제하려면 미등록 외부를 막아야 한다. 메시 전역 또는 네임스페이스 SidecarREGISTRY_ONLY 전환.

방법 A — 네임스페이스 한정(Sidecar 리소스, 권장: 영향 범위 작음):

# sidecar-registry-only.yaml — mesh-test 네임스페이스만 REGISTRY_ONLY
apiVersion: networking.istio.io/v1
kind: Sidecar
metadata:
  name: default
  namespace: mesh-test
spec:
  outboundTrafficPolicy:
    mode: REGISTRY_ONLY
kubectl --context homelab apply -f sidecar-registry-only.yaml

방법 B — 메시 전역(values-istiod.yaml의 주석 해제 후 helm 재적용):

meshConfig:
  outboundTrafficPolicy:
    mode: REGISTRY_ONLY

전환 후 테스트:

# 등록된 외부(httpbin.org) -> 여전히 200 (egress gateway 경유)
kubectl --context homelab -n mesh-test exec deploy/sleep -c sleep -- \
  curl -sS -o /dev/null -w "registered  -> %{http_code}\n" https://httpbin.org/get
#  registered  -> 200

# 차단 전제 확인: example.com이 PassthroughCluster가 아니라 BlackHole로 가는지(=실제 차단됨)
istioctl --context homelab proxy-config cluster deploy/sleep.mesh-test | grep -iE 'example|PassthroughCluster|BlackHole'
#  PassthroughCluster 가 잡히고 example.com 전용 cluster가 없으면 -> REGISTRY_ONLY가 덜 적용됨(ALLOW_ANY 잔재)
#  이 경우 example.com 호출이 200으로 새어 나가므로 "확실한 미등록 차단" 검증이 깨진다 -> §7.4 모드 재적용 확인

# 미등록 외부(example.com) -> 차단 (ServiceEntry 없음)
kubectl --context homelab -n mesh-test exec deploy/sleep -c sleep -- \
  curl -sS -o /dev/null -w "unregistered-> %{http_code}\n" --max-time 5 https://example.com
#  unregistered-> 000

왜 미등록이 000인가: REGISTRY_ONLY + passthrough(TLS)에서는 미등록 SNI가 BlackHoleCluster로 가고 Envoy가 연결을 즉시 reset 한다 → curl이 곧바로 000(연결 실패). 이 경로에선 --max-time 5가 발동할 일이 없다. 반대로 SNI 매칭은 됐지만 upstream이 무응답(hang) 하는 경우엔 --max-time 5가 5초 뒤 끊어 000을 만든다 — 둘 다 000이지만 원인 경로가 다르므로 --max-time은 hang 보호용으로 남긴다. 평문 HTTP였다면 같은 BlackHole이라도 L7 응답 502가 뜬다. 즉 프로토콜(L4 reset vs L7 502)에 따라 차단 신호가 다르다.

7.5 합격 기준 (시나리오 완료 정의)

검증 기대
등록 외부 호출 200
egress gateway access log httpbin.org:443 라인 기록됨(= 경유 증명)
proxy-config routes (sleep) httpbin.org → egress gateway cluster
REGISTRY_ONLY + 미등록 외부 차단(000/502)
istioctl analyze -n mesh-test 경고 0

결과는 repo docs/test-reports/2026-06-02_egress.md에 (기대 vs 실제 + 재현 명령) 기록 권장.


8. 대안 패턴 — TLS Origination (앱은 HTTP, gateway가 TLS 시작)

이 부분(passthrough vs TLS/mTLS origination의 개념·트레이드오프 비교, 모드별 가시성, 어느 쪽을 택할지)은 개념 정본과 중복이므로 생략 — 정본: egress gateway 개념 정본 §03(두 모델 결정 규칙)·§06(HTTP + mTLS origination 전체 CRD)·§07(TLS 모드 정밀 비교), 그리고 HTTP vs HTTPS egress 비교 참조.

본 가이드는 요청 시나리오(“외부 HTTPS 통신” = 앱이 https://를 직접 호출)에 맞춰 passthrough를 메인으로 두고 homelab에서 구성·검증한다. origination이 필요하면(파트너 mTLS 중앙관리, gateway L7 감사) 정본 §06의 CRD 5종을 본 가이드의 repo/검증 절차에 그대로 대입하면 된다.


9. 트러블슈팅

증상 원인 확인/해결
호출은 200인데 egress 로그가 빔 트래픽이 gateway 안 거치고 sidecar 직접 송신(hop1 match 오류) — 드물게는 등록한 “외부” host가 클러스터 DNS로 이미 mesh registry에 있는 k8s Service의 ClusterIP로 우연히 해석되는 경우도 있다(그 IP 전용 default 리스너가 SNI 라우팅용 wildcard 리스너보다 항상 먼저 매치돼 gateway를 구조적으로 못 거침 — hop-gw를 0 replica로 내려도 200이 유지되는 것으로 실측 확인, T47) VirtualService의 hop 1 match(gateways:[mesh], sniHosts)가 맞는지. REGISTRY_ONLY로 바꾸면 경유 강제됨. 그래도 안 되면 istioctl proxy-config listener <client>.<ns> --port 443으로 목적지 ClusterIP 전용 리스너가 있는지 확인 — 있으면 그 host는 이미 클러스터 내부 서비스이므로 등록 대상은 진짜 외부 호스트로 교체
등록 외부도 차단(000) Gateway selector ↔ egress pod 라벨 불일치 kubectl -n istio-system get pod -l istio=egressgateway 와 Gateway selector.istio 비교
503 UH(no healthy upstream) DNS resolution 실패 / ServiceEntry resolution 오류 ServiceEntry resolution: DNS, 호스트 철자, 노드에서 DNS 되는지
미등록인데 안 막힘 ALLOW_ANY(기본) 상태 Sidecar/mesh REGISTRY_ONLY 적용했는지(§7.4)
sleep 1/2 sidecar 미주입 ns 라벨 istio-injection=enabledrollout restart
proxy-config에 httpbin.org cluster 없음 istiod 미동기 istioctl proxy-status로 SYNCED 확인, analyze

진단 1차 루틴:

istioctl --context homelab proxy-status                       # xDS sync 상태
istioctl --context homelab analyze -n mesh-test               # 설정 정합성
bash scripts/proxy-dump.sh sleep.mesh-test                    # sleep Envoy 전체 덤프
kubectl --context homelab -n istio-system logs deploy/istio-egressgateway --tail=50

10. 정리(cleanup)

# egress 시나리오 객체만 제거 (gateway deployment·istiod는 보존)
kubectl --context homelab -n mesh-test delete \
  serviceentry/httpbin-ext gateway/istio-egressgateway \
  destinationrule/egressgateway-for-httpbin \
  virtualservice/direct-httpbin-through-egress \
  sidecar/default --ignore-not-found

# REGISTRY_ONLY를 mesh 전역으로 켰다면 values 원복 후 helm 재적용 필요

egress gateway deployment/Helm release 자체 제거는 메시 영향 위험 작업 → CLAUDE.md §6에 따라 별도 승인.


핵심 정리

머릿속 한 문장으로 되감으면: choke point로 모으되 TLS는 풀지 않는다 — 그러니 gateway는 SNI만 보고 2-홉으로 라우팅하고, “완료"는 200이 아니라 경유의 증명이다. 이 한 문장에서 아래가 전부 따라온다.

  • 2-홉의 정체: 외부 호출이 sidecar→외부로 직접 안 가고 일부러 한 번 더 꺾여 egress gateway를 경유 (hop1 mesh→gateway, hop2 gateway→external). 이 “일부러 꺾기"가 choke point를 만든다.
  • SNI가 유일한 라우팅 키: passthrough라 봉투(TLS)를 안 뜯으니 평문 헤더가 없다. 그래서 ServiceEntry는 protocol: TLS, VirtualService는 tls:/sniHosts, 로그는 L4(status/path 없음)다 — 전부 같은 제약의 결과.
  • gateway만으로는 강제가 안 된다: egress gateway가 떠 있어도 ALLOW_ANY면 sidecar가 그냥 직접 나간다. 강제하려면 REGISTRY_ONLY(라우팅 차단) + Sidecar scope 축소 + L3/L4 네트워크 정책까지 3계층.
  • 라벨 정렬이 생명줄: values labels.istio: egressgateway == Gateway selector.istio: egressgateway. 어긋나면 에러 없이 조용히 트래픽이 안 흐른다.
  • 검증은 교차 3종: 호출 200 + egress access log에 httpbin.org:443 라인 + proxy-config에 cluster 반영. 셋이 다 맞아야 “경유 증명"이 성립한다.
  • 차단 신호는 프로토콜에 따라 다르다: passthrough(L4)에서 미등록은 BlackHole로 즉시 reset → 000, 평문 HTTP였다면 같은 BlackHole이라도 L7 502.

What you might be missing

개념 차원의 주의점(egress gateway는 강제 장치가 아님, passthrough의 L7 가시성 한계, resolution: DNS의 노드 DNS 의존성·주기, Sidecar 리소스의 scope 축소 이중 역할)은 개념 정본과 중복이므로 생략 — 정본: egress gateway 개념 정본 §02(강제 계층 등식)·§04(passthrough 한계·DNS·SNI 위조)·§07(TLS 모드 가시성), sidecar scope 참조.

아래는 이 homelab 가이드를 사내 1.27 메시로 옮길 때의 deltahomelab CNI(Calico) 특이점만 남긴다.

  • CNI 강제 계층은 Calico로 대치: egress gateway는 그 자체로 송신을 막지 못하고 세 계층을 함께 걸어야 강제된다 — ① mesh REGISTRY_ONLY(라우팅 차단) ② Sidecar scope 축소(워크로드가 임의 외부 cluster를 모르게) ③ L3/L4 네트워크 정책으로 egress pod 외 직접 송신 차단. 정본 §02는 ③을 Cilium NetworkPolicy + 노드 라우팅 전제로 기술하지만, homelab의 실제 CNI는 Calico이므로 ③은 CiliumNetworkPolicy가 아니라 Calico NetworkPolicy/GlobalNetworkPolicy의 egress 규칙으로 구현해야 한다(예: 워크로드 pod의 egress를 egress gateway pod로만 허용하고 그 외 0.0.0.0/0 차단). 정본의 Cilium 언급은 Calico로 대치해 읽을 것 — sidecar를 우회하는 root/hostNetwork 경로는 어느 CNI든 L3 정책 없이는 막히지 않는다.

  • 버전 정합: egress gateway 버전은 istiod와 맞춰야 한다(여기선 둘 다 1.30.0). gateway가 istiod보다 높으면 xDS 호환 문제, 낮으면 신규 필드 미지원. 사내 기존 메시가 1.27.x이므로, 본 1.30 가이드를 그대로 옮기기 전 istiod부터 정렬할 것.

  • 이중 홉의 비용 + 홈랩 단일 장애점: 모든 외부 호출이 Envoy를 두 번(앱 sidecar + egress gateway) 통과한다. 이 가이드의 values-egress-gateway.yamlreplicaCount: 1이라 홈랩에선 egress gateway가 단일 장애점이다. 사내 적용 시 HA(replica↑, HPA, PodDisruptionBudget) + 노드 배치를 반드시 설계 — 노드 핀닝·가용성 트레이드오프는 정본 egress gateway 개념 정본 §08 참조.

  • “외부” host가 실은 클러스터 내부 Service일 때의 함정: ServiceEntry로 등록한 host가 클러스터 DNS를 통해 이미 mesh registry에 있는 실제 k8s Service의 ClusterIP로 우연히 resolve되면, Envoy가 자동 생성하는 per-ClusterIP 전용 리스너(SNI 매치 없는 단일 default filterChain)가 SNI 라우팅이 걸린 0.0.0.0_443 wildcard 리스너보다 항상 먼저 매치되어, Gateway/VirtualService/DestinationRule을 아무리 정확히 설정해도 egress gateway가 트래픽을 구조적으로 못 받는다(hop-gw를 0 replica로 내려도 curl은 계속 200 — T47 실측). 진짜 외부 호스트(예: 이 가이드의 httpbin.org)는 클러스터 내부 Service와 이름이 겹칠 일이 없어 이 함정에 걸리지 않지만, 테스트용으로 in-cluster mock 호스트를 “외부"인 것처럼 등록해서 이 시나리오를 재현하려는 경우 반드시 유의할 것 — 상세 원인 분석은 egress CRD 멘탈모델 참조.


12. 참조

  • repo: install/helm/values-egress-gateway.yaml, scenarios/20-egress/README.md, scripts/{traffic,proxy-dump}.sh
  • runbook: docs/runbooks/2026-06-01_istio-1.30-helm-reinstall.md (설치 선행 작업)
  • Istio 공식: “Egress Gateways” / “Egress Gateways for HTTPS Traffic”(SNI passthrough), “Egress TLS Origination”

See also


관련 파일 (실제 IaC)

관련 검증Egress mTLS 리포트 — HTTPS over mTLS (ISTIO_MUTUAL)

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

검증 방법: 공식 Istio/Envoy 문서(레퍼런스·블로그·GitHub 이슈) 대조 + homelab 클러스터(k8s 1.30.6, Istio 1.30.0) 실측.

주장 판정 근거
C1. 기본(ALLOW_ANY) sidecar Envoy는 미등록 외부를 PassthroughCluster로 그냥 통과시킨다 ✅ 문헌 확인 istio.io/…/monitoring-external-service-traffic
C2. egress gateway가 떠 있어도 ALLOW_ANY면 sidecar가 gateway를 안 거치고 직접 나갈 수 있다(강제하려면 방화벽/NetworkPolicy 별도 필요) ✅ 실측 확인 istio.io/…/egress-gateway · T01 실측(idle egress gateway pod가 떠 있어도 Gateway/VS 부재 시 mock 트래픽이 gateway를 0건 경유)
C3. ingress/egress는 동일한 istio/gateway Helm 차트를 쓰고 차이는 values뿐이다 ✅ 문헌 확인 github.com/…/charts/gateway
C4. egress gateway Service는 외부 인입이 없으므로 ClusterIP면 충분하다 ✅ 문헌 확인 github.com/…/charts/gateway
C5. 포트 15443은 이 시나리오(단일 클러스터 passthrough)의 “표준 TLS 포트"다 ❌ 오류 — 본문 교정 raw.githubusercontent.com/…/gateway/values.yaml — 기본 values엔 없고 networkGateway(east-west) 활성화 시에만 추가됨
C6. Gateway selector가 pod 라벨과 어긋나면 에러 없이 조용히 트래픽이 안 흐른다 ✅ 문헌 확인 istio.io/…/reference/config/networking/gateway
C7. TLS Passthrough에서 ServiceEntry 포트 protocol은 HTTP/HTTPS가 아니라 TLS ✅ 문헌 확인 istio.io/…/egress-gateway
C8. tls.mode: PASSTHROUGH는 TLS를 종료하지 않고 그대로 통과시켜 종단간 암호화를 유지한다 ✅ 문헌 확인 istio.io/…/tls-configuration
C9. TLS Passthrough VirtualService는 http:가 아니라 tls:+sniHosts를 쓴다 ✅ 문헌 확인 istio.io/…/virtual-service
C10. TLS Passthrough egress gateway access log는 L4(TCP) 포맷이라 HTTP method/path/status가 없다 🔬 실측 반증 — 본문 교정 envoyproxy.io/…/access_log/usage · T47 실측(“외부” 호스트가 클러스터 내부 Service ClusterIP로 우연히 resolve되는 엣지케이스에서, per-ClusterIP 리스너가 SNI wildcard 리스너보다 우선 적용돼 gateway가 트래픽을 아예 못 받음 — 로그 포맷 주장 자체보다 “gateway가 실제로 경유되는지” 전제가 깨질 수 있다는 함정. 본문 §9·“What you might be missing"에 반영)
C11. REGISTRY_ONLY 전환 시 미등록 목적지는 PassthroughCluster 대신 BlackHoleCluster로 간다 ✅ 실측 확인 istio.io/…/monitoring-external-service-traffic · T02 실측
C12. REGISTRY_ONLY에서 미등록 차단 신호는 프로토콜별로 다르다(TLS=000 연결reset, HTTP=502) ✅ 실측 확인 istio.io/…/egress-control · T02 실측(실제 미등록 외부 호스트 postman-echo.com으로 HTTP=502, TLS=000/curl_exit=35 확인)
C13. Gateway/VirtualService/DestinationRule/ServiceEntry는 apiVersion: networking.istio.io/v1(1.30 기준) ✅ 문헌 확인 istio.io/…/virtual-service
C14. istioctl proxy-status 컬럼 순서는 CDS/LDS/EDS/RDS이며 4개 모두 SYNCED여야 정상 ✅ 문헌 확인 istio.io/…/proxy-cmd
C15. ServiceEntry resolution: DNS는 istiod가 실제 IP를 해석한다 ❌ 오류 — 본문 교정 istio.io/…/service-entry — 실제 해석 주체는 각 Envoy 프록시(데이터 플레인), istiod는 STRICT_DNS 타입으로 프로그래밍만 함
C16. istioctl 1.27.0 vs istiod 1.30.0 skew는 “client 표시 문제일 뿐 메시 동작 이상 아님” 실측 불가 istio.io/…/supported-releases — 공식 정책은 이 보장을 다루지 않음(istioctl-control plane skew의 “표시 문제뿐” 단정은 과신 소지)

Files