개요
이 글은 클러스터로 들어온 요청이 앱 컨테이너에 도달하기까지의 전체 경로를 탐색한다. 지난 글에서 사이드카가 어떤 원리로 주입되고 트래픽을 가로채는지 확인했다면, 이번엔 사이드카에 실제 요청이 어떻게 통과하는지를 살펴본다.
1. 환경 준비
1.1. 클러스터와 istio
이전 글의 k8s-lab 클러스터를 그대로 쓰되, 이번에는 클러스터 밖에서 게이트웨이로 들어가는 경로가 필요하다. 그런데 Kind에서는 이 경로가 기본적으로 막혀있다. 왜 막혀 있는지부터 살펴보자.
Kind는 EKS와 달리 Docker 컨테이너가 하나의 노드 역할을 한다. Docker는 컨테이너를 내부 사설 네트워크(Bridge)에 붙이기 때문에, 노드는 172.18.0.2와 같은 브리지 안의 사설 주소를 할당 받는다. [Docker Docs] 그래서 노드에 NodePort가 열려 있어도, 이 주소로 접근할 수 있느냐는 어디서 부르느냐에 따라 달린다.
- docker를 돌리는 내 머신(맥미니)에서는
노드IP:30080으로 접근 가능. - 다른 기기에서는 X.
172.18.0.x는 맥미니 내부의 사설 대역이라 LAN에서 라우팅되지 않는다. - 참고로 Mac/Windows라면 docker가 숨겨진 리눅스 VM 안에서 돌기 때문에, 같은 머신에서조차 노드 IP 접근이 안 된다.
이 단절을 뚫는 docker의 공식 통로가 포트 매핑(docker run -p)이다. 이렇게 포트를 지정해서 컨테이너를 생성하면, 호스트가 대신 listen하다가, 들어온 패킷을 컨테이너까지 릴레이한다. 아래 kind-config의 extraPortMappings가 바로 이것이고, 컨테이너 시작 시점에만 걸 수 있는 옵션이라 클러스터를 다시 만들었다. [Docker Docs, Kind Docs]

<용어 정리>
- NodePort: Service를 노드의 특정 포트로 노출하면 kube-proxy가 모든 노드에서 그 포트를 열어주는 k8s 기능
- Node: Kind가 띄운 Docker 컨테이너, K8S 입장에서는 이게 서버 하나이고 EKS는 Instance.
1.1.1. 클러스터(kind-lab) 재생성
cat <<EOF > kind-config.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 8080
protocol: TCP
EOF
kind create cluster --name k8s-lab --config kind-config.yaml
1.1.2. Istio 설치
istioctl install --set profile=demo -y
kubectl get svc -n istio-system

istio-ingressgateway의 External-Ip가 <pending>인데, 이는 정상 동작이다. 이 Service는 Type이 LoadBalancer라 클라우드였다면 전용 NLB가 생겨 "외부 입구 제공 + 노드 NodePort로 패킷 전달" 역할을 한다.
kind에는 LB를 만들 클라우드가 없기에, 아까처럼 docker 포트 매핑을 설정한다.
- 입구는 localhost:8080, 도착지는 노드의 30080
남은 문제는 NodePort는 범위 안에서 랜덤 배정이라 K8S가 30080이 아닌 다른 포트를 열 수 있다. 그러면 두 구간이 만나지 않으니, NodePort 또한 30080으로 고정한다. 이렇게 [ 호스트 8080 → 노드 30080 → 게이트웨이 Pod ] 경로가 완성된다.
✔ NodePort를 30080으로 고정
kubectl -n istio-system patch svc istio-ingressgateway --type='json' \
-p='[{"op":"replace","path":"/spec/ports/1/nodePort","value":30080}]'
✔ (참고용) ingressgateway NodePort / LoadBalancer
회사의 istio-ingressgateway 타입은 LoadBalancer가 아니라 NodePort 타입이었다. (개인 노션에 정리) 이 둘의 핵심 차이는 결국 연결될 LB를 만들어주느냐/ 아니냐 이다. [istio docs]
| NodePort | LoadBalancer |
| 모든 노드에 포트를 열어주는 것이 끝! NodeIP:NodePort로 접근. | NodePort를 만들고, 추가로 L4 LB를 프로비저닝 - EKS면 NLB, GKE면 Network LB. |
| EXTERNAL-IP <none> : 쿠베가 외부 진입점을 만들어 주는 방식이 아니고 연결하는 방식 |
EXTERNAL-IP에 LB 주소/호스트네임 : LB가 백엔드로 각 노드의 nodePort에 트래픽 뿌려주는 구조. |
| 이미 공용 LB/Ingress 계층이 있어서, 게이트웨이는 노드포트만 노출하면 되는 구조 | LB type은 LB의 생명주기가 Service에 종속됨. |
1.2. 심플 앱 (v1/v2)
응답으로 자기 버전만 뱉는 단순한 앱을 두 버전 띄운다. namespace에 injection 라벨을 붙여 사이드카가 자동 주입되게 한다.
kubectl create namespace demo
kubectl label namespace demo istio-injection=enabled
kubectl apply -f - <<EOF
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-v1
namespace: demo
spec:
replicas: 2 # 생성될 Pod 개수를 결정한다.
selector:
matchLabels: { app: myapp, version: v1 }
template:
metadata:
labels: { app: myapp, version: v1 }
spec:
containers:
- name: app
image: hashicorp/http-echo:1.0
args: ["-text=response from v1"]
ports: [{ containerPort: 5678 }]
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-v2
namespace: demo
spec:
replicas: 1
selector:
matchLabels: { app: myapp, version: v2 }
template:
metadata:
labels: { app: myapp, version: v2 }
spec:
containers:
- name: app
image: hashicorp/http-echo:1.0
args: ["-text=response from v2"]
ports: [{ containerPort: 5678 }]
---
apiVersion: v1
kind: Service
metadata:
name: myapp # myapp.demo.svc.cluster.local 이라는 DNS 이름이 자동으로 생긴다
namespace: demo
spec:
selector: { app: myapp }
ports:
- port: 80
targetPort: 5678
EOF
#생성 확인
kubectl get pods -n demo -o wide

앱까지 준비 완료! 아직 규칙 설정을 안했기 때문에 응답은 제대로 오지 않는다. 이제 앱까지 준비되었으니 먼저 Mesh를 타지 않는 통신을 살펴보도록 하자.
2. Mesh 밖의 요청 경로
Mesh 밖의 트래픽은 곧 사이드카(Envoy)가 없는 Pod가 보내는 트래픽이다. kube-system의 CoreDNS, injection 라벨이 없는 namespace의 앱들이 여기 속한다.
✔ 들어온 요청은 어떤 Pod로 향할까?
- mesh 밖에서는 kube-proxy가 Pod에 트래픽을 할당하는 역할을 한다.
- 실제로 kube-proxy가 iptables/IPVS 규칙을 커널에 설정만 해두고, 패킷 포워딩(DNAT)은 커널이 직접 한다.
# Mesh를 타지 않을 때 내부 호출 트래픽 흐름
클라이언트 Pod가 ──▶ [목적지: ClusterIP] ──▶ 노드 커널 ──▶ [목적지: Pod IP] ──▶ myapp Pod
curl myapp.demo CoreDNS가 (kube-proxy)
ClusterIP 클라 Pod에게줌 iptables DNAT
클라가 ClusterIP로 재요청
* kube-proxy: 각 노드에서 iptables/IPVS 규칙만 유지 (데이터 경로 밖)
2.1. Service·Endpoints 조회 : ClusterIP 1 & Pod 3
Pod는 수시로 죽고 다시 뜨는 걸 반복하기 때문에 IP가 계속 변동한다. 따라서 클러스터 내부에서 Pod들끼리 통신을 할 때는 변하지 않는 대표 주소, Service의 ClusterIP를 활용한다.
kubectl get svc myapp -n demo # Service의 ClusterIP 확인
kubectl get endpoints myapp -n demo # 그 뒤에 매달린 실제 Pod IP 목록

2.2. DNS 조회
앱은 myapp.demo.svc.cluster.local이라는 이름으로 호출한다. 이름을 IP로 바꿔주는 것은 클러스터 내장 DNS(CoreDNS)다. 그럼 앱이 이 이름을 조회했을 때 정확히 뭘 받는지 직접 살펴보자.
Mesh를 타지 않는 일회용 Pod(dns-test)를 하나 띄우고, 그 안에서 DNS 조회를 한 번 해본다.
# demo namespace에서 실행하면 사이드카가 주입되므로, mesh 밖인 default에서 실행한다
kubectl run dns-test -it --rm --restart=Never --image=busybox:1.36 -- \
nslookup myapp.demo.svc.cluster.local

myapp.demo.svc.cluster.local 앱을 호출했을 때의 응답은, myapp의 ClusterIp임을 확인할 수 있다. 즉, DNS가 돌려주는 건 결국 ClusterIP다. 그러니 myapp을 호출하는 쪽(클라이언트 앱)이 이 할 수 있는 일도 이 대표번호(ClusterIP)로 패킷을 보내는 것까지가 전부다. 진짜 목적지인 Pod IP로 바꾸는 것은 그 다음 단계인 kube-proxy가 수행한다.
2.3. kube-proxy가 저장해둔 iptables 규칙 확인
- iptables는 조건과 행동을 매핑해두는 곳
- 이를 보고 목적지가 10.96.3.3이면 주소를 Pod IP로 바꿔써라와 같은 DNAT 처리를 커널이 수행한다.
- kube-proxy가 이 규칙표를 관리하며,
- kube-proxy는 Service나 Pod가 생기고 죽을 때마다 각 노드의 iptables 규칙을 갱신한다.
# docker exec k8s-lab-control-plane = 노드 안으로 들어가서
# iptables -t nat -S = NAT 규칙 덤프
# grep demo/myapp = kube-proxy가 규칙에 박아둔 <ns>/<svc> 주석으로 필터
docker exec k8s-lab-control-plane iptables -t nat -S | grep "demo/myapp"

출력을 보면 ClusterIP로 온 패킷을 --probability 옵션으로 확률적으로 Pod IP에 DNAT하는 규칙이 보인다.
2.4. 30번 호출 해보기
사이드카가 없는 default namespace에서 일회용 Pod를 띄워 myapp을 30번 호출한다. 결과적으로 v1 Pod 두 개, v2 Pod 한 개에 대한 요청 분배가 Pod 개수 비율(약 2:1) 언저리로 떨어진다.

✔ 요청 실습을 통해 알게된 mesh 밖 규칙은 다음과 같다.
요청이 들어오면 DNS는 대표번호(ClusterIP)를 알려주고, kube-proxy의 iptables가 그 번호로 온 패킷을 확률적으로 Pod IP로 DNAT 처리를 한다. 즉, 요청을 어느 Pod가 받을지는 kube-proxy가 확률적으로 정한다. iptables는 L4라 Host 헤더를 읽을 수 없으니 "myapp.example.com으로 온 요청은 v1으로" 같은 규칙은 애초에 쓸 수가 없고, 분배 비율은 Pod 개수 비율로 고정된다.
3. Istio 등장: 라우팅 규칙을 통해 Mesh 내부 통신하기
Service Mesh의 핵심은 앱의 코드를 하나도 바꾸지 않고 설정만으로 클러스터 전체 통신에 동일한 규격을 설정할 수 있다는 것이다.
이제 myapp.example.com으로 온 요청에 대해 전부 v1 Pod로 전달하도록 하는 Mesh 통신 흐름을 살펴보자.
3.1. Envoy Config 종류와 생성(yaml)
아래 컴포넌트들은 Mesh 통신을 위한 Envoy 설정을 별도의 config로 분리한 것들이다.
- Gateway: 몇 번 포트로, 어떤 호스트명의 트래픽을 입장시킬지 정하는 규칙이다.
- VirtualService:
Gateway를 통해 들어온 요청의 행선지 규칙이다."myapp.example.com으로 온 건 myapp 서비스의 v1으로"같은 규칙을 정의한다. - DestinationRule: Pod들을 라벨(version: v1/v2)으로 묶어 subset이라는 이름을 붙인다. Service의 selector는
app: myapp뿐이다. DR을 통해 특정 라벨 기준을 만들고, 어떻게 부를지 명명한다.

먼저, v1 Pod로 100% 트래픽을 보내는 규칙을 만들어보자.
- Gateway
kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
name: myapp-gateway
namespace: demo
spec:
selector: { istio: ingressgateway } # 이 규칙을 적용할 게이트웨이 Pod (istio-system의 Envoy Pod)
servers:
- port: { number: 80, name: http, protocol: HTTP } # 80 포트로 HTTP를 받는다
hosts: ["myapp.example.com"] # 단, Host 헤더가 이 문자열인 요청만. DNS와 무관한 입장 허용 명단
- VirtualService
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: myapp
namespace: demo
spec:
hosts: ["myapp.example.com"] # 이 Host 헤더를 단 요청에 적용할 규칙
gateways: ["myapp-gateway"] # 위 Gateway로 들어온 트래픽에만 적용
http:
- route:
- destination:
host: myapp.demo.svc.cluster.local # 행선지 Service
subset: v1 # 그중 DestinationRule이 정의한 v1 그룹으로 100%
- DestinationRule
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: myapp
namespace: demo
spec:
host: myapp.demo.svc.cluster.local # 1.2에서 Service 생성 시 자동 부여된 DNS 이름
subsets:
- name: v1 # version: v1 라벨이 붙은 Pod 묶음을 v1이라 부르겠다
labels: { version: v1 }
- name: v2
labels: { version: v2 }
- 적용 스크립트!
kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
name: myapp-gateway
namespace: demo
spec:
selector: { istio: ingressgateway } # 이 규칙을 적용할 게이트웨이 Pod (istio-system의 Envoy Pod)
servers:
- port: { number: 80, name: http, protocol: HTTP } # 80 포트로 HTTP를 받는다
hosts: ["myapp.example.com"] # 단, Host 헤더가 이 문자열인 요청만. DNS와 무관한 입장 허용 명단
---
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: myapp
namespace: demo
spec:
host: myapp.demo.svc.cluster.local # 1.2에서 Service 생성 시 자동 부여된 DNS 이름
subsets:
- name: v1 # version: v1 라벨이 붙은 Pod 묶음을 v1이라 부르겠다
labels: { version: v1 }
- name: v2
labels: { version: v2 }
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: myapp
namespace: demo
spec:
hosts: ["myapp.example.com"] # 이 Host 헤더를 단 요청에 적용할 규칙
gateways: ["myapp-gateway"] # 위 Gateway로 들어온 트래픽에만 적용
http:
- route:
- destination:
host: myapp.demo.svc.cluster.local # 행선지 Service
subset: v1 # 그중 DestinationRule이 정의한 v1 그룹으로 100%
EOF
3.2. 동작 확인
curl -s -H "Host: myapp.example.com" http://localhost:8080/
# response from v1
이것만 봐서는 Mesh 타지 않고 kube-proxy가 확률로 트래픽을 할당한 것인지 가늠이 오지 않으니, 30번 요청을 보내보았다.

2.4의 결과와 정면으로 배치된다. v2 Pod도 떠 있지만, 모든 요청이 v1으로 도달했다. 2장의 메커니즘 어디에도 이 결과를 만들 수 있는 자리가 없으니, 경로 어딘가에 kube-proxy가 아닌 다른 무언가 (envoy !)가 개입했다는 뜻이다.
4. Service Mesh를 타는 통신의 원리
4.1. Envoy
Envoy는 트래픽을 받아서 규칙대로 전달하는 프록시다. Istio는 이 프로그램 하나를 클러스터 곳곳에 배치해서 mesh를 만든다.
✔ 4장 이해를 위해 Envoy 특징 기억할 점 3가지
- Ingress Gateway도 그냥 Envoy 파드다. 사이드카에 넣는 것과 똑같은 Envoy를
istio-system에 단독 Pod로 띄워, 입구에 세운 것이다. Envoy 시작 시점에는 아무것도 모르는 빈 프록시고, 뭘 할지는 전부 Istiod가 내려주는 설정이 정한다. - kube-proxy는 요청을 L4에서 확률적으로 라우팅하지만, Envoy는 L7에서 규칙을 보고 라우팅한다. 즉, Envoy는 패킷의 HTTP 내용물까지 확인한다. Host Header, URL 경로, 버전(subset)을 보고 목적지를 고르므로, 세밀한 조정이 가능해진다.
- Mesh 대상 Pod 안에서는 앱보다 사이드카가 먼저 요청을 받는다. Pod가 뜰 때
istio-init이라는 초기화 컨테이너가 Pod 안의iptables에 들어오는 트래픽은 목적지 포트가 무엇이든 사이드카에게 먼저라는 규칙을 심어두기 때문이다.
4.2. Mesh를 타는 트래픽의 경로
Istio는 kube-proxy의 경로를 고치는 게 아니라, 아예 밟지 않는다. 이걸 확인하기 위해 2가지 케이스에 대해 요청 경로를 살펴보자.
요청은 두 방향이 있다.
| 부르는 쪽 | 목적지 주소 | |
| A. 외부 유입 | 클러스터 밖 (curl 등) | 노드 IP : NodePort |
| B. 내부 호출 | 클러스터 내부의 다른 Pod | Service의 ClusterIP |
4.2.1. 요청 A) 클러스터 외부 유입
외부 유입 Mesh 타지 않음

외부 유입 Mesh를 탐

4.2.2. 요청 B) 내부 통신
내부 통신 Mesh 타지 않음

내부 통신 Mesh를 탐

이제 전체 흐름 이해 ㅇㅇ. 다음에 Envoy Config 뜯어보면 될듯
'DevOps > DevOps' 카테고리의 다른 글
| [K8S] Sidecar (Native, Ambient) (0) | 2026.07.07 |
|---|---|
| [K8S] Service Mesh와 Istio (0) | 2026.07.05 |
| [K8S] Mac Mini(Ubuntu)에 kind 기반 로컬 K8S Cluster 환경 구축하기 (0) | 2026.06.21 |
| [DevOps] SSH 없이 안전하게 EC2 운영하기: SSM 기반 접근 제어와 전역 정책 설계 (0) | 2025.12.07 |
| [K8S] 자주 사용하는 Kubernetes CLI 정리 (1) | 2025.09.28 |