---
type: report
tags: [istio, ingress, egress, gateway, report]
created: 2026-06-07
---
# Test Report — Ingress / Egress Gateway 동작 검증
> [!abstract]
> 홈랩 클러스터에서 Ingress·Egress gateway 동작을 실측 검증한 리포트.
> **하나의 그림:** gateway는 메시 경계에 선 전용 Envoy proxy다. **ingress는 자기가 TLS를 끝내므로(termination) L7 전체가 보여 host/path로 분기**하고, **egress는 나가는 HTTPS를 복호화하지 않고 SNI만 보고 L4로 한 chokepoint를 거치게 강제**한다 — 이 *L7 vs L4* 비대칭이 두 gateway 검증법까지 갈라놓는다.
> **결론 — Ingress**: host/path 라우팅 + TLS termination PASS. **Egress**: HTTPS SNI PASSTHROUGH로 외부 호출이 egress gateway를 강제 경유함을 access log로 입증(200). REGISTRY_ONLY 미등록 차단은 메시 전역 영향 탓에 의도적 보류.
**Date:** 2026-06-07
**Cluster:** homelab (kubespray bare-metal, k8s v1.30.6, Calico)
**Istio:** 1.30.0 (istiod + ingress/egress gateway, Helm 설치)
**Scenario:** 10-ingress, 20-egress
**NS:** mesh-test (istio-injection=enabled)
---
## 0. 배경 — 왜 gateway가 따로 있고, 왜 둘의 검증법이 다른가
sidecar가 이미 모든 pod에 붙어 mTLS·라우팅·관측을 한다면, 경계에 *또 하나의* Envoy(gateway)를 세우는 이유는 무엇인가. 답은 **경계 트래픽은 sidecar가 다루기 곤란한 특성**을 갖기 때문이다.
- **Ingress 쪽 문제:** 외부에서 오는 클라이언트는 mesh identity(SPIFFE cert)가 없다. 그냥 공개 HTTPS로 들어온다. 누군가는 그 외부 TLS를 **종료(termination)**하고, 복호화한 Host/path를 보고 어느 내부 서비스로 보낼지 정해야 한다. 이걸 모든 pod에 흩뿌릴 수 없으니 **단일 진입점**이 필요하다.
- **Egress 쪽 문제:** 기본값(`outboundTrafficPolicy: ALLOW_ANY`)에선 각 sidecar가 외부로 *제멋대로* 나간다. 그러면 egress IP가 노드마다 흩어지고, 방화벽 화이트리스트가 복잡해지고, 외부 호출 로그가 흩어진다. 그래서 나가는 트래픽을 **한 pod(chokepoint)로 모아** 단일 통제·관측 지점을 만든다.
그래서 ingress와 egress는 같은 "경계 gateway"지만 **푸는 문제가 정반대**다. ingress는 *외부 TLS를 끝내고 안을 들여다봐야* 하고(그래야 path로 분기), egress는 *나가는 TLS를 그대로 둔 채 어디로 가는지만* 알면 된다. 이 한 줄의 차이가 이 리포트의 핵심 — **ingress = L7 termination, egress = L4 SNI passthrough** — 으로 굳는다.
```mermaid
flowchart LR
client["external client
(no mesh identity)"]
igw["ingress GW
TLS termination → L7"]
svc["httpbin
(internal)"]
sleep["sleep sidecar
(in mesh)"]
egw["egress GW
SNI passthrough → L4"]
ext["httpbin.org:443
(external HTTPS)"]
client -- "HTTPS in" --> igw
igw -- "decrypted Host/path route" --> svc
sleep -- "HTTPS out (still encrypted)" --> egw
egw -- "route by SNI only" --> ext
```
이 비대칭의 직접적 귀결: **검증법이 다르다.** ingress는 "복호화가 됐나 + path가 맞게 갈렸나"를 status code(200/418/404)로 본다. egress는 payload가 암호화돼 gateway가 status를 볼 수 없으니, "200이 떴나"가 아니라 **"정말 그 pod를 *경유*했나"**를 access log로 본다. 이 차이를 모르면 egress의 `200`을 "gateway가 반환한 200"으로 오독한다(→ §2, What you might be missing).
**선행 개념:** Envoy listener/route/cluster, cluster 이름 규칙 `direction|port|subset|fqdn`, SNI(TLS handshake에 평문 노출되는 목적지 호스트명), ServiceEntry/Gateway/VirtualService/DestinationRule 4종 CRD. 깊은 레퍼런스는 [Egress Gateway 정본](gw__src-egress-gateway.html), [Cluster 해부](xds__src-cluster-anatomy.html).
---
## 1. 사전 확인 — 출발선이 깨끗한가
검증 전에 메시 자체가 정상이어야 결과를 믿을 수 있다.
- 메시 상태 정상: istiod/ingress/egress 모두 `1/1 Running`, proxy-status 2 proxies SYNCED.
- ⚠️ 초기 "비정상" 의심은 **istioctl 1.27 클라이언트로 1.30 컨트롤플레인을 조회**한 버전 불일치 착시였음. 실제 이상 없음 — *진단 도구의 버전부터 의심하라*는 교훈.
- sample app(`httpbin`, `sleep`) READY 2/2 → sidecar 주입 정상.
- baseline: 내부(sleep→httpbin) 200, 외부(httpbin.org) 200 (기본 `outboundTrafficPolicy: ALLOW_ANY` — 이 시점엔 egress gateway를 *안 거치고도* 외부가 뚫린다는 뜻. §2의 "경유 강제"는 이 baseline 위에 *길을 새로 까는* 작업이다).
---
## 2. Ingress Gateway — L7 termination 검증
**메커니즘:** ingress gateway는 외부에서 받은 HTTPS를 자기가 **termination**(복호화)한다. 일단 복호화하면 L7 전체(Host 헤더·path·method)가 보이므로, 그 정보로 내부 서비스에 분기할 수 있다. egress가 SNI만 보는 L4 라우팅인 것과 정확히 대비되는 지점이다 — **자기가 TLS를 끝내므로 L7 전체가 보인다.** 그래서 검증 포인트는 둘로 갈린다: (1) TLS termination이 동작하는가, (2) 복호화된 L7로 host/path 분기가 올바른가(매칭 안 되면 404).
### 적용 manifest
- `scenarios/10-ingress/gateway-ingress.yaml` — Gateway(selector `istio: ingressgateway`), HTTP:80 + HTTPS:443(TLS termination, `credentialName: httpbin-tls`), host `httpbin.example.com`
- `scenarios/10-ingress/virtualservice-httpbin.yaml` — `/status*` 명시 매칭 + `/*` catch-all → `httpbin.mesh-test.svc:8000`
- TLS secret: 자체서명 cert → `kubectl -n istio-system create secret tls httpbin-tls` (cert는 `tmp/certs/`, gitignored)
### 검증 (NodePort http 31080 / https 31443, NODE=203.0.113.212)
각 테스트가 위 두 포인트 중 *무엇을* 입증하는지 보라 — 단순 200 나열이 아니다.
| 테스트 | 명령 | 기대 | 실제 | 입증 |
|---|---|---|---|---|
| HTTP catch-all | `curl -H "Host: httpbin.example.com" http://NODE:31080/get` | 200 | **200** | `/*` catch-all 도달 |
| path match | `.../status/418` | 418 | **418** | `/status*` 명시 route가 catch-all보다 우선 |
| TLS termination | `curl -k --resolve httpbin.example.com:31443:NODE https://.../get` | 200 | **200** | gateway가 외부 TLS 복호화 |
| host 분기 | `curl -H "Host: nope.example.com" .../get` | 404 | **404** | 매칭 안 되는 host는 라우팅 거부 |
`418`이 `200`보다 중요하다: catch-all `/*`만 있었다면 `/status/418`도 200 본문을 받았을 것이다. 418이 떴다는 건 **더 구체적인 `/status*` route가 먼저 매칭**됐다는 뜻 — Envoy route 우선순위가 살아있음을 보여준다. `404`(nope.example.com)는 그 반대편 증거: 정의되지 않은 host는 *조용히 어디론가 가지 않고* 거부된다.
- `istioctl proxy-config routes deploy/istio-ingressgateway.istio-system` 에 `http.8080` / `https.443.*` route 반영 확인.
- `istioctl analyze -n mesh-test` → 이슈 0.
### 합격 판정: PASS
외부→gateway 200, TLS termination 동작, host/path 라우팅 분기 정상.
---
## 3. Egress Gateway — L4 SNI passthrough + 경유 강제 검증
**메커니즘과 "왜":** 외부 대상이 HTTPS(`httpbin.org:443`)이면 sleep sidecar→egress 구간은 *이미 암호화*돼 있다 — gateway가 페이로드를 못 본다. 평문 HTTP였다면 gateway가 L7에서 헤더 보고 라우팅하겠지만, HTTPS면 그게 불가능하다. 그래서 헤더가 아니라 **TLS handshake의 SNI**(평문으로 노출되는 목적지 호스트명)로만 L4 라우팅한다. 이게 **PASSTHROUGH** 패턴이다 — gateway는 복호화 없이 SNI만 보고 흘려보낸다.
그러면 "굳이 왜 egw를 거치나?" §0의 답이 여기서 검증 질문을 정의한다: chokepoint를 만드는 게 목적이므로, 핵심 질문은 **"호출이 성공하느냐"가 아니라 "호출이 정말 그 pod를 *경유*하느냐"**다. 그래서 합격 판정도 단순 200이 아니라 **`200 + 경유 강제`**다. 개념·메커니즘 상세는 [Egress Gateway 정본](gw__src-egress-gateway.html), passthrough vs TLS origination 비교는 [Egress HTTP vs HTTPS](gw__src-egress-http-vs-https.html) 참조.
### 적용 manifest
- `scenarios/20-egress/serviceentry-httpbin-ext.yaml` — httpbin.org:443 TLS, `MESH_EXTERNAL`, `resolution: DNS`
- `scenarios/20-egress/gateway-egress.yaml` — Gateway(selector `istio: egressgateway`), 443 TLS `PASSTHROUGH`
- `scenarios/20-egress/destinationrule-egress.yaml` — egress gateway subset `httpbin`
- `scenarios/20-egress/virtualservice-egress.yaml` — **2단 라우팅(`tls:` + `sniHosts`, `http:` 아님 — SNI 기반 L4 라우팅)**: mesh leg(sleep→egress subset), egress leg(egress→httpbin.org)
이 3개 CRD의 값이 *제각각이면 안 되고 한 줄로 정렬*돼야 한다 — 셋 중 하나라도 L7(HTTP)을 가정하면 gateway가 복호화를 시도하다 깨진다.
> **SNI PASSTHROUGH 정합 3요소** — 셋이 모두 맞아야 L4 SNI 라우팅이 성립한다(상세: [Egress Gateway 정본](gw__src-egress-gateway.html)):
> - ServiceEntry 포트 `protocol: TLS`
> - Gateway server `tls.mode: PASSTHROUGH`
> - VirtualService를 `http:`가 아닌 `tls:` + `sniHosts: [httpbin.org]`로 작성
>
> `http:` 라우팅으로 작성하면 gateway가 TLS를 복호화하려다 실패한다.
### 검증 — config가 깔렸나 → 호출 됐나 → *경유*했나
검증은 세 층으로 내려간다: ① Envoy에 config가 반영됐나, ② 실제 호출이 200인가, ③ 그 호출이 정말 egw를 *통과*했나(이게 핵심).
| 항목 | 확인 방법 | 결과 |
|---|---|---|
| sleep proxy에 egress cluster | `proxy-config clusters deploy/sleep.mesh-test` | `...egressgateway...443 httpbin` subset cluster 존재 |
| egress gateway listener | `proxy-config listeners deploy/istio-egressgateway` | `0.0.0.0:8443 SNI: httpbin.org → outbound|443||httpbin.org` ¹ |
| 실제 호출 | `sleep -> curl -sI https://httpbin.org/get` | **HTTP/2 200** |
| **경유 강제** | egress gateway access log 신규 라인 | `outbound|443||httpbin.org` 기록 (dest 44.213.156.185:443) |
¹ Gateway server는 `443`으로 정의했으나 실제 리스너는 `8443`이다. 비권한 포트로 listen하기 위해 **Service 포트 443 → pod targetPort 8443**으로 매핑한 결과이며, 포트 불일치가 아니다. Ingress의 `http.8080`(Service 80→targetPort 8080)과 동일한 패턴.
access log 발췌:
```
[2026-06-07T01:49:40Z] "- - -" 0 ... "44.213.156.185:443" outbound|443||httpbin.org
10.255.126.47:49456 10.255.126.47:8443 10.255.126.49:37006 httpbin.org -
```
gateway는 PASSTHROUGH이므로 이 로그는 **HTTP 포맷이 아니라 TCP access log 포맷**이다 — method/path/status code가 없고 SNI(`httpbin.org`)·바이트·duration·peer IP만 기록된다(L7 가시성 없음). 위 `"- - -" 0`을 위 표의 `HTTP/2 200`과 같은 라인으로 오해하지 말 것: **200은 egress가 본 status가 아니라 sleep→외부 end-to-end 호출 결과**다. egress gateway pod IP `10.255.126.47:8443` 수신 → 외부 `44.213.156.185:443`(httpbin.org) 송신 = **경유 확인**. 로그에 *이 라인이 새로 생겼다*는 것 자체가 §1 baseline의 "egw 없이 직접 나가던" 경로가 egw를 *통과*하는 경로로 바뀌었다는 증거다.
```mermaid
flowchart LR
sleep["sleep sidecar
10.255.126.49"]
egw["egress GW :8443
PASSTHROUGH
10.255.126.47"]
ext["httpbin.org:443
44.213.156.185"]
sleep -- "mesh leg
SNI route (tls/sniHosts)" --> egw
egw -- "egress leg
outbound|443||httpbin.org" --> ext
```
### 합격 판정: PASS (경유 강제 + 200)
### 미수행 (의도적 보류)
- **REGISTRY_ONLY 차단 테스트**: `outboundTrafficPolicy`는 mesh 전역(istio configmap) 설정 → 메시 전체 영향. 위험 작업 정책상 사용자 승인 후 별도 진행 예정. 현재는 `ALLOW_ANY` 유지 상태에서 VirtualService 기반 경유만 검증. (그래서 이 리포트는 *경유 강제*는 입증하지만 *차단*은 입증하지 못한다 — 둘은 다른 명제. → What you might be missing)
- TLS origination(평문→egress에서 TLS 시작): 본 검증은 passthrough 채택. 비교는 [Egress HTTP vs HTTPS](gw__src-egress-http-vs-https.html), 별도 시나리오로 분리.
---
## 4. 트러블슈팅
- `kubectl apply -f scenarios/00-sample-apps/`: 알파벳 순 처리로 `httpbin.yaml`이 `namespace.yaml`보다 먼저 적용되어 NotFound. **재적용(2회)으로 해결** — 디렉토리 apply 시 ns 선생성 의존성. (개선: `--server-side` 또는 ns 분리 적용 권장)
---
## 5. 재현 명령 요약
```bash
# 0. sample apps
kubectl apply -f scenarios/00-sample-apps/ # ns 의존으로 2회 또는 ns 먼저
# 1. ingress
kubectl -n istio-system create secret tls httpbin-tls --cert=tmp/certs/cert.pem --key=tmp/certs/key.pem
kubectl apply -f scenarios/10-ingress/
NODE=203.0.113.212
curl -H "Host: httpbin.example.com" http://$NODE:31080/get
# 2. egress
kubectl apply -f scenarios/20-egress/
kubectl -n mesh-test exec deploy/sleep -c sleep -- curl -sI https://httpbin.org/get
kubectl -n istio-system logs deploy/istio-egressgateway | grep httpbin.org
```
## 6. 다음 작업
- REGISTRY_ONLY 전환 후 미등록 외부 차단 검증(승인 필요).
- 30-security: PeerAuthentication STRICT + AuthorizationPolicy.
- TLS origination egress 변형.
- ✅ ISTIO_MUTUAL egress(HTTPS over mTLS) 검증 완료 → [Egress mTLS 리포트](gw__report-2026-06-08_egress-mtls.html)
---
## 핵심 정리
- **한 문장:** ingress=L7 termination(복호화→host/path 분기), egress=L4 SNI passthrough(복호화 안 함→경유 강제). 이 비대칭이 검증법까지 가른다(ingress는 status code, egress는 access log).
- **Ingress**: host/path 라우팅 분기(404 포함, `/status*`>`/*` 우선순위) + TLS termination(`credentialName`) 모두 PASS. route는 `http.8080`/`https.443.*`로 Envoy에 반영.
- **Egress(SNI PASSTHROUGH)**: ServiceEntry `protocol: TLS` + Gateway `tls.mode: PASSTHROUGH` + VirtualService `tls`+`sniHosts` 3요소가 한 줄로 정렬돼야 성립. http 라우팅 아님.
- **경유 강제 입증**: egress gateway access log에 `outbound|443||httpbin.org`(dest 44.213.156.185:443) 신규 라인 + 호출 200. PASSTHROUGH라 로그는 TCP 포맷(status 없음).
- **포트 매핑**: gateway listener 8443은 Service 443→targetPort 8443 비권한 매핑. 불일치 아님.
- **보류**: REGISTRY_ONLY 미등록 차단은 메시 전역(`outboundTrafficPolicy`) 영향 → 승인 후 별도 진행.
---
## What you might be missing
- **200은 egress가 본 status가 아니다.** PASSTHROUGH egress의 access log는 TCP 포맷이라 method/path/status code가 없다. 검증표의 `HTTP/2 200`은 sleep→외부 end-to-end 호출 결과이고, gateway가 본 것은 SNI·바이트·duration·peer IP뿐(L7 가시성 없음). 둘을 같은 라인으로 오해하면 "egress가 200을 반환했다"는 잘못된 결론에 이른다. 이건 비대칭의 직접적 귀결 — egress가 L4라서 status가 *원래 안 보이는* 것이다.
- **경유 강제 ≠ 차단.** 본 리포트는 VirtualService 기반 *경유 강제*만 입증했다. 미등록 외부 호출 *차단*은 `outboundTrafficPolicy: REGISTRY_ONLY`가 필요한데, 이 값은 mesh 전역(istio configmap) 설정이라 메시 전체에 영향을 준다. `ALLOW_ANY` 상태에선 누군가 VirtualService를 우회해 *여전히 직접* 나갈 수 있으므로, 경유가 보장돼도 차단은 보장되지 않는다 — 별도 승인·검증이 필요하다.
- **ingress 404 vs egress 0**: ingress의 미매칭은 L7이라 명확한 `404`로 떨어지지만, egress PASSTHROUGH에서 SNI 불일치는 L4라 `404`가 아니라 connection reset/drop으로 나타난다. "왜 4xx가 안 보이지?"의 답은 같은 비대칭이다.
---
## 관련 파일 · 참조
**Ingress**
- 📎 [gateway-ingress.yaml](attachment/scenarios/10-ingress/gateway-ingress.yaml) · 📎 [virtualservice-httpbin.yaml](attachment/scenarios/10-ingress/virtualservice-httpbin.yaml) · 📎 [10-ingress/README.md](attachment/scenarios/10-ingress/README.md)
**Egress**
- 📎 [gateway-egress.yaml](attachment/scenarios/20-egress/gateway-egress.yaml) · 📎 [serviceentry-httpbin-ext.yaml](attachment/scenarios/20-egress/serviceentry-httpbin-ext.yaml) · 📎 [virtualservice-egress.yaml](attachment/scenarios/20-egress/virtualservice-egress.yaml) · 📎 [destinationrule-egress.yaml](attachment/scenarios/20-egress/destinationrule-egress.yaml) · 📎 [20-egress/README.md](attachment/scenarios/20-egress/README.md)
**검증 스크립트 · 설치**
- 📎 [traffic.sh](attachment/scripts/traffic.sh) · 📎 [proxy-dump.sh](attachment/scripts/proxy-dump.sh) · 📎 [install/helm/README.md](attachment/install/helm/README.md) · 📎 [verify.sh](attachment/install/verify.sh)
- 📎 [원본 test-report](attachment/docs/test-reports/2026-06-07_ingress-egress.md) · 설치 선행: [Helm 재설치 런북](arch__runbook-helm-reinstall.html)
- ↗ [Istio: Egress Gateways](https://istio.io/latest/docs/tasks/traffic-management/egress/egress-gateway/)