Egress 신원 기반 통제 — mTLS 신원이 공용 gateway에 멀티테넌시를 만든다
“egress gateway만 세우면 외부 통신이 통제된다"는 직관의 빈칸을 메우는 문서. 방화벽·NetworkPolicy는 “외부로 나가는 유일한 경로가 gateway인가”(Q1) 에만 답하고, “그 gateway를 누가, 어느 목적지로 쓸 수 있나”(Q2) 에는 답하지 못한다. Q2의 판정 재료가 mesh mTLS가 운반하는 SPIFFE 신원이고, 판정 장치가 gateway 위의 AuthorizationPolicy(principal × SNI) 다. 이 문서는 ① 그 논리를 통제 체인(경로 강제 → 검문소 → 신원 판정)으로 세우고 ② SA 2개가 서로 다른 목적지만 허용받는 테스트 클러스터 전체 구성(YAML 주석 포함)과 검증·함정까지 따라간다. 이중 TLS 구조 자체의 해부는 HTTPS over mTLS 정본, 4-CRD 직관은 Egress 4-CRD 멘탈모델이 정본 — 본 문서는 그 구조 위에 올라가는 “통제” 를 다룬다.
대상 환경: Istio 1.30.0, sidecar mesh, Helm gateway chart (테스트 클러스터) 대상 독자: egress gateway를 보안 요건(최소권한·감사 추적) 충족 수단으로 도입하려는 DevOps/SRE 범위: 신원 기반 통제의 논리 → 테스트 클러스터 구성 → 검증 매트릭스 → 설계 결정 노트(분리 전략·TCP·wildcard) 선행 개념: egress 2-leg 라우팅(4-CRD 멘탈모델), SPIFFE 신원(정본), AuthorizationPolicy 평가(멘탈모델)
1. 멘탈모델 한 문장 — 통제는 체인이고, 신원은 그 마지막 고리다
강제(enforcement)는 경로가 하고(NetworkPolicy·방화벽), 판정은 검문소(gateway)가 하며, 판정에 “누가"를 공급하는 유일한 장치가 sidecar↔gateway 구간의 mesh mTLS(SPIFFE 인증서)다.
NetworkPolicy / IDC firewall : "egress gw 경유 외 외부행 차단" (경로 강제, L3/L4)
|
v
egress gateway : 유일한 검문소가 됨
|
v
검문소에서 "누가 -> 어디로" 판정 필요 <- 여기서 신원(SPIFFE)이 등장
이 체인에서 어느 고리 하나만 빠져도 통제가 성립하지 않는다.
- 경로 강제 없이 gateway만 → 침해된 pod가 sidecar를 우회(iptables 조작, UID 1337)해 직접 나간다. Istio 레이어는 우회를 못 막는다.
- 검문소까지 만들고 신원 없이 → gateway가 보는 건 source pod IP(휘발)와 SNI(클라이언트 제공 값)뿐. gateway에 도달 가능한 모든 pod가 gateway에 설정된 모든 경로를 쓸 수 있다. PG사 경로를 하나 뚫는 순간 전사 모든 워크로드가 PG사로 나갈 수 있는 상태가 된다.
신원 기반 authz는 결국 “전용 gateway N개를 정책 N줄로 치환” 하는 장치다. 물리 분리 없이 공용 gateway 위에 논리적 멀티테넌시를 만드는 것 — 이게 이 패턴의 본질이다.
2. 왜 신원인가 — 흔한 두 반론을 메커니즘으로 해소
반론 1: “ServiceEntry로 특정 namespace에서 외부 주소를 못 보게 막으면 되지 않나”
ServiceEntry(+exportTo)·REGISTRY_ONLY는 차단 장치가 아니라 설정 배포 범위 제어다. “team-a namespace에는 이 외부 호스트 설정을 안 내려준다"일 뿐이고, 집행 주체가 클라이언트 자신의 sidecar라는 게 구조적 약점이다. sidecar는 pod와 같은 network namespace에 있어서, root 권한을 가진 침해 pod는 sidecar를 건너뛰고 직접 나갈 수 있다. Istio 공식 Security Best Practices가 REGISTRY_ONLY를 보안 경계가 아닌 best-effort로 간주하라고 명시하는 이유다. 그래서 집행은 공격자가 통제할 수 없는 지점(CNI NetworkPolicy, IDC 방화벽, gateway)으로 옮겨야 한다.
반론 2: “어차피 egress 노드가 아니면 방화벽에서 막히는데, 그걸로 충분하지 않나”
절반은 맞다. 방화벽과 신원은 다른 질문에 답하는 장치라서 분리해야 한다.
Q1. 외부로 나가는 유일한 경로가 gateway인가? -> 방화벽이 답함 (O)
Q2. gateway를 "누가" "어느 목적지로" 쓸 수 있나? -> 방화벽은 못 답함
방화벽이 보는 건 gateway 노드 CIDR → 대외기관 IP뿐이다. 모든 워크로드가 같은 SNAT IP로 나가니 app-a와 app-b를 구분할 수 없고, gateway Service는 ClusterIP라 클러스터 내 모든 pod가 도달 가능하다. 보안 요건이 “모든 외부 통신은 통제된 경로 + 전사 공통 allowlist"까지라면 passthrough + 방화벽으로 충분하다. 요건에 워크로드 단위 최소권한(least privilege)과 주체 식별 가능한 감사 추적이 포함될 때 비로소 신원이 필요해진다 — 그 판정을 할 수 있는 유일한 지점이 gateway이고, 판정 재료가 신원이기 때문이다.
“passthrough(A)냐 이중 TLS(B)냐"는 기술 선호가 아니라 사내 보안 심사 요건 문서에 “워크로드별
차등 통제·주체 식별"이 명시되어 있는지로 판가름한다. 없으면 A로 시작하고, 요건이 생기면 같은
토폴로지에서 Gateway tls.mode + DR trafficPolicy + AuthorizationPolicy만 추가하는 증분 적용이
가능하다(델타 3곳). 같은 환경에서 신원을 CNI pod-selector로
대신 식별하는 반대 선택의 근거는 이중 TLS 없는 egress 신원.
신원이 있을 때만 가능해지는 통제
| 제어 | 내용 | 신원 없이 가능? |
|---|---|---|
| 워크로드별 목적지 allowlist | app-a→PG사만, app-b→신용평가사만 | ✗ (전 워크로드 동일 권한) |
| default-deny + 신청·승인 운영 | 정책 1줄 = 승인 1건, 감사 대응 직결 | △ (목적지 단위만) |
| 감사 로그 | “어느 SA가 언제 어디로” — pod IP churn 무관 | ✗ |
| 즉시 회수 | 사고 시 해당 SA 정책 삭제로 차단 | ✗ |
사실관계 한 줄 — 이중 TLS는 “표준 패턴"이 아니라 “공식 문서화된 변형”
공식 task 문서(Egress Gateways)는 단순 SNI passthrough까지만 제시한다. ISTIO_MUTUAL 이중 TLS가 등장하는 곳은 공식 블로그·하드닝 문맥이다 — egress SNI 라우팅 블로그(2023)는 gateway를 mesh 내부 클라이언트 전용으로 잠그려면 sidecar↔gateway에 ISTIO_MUTUAL을 강제해야 하고 그 결과 TLS가 두 겹이 된다고 명시하고, Security Best Practices는 egress 보호를 gateway + NetworkPolicy 결합으로 설명한다. 즉 기본 권장은 passthrough가 맞고, 신원 기반 통제가 요건일 때의 문서화된 경로가 이중 TLS다.
3. 부품표 — 각 부품 = 통제 질문 하나
라우팅 4-CRD의 역할은 4-CRD 멘탈모델이 정본. 여기서는 통제 관점에서 각 부품이 답하는 질문만 다시 정렬한다.
| 통제 질문 | 답 = 부품 | 핵심 한 줄 |
|---|---|---|
| “gateway를 mesh 내부만 쓰게 강제하려면?” | Gateway tls.mode: ISTIO_MUTUAL |
리스너가 mesh CA 발급 client cert를 요구 — 없으면 handshake 거부 |
| “gateway가 목적지를 무엇으로 분기하나?” | DR tls.sni |
sidecar가 만드는 outer ClientHello의 SNI에 원본 호스트 적재 = 리스너 매칭 키 |
| “신원은 어디서 추출되나?” | outer mTLS 종단 시 | client cert SAN의 spiffe://... → source.principal |
| “누가 → 어디로의 판정은?” | AuthorizationPolicy | principals × connection.sni. deny-all + 명시 allow 쌍이 기본형 |
| “이 외부 호스트가 존재하긴 하나?” | ServiceEntry | 주소록 등록(배포 제어일 뿐, 집행 아님 — §2) |
| “우회는 누가 막나?” | NetworkPolicy + 방화벽 | Istio 밖. 일반 워크로드 external egress deny, gw pod+DNS만 허용 |
요청 한 번의 통제 흐름 (어디서 거부되는가)
[app pod, sa=app-a]
| (1) HTTPS 요청 (inner TLS, dst=api.partner.com)
v
[sidecar Envoy]
| (2) VS 매칭(mesh, sniHosts) -> egress gw로 라우팅
| (3) outer ISTIO_MUTUAL 래핑
| client cert SAN = spiffe://cluster.local/ns/team-a/sa/app-a
v
[egress gw listener, mode=ISTIO_MUTUAL]
| (4) mesh CA로 client cert 검증 -> principal 추출
| * cert 없음/타 CA = handshake 거부 <- "내부만 강제"의 실체
| (5) AuthorizationPolicy: principal x connection.sni 평가
| * 매칭 allow 없음 = connection reset <- 신원 기반 거부 (403 아님!)
| (6) outer TLS 벗김 -> inner TLS 바이트를 SNI 기반 라우팅
v
[api.partner.com:443] <- inner TLS는 여기서 종단 (end-to-end 보존)
거부 지점이 둘이다: (4) handshake 거부 = “mesh 밖 클라이언트”, (5) connection reset = “신원은 있으나 권한 없음”. 이 구분이 §6 검증과 운영 디버깅의 골격이 된다.
4. 구성 따라하기 — 테스트 클러스터에 신원 차등 통제 세우기
증명하려는 것: 같은 gateway를 공유하는 두 워크로드(SA만 다름)가 서로 다른 목적지만 허용받는다.
ns: egress-test ns: istio-egress
+--------------------+ +----------------------+
| netshoot-a | | egressgateway |
| (sa: app-a) | outer mTLS | Deployment (chart) |
| app -> sidecar ---+----------------->| Envoy :8443 |
+--------------------+ SNI=target | 1) mTLS termination |
host | 2) AuthzPolicy 평가 |
+--------------------+ Svc:443 | 3) SNI로 라우팅 |
| netshoot-b | -> pod:8443 +----------+-----------+
| (sa: app-b) | | raw TCP
+--------------------+ | (inner TLS 그대로)
v
api-a.example.com:443
api-b.example.net:443
목표 정책 매트릭스:
| 출발 | api-a | api-b |
|---|---|---|
| sa: app-a | ✅ 허용 | ❌ 거부 |
| sa: app-b | ❌ 거부 | ✅ 허용 |
| sidecar 없는 pod → gw 직접 | ❌ handshake 거부 | ❌ |
포트 흐름 한 줄: sidecar → gw Service:443 → gw pod:8443(Envoy listener) → 외부:443. Gateway CRD의 포트는 컨테이너 포트인 8443으로 선언한다 (non-root Envoy는 443 바인딩 불가, Service가 443→8443 매핑).
4.1 Gateway 설치 (Helm)
kubectl create namespace istio-egress
# values-egress.yaml
service:
type: ClusterIP # egress는 외부 노출 불필요. ClusterIP로 mesh 내부에서만 접근
ports:
- name: status-port
port: 15021 # readiness probe용
targetPort: 15021
- name: tls-egress
port: 443 # sidecar가 바라보는 Service 포트
targetPort: 8443 # Envoy가 실제 listen할 포트 (Gateway CRD와 일치해야 함)
podAnnotations:
# gateway chart는 injection 방식이라 이미지가 istiod 설정을 따름.
# 사설 레지스트리 이미지를 명시적으로 고정:
sidecar.istio.io/proxyImage: registry.example.com/istio/proxyv2:1.30.0
resources:
requests: { cpu: 100m, memory: 128Mi }
helm install egressgateway \
oci://registry.example.com/charts/istio/gateway \
--version 1.30.0 -n istio-egress -f values-egress.yaml
# 라벨 확인 — 이후 Gateway CRD selector와 AuthzPolicy selector가 이 라벨을 참조함
kubectl get pods -n istio-egress --show-labels
# 기대: istio=egressgateway 라벨 존재. 다르면 아래 모든 selector를 실제 값으로 교체
4.2 테스트 클라이언트 — 신원의 단위는 ServiceAccount
# clients.yaml
apiVersion: v1
kind: Namespace
metadata:
name: egress-test
labels:
istio-injection: enabled # sidecar 자동 주입
---
apiVersion: v1
kind: ServiceAccount
metadata: { name: app-a, namespace: egress-test } # 신원 = SA. 이게 정책의 주체가 됨
---
apiVersion: v1
kind: ServiceAccount
metadata: { name: app-b, namespace: egress-test }
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: netshoot-a, namespace: egress-test }
spec:
replicas: 1
selector: { matchLabels: { app: netshoot-a } }
template:
metadata:
labels: { app: netshoot-a }
spec:
serviceAccountName: app-a # 핵심: 워크로드별 전용 SA
containers:
- name: netshoot
image: registry.example.com/netshoot:latest
command: ["sleep", "infinity"]
---
apiVersion: apps/v1
kind: Deployment
metadata: { name: netshoot-b, namespace: egress-test }
spec:
replicas: 1
selector: { matchLabels: { app: netshoot-b } }
template:
metadata:
labels: { app: netshoot-b }
spec:
serviceAccountName: app-b
containers:
- name: netshoot
image: registry.example.com/netshoot:latest
command: ["sleep", "infinity"]
4.3 라우팅 CRD 4종
# routing.yaml
# (1) ServiceEntry: 외부 호스트를 레지스트리에 등록.
# sidecar와 gateway 양쪽 모두 이게 있어야 라우팅 가능
apiVersion: networking.istio.io/v1
kind: ServiceEntry
metadata:
name: external-apis
namespace: istio-egress
spec:
hosts:
- api-a.example.com
- api-b.example.net
ports:
- number: 443
name: tls
protocol: TLS # TLS = passthrough 대상 (HTTPS로 쓰면 종단 시도하므로 주의)
resolution: DNS # gateway가 클러스터 DNS로 FQDN 해석해서 나감
---
# (2) Gateway: gw pod의 Envoy에 8443 리스너 생성.
# ISTIO_MUTUAL = mesh CA 클라이언트 인증서 요구. "내부 클라이언트만" 강제의 실체
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
name: egress-tls
namespace: istio-egress
spec:
selector:
istio: egressgateway # 4.1에서 확인한 pod 라벨
servers:
- port:
number: 8443 # 컨테이너 포트 (Service의 targetPort와 일치)
name: tls-egress
protocol: TLS
hosts:
- api-a.example.com # 정적 환경이므로 열거 (와일드카드 지양)
- api-b.example.net
tls:
mode: ISTIO_MUTUAL # B안의 핵심 한 줄. outer mTLS 종단 + 신원 추출
---
# (3) DestinationRule: sidecar -> gw 구간의 outer TLS 설정.
# subset마다 sni를 다르게 — gw 리스너가 outer SNI로 목적지를 분기하기 위함.
# sni 누락 = gw에서 매칭 실패 = 최다 빈도 장애 포인트
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: egressgateway
namespace: istio-egress
spec:
host: egressgateway.istio-egress.svc.cluster.local
subsets:
- name: api-a
trafficPolicy:
portLevelSettings:
- port: { number: 443 } # Service 포트 기준
tls:
mode: ISTIO_MUTUAL # outer를 mesh mTLS로 래핑
sni: api-a.example.com # outer ClientHello의 SNI에 원본 호스트 적재
- name: api-b
trafficPolicy:
portLevelSettings:
- port: { number: 443 }
tls:
mode: ISTIO_MUTUAL
sni: api-b.example.net
---
# (4) VirtualService: 호스트당 1개. 라우트 2단 구성
# [tls #1] mesh(sidecar)에서: 이 SNI면 gw subset으로 보내라
# [tls #2] gateway에서: outer 종단 후, 이 SNI면 진짜 외부 호스트로 보내라
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: api-a-via-egress
namespace: istio-egress
spec:
hosts: ["api-a.example.com"]
gateways:
- mesh # 모든 sidecar에 적용되는 예약어
- istio-egress/egress-tls
tls:
- match:
- gateways: [mesh]
port: 443
sniHosts: ["api-a.example.com"] # 앱이 보낸 inner SNI
route:
- destination:
host: egressgateway.istio-egress.svc.cluster.local
subset: api-a # -> DR subset (sni 적재)
port: { number: 443 }
- match:
- gateways: [istio-egress/egress-tls]
port: 8443
sniHosts: ["api-a.example.com"] # gw에 도착한 outer SNI
route:
- destination:
host: api-a.example.com # ServiceEntry의 호스트
port: { number: 443 }
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: api-b-via-egress
namespace: istio-egress
spec:
hosts: ["api-b.example.net"]
gateways: [mesh, istio-egress/egress-tls]
tls:
- match:
- gateways: [mesh]
port: 443
sniHosts: ["api-b.example.net"]
route:
- destination:
host: egressgateway.istio-egress.svc.cluster.local
subset: api-b
port: { number: 443 }
- match:
- gateways: [istio-egress/egress-tls]
port: 8443
sniHosts: ["api-b.example.net"]
route:
- destination:
host: api-b.example.net
port: { number: 443 }
tcp가 아니라 tls/sniHosts인 이유ISTIO_MUTUAL 종단은 outer SNI를 소비하지만, 이 구성은 호스트 2개를 한 리스너에서 분기해야
하므로 종단 전 outer SNI로 filter chain을 고른다(8443 match가 그것). 단일 호스트라 분기가 필요
없으면 leg-2를 tcp로 흘리는 변형도 있다 — 그 비대칭의 원리는
4-CRD 멘탈모델 §7과 실측 리포트 참조.
4.4 AuthorizationPolicy — 통제의 본체
# authz.yaml
# deny-all: ALLOW 정책에 rules가 없으면 아무것도 허용 안 함 = 기본 거부.
# 이게 없으면 "신원은 보이는데 통제는 없는" 상태가 됨 — 가장 흔한 구성 실수
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: deny-all
namespace: istio-egress
spec:
selector:
matchLabels: { istio: egressgateway }
---
# 허용 1건 = 정책 1건. "누가(principal) -> 어디로(sni)"가 그대로 승인 문서가 됨
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-app-a-to-api-a
namespace: istio-egress
spec:
selector:
matchLabels: { istio: egressgateway }
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/egress-test/sa/app-a"] # outer mTLS 인증서의 SPIFFE ID
when:
- key: connection.sni
values: ["api-a.example.com"]
---
apiVersion: security.istio.io/v1
kind: AuthorizationPolicy
metadata:
name: allow-app-b-to-api-b
namespace: istio-egress
spec:
selector:
matchLabels: { istio: egressgateway }
action: ALLOW
rules:
- from:
- source:
principals: ["cluster.local/ns/egress-test/sa/app-b"]
when:
- key: connection.sni
values: ["api-b.example.net"]
정렬 지도 — 같은 문자열이 어디서 일치해야 하나
4-CRD 정렬 지도에 더해, 통제 레이어가 추가하는 정렬 2묶음:
+- 외부 host 문자열 (목적지당 1개) -----------------------------+
| SE.hosts == Gateway.servers.hosts == VS.hosts |
| == VS sniHosts(양 leg) == DR.subsets[].tls.sni |
| == AuthzPolicy when.connection.sni <- 추가 |
+--------------------------------------------------------------+
+- 신원 문자열 ------------------------------------------------+
| Deployment.serviceAccountName(app-a) |
| == AuthzPolicy principals 의 |
| "cluster.local/ns/<ns>/sa/<sa>" 마지막 두 칸 <- 추가 |
+--------------------------------------------------------------+
+- selector 라벨 ----------------------------------------------+
| gw pod label(istio=egressgateway) |
| == Gateway.selector == AuthzPolicy.selector |
+--------------------------------------------------------------+
+- 포트 체인 --------------------------------------------------+
| Service 443 -> targetPort 8443 == Gateway.port |
| DR portLevelSettings.port = 443 (Service 포트 기준) |
+--------------------------------------------------------------+
5. 테스트 매트릭스 — 무엇을 실행하고 무엇이 나와야 하나
A="kubectl exec -n egress-test deploy/netshoot-a -c netshoot --"
B="kubectl exec -n egress-test deploy/netshoot-b -c netshoot --"
| # | 명령 | 기대 결과 | 증명하는 것 |
|---|---|---|---|
| 1 | $A curl -sv https://api-a.example.com/api/ping |
200 | 정상 경로 |
| 2 | $A curl -sv https://api-b.example.net/healthz |
connection reset | 신원 기반 거부 |
| 3 | $B curl -sv https://api-b.example.net/healthz |
200 | SA별 차등 |
| 4 | $B curl -sv https://api-a.example.com/api/ping |
reset | 대칭 확인 |
| 5 | sidecar 없는 pod에서 openssl s_client -connect egressgateway.istio-egress:443 |
handshake 실패 | 비-mesh 클라이언트 차단 |
L4(TLS) 라우트라 RBAC 거부 = 연결 끊김이다. curl에서는 OpenSSL SSL_connect: SSL_ERROR_SYSCALL
또는 Connection reset by peer로 보인다. 모니터링·런북에 이걸 명시하지 않으면 운영에서 정책 거부를
“장애"로 오인한다. 메커니즘은 AuthorizationPolicy 멘탈모델의
HTTP vs TCP 절 참조.
6. 무엇을 봐야 하는가 — 검증 4단 (이 순서가 곧 운영 디버깅 표준)
진단 멘탈모델: 외부 기관 문제는 (a)에서, 인가 문제는 (b)에서, 라우팅 문제는 (c)에서 끝난다. tcpdump는 마지막 수단.
(a) 거쳐갔는지 — gateway access log
# Telemetry로 access log 활성화 (이미 전사 설정이 있으면 생략)
apiVersion: telemetry.istio.io/v1
kind: Telemetry
metadata: { name: egress-logs, namespace: istio-egress }
spec:
accessLogging:
- providers: [{ name: envoy }]
kubectl logs -n istio-egress deploy/egressgateway | tail
# 테스트 1 실행 후 기대: 목적지 cluster가 외부 호스트로 찍힘
# ... outbound|443||api-a.example.com ... <client pod IP> ... api-a.example.com
# 마지막 필드(REQUESTED_SERVER_NAME) = outer SNI. DR sni 설정이 동작한 증거
(b) 누가 평가됐는지 — RBAC 디버그 로그
istioctl proxy-config log deploy/egressgateway -n istio-egress --level rbac:debug
kubectl logs -n istio-egress deploy/egressgateway -f | grep rbac
# 테스트 2 실행 시 기대:
# principal: cluster.local/ns/egress-test/sa/app-a ... enforced denied
# -> "gateway가 SPIFFE 신원을 보고 판정한다"의 직접 증거. 확인 후 --level rbac:info로 복원
(c) 설정이 내려갔는지 — istioctl
# gw에 8443 리스너 + SNI별 filter chain 2개가 생성됐는지
istioctl proxy-config listeners deploy/egressgateway -n istio-egress --port 8443
# sidecar가 api-a SNI를 gw subset으로 라우팅하는지
istioctl proxy-config listeners deploy/netshoot-a -n egress-test --port 443 -o json | grep -A3 sni
# mesh 인증서 존재 확인
istioctl proxy-config secret deploy/egressgateway -n istio-egress
(d) 이중 TLS의 실체 — 인증서 비교 + tcpdump
# inner: 앱이 보는 인증서 = 진짜 목적지 인증서 (end-to-end TLS 보존의 증거)
$A curl -v https://api-a.example.com/api/ping 2>&1 | grep -E "subject|issuer"
# outer: 와이어에서 보이는 건 gw로 가는 TLS 한 겹뿐
$A tcpdump -i eth0 -nn 'tcp port 8443' -c 20
# 기대: 목적지가 외부 IP가 아니라 gateway pod IP:8443.
# ClientHello의 SNI는 api-a.example.com (DR이 적재), 그러나 이 세션의 인증서는 Istio CA 발급.
# inner TLS는 이 안에 캡슐화되어 보이지 않음 <- "tcpdump에 TLS가 여러 번 보인다"의 정확한 구조
자주 깨지는 곳 — 증상이 아니라 왜
| 증상 | 원인 (메커니즘) |
|---|---|
| gw 로그에 아예 안 찍힘 | sidecar VS 매칭 실패 — 미주입(istio-proxy 컨테이너 확인) 또는 sniHosts 오타. sidecar 로그에서 PassthroughCluster로 직행 중인지 확인 |
| gw까지 오는데 reset | DR sni 누락/오타 → 리스너 filter chain 매칭 실패. 또는 deny-all만 있고 allow 누락 |
| 8443 리스너 없음 | Gateway selector ≠ pod 라벨, 또는 CRD 포트를 8443이 아닌 443으로 선언 (Envoy는 targetPort에 bind) |
| handshake 즉시 실패 | 클라이언트가 비-mesh (그게 정상 동작), 또는 gw는 ISTIO_MUTUAL인데 sidecar DR이 평문 |
7. 설계 결정 노트 — 운영 규모로 갈 때의 세 갈림길
7.1 Gateway “분리"는 세 레이어를 구분해서 말해야 한다
Gateway CRD ----(selector)----> Deployment(pods) ----> Node(전용 노드풀)
[config, 무료] [리소스, 비용] [IP 대역, 방화벽]
Gateway CRD는 gateway pod의 Envoy에 listener 설정을 붙이는 선언일 뿐이라 하나의 Deployment에 여러 Gateway CRD를 붙일 수 있다. 팀별/목적지별 CRD 분리는 설정 격리일 뿐 비용이 거의 없고, 진짜 비용은 Deployment를 나눌 때 발생한다.
서비스마다 egress gateway pod 배포는 비권장이다:
- 서비스 N개 × (Deployment+HPA+PDB+모니터링+방화벽 source IP 등록)이 선형 증가 — egress 중앙화의 목적 자체가 무너진다. 대외 기관 whitelist에 등록할 IP가 늘어나는 건 금융 환경에서 실질 페널티.
- Envoy는 유휴 상태에도 메모리를 점유한다.
- 신원 기반 authz가 이미 공유 pod 위의 논리적 테넌트 격리를 달성하므로 물리 분리의 추가 보안이 거의 없다.
Deployment 분리가 정당화되는 예외: 망분리 구역/전용선 등 네트워크 경로 자체가 다를 때, 특정 대외 기관의 장애 반경·QoS를 물리적으로 떼야 할 때, noisy neighbor가 실측될 때. 권장 구조는 망 존 단위 소수 풀(2~4개) + CRD/AuthzPolicy 논리 분리. 전용 노드풀(taint/affinity)은 ① 방화벽 등록용 SNAT source IP 고정 ② 일반 워크로드와 장애 격리를 동시에 얻는다.
7.2 MySQL 등 raw TCP — 가능하지만 라우팅 키가 포트로 바뀐다
ServiceEntry(TCP 포트) + Gateway TCP listener + VS tcp 라우트로 가능하고, outer ISTIO_MUTUAL도 L4 래핑이라 신원 authz가 유지된다. 단 SNI가 없다 — 평문 MySQL은 당연히 없고, TLS를 켠 MySQL/PostgreSQL도 프로토콜 내부 STARTTLS 협상이라 연결 시작 시점에 ClientHello가 안 보인다. 따라서 라우팅 키는 gateway 포트 번호가 되고, 목적지 DB마다 전용 listener 포트를 할당해야 한다(DB 3개 → 포트 3개). DB는 long-lived connection이라 gateway drain 시 일괄 끊김 영향도 HTTPS보다 크다 — graceful termination MOC의 시나리오가 TCP에서 더 민감하게 재등장한다.
7.3 wildcard 목적지와 EnvoyFilter — 정적 환경엔 불필요
근거 블로그가 EnvoyFilter를 쓰는 건 wildcard 도메인 동적 라우팅 전용이고, 이중 TLS·신원 통제와는 독립 부품이다. 표준 VS의 TLS 라우트는 *.example.org를 매칭할 수는 있어도 목적지는 정적 단일 호스트로만 보낼 수 있다. 이 간극(“매칭은 wildcard, 포워딩은 고정”)을 메우려면 연결마다 inner SNI를 재검사해 동적 TCP proxy의 목적지로 쓰는 Envoy 기능이 필요한데, 이건 VS로 설정 불가라 EnvoyFilter가 등장한다.
[정적 목적지 N개] VS: sniHosts 매칭 -> 고정 host 라우팅 <- 표준 CRD로 충분 (본 문서)
[wildcard 도메인] VS: 매칭은 가능, 목적지가 동적이어야 함 <- EnvoyFilter 필요 (블로그)
같은 이유로 DR sni 필드의 유무도 모순이 아니라 토폴로지 차이다: gateway가 outer SNI로 호스트를 분기하는 정적 구조에선 필수(매칭 키), 블로그처럼 전량을 internal listener로 넘겨 inner SNI를 재검사하는 구조에선 불필요(outer SNI를 매칭에 안 씀). Gateway hosts: ["*"]도 트래픽 필터가 아니라 “어떤 VS가 바인딩될 수 있나"의 범위 선언이라 동적 구조에선 정상 — 단 정적 환경에선 열거가 맞다.
8. What you might be missing
- SA 위생이 전제조건이다. 워크로드들이
defaultSA를 공유하면 principal이 전부 같아져 신원 모델 전체가 무의미해진다. workload당 전용 SA가 이 패턴의 숨은 선행 작업이고, 수백 앱 규모면 거버넌스(Kyverno로 default SA 사용 금지 등)부터 필요하다. - TLS 라우트의 authz 조건은 connection 레벨뿐이다. principal, namespace,
connection.sni, IP까지. passthrough라 HTTP path/method/header 조건은 불가 — 보안팀에 약속할 수 있는 통제 범위를 처음부터 명확히 해야 한다. L7 조건이 필요하면 gateway가 종단하는 다른 패턴이 필요하다. - SNI는 클라이언트 제공 값이다. 다만 정적 라우트에선 SNI를 위조해도 위조한 그 호스트로 라우팅될 뿐 임의 IP 터널링은 안 된다. wildcard 호스트를 쓰기 시작하면 이 보장이 약해진다 — 그게 위 블로그가 inner SNI 재검사를 강조하는 이유다. 그리고 inner TLS의 실제 인증서 검증은 끝까지 앱 책임이다. “gateway 통과 = 검증 완료"로 오해하는 팀이 나오지 않게 정책으로 명문화할 것.
- 3중 통제 없이는 gateway가 보안 경계가 아니다. ① CNI NetworkPolicy(일반 워크로드 external egress deny, gw pod+DNS만 허용) ② IDC 방화벽(egress 전용 노드풀 CIDR만 허용) ③
outboundTrafficPolicy: REGISTRY_ONLY(보조). Istio CRD만으로 구성하면 심사에서 “우회 경로 통제"를 지적받는다. 그리고 DNS 자체가 exfiltration 채널이다 — 외부 도메인 resolution 경로(내부 resolver forwarding 정책, Istio DNS proxying)도 설계에 포함해야 한다. - 감사 로그는 신원 확보로 끝나지 않는다. access log 포맷에
DOWNSTREAM_PEER_URI_SAN(SPIFFE ID)을 넣고 보존 기간 요건(예: PCI-DSS 1년/3개월 즉시조회)까지 연결해야 감사 요건이 완성된다. - gateway는 SPOF이자 chokepoint다. 전사 egress가 한 곳에 모인다. HPA·PDB·graceful drain, 특히 대외 long-lived connection(전문 통신, gRPC) drain 시나리오를 함께 설계할 것.
9. 참조
아카이브 내부
- Egress Gateway 도입 가이드 (사내 공유본) — 이 통제 모델이 채택된 의사결정 문서 (Passthrough vs mTLS 비교·근거·표준 1벌)
- Egress TCP 병목 정본 — 이 구성 위에 gateway를 운영할 때의 연결·포트·conntrack 한계와 완화 운영값
- TCP 병목 한계 축소 재현 랩 — 이 문서의 테스트 클러스터를 그대로 써서 병목을 직접 재현
- HTTPS over mTLS 구조 정본 — 이중 TLS 패턴 자체의 해부 (이 문서의 토대)
- Egress 4-CRD 멘탈모델 — 4-CRD 직관·정렬 지도·tls/tcp 비대칭
- 이중 TLS 없는 egress 신원 — passthrough + Calico로 같은 결과를 내는 반대 선택의 근거
- Egress mTLS 실측 리포트 — 홈랩 검증과 두 함정
- mTLS/SPIFFE 신원 — 신원 발급·검증 파이프라인 정본
- AuthorizationPolicy 멘탈모델 — 평가 위치·순서, TCP 거부=reset의 원리
- 보안 리소스 trio — “증명은 인증이, 차단은 인가가"의 역할 분담
외부
- istio.io blog (2023) — Routing egress traffic to wildcard destinations — ISTIO_MUTUAL로 gateway를 mesh 내부 전용으로 잠그는 패턴의 출처
- istio.io — Security Best Practices — REGISTRY_ONLY는 best-effort, egress 보호는 gateway+NetworkPolicy 결합