Egress 4-CRD 직관 — "한 번의 curl = 두 hop", 4개를 어떤 순서로 어떻게 채우나
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 listener에 8443 / 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), DRtrafficPolicy(subset만→mTLS+sni), VS leg-2(tls→tcp). 나머지는 전부 동일. - 정렬이 곧 정상 동작: 외부 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, DRtrafficPolicy, VS leg-2 route 타입 — 골격을 외우면 패턴 전환은 “델타 몇 줄"이다. origination(gateway가 외부로 TLS 시작)은 SE가protocol: HTTP가 되고 DR에 외부용 TLS가 붙는 또 다른 델타다(HTTP vs HTTPS). - Istio 라우팅 ≠ 강제. 이 골격은 “어떻게 나가는가"를 정의할 뿐, sidecar 우회를 막지 못한다. 진짜 강제는 Calico NetworkPolicy로 egress pod 외 직접 송신을 차단해야 선다(정본 §02).
8. 참조
아카이브 내부
- Egress Gateway 도입 가이드 (사내 공유본) — 이 멘탈모델이 실전 의사결정·표준 1벌로 압축된 형태
- Egress TCP 병목 정본 — 4-CRD 다음에 만나는 운영 한계(연결·포트·conntrack)
- 이중 gateway 검증 랩 · egress route 스코핑
- Egress Gateway 개념 정본(필드 사전) · HTTPS over mTLS 해부
- HTTPS passthrough 가이드 · HTTP vs HTTPS · egress operations
- Envoy filter chain · SPIFFE 신원
관련 IaC (실제 manifest)
Files
- 10-passthrough.yaml 3 KB
- 20-mtls.yaml 4 KB
- values-egw-mtls.yaml 1 KB
- values-egw-pt.yaml 963 B
- Raw Markdown (index.md)