개요
지난 글에서는 Service Mesh를 타는/타지 않는 경우에 대한 트래픽 흐름을 살펴보았다. Mesh를 타는 흐름 즉, Envoy를 통해 트래픽이 오갈 때 Gateway, Virtual Service, Destination Rule이 주는 영향을 직접 단계별로 살펴보자.
1. 들어가며 : K8S Architecture와 CRD(Gateway, VirtualService, DestinationRule)

GW, VS, DR은 내장 리소스가 아니라 istio가 CRD(커스텀 리소스!)로 등록한 것이다. CRD를 등록하는 순간 API Server가 그 타입을 이해하게 되고, 그 뒤로는 내장 리소스(Deployment, Service 등)와 완전히 동일하게 동작한다.
CRD kubectl apply 후, Envoy 반영까지 일어나는 일, 총 5단계
1. API Server가 가장 먼저 요청을 받고, 인증/인가/검증을 진행
여기서 말하는 인증/인가/검증은...
- 인증:
kubeconfig의 인증서와 토큰으로 신원을 확인하고, 실패하면 401을 응답한다. - 인가: '이 유저가 demo namespace의 vistualservices를 update해도 되는가?'를 RBAC 규칙과 대조하고, 실패하면 403을 응답한다.
- 검증: CRD에 등록된 스키마로 YAML 필드를 검사하고, 어드미션 웹훅이 추가로 검문한다.
2. etcd에 저장
etcd는 /etc + d 의 의미로 쿠베의 설정과 메타데이터를 담는 곳이다.

3. istiod가 watch로 변경 감지
istiod는 Kubernetes의 변경사항을 각 Pod에 밀어넣는 역할을 한다. 그렇다면 Kubernetes에 어떤 변경사항이 생겼을 때, Istiod는 어떻게 감지할까? 바로 API Server가 제공하는 watch다. [Kubernetes Docs]
① istiod → API Server: "VS/GW/DR을 watch할게" (HTTP 요청)
② API Server가 이 연결을 열어둔 채로 두고, (연결 자체는 주기적으로 재수립)
③ 누가 VS를 apply/수정/삭제하면
④ 열려 있는 연결로 이벤트를 스트리밍
{ "type": "MODIFIED", "object": { ...바뀐 VS 전체... } }
⑤ istiod는 받고 xDS 변환 → Envoy push
4. Envoy 설정(xDS)으로 변환해서 Push
5. ingressgateway / 사이드카 Envoy에 주입, 설정들이 트래픽에 영향
2. 환경 준비
GW, VS, DR 각각이 어떻게 주입/설정되고, 트래픽이 흐르는지 살펴본다.
2.1. 지난 글의 환경 그대로 활용 [link]
- k8s-lab 클러스터 (kind, 호스트 8080 → 노드 30080 포트 매핑)
- istio demo profile, demo namespace에 injection 라벨
- myapp-v1 (replicas 2), myapp-v2 (replicas 1), Service myapp (80 → 5678)
2.2. 규칙 초기화
지난 글에서 만든 규칙을 모두 초기화하기 위해 GW/VS/DR 설정을 모두 지웠다.
kubectl delete gateway myapp-gateway -n demo
kubectl delete virtualservice myapp -n demo
kubectl delete destinationrule myapp -n demo
2.3. 큰 흐름 살펴보기 : istioctl proxy-config
Envoy는 요청을 네 단계로 처리하고, istioctl proxy-config는 단계별 설정을 각각 꺼내볼 수 있다.

- Listener: Envoy가 listen 중인 포트와 프로토콜
- Route: Host 헤더/경로를 보고 어느 Cluster로 보낼지 정하는 규칙 표
- Cluster: 같은 목적지로 묶인 엔드포인트 그룹
- Endpoint: Cluster에 매달린 실제 Pod IP 목록
istioctl proxy-config listener <pod> # ① 열려 있는 포트
istioctl proxy-config route <pod> # ② 라우팅 규칙
istioctl proxy-config cluster <pod> # ③ 목적지 그룹
istioctl proxy-config endpoint <pod> # ④ 그룹별 실제 Pod IP
[이해를 위한 메모]
1. 아무 설정이 없는 상태에서 위 명령어를 작성했을 때에도 설정값이 나오긴 하는데, 이건 트래픽용 설정이 아니라 운영용 설정이다.
2. 각 Config 읽는 방법
사실 설정을 주입하기 전에 모든 리소스에 대해 istioctl proxy-config ${resource} ... 해봤는데, 생소한 사람에게는 꽤 복잡한 설정값들이 출력된다. 각 표에서 볼 것만 추려보면 :
- Listener: ADDRESSES:PORT만 본다. "어떤 포트가 열려 있나". DESTINATION 칸에는 그 포트로 들어온 요청을 넘길 Route 이름(http.8080)이 적혀 있다
- Route: DOMAINS(어떤 Host 헤더를 받나)와 VIRTUAL SERVICE(이 규칙이 어느 VS에서 왔나)를 본다. 어느 Cluster로 보내는지는 표에 안 나오고 -o json에 있다
- Cluster: 표보다 이름 자체가 정보다. outbound|80|v1|myapp... = 방향|포트|subset|서비스FQDN.
- Endpoint: 그 Cluster 이름 뒤에 실제로 어떤 Pod IP들이 매달려 있는지
3. 그리고 네 표는 따로 노는 게 아니고 tree로 이어지는 체인.
listener의 DESTINATION (http.8080)
└─▶ route의 NAME이 http.8080인 규칙표
└─▶ route(-o json)의 "cluster": outbound|80|v1|myapp...
└─▶ cluster 표의 그 이름
└─▶ endpoint --cluster "outbound|80|v1|myapp...
요청이 LRCE로 흐르니까 설정을 추적할 떄에도 이와 같은 흐름으로 따라가면 된다!
3. 아무것도 적용하지 않은 Envoy 설정 살펴보기
GW/VS/DR을 주입하기 전에 ingressgateway Envoy의 설정을 먼저 살펴본다.
mkdir snapshots
istioctl proxy-config listener deploy/istio-ingressgateway -n istio-system > snapshots/0-listener.txt
istioctl proxy-config route deploy/istio-ingressgateway -n istio-system > snapshots/0-route.txt
istioctl proxy-config cluster deploy/istio-ingressgateway -n istio-system \
--fqdn myapp.demo.svc.cluster.local > snapshots/0-cluster.txt
적용 전후 비교를 위해 스냅샷을 미리 남겨두었다.
Service Regitry
찍어둔 스냅샷을 열어보면 GW와 VS는 아무 설정이 없어서 단순히 health와 metric 경로만 지정되어있고, Cluster에는 목적지 항목이 정의되어있다. istiod는 기본 설정에서 클러스터의 모든 Service 목록을 모든 Envoy에 내려준다. [Istio Docs] 이때 istiod가 들고 있는 원장 명부를 Service Registry라고 한다.

- 모든 Service 목록을 istiod가 들고있다가 모든 Envoy에 내려주면 부하는 어떡하나? 싶어서 질문했을 때
- 우리는 Istio Sidecar Controller를 자체 개발해서 mesh 대상인 주소가 업데이트되면 주입되도록 자동화되어있다.
- Kontrol Workload의 Mesh 통신 대상에서 확인 가능!

쿠버네티스 API Server (Service/Endpoints 객체)
↓ istiod가 watch로 수집
Service Registry — istiod 내부의 "원본 명부"
↓ xDS로 push
각 Envoy의 Cluster/Endpoint — 명부의 "복사본" ← 지금 스냅샷에서 본 게 이것
4. Gateway
4.1. Gateway 설정이 없고, Listener가 없는 상태
바로 위 스냅샷을 확인해보면 myapp이 트래픽을 받을 Listener는 없는 상태다.


EKS와 대응해보면...
| Kind | EKS |
| 호스트의 8080 (docker-proxy) | NLB 리스너 |
| 노드(컨테이너)의 30080 | EC2 인스턴스의 NodePort (타겟그룹 대상 포트) |
| 게이트웨이 Pod의 8080 | 게이트웨이 Pod의 8080 (동일) |
EKS였다면 NLB 헬스체크가 타겟을 unhealthy로 판정해, 트래픽을 아예 흘리지 않는 형태로 나타난다.
4.2. Gateway 설정을 통해 Listener 확인
kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1
kind: Gateway
metadata:
name: myapp-gateway
namespace: demo
spec:
selector: { istio: ingressgateway } # 이 규칙을 적용할 게이트웨이 Pod
servers:
- port: { number: 80, name: http, protocol: HTTP }
hosts: ["myapp.example.com"] # 이 Host 헤더만 입장 허용
EOF

Gateway 리소스에 대해 apply하면 etcd에 CRD가 저장된다. etcd에 저장된 Gateway 선언을 istiod가 감지해서 xDS로 변환하고, ingressgateway Envoy에 push한 결과가 나타난 것이다.

Gateway 설정을 밀어넣은 후 다시 curl.. 요청을 해보면 응답 종류가 바뀌었는데, 이는 Listener 등록으로 패킷이 어느 문을 통과할지까지 결정됐지만 어디로 요청이 갈지에 대한 규칙이 없는 상태이기에, 404 응답으로 바뀐 것이다.

GW가 없을 때 : 요청 받을 포트가 없음 → TCP 연결 불가(RST), L4(listener)에서 커널이 연결을 끊었고(Connection reset), 지금은 Envoy가 TCP를 수락하고 HTTP 요청을 읽은 뒤(헤더를 까봄) L7에서 404로 응답한 점을 기억하자.
5. VirtualService
5.1. VirtualService 설정하고 Route 확인
Route는 Listener에서 열어둔 포트로 들어온 요청을 보고, 어느 목적지로 보낼지. 말 그대로 라우팅을 어떻게 할지 설정하는 것이다.
subset이라는 개념이 있는데 일단 subset은 단어만 기억해두고, 없이 Service 전체로 VS를 등록한다.
kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: myapp
namespace: demo
spec:
hosts: ["myapp.example.com"]
gateways: ["myapp-gateway"]
http:
- route:
- destination:
host: myapp.demo.svc.cluster.local # subset 없이 Service 전체로
EOF

어떤 Host 헤더에 적용할 규칙인지, 어떤 경로에 적용될지, 이 규칙의 출처는 어디인지 등 Route가 주입된 바를 확인할 수 있다. 위 내용을 한 문장으로 풀면 다음과 같다.
- 8080 문으로 들어온 요청(listener 설정) 중[Name], Host가 myapp.example.com[Domains] 이면, 경로가 뭐든(/*)[Match] VS가 지정한 목적지로 보내라.


VirtualService 설정을 밀어넣은 후 다시 curl.. 요청을 해보면! Pod에서 응답이 온다. 지금 VS는 'myapp 전체로 보내'는 규칙이라, 버전 구분없이 섞여 나오는 게 정상이다. 지금은 Envoy의 로드밸런싱으로 요청 분배되지만, 'v1만 요청을 받는다.'와 같은 설정이 가능하다. → DR

6. DestinationRule
이름부터 보면 '목적지에 대한 규칙'이다. VS가 가는 길의 규칙(route)이라면, DR은 도착지 자체의 규칙이다.
6.1. DR subset 설정
VS까지 설정했을 때에는 요청이 myapp까지 도달하지만, 응답은 v1, v2가 섞여 나왔따. Service의 selector가 app : myapp 뿐이라 버전을 구분하지 못하기 때문이다. 'v1만 골라보내고 싶다'를 하려면 먼저 v1과 v2를 구분해서 부를 이름이 있어야 하는데, 그 이름표를 만드는 게 DR의 subset이다.
DR: "version: v1 라벨 Pod 묶음을 v1이라 부르겠다" → Envoy에 v1 전용 목적지 항목 생성
"version: v2 라벨 Pod 묶음을 v2라 부르겠다" → Envoy에 v2 전용 목적지 항목 생성
↓ 이름이 생긴 뒤에야
VS: "v1으로 90%, v2로 10%" 같은 지정이 가능해진다 (= 카나리 배포)
kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata:
name: myapp
namespace: demo
spec:
host: myapp.demo.svc.cluster.local
subsets:
- name: v1 # version: v1 라벨 Pod 묶음을 v1이라 부르겠다
labels: { version: v1 } # spec.template.metadata.labels, 즉 pod 템플릿의 라벨
- name: v2
labels: { version: v2 }
EOF
비교를 위해.. 먼저 DR 이전의 Cluster(목적지) 목록은 아래 한 줄이 전부다.

DR apply 후 목적지 목록은 다음과 같이 subset으로 구분된다.
istioctl proxy-config cluster deploy/istio-ingressgateway -n istio-system

| outbound|80||myapp... (원래 있던 것) | v1+v2 전부 (3개) |
| outbound|80|v1|myapp... (새로 생김) | v1 Pod 2개만 |
| outbound|80|v2|myapp... (새로 생김) | v2 Pod 1개만 |

이 DR을 사용하기 위해 VS에 subset을 추가해준다.
kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: myapp
namespace: demo
spec:
hosts: ["myapp.example.com"]
gateways: ["myapp-gateway"]
http:
- route:
- destination:
host: myapp.demo.svc.cluster.local
subset: v1 # ← 이 줄 추가
EOF

destination을 두 개 쓰고 weight를 붙이면 'v1으로 90%, v2로 10%' 카나리가 가능하다.