--- type: src tags: [istio, sidecar, iptables, nftables, traffic-capture, envoy, dataplane] created: 2026-06-07 --- # Sidecar 트래픽 캡처 — iptables/nftables와 15001·15006 > [!abstract] 이 문서가 다루는 것 > 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를 통과한다. > [!warning] 범위 > 본 문서는 **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 안에 박는 것이 전부다. ```text [ 트래픽 캡처가 없으면 ] app socket → 커널 → NIC → 진짜 목적지 (Envoy를 건너뜀 = mesh 무력화) [ 트래픽 캡처가 있으면 ] app socket → 커널 netfilter REDIRECT → Envoy 15001/15006 → ... → 진짜 목적지 └─ app은 이 우회를 전혀 모름 (transparent) ─┘ ``` 이 한 우회가 **mTLS/route/authz/telemetry 전부의 전제**다. 캡처가 안 걸리면 Envoy 설정이 아무리 완벽해도 트래픽이 Envoy를 안 지나가니 mesh 기능이 통째로 죽는다. 그래서 "왜 내 정책이 안 먹지?"의 1차 용의자는 항상 캡처가 실제로 박혔는지다. --- ## 01. 멘탈모델 앵커 — 두 개의 입구, 하나의 불변식 > [!key] 한 문장 멘탈모델 > Istio sidecar 캡처의 본질은 단 세 가지다 — (1) app이 **밖으로 나가는** 패킷은 커널이 Envoy의 **outbound 입구 15001**로 REDIRECT한다, (2) pod로 **들어오는** 패킷은 Envoy의 **inbound 입구 15006**으로 REDIRECT한다, (3) 단 **Envoy(UID/GID 1337) 자신이 만든 패킷은 다시 잡지 않는다**(무한 루프 방지). 이 세 줄에서 나머지 모든 체인·포트·예외가 따라 나온다. 이 그림 하나만 머리에 박으면 된다. ```text 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**일 뿐이고, 소스 기본값도 그냥 박혀 있다. ```go 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 멘탈모델](sec__src-authorizationpolicy-mental-model.html)). 공식 디버깅 모델로도 모든 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`)을 일관되게 쓴다. > [!tip] 숫자 대신 의미로 > ```text > 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다. ### 소스 흐름 ```text 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은 같다. 다르게 하는 건 **누가, 어느 시점에, 어떤 권한으로** 박느냐다. ```text 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개 진입점**으로 구성된다. ```text 진입점: 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) ```text 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) — 불변식이 사는 곳 ```text OUTPUT -j ISTIO_OUTPUT ISTIO_OUTPUT: outbound exclude port면 → RETURN loopback/self-call 특수 처리 → RETURN proxy UID/GID(1337)가 만든 트래픽 → RETURN ★ 무한 루프 방지 outbound include CIDR가 "*"면 → ISTIO_REDIRECT ``` > [!warning] proxy UID/GID 1337 RETURN — 무한 루프 방지의 핵심 > 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 여부에 따라 달라진다. ```text *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이 정말 생성되는가"를 부작용 없이 본다. ```bash kubectl exec -n -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 '*' ``` 플래그 의미: ```text -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 라인이 다 보이면 정상이다. > [!tip] `-m`은 inbound 전용 mode > `-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)이 다른 문법으로 표현될 뿐이다. ```text 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도 함께 설정한다. > [!warning] 함정 — iptables backend(iptables-nft) vs native nftables를 혼동하지 말 것 > 둘 다 결국 커널 netfilter를 쓰지만 의미가 다르다. > ```text > 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 확인 ```bash # debug container(netshoot)를 app container의 netns에 붙여서 확인 kubectl debug -n -it pod/ \ --image=nicolaka/netshoot \ --target= \ --profile=netadmin \ -- iptables-save -t nat # 또는 istio-proxy 컨테이너에 도구가 있으면 직접 kubectl exec -n -c istio-proxy -- iptables-save -t nat ``` `iptables-save -t nat` 출력에서 capture가 적용됐다면 다음 핵심 라인들이 보여야 한다. ```text -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 확인 ```bash kubectl debug -n -it pod/ \ --image=nicolaka/netshoot \ --target= \ -- nft list ruleset ``` > [!tip] `--target`의 의미 > `kubectl debug ... --target=`는 debug 컨테이너를 **app 컨테이너와 같은 network namespace**에 join시킨다. iptables rule은 pod netns 단위로 존재하므로, target을 지정해야 app pod가 실제로 보는 rule을 관찰할 수 있다. target 없이 띄우면 다른 netns를 보게 되어 rule이 안 보일 수 있다. 캡처가 실제로 동작하는지(트래픽이 Envoy를 통과하는지)는 Envoy 설정 쪽에서 교차 검증한다 — `istioctl proxy-config listener `로 `virtualOutbound(0.0.0.0:15001)` / `virtualInbound(0.0.0.0:15006)` listener 존재를 확인하면 된다(→ [xDS 계층과 진단](xds__src-xds-layers-and-diagnosis.html)). rule(커널 측)과 listener(Envoy 측) 둘 다 있어야 캡처가 진짜 닫힌 고리로 동작한다. --- ## 07. packet flow 다이어그램 ### outbound flow ```mermaid flowchart TD A["app: connect svc:9080"] --> B["nat/OUTPUT - ISTIO_OUTPUT"] B --> C{"uid/gid 1337?
localhost?"} C -->|"예: RETURN"| Z["그대로 통과"] C -->|"아니오"| D["ISTIO_REDIRECT"] D --> E["REDIRECT :15001"] E --> F["Envoy virtualOutbound 15001"] F --> G["virtual listener 9080
(handoff)"] G -.-> X["이후 xDS 처리
- xds-layers-and-diagnosis"] classDef later stroke-dasharray: 5 5,fill:#f6f6f6,stroke:#999; class X later; ``` ### inbound flow ```mermaid flowchart TD A["remote peer → podIP:9080"] --> B["nat/PREROUTING → ISTIO_INBOUND"] B --> C{"exclude port?
15008/15020/15021/15090"} C -->|"예: RETURN"| Z["redirect 안 함"] C -->|"아니오"| D["ISTIO_IN_REDIRECT"] D --> E["REDIRECT :15006"] E --> F["Envoy virtualInbound 15006"] F --> G["mTLS 종료 / authz(RBAC) / telemetry"] G --> H["app container :9080"] ``` 캡처가 끝나는 지점은 Envoy 입구(15001/15006)까지다. 그 다음 listener→route(RDS)→cluster(CDS)→endpoint(EDS)로 이어지는 처리는 별도 xDS 영역이다(→ [xDS 계층과 진단](xds__src-xds-layers-and-diagnosis.html)). --- ## 핵심 정리 - **존재 이유**: 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 동작뿐 아니라 권한 표면도 함께 검증해야 한다.