homelab89 Docs Logs Legacy Files ☰ TOC 🌓
guideistio 2026-07-02istioegressmental-modelcrd

Egress 4-CRD 직관 — "한 번의 curl = 두 hop", 4개를 어떤 순서로 어떻게 채우나

ABSTRACT

egress gateway 설정이 “Gateway·VirtualService·ServiceEntry·DestinationRule을 왜 4개나, 어느 필드를 어디에"로 막히는 이유는 멘탈모델 없이 필드부터 보기 때문이다. 이 문서는 거꾸로 간다 — 먼저 “한 번의 외부 호출이 두 hop으로 쪼개지고, 4개 CRD는 각자 다른 hop의 다른 질문에 답하는 부품"이라는 그림을 세운 뒤, 실제로 두 패턴(passthrough / outer-mTLS)을 어떤 순서로 어떻게 만들었는지를 그때의 YAML과 함께 따라간다. 결론: 4개는 따로 노는 게 아니라 두 hop을 굴리기 위한 질문 4개이고, 패턴 전환은 “3개 델타"일 뿐이다. 필드 단위 레퍼런스(=사전)는 Egress Gateway 정본·HTTPS over mTLS 해부, 검증 랩은 이중 gateway 가이드.

대상 환경: homelab (kubespray bare-metal, k8s v1.30.6, CNI Calico), Istio 1.30.0, Helm gateway chart 대상 독자: egress gateway config를 직접 만들어야 하는데 4개 CRD 관계가 안 잡히는 사람 선행 개념: sidecar 트래픽 캡처(15001 outbound), Envoy listener/cluster, mTLS·SNI 기초 다루는 것: ① 왜 egress·왜 4개 ② 멘탈모델·부품표 ③ 구성 순서대로 실제 YAML과 이유 ④ 필드 정렬 지도 ⑤ 단 하나의 비대칭(tls vs tcp)


1. 배경 — 왜 egress gateway이고, 왜 하필 CRD가 4개인가

mesh 안의 pod가 curl https://example.org를 하면, sidecar가 outbound 트래픽(15001)을 가로챈다. 여기서 두 가지를 “통제하고 싶다"는 욕구가 egress gateway의 존재 이유다:

  • 관측·정책의 단일 통로. 외부로 나가는 트래픽을 pod마다 제각각 내보내면 “누가 어디로 나갔나"를 한 곳에서 볼 수 없다. egress gateway는 모든 외부 호출을 한 노드(pod)로 모으는 깔때기다 — 거기서 egress IP 고정, 로그·메트릭 집중, 정책 한 점 적용이 가능해진다.
  • 신원 있는 외부 호출. “어느 app이 이 외부 host로 나갔는가"를 mesh mTLS(SPIFFE)로 증명하려면, sidecar→gateway 구간을 mTLS로 감싸고 gateway에서 그 신원을 검증해야 한다. 이게 outer-mTLS 패턴이다.

문제는, 이 “통로"를 만들려면 단 한 번의 외부 호출이 두 개의 구간으로 쪼개진다는 점이다. app은 한 번 curl했지만 물리적으로는 app→gateway, gateway→외부 두 개의 hop이 생긴다. 두 hop을 각각 “어디로 보낼지·어떻게 말 걸지·어느 문으로 받을지·이 host가 존재하긴 하는지"로 설정해야 하니, 답해야 할 질문이 자연히 여러 개가 된다. Istio는 이 질문들을 관심사별로 다른 CRD에 분산시켰다 — 그래서 4개다. 4개라서 어려운 게 아니라, 두 hop × 관심사 분리의 결과가 4개일 뿐이다. 이 문서는 그 4개를 “질문 4개"로 되돌려 직관을 세운다.

같은 4-CRD 골격은 ingress가 아니라 외부로 나가는 egress 시나리오 전용 멘탈모델이다. 더 넓은 운영 맥락은 egress operations, 라우팅 스코핑은 VS 스코핑.


2. 모든 걸 푸는 한 문장 (멘탈모델 anchor)

curl https://example.org 한 번은 Istio 안에서 “두 개의 hop"으로 쪼개진다. CRD 4개는 각자 다른 hop의 다른 질문에 답하는 부품일 뿐이다.

이 한 문장만 머리에 박으면 나머지는 다 따라온다. 물리적으로 일어나는 일은 이게 전부다:

flowchart LR
  app[app + sidecar] -->|hop1: sidecar가 나가는 길| gw[egress gateway pod]
  gw -->|hop2: listener가 받아 외부로| ext[example.org:443]

  VS1[VS leg-1: host -> gw] -.batches.-> app
  DR[DR: hop1을 어떻게 plain/mTLS] -.batches.-> app
  GW[Gateway: 문 열고 보안 모드] -.batches.-> gw
  VS2[VS leg-2: 받은 걸 외부로] -.batches.-> gw
  SE[ServiceEntry: example.org는 존재함] -.전제, hop 무관.-> app

ASCII로 같은 그림 — “두 hop"의 골격만 먼저:

  app  ───hop1───>  egress gateway pod  ───hop2───>  example.org
       (sidecar가              (listener가              (외부 서버)
        나가는 길)              받는 자리)

나머지 모든 설정은 “이 두 hop을 어떻게 동작시킬까"를 채우는 것이다. 리소스가 4개인 이유는 두 hop을 굴리려면 답해야 할 질문이 4개이기 때문이다 — §1에서 본 “관심사 분리"가 여기서 4개의 부품으로 떨어진다.


3. 부품표 — 각 CRD = 하나의 질문에 대한 답

답해야 할 질문 답 = CRD 한 줄 직관
“이 외부 host가 존재하긴 하나? 메시가 알아도 되나?” ServiceEntry 메시의 주소록에 등록. 없으면 unknown(REGISTRY_ONLY면 차단).
“트래픽이 뜨면 어디로?(hop1) / gateway 도착 뒤 어디로?(hop2)” VirtualService 라우팅 규칙. 4개 중 유일하게 두 hop에 다 걸친다 → route 블록 2개.
“sidecar가 gateway에게 어떻게 말 거나? 평문? mTLS로 감싸서?” DestinationRule hop1 목적지(gateway)에게 말 거는 방식. “mTLS로 감싸라"를 여기서 지정.
“gateway는 어느 문(listener) 을 열어 hop1을 받나? cert를 요구하나?” Gateway egress pod에 포트를 열고 보안 모드 설정(ISTIO_MUTUAL = cert 요구·검증).

이 표가 핵심이다. “필드 4묶음"이 아니라 “질문 4개” 로 보면, 작성은 질문에 순서대로 답하는 일이 된다.

교환원(switchboard)을 거쳐 외부로 전화 거는 비유:

  • ServiceEntry = 그 외부 번호가 회사 전화번호부에 있어야 함(없는 번호는 차단)
  • Gateway = 교환원 책상 + “신분증 보여주는 사람 전화만 받음” 규칙
  • DestinationRule = 내 전화기 지시 “교환원한테 걸 땐 네 신분증을 제시해”
  • VirtualService = 통화 라우팅 스크립트 “cnn행 → 교환원에게(leg1)”, “교환원아, 이걸 진짜 cnn으로(leg2)”

비유의 한계: mTLS 패턴에서 교환원(gateway)은 바깥 봉투(outer mTLS)만 뜯고 안쪽 편지(inner 앱 TLS)는 못 읽은 채 그대로 넘긴다. 전화 비유엔 “이중 봉투"가 없다 — 그 부분은 §7.

공간 지도 — 어느 부품이 경로의 어디에 앉나

부품을 질문으로 알았으면, 이번엔 그 부품이 두 hop의 어느 쪽에 매달리는지를 본다. hop1은 sidecar 쪽이 만들고, hop2는 gateway 쪽이 받는다 — 4개가 이 두 진영으로 깔끔히 갈린다:

   app        sidecar  ════ hop1 ════>   egress gw   ════ hop2 ════>   example.org:443
                 │                            │
   VS leg-1 ─────┤ "host -> gw 로 보내"        │
   DR ───────────┘ "그 길을 어떻게(plain/mTLS)" │
                                              ├──── Gateway "포트 열고, 보안 모드"
                                              └──── VS leg-2 "받은 걸 외부로 흘려"

   ServiceEntry ── "example.org 는 존재함" (그림 전체에 적용 — hop 무관)
  • hop1을 만드는 건 sidecar 쪽: VS leg-1(어디로) + DR(어떻게).
  • hop1을 받는 건 gateway 쪽: Gateway(문) + VS leg-2(받은 뒤 어디로).
  • ServiceEntry는 양쪽 어디에도 안 붙는 “전제” — host 등록.

4. 구성 따라하기 — 실제로 이렇게 만들었다

홈랩에 두 패턴을 별도 pod로 띄웠다. 핵심은 포트·구조를 똑같이 맞춰 두 패턴의 차이를 최소한으로 드러낸 것이다 — 둘은 3곳만 다르고 나머지는 동일하다. 그래서 “공통 뼈대를 만들고, mTLS는 거기에 3개 델타를 얹는다"로 읽으면 된다. 부품 의존 순서대로 SE → Gateway → DR → VS로 채운다(VS가 나머지를 가리키므로 마지막).

Helm으로 gateway pod부터 두 개 띄운다(label만 다름, 포트는 둘 다 443→8443으로 통일):

helm upgrade --install egw-pt   istio/gateway -n egress-pt   --version 1.30.0 -f install/helm/values-egw-pt.yaml   --wait
helm upgrade --install egw-mtls istio/gateway -n egress-mtls --version 1.30.0 -f install/helm/values-egw-mtls.yaml --wait
# 둘 다 Service: tls 443 -> targetPort 8443  (443은 privileged라 Envoy는 8443에 bind)
# 다른 점은 label뿐: istio=egw-pt  /  istio=egw-mtls  -> 각 Gateway 리소스 selector가 자기 pod만 잡음

4.1 ServiceEntry — “이 host가 존재한다” (두 패턴 동일)

kind: ServiceEntry
metadata: { name: pt-ext, namespace: mesh-test }   # mtls는 name: wiki-ext, host만 다름
spec:
  exportTo: [".", "egress-pt"]   # client sidecar(.) + 해당 egress ns 에만 노출(메시 전역 누수 금지)
  hosts: [example.org]           # mtls: www.wikipedia.org
  ports:
    - number: 443
      name: tls
      protocol: TLS              # ★ TLS 라야 sniHosts 라우팅이 붙는다(TCP면 SNI 못 봄). gateway가 L7 안 봄.
  resolution: DNS                # STRICT_DNS — multi-IP A record를 다 endpoint로 펼침(LOGICAL_DNS 단일-endpoint 함정 회피)

왜 이렇게: protocol: TLS는 “gateway가 앱 payload를 복호화하지 않는다(L4)“는 선언이다. origination(앱 평문화)이면 여기가 HTTP였을 것. exportTo는 host 정보를 필요한 곳에만 흘려 다른 gateway가 잘못 받아 NACK 나는 사고를 막는다 (스코핑 §5).

4.2 Gateway — “egress pod에 문 열기” (★ 델타 1: tls.mode)

kind: Gateway
metadata: { name: egw-pt-gateway, namespace: egress-pt }   # Gateway 리소스는 workload와 같은 ns
spec:
  selector: { istio: egw-pt }    # 이 ns의 egw-pt pod만 선택 — 멀티-gateway 격리의 핵심
  servers:
    - port: { number: 443, name: tls, protocol: TLS }   # CRD엔 443, Envoy는 Service targetPort 8443에 bind
      hosts: [example.org]
      tls:
        mode: PASSTHROUGH        # ◀── 델타 1

mTLS는 이 한 줄만 바뀐다:

      tls:
        mode: ISTIO_MUTUAL       # ◀── 델타 1 (mtls): outer 메시 mTLS를 *종단* + client cert 강제·검증

왜 이렇게: PASSTHROUGH는 복호화 없이 SNI로 라우팅만. ISTIO_MUTUAL은 listener를 “client cert 강제 (requireClientCertificate: true) + mesh CA로 SPIFFE 검증 + outer 종단"으로 만든다 — “누가 나가는가"가 여기서 확정된다. 포트(443)는 둘이 같아도 된다: 다른 pod·다른 ns라 충돌이 없다. (같은 pod에 두 모드를 얹을 때만 포트를 갈라야 했다.)

4.3 DestinationRule — “그 문을 어떻게 두드리나” (★ 델타 2: trafficPolicy.tls)

kind: DestinationRule
metadata: { name: egw-pt-dr, namespace: mesh-test }
spec:
  exportTo: ["."]
  host: egw-pt.egress-pt.svc.cluster.local   # = Helm release name = Service name
  subsets:
    - name: pt                   # passthrough: subset 식별만, TLS 정책 없음(앱 TLS 그대로 전달)

mTLS는 그 subset에 trafficPolicy가 붙는다(이게 hop1을 mTLS로 감싸는 트리거):

  host: egw-mtls.egress-mtls.svc.cluster.local
  subsets:
    - name: mtls
      trafficPolicy:
        portLevelSettings:
          - port: { number: 443 }            # sidecar가 dial하는 gateway Service 포트(= Gateway server port)
            tls:
              mode: ISTIO_MUTUAL              # ◀── 델타 2: sidecar가 SPIFFE client cert 제시 + outer mTLS 생성
              sni: www.wikipedia.org          # ◀── gateway가 filter chain 고르는 키. Gateway hosts·VS sniHosts와 일치 필수

왜 이렇게: passthrough는 sidecar가 앱 TLS를 그대로 넘기면 되니 DR에 tls가 없다. mTLS는 sidecar가 한 겹 더(outer) 를 만들어야 하므로 DR에서 ISTIO_MUTUAL을 명시한다. sni는 outer TLS의 SNI를 박는 것 — 이게 어긋나면 gateway listener 매칭 실패로 연결이 끊긴다.

4.4 VirtualService — “두 leg을 배선” (★ 델타 3: leg-2 tcp)

VS는 하나에 두 leg이 들어간다. leg-1(sidecar→gw)은 두 패턴 동일, leg-2(gw→외부)만 다르다.

# (1) client용 VS — mesh-test에 둠. 두 패턴 구조 동일.
kind: VirtualService
metadata: { name: pt-client, namespace: mesh-test }   # mtls: wiki-client
spec:
  exportTo: ["."]
  hosts: [example.org]
  gateways: [mesh]                       # leg-1은 sidecar(mesh)에 붙음
  tls:
    - match: [{ gateways: [mesh], port: 443, sniHosts: [example.org] }]
      route:
        - destination:
            host: egw-pt.egress-pt.svc.cluster.local   # 어느 gateway로
            subset: pt                                  # 어떤 말투로(DR subset)
            port: { number: 443 }

leg-2(gateway용 VS)는 여기서 갈린다:

# (2-passthrough) gateway용 VS — egress-pt에 둠
metadata: { name: pt-gateway, namespace: egress-pt }
spec:
  gateways: [egw-pt-gateway]
  tls:                                   # 종단 안 함 → tls/sniHosts
    - match: [{ gateways: [egw-pt-gateway], port: 443, sniHosts: [example.org] }]
      route: [{ destination: { host: example.org, port: { number: 443 } } }]
# (2-mtls) gateway용 VS — egress-mtls에 둠
metadata: { name: wiki-gateway, namespace: egress-mtls }
spec:
  gateways: [egw-mtls-gateway]
  tcp:                                   # ◀── 델타 3: 종단함 → tcp (이유는 §7)
    - match: [{ gateways: [egw-mtls-gateway], port: 443 }]
      route: [{ destination: { host: www.wikipedia.org, port: { number: 443 } } }]

정리 — 두 패턴은 딱 3곳만 다르다:

            passthrough            outer-mTLS
  Gateway   tls.mode: PASSTHROUGH  tls.mode: ISTIO_MUTUAL          (델타 1)
  DR        subset만               subset + trafficPolicy(mTLS,sni) (델타 2)
  VS leg-2  tls / sniHosts         tcp                              (델타 3)
  ----------------------------------------------------------------------
  나머지(SE, 포트 443->8443, leg-1 구조, ns 분리, exportTo)는 전부 동일

5. 필드 정렬 지도 — “왜 같은 문자열이 여기저기?“의 해소

4개가 따로 노는 게 아니라 특정 필드가 서로 같아야 연결된다. 여기서 길을 잃기 쉽다. 묶음으로 보면 끝난다:

 ┌─ 외부 host 이름 (example.org) — 같아야 함 ───────────────────┐
 │   SE.hosts · Gateway.servers.hosts · VS.hosts                 │
 │   VS leg-1 sniHosts · VS leg-2 dest.host                      │
 │   (mTLS만) DR.tls.sni    ← gateway filter chain 고르는 키       │
 └─────────────────────────────────────────────────────────────┘
 ┌─ gateway Service FQDN (egw-pt.egress-pt.svc) — 2곳 ─┐
 │   DR.host  ==  VS leg-1 destination.host             │  "교환원이 누구"
 └─────────────────────────────────────────────────────┘
 ┌─ subset 이름 (pt / mtls) — 2곳 ────────────────────┐
 │   DR.subsets.name == VS leg-1 destination.subset    │  "어떤 말투로"
 └─────────────────────────────────────────────────────┘
 ┌─ 포트 443 — 전부 443으로 통일 ──────────────────────┐
 │   SE.port · Gateway.port · DR.portLevelSettings.port │
 │   VS leg-1 dest.port · VS leg-2 match.port · 외부 dest.port │
 │   (Envoy 실제 listener는 Service targetPort 8443에 bind) │
 └─────────────────────────────────────────────────────┘
 ┌─ gateway 바인딩 ────────────────────────────────────┐
 │   Gateway.selector(istio:egw-pt) == egress pod label │
 │   VS.gateways == [mesh(leg1), <Gateway이름>(leg2)]    │
 └─────────────────────────────────────────────────────┘

포트를 둘 다 443으로 통일한 효과가 여기서 드러난다 — “443이냐 15443이냐"라는 혼란 축이 통째로 사라지고, 443은 다 같은 443으로 읽힌다. 실측 함정도 전부 이 정렬 위반이다: sni↔hosts↔sniHosts 어긋남 = SSL 오류.


6. 적용·결과 — 떴는지 한 번만 본다

구성이 맞으면 listener가 뜨고 호출이 통한다. 길게 검증하지 않고 “listener 존재 + 호출 성공"만 확인한다:

istioctl proxy-config listener deploy/egw-mtls.egress-mtls   # PORT 8443, SNI www.wikipedia.org 행이 보이면 OK
# (ISTIO_MUTUAL이면) --port 8443 -o json | grep requireClientCertificate  -> true
SLEEP=$(kubectl -n mesh-test get pod -l app=sleep -o jsonpath='{.items[0].metadata.name}')
kubectl -n mesh-test exec $SLEEP -c sleep -- curl -sS -o /dev/null -w '%{http_code}\n' https://www.wikipedia.org  # 200
  • proxy-config listener8443 / SNI www.wikipedia.org 행이 보이면 Gateway·VS leg-2 배선이 산 것이다.
  • requireClientCertificate: true면 ISTIO_MUTUAL listener가 client cert를 강제하도록 떴다는 뜻(델타 1이 먹은 증거).
  • 마지막 curl 200이면 두 hop이 끝까지 이어졌다 — hop1(mTLS 감쌈) → hop2(종단 후 외부) 완주.

listener 행이 안 보이면 십중팔구 §7(종단인데 leg-2를 tls로) 또는 §5(정렬 어긋남)다.


7. 단 하나의 비대칭 — 왜 leg-1은 tls, leg-2는 tcp인가 (mTLS 한정)

passthrough는 양쪽 leg 모두 tls/sniHosts다. mTLS만 leg-2가 tcp로 갈린다. 이유:

  passthrough : sidecar [SNI 봄] ──tls──> gw [SNI 봄, 종단 안 함] ──tls──> 외부
  outer-mTLS  : sidecar [outer 생성] ─tls─> gw [outer 종단! SNI 소비] ─tcp─> 외부
                                                        └ 종단 뒤엔 SNI가 없다 → tcp_proxy로 흘릴 수밖에

ISTIO_MUTUAL gateway는 outer 메시 mTLS를 종단한다. 종단된 listener에 tls/sniHosts route를 걸면 network filter가 안 생겨 listener가 통째로 누락된다(istiod: must have more than 0 chains로 omit). 종단 뒤 남은 inner 앱 TLS 바이트는 L4 tcp_proxy로 흘려야 하므로 leg-2는 반드시 tcp. 이게 mTLS egress의 가장 흔한 함정이다. (filter chain 원리: Envoy filter chain)


핵심 정리

  • 한 번의 외부 curl = 두 hop(app→gw, gw→외부). 4개 CRD는 두 hop × 관심사 분리의 결과 — “필드 4묶음"이 아니라 질문 4개.
  • 질문 매핑: SE=“host 존재하나”(주소록) · VS=“어디로”(두 leg, 유일하게 양 hop) · DR=“hop1을 어떻게(plain/mTLS)” · Gateway=“어느 문·보안 모드”.
  • 공간 배치: hop1은 sidecar 쪽(VS leg-1 + DR), hop2는 gateway 쪽(Gateway + VS leg-2), SE는 hop 무관 전제.
  • passthrough → outer-mTLS = 델타 3개뿐: Gateway tls.mode(PASSTHROUGH→ISTIO_MUTUAL), DR trafficPolicy(subset만→mTLS+sni), VS leg-2(tlstcp). 나머지는 전부 동일.
  • 정렬이 곧 정상 동작: 외부 host 이름·gateway FQDN·subset·포트 443·selector가 여러 곳에서 일치해야 연결된다. 어긋남 = SSL 오류·listener 누락.
  • 비대칭 1곳: ISTIO_MUTUAL은 outer를 종단 → SNI 소비 → leg-2는 반드시 tcp(tls면 0 chains로 listener omit).

What you might be missing

  • VS의 gateways 리스트가 leg을 가른다. mesh가 leg-1(sidecar), <Gateway이름>이 leg-2(gateway). 한 VS에 둘을 같이 넣으면 한 리소스가 두 hop을 다 배선하지만, 소유권/스코핑이 섞인다. 그래서 본 랩은 client용·gateway용 VS를 별도 ns로 분리했다 — 이유는 스코핑 노트.
  • 포트를 통일할 수 있는 건 “다른 pod"이기 때문이다. 한 egress pod에 passthrough와 ISTIO_MUTUAL을 같이 얹으면 같은 포트 공존이 불가(merge 충돌)라 15443 등으로 갈라야 한다. dual-pod로 쪼개면 그 제약이 사라져 443으로 통일 가능 → 설정이 직관적이 된다. 즉 pod를 나누는 결정이 포트 설계를 단순하게 만든다.
  • 이 4-CRD 골격은 세 패턴(passthrough/mTLS/origination)에 공통이다. 바뀌는 건 Gateway tls.mode, DR trafficPolicy, VS leg-2 route 타입 — 골격을 외우면 패턴 전환은 “델타 몇 줄"이다. origination(gateway가 외부로 TLS 시작)은 SE가 protocol: HTTP가 되고 DR에 외부용 TLS가 붙는 또 다른 델타다(HTTP vs HTTPS).
  • Istio 라우팅 ≠ 강제. 이 골격은 “어떻게 나가는가"를 정의할 뿐, sidecar 우회를 막지 못한다. 진짜 강제는 Calico NetworkPolicy로 egress pod 외 직접 송신을 차단해야 선다(정본 §02).

8. 참조

아카이브 내부

관련 IaC (실제 manifest)

Files