Sidecar 트래픽 캡처 — iptables/nftables와 15001·15006
mesh의 모든 기능(mTLS·route·authz·telemetry)은 "app의 모든 패킷이 Envoy를 지나간다"는 단 하나의 전제 위에 서 있다. 그 전제를 app 코드 수정 없이 강제하는 장치가 커널 netfilter(iptables/nftables) redirection이다. 이 문서는 그 redirection을 — 왜 필요한지(배경)부터, 누가 어떻게 rule을 거는지(pilot-agent istio-iptables → IptablesConfigurator.Run → iptables-restore --noflush), REDIRECT mode의 핵심 체인과 proxy UID/GID 1337 무한 루프 방지 불변식, 실제 *nat 블록·--dry-run·native nftables 경로·검증 명령까지 — 한 줄기로 따라간다. 결론: app은 평소처럼 service:port로 connect하지만 모든 트래픽이 app 모르게 투명하게 Envoy를 통과한다.
본 문서는 sidecar mode 전용이다. Ambient mode는 per-pod 15001/15006 모델이 아니라 CNI + ztunnel 기반 redirection을 쓰므로 datapath가 완전히 다르다 — 본 문서 범위 밖.
00. 배경 — 왜 "투명 캡처"가 필요한가
mesh가 약속하는 것들 — 두 pod 사이 자동 mTLS, VirtualService route, AuthorizationPolicy 차단, 요청 단위 telemetry — 은 전부 "트래픽이 어딘가의 proxy를 거쳐야" 가능하다. proxy가 패킷을 손에 쥐어야 암호화하고, 경로를 바꾸고, 거부하고, 세는 것이 가능하기 때문이다.
문제는 app이 그 proxy의 존재를 몰라야 한다는 점이다. "트래픽을 proxy로 보내라"를 app에게 요구하면(예: 환경변수 HTTP_PROXY, SDK 주입, 호스트네임 변경) mesh는 더 이상 인프라가 아니라 애플리케이션 변경이 된다. 수백 개 서비스에 코드/설정 변경을 강제할 수 없다. 그래서 Istio가 푸는 진짜 문제는 이것이다.
app은
service:9080으로 평소처럼 connect하는데, 그 패킷이 app도 모르게 같은 pod 안의 Envoy로 빨려 들어가게 만들 수 있는가?
답은 L4 아래, 커널에서 가로채기다. app의 socket이 보낸 패킷은 커널 network stack을 반드시 통과하고, 커널에는 패킷의 목적지를 바꾸는 hook 지점(netfilter)이 이미 있다. Istio는 그 hook에 rule을 심어, app이 의도한 목적지 대신 로컬 Envoy 포트로 목적지를 바꿔치기(REDIRECT) 한다. app socket 입장에서는 여전히 service:9080에 연결했다고 믿지만, 실제 패킷은 127.0.0.1:15001(Envoy)로 들어간다. 이것이 "투명(transparent)"의 의미다.
선행 개념 — netfilter REDIRECT 한 가지만: Linux netfilter의 nat 테이블에는 PREROUTING(들어오는 패킷이 라우팅되기 전), OUTPUT(로컬 프로세스가 내보내는 패킷) 같은 hook이 있고, REDIRECT target은 패킷의 목적지 주소/포트를 로컬 머신의 다른 포트로 갈아끼운다. iptables는 그 rule을 거는 전통 도구이고, nftables는 후속 세대다. Istio가 하는 일은 결국 이 hook에 "app 트래픽이면 로컬 Envoy 포트로 REDIRECT"라는 rule 한 묶음을 pod의 network namespace 안에 박는 것이 전부다.
[ 트래픽 캡처가 없으면 ]
app socket → 커널 → NIC → 진짜 목적지 (Envoy를 건너뜀 = mesh 무력화)
[ 트래픽 캡처가 있으면 ]
app socket → 커널 netfilter REDIRECT → Envoy 15001/15006 → ... → 진짜 목적지
└─ app은 이 우회를 전혀 모름 (transparent) ─┘
이 한 우회가 mTLS/route/authz/telemetry 전부의 전제다. 캡처가 안 걸리면 Envoy 설정이 아무리 완벽해도 트래픽이 Envoy를 안 지나가니 mesh 기능이 통째로 죽는다. 그래서 "왜 내 정책이 안 먹지?"의 1차 용의자는 항상 캡처가 실제로 박혔는지다.
01. 멘탈모델 앵커 — 두 개의 입구, 하나의 불변식
Istio sidecar 캡처의 본질은 단 세 가지다 — (1) app이 밖으로 나가는 패킷은 커널이 Envoy의 outbound 입구 15001로 REDIRECT한다, (2) pod로 들어오는 패킷은 Envoy의 inbound 입구 15006으로 REDIRECT한다, (3) 단 Envoy(UID/GID 1337) 자신이 만든 패킷은 다시 잡지 않는다(무한 루프 방지). 이 세 줄에서 나머지 모든 체인·포트·예외가 따라 나온다.
이 그림 하나만 머리에 박으면 된다.
pod network namespace
┌──────────────────────────────────────────────────────────────┐
│ │
│ app ──connect svc:9080──> [OUTPUT] ─ ISTIO_OUTPUT │
│ │ │
│ uid/gid 1337? ─yes─> RETURN ──────┼─> 진짜 목적지
│ │ no │ (Envoy가 나간 것)
│ v │
│ REDIRECT :15001 ─> Envoy out │
│ │
│ remote ──> podIP:9080 ─ [PREROUTING] ─ ISTIO_INBOUND │
│ │ │
│ 15008/15020/15021/15090? ─yes─> RETURN│
│ │ no │
│ v │
│ REDIRECT :15006 ─> Envoy in ─┼─> app :9080
│ │
└──────────────────────────────────────────────────────────────┘
방향을 가르는 것이 핵심이다. outbound는 OUTPUT hook(로컬 app이 내보냄)에서, inbound는 PREROUTING hook(밖에서 들어옴)에서 잡힌다. 이건 netfilter의 hook 의미상 자연스럽다 — 나가는 패킷은 OUTPUT을, 들어오는 패킷은 PREROUTING을 지나니까.
왜 outbound 15001 / inbound 15006을 분리했나
포트 숫자 자체엔 네트워크 표준 의미가 없다. Istio 내부 convention일 뿐이고, 소스 기본값도 그냥 박혀 있다.
ProxyPort: "15001" // outbound capture port
InboundCapturePort: "15006" // inbound capture port
InboundTunnelPort: "15008" // tunnel/HBONE inbound tunnel port
중요한 건 왜 하나로 안 합쳤나다. 같은 패킷이라도 "내 pod에서 나가는 요청"과 "내 pod로 들어오는 요청"은 Envoy 안에서 적용되는 것이 완전히 다르다 — filter chain, trafficDirection, mTLS를 거는 위치(나갈 땐 client측 origination, 들어올 땐 server측 termination), AuthorizationPolicy 평가 위치, telemetry 방향. 입구에서부터 물리적으로 다른 listener로 갈라놓아야 이 둘을 섞지 않고 정책을 건다. 즉 15001/15006 분리는 방향별 정책 적용의 전제다(정책 평가 지점 자체는 AuthorizationPolicy 멘탈모델).
공식 디버깅 모델로도 모든 sidecar에는 outbound용 virtualOutbound(0.0.0.0:15001)와 inbound용 virtualInbound(0.0.0.0:15006) listener가 존재한다. virtualOutbound(15001)는 capture된 요청을 original destination에 맞는 virtual listener로 넘긴다 — 예컨대 원래 9080으로 향했던 outbound HTTP는 15001로 capture된 뒤 Envoy 내부 0.0.0.0:9080 virtual listener로 handoff된다. 이후로 이 명칭(virtualOutbound/virtualInbound)을 일관되게 쓴다.
15001 = app이 밖으로 나갈 때 처음 빨려 들어가는 Envoy 입구 (outbound)
15006 = 밖에서 app으로 들어올 때 처음 빨려 들어가는 Envoy 입구 (inbound)
잡지 않는 포트들 (15008 / 15020 / 15021 / 15090)
캡처가 "app 트래픽만" 잡아야 하므로, Envoy/Istio 자체 인프라 포트로 향하는 트래픽은 redirect하지 않고 RETURN(=원래 흐름대로 통과)한다. 이걸 안 빼면 health probe·metrics scrape가 Envoy 입구로 잘못 빨려 들어가 깨진다.
| 포트 | 역할 | redirection |
|---|---|---|
15001 |
outbound capture (virtualOutbound listener) | OUTPUT → REDIRECT 대상 |
15006 |
inbound capture (virtualInbound listener) | PREROUTING → REDIRECT 대상 |
15008 |
HBONE/tunnel inbound tunnel port | INBOUND에서 RETURN |
15020 |
pilot-agent: merged Prometheus metrics + health probe rewrite | INBOUND에서 RETURN |
15021 |
sidecar status / readiness probe 포트(/healthz/ready) — kubelet이 proxy readiness를 probe해 'Envoy ready ⟺ pod ready'가 되게 함 |
INBOUND에서 RETURN |
15090 |
Envoy Prometheus telemetry(merged metrics) 포트 | INBOUND에서 RETURN |
pilot-agent istio-iptables 플래그에서도 --envoy-port/-p 기본값이 15001, --inbound-capture-port/-z 기본값이 15006으로 명시된다.
02. 누가 rule을 거나 — pilot-agent istio-iptables
rule을 거는 주체는 app 컨테이너도, istiod도 아니다. pod 시작 시 실행되는 pilot-agent istio-iptables(init container 또는 Istio CNI plugin)가 pod의 network namespace 안에서 rule을 programming한다. istiod는 Envoy의 설정(xDS)을 주지만, 트래픽을 Envoy로 밀어넣는 커널 rule은 pod 로컬에서 이 도구가 박는다 — 둘은 다른 layer다.
소스 흐름
pilot-agent istio-iptables
↓
Config 로드
↓
NativeNftables 여부 판단
↓ (false면 기본 iptables 경로)
ProgramIptables
↓
capture.NewIptablesConfigurator(...)
↓
IptablesConfigurator.Run()
↓
IptablesRuleBuilder.AppendRule(...) // rule 한 줄씩 누적
↓
BuildV4Restore() // *nat / rule / COMMIT 형태 input 생성
↓
iptables-restore --noflush 실행 // 한 번에 atomic 적용
여기서 왜 이 구조인가가 핵심이다. rule을 iptables 명령 하나하나로 실행하지 않는다. Istio는 rule들을 메모리에서 다 누적해(AppendRule) iptables-restore 포맷(*nat ... COMMIT) 한 덩어리를 만든 뒤 iptables-restore --noflush로 한 번에 atomic하게 적용한다. 한 줄씩 박으면 중간 상태(rule이 절반만 들어간 순간)에 트래픽이 흘러 일부만 capture되거나 누락되는 partial state가 생긴다. atomic 적용은 그 창을 없앤다. --noflush는 "기존 rule을 비우지 말고 추가만"이라는 뜻으로, 호스트/CNI가 이미 박아둔 rule을 보존한다.
소스에서 NativeNftables가 true면 ProgramNftables 경로를, 아니면 기본 ProgramIptables 경로를 탄다. --envoy-port/-p, --inbound-capture-port/-z, --istio-service-cidr/-i, --istio-inbound-ports/-b, --network-namespace, --native-nftables 같은 플래그를 받는다.
istio-init vs Istio CNI — 박는 주체·시점·권한의 차이
결과로 박히는 *nat rule은 같다. 다르게 하는 건 누가, 어느 시점에, 어떤 권한으로 박느냐다.
istio-init (init container 방식)
= pod마다 NET_ADMIN/NET_RAW 권한을 가진 init container가 떠서
그 pod의 netns 안에 iptables rule을 박고 종료
Istio CNI plugin 방식
= CNI chain에 Istio CNI가 끼어들어 pod network 셋업 시점에 rule을 박음
= pod에 NET_ADMIN을 주지 않아도 됨 (보안상 권장)
CNI 방식은 app pod의 권한 표면을 줄인다. 모든 app pod에 NET_ADMIN을 주는 건 PodSecurity 관점에서 큰 공격 면이므로, production에서는 CNI 방식이 선호된다. 캡처 동작만 같다고 둘을 같다고 보면 안 된다 — 권한 측면이 다르다.
03. REDIRECT mode 핵심 체인 — 그림에서 rule로
00·01에서 세운 그림을 그대로 rule로 옮기면 된다. REDIRECT mode는 4개의 custom chain + 2개 진입점으로 구성된다.
진입점:
PREROUTING → ISTIO_INBOUND (밖에서 들어오는 패킷)
OUTPUT → ISTIO_OUTPUT (app이 밖으로 내보내는 패킷)
redirect 종착 체인:
ISTIO_REDIRECT → REDIRECT --to-ports 15001 (outbound capture)
ISTIO_IN_REDIRECT → REDIRECT --to-ports 15006 (inbound capture)
소스상 ISTIO_REDIRECT는 cfg.cfg.ProxyPort(=15001)로, ISTIO_IN_REDIRECT는 cfg.cfg.InboundCapturePort(=15006)로 redirect한다. 진입점 chain(ISTIO_INBOUND/ISTIO_OUTPUT)은 "잡을지 말지 분기"하고, 종착 chain(ISTIO_*REDIRECT)은 "실제로 Envoy 포트로 보낸다". 분기와 실행을 chain으로 쪼개둔 덕에 예외(RETURN)를 진입점에서 깔끔히 처리할 수 있다.
inbound 분기 (ISTIO_INBOUND)
PREROUTING -p tcp -j ISTIO_INBOUND
if InboundPortsInclude == "*":
exclude port(15008/15020/15021/15090 등) → RETURN
나머지 inbound TCP → ISTIO_IN_REDIRECT
else:
명시된 port 목록만 → ISTIO_IN_REDIRECT
outbound 분기 (ISTIO_OUTPUT) — 불변식이 사는 곳
OUTPUT -j ISTIO_OUTPUT
ISTIO_OUTPUT:
outbound exclude port면 → RETURN
loopback/self-call 특수 처리 → RETURN
proxy UID/GID(1337)가 만든 트래픽 → RETURN ★ 무한 루프 방지
outbound include CIDR가 "*"면 → ISTIO_REDIRECT
Envoy가 upstream으로 새 connection을 맺으면 그 패킷도 OUTPUT chain을 다시 통과한다. 만약 이 패킷까지 15001로 redirect하면 Envoy → 15001 → Envoy → 15001 ... 무한 루프에 빠진다.
그래서 Envoy(istio-proxy)는 UID/GID 1337로 실행되고, -m owner --uid-owner 1337 / --gid-owner 1337에 걸리는 트래픽은 RETURN되어 재-capture되지 않는다. "app이 만든 트래픽만 잡고, proxy가 만든 트래픽은 통과" — 이것이 redirection의 가장 중요한 불변식이다. 캡처가 "방향"으로 안 갈리고 "누가 만들었나(UID)"로 갈린다는 점이 핵심: 같은 OUTPUT hook을 app도 Envoy도 지나지만, UID로 둘을 구분해 app 것만 redirect한다.
-d 127.0.0.1/32 -j RETURN 같은 localhost 예외도 self-call(같은 pod 내 app ↔ proxy loopback)이 잘못 redirect되는 것을 막는다.
04. 적용된 실제 rule — *nat 블록과 dry-run
위 그림을 실제로 박으면 이렇게 생긴다. 아래는 대표적인 REDIRECT mode *nat 블록이다. 실제 출력은 Istio 버전, CNI 사용 여부, DNS capture, include/exclude annotation, IPv6, TPROXY 여부에 따라 달라진다.
*nat
-N ISTIO_REDIRECT
-N ISTIO_IN_REDIRECT
-N ISTIO_INBOUND
-N ISTIO_OUTPUT
# inbound packet 진입점
-A PREROUTING -p tcp -j ISTIO_INBOUND
# outbound packet 진입점
-A OUTPUT -j ISTIO_OUTPUT
# outbound는 Envoy 15001로
-A ISTIO_REDIRECT -p tcp -j REDIRECT --to-ports 15001
# inbound는 Envoy 15006으로
-A ISTIO_IN_REDIRECT -p tcp -j REDIRECT --to-ports 15006
# tunnel/status/metrics 등 제외 (RETURN)
-A ISTIO_INBOUND -p tcp --dport 15008 -j RETURN
-A ISTIO_INBOUND -p tcp --dport 15020 -j RETURN
-A ISTIO_INBOUND -p tcp --dport 15021 -j RETURN
-A ISTIO_INBOUND -p tcp --dport 15090 -j RETURN
# 나머지 inbound app traffic capture
-A ISTIO_INBOUND -p tcp -j ISTIO_IN_REDIRECT
# Envoy 자신이 만든 traffic은 다시 capture하지 않음 (무한 루프 방지)
-A ISTIO_OUTPUT -m owner --uid-owner 1337 -j RETURN
-A ISTIO_OUTPUT -m owner --gid-owner 1337 -j RETURN
# localhost/self-call 예외
-A ISTIO_OUTPUT -d 127.0.0.1/32 -j RETURN
# 나머지 outbound capture
-A ISTIO_OUTPUT -j ISTIO_REDIRECT
COMMIT
BuildV4Restore()가 바로 이 *nat / rule line / COMMIT 형태의 restore input을 구성하고, 실행 경로는 iptables-restore --noflush로 한 번에 적용한다. (※ inbound capture는 wildcard(-b '*')면 위처럼 모든 app port를 잡고, 특정 port 목록이면 -A ISTIO_INBOUND -p tcp --dport 9080 -j ISTIO_IN_REDIRECT처럼 해당 port만 redirect한다.) 단 wildcard inbound라도 status/metrics 포트 외에 loopback이나 특정 source에 대한 RETURN 예외가 추가로 끼어들 수 있으므로, 위 블록을 "모든 inbound가 무조건 15006으로 간다"로 과단순화하면 안 된다.
적용 전에 생성될 rule 미리 보기 — --dry-run
실제 rule을 적용하지 않고 어떤 rule이 만들어질지 확인하려면 --dry-run을 쓴다. 캡처 트러블슈팅의 첫 수: "내가 기대한 rule이 정말 생성되는가"를 부작용 없이 본다.
kubectl exec -n <ns> <pod-name> -c istio-proxy -- \
pilot-agent istio-iptables \
--dry-run \
-p 15001 \
-z 15006 \
-u 1337 \
-g 1337 \
-m REDIRECT \
-b '*' \
-d '15090,15021,15020' \
-i '*'
플래그 의미:
-p 15001 = outbound redirect target (--envoy-port)
-z 15006 = inbound redirect target (--inbound-capture-port)
-u 1337 = 이 UID의 traffic은 redirect 제외 (--proxy-uid)
-g 1337 = 이 GID의 traffic은 redirect 제외 (--proxy-gid)
-m REDIRECT = inbound capture mode (REDIRECT | TPROXY | NONE)
-b '*' = 모든 inbound port capture (--istio-inbound-ports)
-d ... = inbound capture 제외 port (--istio-inbound-ports-exclude)
-i '*' = 모든 outbound IP range capture (--istio-service-cidr)
기대 출력은 04 첫머리의 *nat ... COMMIT 블록과 동형이어야 한다. dry-run 출력에 --to-ports 15001/15006, --uid-owner 1337 -j RETURN, exclude 포트 RETURN 라인이 다 보이면 정상이다.
-m은 inbound capture mode다. MeshConfig 기준 outbound는 항상 iptables REDIRECT를 쓰고, inbound만 기본 REDIRECT / 고급 TPROXY / redirection 없음 NONE 중 고른다. TPROXY는 original source IP를 보존해야 할 때 쓰지만 운영 복잡도가 올라간다.
05. nftables 경로 — 같은 그림, 다른 표현
현재 sidecar mode의 기본은 여전히 iptables 경로다. 다만 --native-nftables를 켜면 Istio가 native nftables rule 경로(ProgramNftables)를 쓴다. 구조는 iptables와 거의 동형이다 — outbound는 redirect to :15001, inbound는 redirect to :15006. 같은 멘탈모델(두 입구 + 1337 RETURN)이 다른 문법으로 표현될 뿐이다.
table inet istio_proxy_nat {
chain prerouting {
type nat hook prerouting priority dstnat; policy accept;
meta l4proto tcp counter jump ISTIO_INBOUND
}
chain output {
type nat hook output priority dstnat; policy accept;
counter jump ISTIO_OUTPUT
}
chain ISTIO_REDIRECT {
meta l4proto tcp counter redirect to :15001
}
chain ISTIO_IN_REDIRECT {
meta l4proto tcp counter redirect to :15006
}
chain ISTIO_INBOUND {
meta l4proto tcp tcp dport 15008 counter return
meta l4proto tcp counter jump ISTIO_IN_REDIRECT
}
chain ISTIO_OUTPUT {
skuid 1337 counter return # proxy UID RETURN
skgid 1337 counter return # proxy GID RETURN
counter jump ISTIO_REDIRECT
}
}
hook prerouting/hook output은 iptables의 PREROUTING/OUTPUT 진입점에, skuid 1337/skgid 1337은 -m owner --uid-owner/--gid-owner 1337에 대응한다. native nftables 경로는 성공하면 (TPROXY 사용 시) TPROXY route도 함께 설정한다.
둘 다 결국 커널 netfilter를 쓰지만 의미가 다르다.
iptables 경로
= Istio가 iptables/iptables-restore 명령을 호출함.
단, OS의 iptables binary가 내부적으로 iptables-nft backend일 수 있음
(요즘 distro 기본). 이 경우에도 "Istio 입장에선 iptables 경로"임.
native nftables 경로
= Istio가 nftables rule builder 경로(ProgramNftables)를 사용함.
--native-nftables가 켜진 경우에만.
즉 호스트가 nftables를 쓴다고 Istio가 native nftables 경로를 쓰는 게 아니다. 운영자가 보는 명령(iptables-save vs nft list ruleset)과 rule 표현이 달라지므로, 진단 전에 어느 경로인지 먼저 확인해야 한다.
06. 떴는지 확인 — 검증 명령
실제 pod에서 rule이 어떻게 박혔는지 확인하는 방법이다. 핵심은 app pod의 netns를 정확히 들여다보는 것이다(iptables rule은 pod netns 단위로 존재).
iptables rule 확인
# debug container(netshoot)를 app container의 netns에 붙여서 확인
kubectl debug -n <ns> -it pod/<pod-name> \
--image=nicolaka/netshoot \
--target=<app-container> \
--profile=netadmin \
-- iptables-save -t nat
# 또는 istio-proxy 컨테이너에 도구가 있으면 직접
kubectl exec -n <ns> <pod-name> -c istio-proxy -- iptables-save -t nat
iptables-save -t nat 출력에서 capture가 적용됐다면 다음 핵심 라인들이 보여야 한다.
-A ISTIO_REDIRECT -p tcp -j REDIRECT --to-ports 15001
-A ISTIO_IN_REDIRECT -p tcp -j REDIRECT --to-ports 15006
-A ISTIO_OUTPUT -m owner --uid-owner 1337 -j RETURN
-A ISTIO_INBOUND -p tcp --dport 15020 -j RETURN
이 라인들이 안 보이면 ① capture 자체가 미적용(CNI/init container 실패)이거나 ② 지금 보는 것이 app pod의 netns가 아닌(=--target 누락) 다른 netns라는 뜻이다. kubectl debug로 iptables를 보려면 debug 컨테이너에 NET_ADMIN이 필요할 수 있으므로 --profile=netadmin을 붙여야 할 수 있다.
nftables ruleset 확인
kubectl debug -n <ns> -it pod/<pod-name> \
--image=nicolaka/netshoot \
--target=<app-container> \
-- nft list ruleset
kubectl debug ... --target=<app-container>는 debug 컨테이너를 app 컨테이너와 같은 network namespace에 join시킨다. iptables rule은 pod netns 단위로 존재하므로, target을 지정해야 app pod가 실제로 보는 rule을 관찰할 수 있다. target 없이 띄우면 다른 netns를 보게 되어 rule이 안 보일 수 있다.
캡처가 실제로 동작하는지(트래픽이 Envoy를 통과하는지)는 Envoy 설정 쪽에서 교차 검증한다 — istioctl proxy-config listener <pod>로 virtualOutbound(0.0.0.0:15001) / virtualInbound(0.0.0.0:15006) listener 존재를 확인하면 된다(→ xDS 계층과 진단). rule(커널 측)과 listener(Envoy 측) 둘 다 있어야 캡처가 진짜 닫힌 고리로 동작한다.
07. packet flow 다이어그램
outbound flow
inbound flow
캡처가 끝나는 지점은 Envoy 입구(15001/15006)까지다. 그 다음 listener→route(RDS)→cluster(CDS)→endpoint(EDS)로 이어지는 처리는 별도 xDS 영역이다(→ xDS 계층과 진단).
핵심 정리
- 존재 이유: mesh의 모든 기능은 "app 트래픽이 Envoy를 통과한다"가 전제. 그걸 app 수정 없이 강제하려고 커널 netfilter REDIRECT로 목적지를 Envoy 로컬 포트로 바꿔치기한다. 캡처가 안 걸리면 mTLS/route/authz/telemetry 전부 죽는다.
- 두 입구:
15001= outbound capture(app이 나갈 때OUTPUT에서 잡혀 Envoy로),15006= inbound capture(밖에서 들어올 때PREROUTING에서 잡혀 Envoy로). 방향별 정책 적용을 위해 물리적으로 분리. - 불변식: proxy(istio-proxy) UID/GID
1337트래픽은 RETURN → "app 트래픽만 잡고 proxy 트래픽은 통과" → 무한 루프 방지. 캡처는 방향이 아니라 UID로 app/proxy를 가른다. - 제외 포트:
15008(tunnel)/15020(metrics+probe)/15021(health)/15090(metrics)은 RETURN(redirect 제외). - rule 생성/주입:
pilot-agent istio-iptables→IptablesConfigurator.Run→BuildV4Restore→iptables-restore --noflush(atomic, 기존 보존). 주입 주체는 istio-init(init container, NET_ADMIN 필요) 또는 Istio CNI plugin(권한 회수, production 선호). - 경로/검증: 기본은 iptables 경로(OS backend는 iptables-nft일 수 있음),
--native-nftables면 native nftables. 확인은kubectl debug --target/kubectl exec -c istio-proxy로iptables-save -t nat또는nft list ruleset, 사전 점검은pilot-agent istio-iptables --dry-run.
What you might be missing
- redirection은 보안 경계가 아니라 단순 트래픽 우회다. 패킷이 Envoy를 통과한다고 "차단"되는 게 아니다. NET_ADMIN을 가진 악성 컨테이너나 pod hostNetwork는 rule을 우회할 수 있으므로, egress 통제는 iptables가 아니라
outboundTrafficPolicy: REGISTRY_ONLY+ ServiceEntry + (필요 시) NetworkPolicy 조합으로 별도 보장해야 한다. - proxy UID 1337 RETURN은 "Envoy가 1337로 떠야만" 성립한다. custom security context로 istio-proxy UID를 바꾸거나 app이 우연히 UID 1337로 돌면 redirection 불변식이 깨져 트래픽이 Envoy를 통과하지 않거나(누락) 무한 루프에 빠질 수 있다.
iptables-restore --noflush이므로 기존 호스트 rule과의 순서/충돌에 주의. Calico 등 CNI가 같은 netns에 rule을 박으면 진입점 chain의 평가 순서에 따라 Istio capture가 먼저 잡거나 CNI policy가 먼저 drop할 수 있다. 장애 시iptables-save -t nat로 chain 순서를 직접 봐야 한다.- CNI 방식 전환 시 init container 권한 회수 효과를 잊지 말 것. istio-init 방식은 app pod에 NET_ADMIN/NET_RAW를 요구하지만 Istio CNI 방식으로 옮기면 그 권한을 회수할 수 있다. PodSecurity/보안 감사 관점에서 의미 있는 차이이므로, capture 동작뿐 아니라 권한 표면도 함께 검증해야 한다.