Istio External Authorization의 로컬리티 로드 밸런싱 확인하기
수많은 마이크로서비스가 있을 때 일부 서비스에 공통 인증이 필요한 상황을 가정해 보자. 비교적 간단한 JWT 토큰을 검증하는 정도라면 Istio의 RequestAuthentication을 사용할 수 있겠지만 더 복잡한 인증 로직이 있다면 이를 사용할 수 없다.
내부에 인증 서버가 이미 구축돼 있다고 할 때, 인증이 필요한 서비스는 인증 서버를 거치도록 해야 한다. 각 서비스에서 인증 서버로 리다이렉트하거나, 요청을 처리하기 전에 인증 서버에서 인증 여부를 확인하도록 구현할 수도 있다. 하지만 모든 서비스가 이를 구현하면 작업 비용이 많이 들고 구현 수준도 달라져 좋지 않다. 그런 면에서 가능하다면 인프라 차원에서 처리하는 것이 훨씬 좋다.
Istio는 이러한 경우에 External Authorization과 Authorization Policy를 이용해 인증을 위임할 수 있도록 지원한다. 이때 설정에는 <service-name>.<namespace>.svc.cluster.local 형식의 클러스터 도메인을 지정한다. 이 설정에도 Istio의 Locality 로드 밸런싱이 적용되는지 궁금해 찾아보았다.
External Authorization
먼저 External Authorization이 무엇인지 살펴보자.
1apiVersion: install.istio.io/v1alpha1
2kind: IstioOperator
3metadata:
4 name: istio-lab
5 namespace: istio-system
6spec:
7 meshConfig:
8 extensionProviders:
9 - name: extauth
10 envoyExtAuthzGrpc:
11 service: extauth-server.extauth.svc.cluster.local
12 port: "8085"
13 statusOnError: "500"
Istio의 전역 설정인 meshConfig에서 extensionProviders에 envoyExtAuthzGrpc나 envoyExtAuthzHttp를 지정하면 클러스터에서 외부 인증을 사용할 수 있다. 위 예시는 envoyExtAuthzGrpc를 사용한다.
여기서 gRPC와 HTTP는 Envoy가 인증을 위임할 때 사용하는 프로토콜이므로, 실제 서비스 트래픽이 HTTP인지 gRPC인지는 상관없다. 두 구현체는 동작이 다르다. 일반적으로 인증 서버는 gRPC로 더 많이 구현하는 것으로 보인다. 대표적인 차이점은 envoyExtAuthzGrpc가 기본적으로 모든 헤더와 호출한 서비스 정보를 전달한다는 점이다. 반면 envoyExtAuthzHttp는 헤더를 설정에 명시해야 전달하며 호출한 서비스 정보는 전달하지 않는다.
이렇게 설정한 envoyExtAuthzGrpc나 envoyExtAuthzHttp는 Authorization Policy를 이용해서 인증을 위임할 수 있다.
1apiVersion: security.istio.io/v1
2kind: AuthorizationPolicy
3metadata:
4 name: demo-app
5 namespace: demo
6spec:
7 selector:
8 matchLabels:
9 app: demo-app
10 action: CUSTOM
11 provider:
12 name: extauth
13 rules:
14 - to:
15 - operation:
16 methods:
17 - GET
18 paths:
19 - /api/*
이렇게 구성하면 demo-app으로 들어오는 요청 중 /api/로 시작하는 GET 요청은 아래 그림처럼 extauth 서버로 전달된다.

demo-app.example.com/api로 GET 요청이 들어온다.- istio-proxy가 받아서
AuthorizationPolicy를 확인하고demo-app으로 요청을 보내기 전에extauth-server.extauth.svc.cluster.local로extauth-server로 보낸다. extauth-server는auth-backend에 요청을 보내 인증을 확인한다. (사실 이 과정은 필요 없고 일반적인 서버 간 호출일 뿐이지만 인증 로직의 복잡성을 표현하려고 추가했다.)- 인증에 성공하면
demo-app의istio-proxy가 응답을 받고demo-app으로 요청을 전달한다. 실패하면 요청을 즉시 거절한다. (demo-app에는 트래픽이 전혀 들어가지 않는다.)
인증이 필요한 곳에 AuthorizationPolicy를 설정하면 쉽게 인증을 연동할 수 있고 서비스마다 구현 수준이 다른 문제도 쉽게 피할 수 있다.
Locality Load Balancing
로컬리티 로드 밸런싱은 워크로드의 지리적 위치를 고려해 같은 지역으로 트래픽을 먼저 보내는 설정이다. 다음 세 레이블을 이용한다.
- 리전:
topology.kubernetes.io/region - 존:
topology.kubernetes.io/zone - 서브존:
topology.istio.io/subzone
같은 지역에 있으면 지리적으로 가까워 응답 속도가 빨라질 가능성이 크다. 존이나 리전이 다르면 트래픽 비용이 발생할 수 있어 비용을 줄이는 효과도 있다.
1meshConfig:
2 extensionProviders:
3 - name: extauth
4 envoyExtAuthzGrpc:
5 service: extauth-server.extauth.svc.cluster.local
6 port: "8085"
7 statusOnError: "500"
8 localityLbSetting:
9 enabled: True
meshConfig에서 localityLbSetting.enabled를 True로 설정하면 전역에서 로컬리티가 활성화된다. 다만 이 설정만으로 로컬리티 로드 밸런싱이 동작하지는 않는다.
로컬리티는 다음과 같은 조건을 계산하면서 적용된다.

녹색 박스로 표시된 조건에 도달해야 실제로 로컬리티 로드밸런싱이 적용된다고 보면 된다.
distribute
조건 중 첫 번째에 해당하는 distribute는 DestinationRule에서 설정할 수 있다. 아래와 같이 레이블별로 가중치를 지정하면 그 가중치에 따라 로컬리티 로드 밸런싱이 동작한다.
1apiVersion: networking.istio.io/v1
2kind: DestinationRule
3metadata:
4 name: demo-app
5spec:
6 host: demo-app.example.com
7 trafficPolicy:
8 loadBalancer:
9 localityLbSetting:
10 enabled: true
11 distribute:
12 - from: region1/zone1/*
13 to:
14 "region1/zone1/*": 70
15 "region1/zone2/*": 20
16 "region3/zone4/*": 10
outlierDetection
두 번째 조건인 outlierDetection도 DestinationRule에서 설정한다. outlierDetection은 업스트림 서비스 상태에 따라 서킷 브레이커를 구현하는 방식이며, 같은 지역에 문제가 생겼을 때 페일오버를 위해 필요하다.
1apiVersion: networking.istio.io/v1
2kind: DestinationRule
3metadata:
4 name: demo-app
5spec:
6 host: demo-app.example.com
7 trafficPolicy:
8 connectionPool:
9 tcp:
10 maxConnections: 100
11 http:
12 http2MaxRequests: 1000
13 maxRequestsPerConnection: 10
14 outlierDetection:
15 consecutive5xxErrors: 7
16 interval: 5m
17 baseEjectionTime: 15m
로컬리티 데모
동작을 자세히 이해하려고 데모 앱을 구성했다. 이제는 AI로 이런 데모 앱을 쉽게 만들 수 있으니 참 신기한 세상이다.
데모는 kind를 사용했다. 로컬에서 Kubernetes를 테스트할 때 가장 편하다고 생각한다.
Kind 클러스터 설정
1# kind-config.yaml
2kind: Cluster
3apiVersion: kind.x-k8s.io/v1alpha4
4name: istio-lab
5networking:
6 ipFamily: ipv4
7nodes:
8 - role: control-plane
9 image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
10 extraPortMappings:
11 - containerPort: 30080
12 hostPort: 8080
13 listenAddress: "127.0.0.1"
14 protocol: TCP
15 - containerPort: 30443
16 hostPort: 8443
17 listenAddress: "127.0.0.1"
18 protocol: TCP
19 - role: worker
20 image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
21 kubeadmConfigPatches:
22 - |
23 kind: JoinConfiguration
24 nodeRegistration:
25 kubeletExtraArgs:
26 node-labels: "topology.kubernetes.io/zone=zone-a"
27 - role: worker
28 image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
29 kubeadmConfigPatches:
30 - |
31 kind: JoinConfiguration
32 nodeRegistration:
33 kubeletExtraArgs:
34 node-labels: "topology.kubernetes.io/zone=zone-a"
35 - role: worker
36 image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
37 kubeadmConfigPatches:
38 - |
39 kind: JoinConfiguration
40 nodeRegistration:
41 kubeletExtraArgs:
42 node-labels: "topology.kubernetes.io/zone=zone-c"
43 - role: worker
44 image: kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5
45 kubeadmConfigPatches:
46 - |
47 kind: JoinConfiguration
48 nodeRegistration:
49 kubeletExtraArgs:
50 node-labels: "topology.kubernetes.io/zone=zone-c"
이 kind-config.yaml를 사용해서 Kubernetes 클러스터를 생성한다.
1$ kind create cluster --config kind-config.yaml --wait 5m
2Creating cluster "istio-lab" ...
3 ✓ Ensuring node image (kindest/node:v1.36.1) 🖼
4 ✓ Preparing nodes 📦 📦 📦 📦 📦
5 ✓ Writing configuration 📜
6 ✓ Starting control-plane 🕹️
7 ✓ Installing CNI 🔌
8 ✓ Installing StorageClass 💾
9 ✓ Joining worker nodes 🚜
10 ✓ Waiting ≤ 5m0s for control-plane = Ready ⏳
11 • Ready after 9s 💚
12Set kubectl context to "kind-istio-lab"
13You can now use your cluster with:
14
15kubectl cluster-info --context kind-istio-lab
16
17Thanks for using kind! 😊
클러스터 설정에서 보듯이 컨트롤 플레인 1대와 워커 노드 4대가 있는데 2대는 a존에 2대는 c존에 배치된 것처럼 topology.kubernetes.io/zone 레이블을 설정했다.
1$ kubectl get nodes -L topology.kubernetes.io/zone
2NAME STATUS ROLES AGE VERSION ZONE
3istio-lab-control-plane Ready control-plane 24m v1.36.1
4istio-lab-worker Ready <none> 23m v1.36.1 zone-a
5istio-lab-worker2 Ready <none> 23m v1.36.1 zone-a
6istio-lab-worker3 Ready <none> 23m v1.36.1 zone-c
7istio-lab-worker4 Ready <none> 23m v1.36.1 zone-c
Istio 설치
클러스터에 Istio를 설치한다.(실제 설정은 데모앱을 구현하기 위해 좀더 설정이 있지만 여기서는 예제와 관련된 주요 설정만 넣었다.)
1# istio-operator.yaml
2apiVersion: install.istio.io/v1alpha1
3kind: IstioOperator
4metadata:
5 name: istio-lab
6 namespace: istio-system
7spec:
8 profile: demo
9 meshConfig:
10 localityLbSetting:
11 enabled: false
12 extensionProviders:
13 - name: extauth
14 envoyExtAuthzGrpc:
15 service: extauth-server.extauth.svc.cluster.local
16 port: "8085"
17 statusOnError: "500"
18 components:
19 ingressGateways:
20 - name: istio-ingressgateway
21 enabled: true
22 k8s:
23 overlays:
24 - kind: Service
25 name: istio-ingressgateway
26 patches:
27 - path: spec.ports.[name:http2].nodePort
28 value: 30080
29 - path: spec.ports.[name:https].nodePort
30 value: 30443
31 values:
32 gateways:
33 istio-ingressgateway:
34 type: NodePort
35
데모 앱
상황을 재현하는 데모 앱은 app-a, app-b, extauth로 구성했다. 모두 replica를 2개로 설정하고 topologySpreadConstraints를 사용해 a 존과 c 존에 하나씩 배치했다. (아래에는 시각화 등을 위한 추가 설정을 제외하고, 데모 시나리오와 관련된 주요 설정만 간추렸다.)
1# app-a.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: app-a
6 namespace: app-a
7 labels:
8 app: app-a
9spec:
10 replicas: 2
11 strategy:
12 type: RollingUpdate
13 rollingUpdate:
14 maxSurge: 0
15 maxUnavailable: 1
16 selector:
17 matchLabels:
18 app: app-a
19 template:
20 metadata:
21 labels:
22 app: app-a
23 spec:
24 serviceAccountName: app-a
25 topologySpreadConstraints:
26 - maxSkew: 1
27 minDomains: 2
28 topologyKey: topology.kubernetes.io/zone
29 whenUnsatisfiable: DoNotSchedule
30 nodeTaintsPolicy: Honor
31 labelSelector:
32 matchLabels:
33 app: app-a
34 - maxSkew: 1
35 topologyKey: kubernetes.io/hostname
36 whenUnsatisfiable: DoNotSchedule
37 nodeTaintsPolicy: Honor
38 labelSelector:
39 matchLabels:
40 app: app-a
41 containers:
42 - name: app-a
43 image: python:3.13-alpine
44 imagePullPolicy: IfNotPresent
45 command:
46 - python
47 - /app/server.py
48 ports:
49 - name: http
50 containerPort: 8080
51---
52apiVersion: v1
53kind: Service
54metadata:
55 name: app-a
56 namespace: app-a
57spec:
58 selector:
59 app: app-a
60 ports:
61 - name: http
62 port: 8080
63 targetPort: http
64---
65apiVersion: security.istio.io/v1
66kind: AuthorizationPolicy
67metadata:
68 name: app-a-extauth
69 namespace: app-a
70spec:
71 selector:
72 matchLabels:
73 app: app-a
74 action: CUSTOM
75 provider:
76 name: extauth
77 rules:
78 - to:
79 - operation:
80 methods:
81 - GET
82 paths:
83 - /auth
84---
85apiVersion: networking.istio.io/v1
86kind: Gateway
87metadata:
88 name: app-a-gateway
89 namespace: app-a
90spec:
91 selector:
92 istio: ingressgateway
93 servers:
94 - port:
95 number: 80
96 name: http
97 protocol: HTTP
98 hosts:
99 - app-a.example.test
100---
101apiVersion: networking.istio.io/v1
102kind: VirtualService
103metadata:
104 name: app-a
105 namespace: app-a
106spec:
107 hosts:
108 - app-a.example.test
109 gateways:
110 - app-a-gateway
111 http:
112 - corsPolicy:
113 allowOrigins:
114 - exact: http://visualize.example.test:8080
115 allowMethods:
116 - GET
117 - OPTIONS
118 allowHeaders:
119 - x-request-id
120 - x-ext-authz
121 route:
122 - destination:
123 host: app-a
124 port:
125 number: 8080
1# app-b.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: app-b
6 namespace: app-b
7 labels:
8 app: app-b
9 app.kubernetes.io/name: app-b
10 app.kubernetes.io/version: v1
11spec:
12 replicas: 2
13 strategy:
14 type: RollingUpdate
15 rollingUpdate:
16 maxSurge: 0
17 maxUnavailable: 1
18 selector:
19 matchLabels:
20 app: app-b
21 template:
22 metadata:
23 labels:
24 app: app-b
25 app.kubernetes.io/name: app-b
26 app.kubernetes.io/version: v1
27 spec:
28 serviceAccountName: app-b
29 securityContext:
30 runAsNonRoot: true
31 runAsUser: 10001
32 runAsGroup: 10001
33 topologySpreadConstraints:
34 - maxSkew: 1
35 minDomains: 2
36 topologyKey: topology.kubernetes.io/zone
37 whenUnsatisfiable: DoNotSchedule
38 nodeTaintsPolicy: Honor
39 labelSelector:
40 matchLabels:
41 app: app-b
42 - maxSkew: 1
43 topologyKey: kubernetes.io/hostname
44 whenUnsatisfiable: DoNotSchedule
45 nodeTaintsPolicy: Honor
46 labelSelector:
47 matchLabels:
48 app: app-b
49 containers:
50 - name: app-b
51 image: python:3.13-alpine
52 imagePullPolicy: IfNotPresent
53 command:
54 - python
55 - /app/server.py
56 env:
57 - name: APP_NAME
58 value: app-b
59 - name: PORT
60 value: "8080"
61 - name: PYTHONDONTWRITEBYTECODE
62 value: "1"
63 - name: POD_NAME
64 valueFrom:
65 fieldRef:
66 fieldPath: metadata.name
67 - name: POD_IP
68 valueFrom:
69 fieldRef:
70 fieldPath: status.podIP
71 - name: NODE_NAME
72 valueFrom:
73 fieldRef:
74 fieldPath: spec.nodeName
75 - name: POD_NAMESPACE
76 valueFrom:
77 fieldRef:
78 fieldPath: metadata.namespace
79 ports:
80 - name: http
81 containerPort: 8080
82 readinessProbe:
83 httpGet:
84 path: /healthz
85 port: http
86 initialDelaySeconds: 1
87 periodSeconds: 3
88 livenessProbe:
89 httpGet:
90 path: /healthz
91 port: http
92 initialDelaySeconds: 3
93 periodSeconds: 10
94 resources:
95 requests:
96 cpu: 25m
97 memory: 32Mi
98 limits:
99 cpu: 250m
100 memory: 128Mi
101 securityContext:
102 allowPrivilegeEscalation: false
103 readOnlyRootFilesystem: true
104 capabilities:
105 drop:
106 - ALL
107 volumeMounts:
108 - name: app-code
109 mountPath: /app
110 readOnly: true
111 volumes:
112 - name: app-code
113 configMap:
114 name: app-b-code
115---
116apiVersion: v1
117kind: Service
118metadata:
119 name: app-b
120 namespace: app-b
121spec:
122 selector:
123 app: app-b
124 ports:
125 - name: http
126 port: 8080
127 targetPort: http
128---
129apiVersion: networking.istio.io/v1
130kind: Gateway
131metadata:
132 name: app-b-gateway
133 namespace: app-b
134spec:
135 selector:
136 istio: ingressgateway
137 servers:
138 - port:
139 number: 80
140 name: http
141 protocol: HTTP
142 hosts:
143 - app-b.example.test
144---
145apiVersion: networking.istio.io/v1
146kind: VirtualService
147metadata:
148 name: app-b
149 namespace: app-b
150spec:
151 hosts:
152 - app-b.example.test
153 gateways:
154 - app-b-gateway
155 http:
156 - route:
157 - destination:
158 host: app-b
159 port:
160 number: 8080

이 두 앱은 app-a가 app-b를 호출하는 관계다.
테스트를 위해 /etc/hosts에 아래 도메인을 등록했다.
1127.0.0.1 app-a.example.test
2127.0.0.1 app-b.example.test
3127.0.0.1 extauth.example.test
4127.0.0.1 visualize.example.test
이렇게 설정하고 나면 app-a.example.test:8080/hello로 GET 요청을 보내면 app-a를 거쳐서 app-b까지 호출한 뒤에 응답을 돌려준다.
아래는 app-a에서 인증을 위임할 extauth에 대한 구성이다.
1# extauth.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: extauth-server
6 namespace: extauth
7 labels:
8 app: extauth-server
9spec:
10 replicas: 2
11 strategy:
12 type: RollingUpdate
13 rollingUpdate:
14 maxSurge: 0
15 maxUnavailable: 1
16 selector:
17 matchLabels:
18 app: extauth-server
19 template:
20 metadata:
21 labels:
22 app: extauth-server
23 spec:
24 serviceAccountName: extauth-server
25 securityContext:
26 runAsNonRoot: true
27 runAsUser: 10001
28 runAsGroup: 10001
29 topologySpreadConstraints:
30 - maxSkew: 1
31 minDomains: 2
32 topologyKey: topology.kubernetes.io/zone
33 whenUnsatisfiable: DoNotSchedule
34 nodeTaintsPolicy: Honor
35 labelSelector:
36 matchLabels:
37 app: extauth-server
38 - maxSkew: 1
39 topologyKey: kubernetes.io/hostname
40 whenUnsatisfiable: DoNotSchedule
41 nodeTaintsPolicy: Honor
42 labelSelector:
43 matchLabels:
44 app: extauth-server
45 containers:
46 - name: extauth-server
47 image: gcr.io/istio-testing/ext-authz@sha256:20ed075acb84d9a3ffaa2ded4d7945cb49bed5fbb14a3d6fc6e09ff85848942a
48 imagePullPolicy: IfNotPresent
49 ports:
50 - name: http
51 containerPort: 8000
52 - name: grpc
53 containerPort: 9000
54 readinessProbe:
55 tcpSocket:
56 port: grpc
57 initialDelaySeconds: 1
58 periodSeconds: 3
59 livenessProbe:
60 tcpSocket:
61 port: grpc
62 initialDelaySeconds: 3
63 periodSeconds: 10
64 resources:
65 requests:
66 cpu: 25m
67 memory: 32Mi
68 limits:
69 cpu: 250m
70 memory: 128Mi
71 securityContext:
72 allowPrivilegeEscalation: false
73 readOnlyRootFilesystem: true
74 capabilities:
75 drop:
76 - ALL
77---
78apiVersion: v1
79kind: Service
80metadata:
81 name: extauth-server
82 namespace: extauth
83spec:
84 selector:
85 app: extauth-server
86 ports:
87 - name: grpc
88 appProtocol: grpc
89 port: 8085
90 targetPort: grpc
91 - name: http
92 appProtocol: http
93 port: 8080
94 targetPort: http
95---
96apiVersion: networking.istio.io/v1
97kind: Gateway
98metadata:
99 name: extauth-gateway
100 namespace: extauth
101spec:
102 selector:
103 istio: ingressgateway
104 servers:
105 - port:
106 number: 80
107 name: http
108 protocol: HTTP
109 hosts:
110 - extauth.example.test
111---
112apiVersion: networking.istio.io/v1
113kind: VirtualService
114metadata:
115 name: extauth
116 namespace: extauth
117spec:
118 hosts:
119 - extauth.example.test
120 gateways:
121 - extauth-gateway
122 http:
123 - route:
124 - destination:
125 host: extauth-server
126 port:
127 number: 8080
인증 서버를 배포하면 app-a의 /auth 경로로 들어오는 GET 요청은 AuthorizationPolicy 설정에 따라 extauth로 인증을 위임한다.

이제 이러한 동작을 시각화하는 앱을 적용해보자.
1# visualize.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5 name: traffic-viewer
6 namespace: visualize
7 labels:
8 app: traffic-viewer
9 app.kubernetes.io/name: traffic-viewer
10 app.kubernetes.io/version: v1
11spec:
12 replicas: 1
13 selector:
14 matchLabels:
15 app: traffic-viewer
16 template:
17 metadata:
18 labels:
19 app: traffic-viewer
20 app.kubernetes.io/name: traffic-viewer
21 app.kubernetes.io/version: v1
22 spec:
23 serviceAccountName: traffic-viewer
24 securityContext:
25 runAsNonRoot: true
26 runAsUser: 10001
27 runAsGroup: 10001
28 containers:
29 - name: traffic-viewer
30 image: python:3.13-alpine
31 imagePullPolicy: IfNotPresent
32 command:
33 - python
34 - /viewer/server.py
35 env:
36 - name: PORT
37 value: "8080"
38 ports:
39 - name: http
40 containerPort: 8080
41 readinessProbe:
42 httpGet:
43 path: /
44 port: http
45 initialDelaySeconds: 1
46 periodSeconds: 3
47 livenessProbe:
48 httpGet:
49 path: /
50 port: http
51 initialDelaySeconds: 3
52 periodSeconds: 10
53 resources:
54 requests:
55 cpu: 10m
56 memory: 16Mi
57 limits:
58 cpu: 100m
59 memory: 64Mi
60 securityContext:
61 allowPrivilegeEscalation: false
62 readOnlyRootFilesystem: true
63 capabilities:
64 drop:
65 - ALL
66 volumeMounts:
67 - name: viewer
68 mountPath: /viewer
69 readOnly: true
70 volumes:
71 - name: viewer
72 configMap:
73 name: traffic-viewer
74---
75apiVersion: v1
76kind: Service
77metadata:
78 name: traffic-viewer
79 namespace: visualize
80spec:
81 selector:
82 app: traffic-viewer
83 ports:
84 - name: http
85 port: 8080
86 targetPort: http
87---
88apiVersion: networking.istio.io/v1
89kind: Gateway
90metadata:
91 name: visualize-gateway
92 namespace: visualize
93spec:
94 selector:
95 istio: ingressgateway
96 servers:
97 - port:
98 number: 80
99 name: http
100 protocol: HTTP
101 hosts:
102 - visualize.example.test
103---
104apiVersion: networking.istio.io/v1
105kind: VirtualService
106metadata:
107 name: visualize
108 namespace: visualize
109spec:
110 hosts:
111 - visualize.example.test
112 gateways:
113 - visualize-gateway
114 http:
115 - route:
116 - destination:
117 host: traffic-viewer
118 port:
119 number: 8080
이렇게 배포한 뒤에 http://visualize.example.test:8080/에 접속하면 다음과 같은 화면을 볼 수 있다.

배포된 서비스의 상태를 시각화했다. 앞에서 말한 대로 app-a와 app-b, extauth가 있고, 모두 replica 2로 topologySpreadConstraints를 사용해 a존과 c존에 하나씩 배치했다.
/hello 버튼을 누르면 10개의 요청을 보내고 메트릭을 수집한다. 요청이 같은 존으로 갔는지, 다른 존(a존 -> c존 또는 c존 -> a존)으로 갔는지는 비율로 쉽게 확인할 수 있다. /hello 요청은 app-a를 거쳐 app-b까지 호출한 뒤 응답을 돌려준다. 아직 로컬리티를 활성화하지 않았으므로 크로스 존 트래픽이 발생한다.
이번에는 extauth로 인증을 위임하는 /auth 경로로 똑같이 테스트를 했다. 앞에서도 설명했듯이 /auth 요청은 app-a의 istio-proxy에서 extauth로 요청을 보냈다가 app-a가 app-b를 호출한 뒤에 응답을 돌려주게 된다. 이 경우에도 로컬리티가 활성화되어 있지 않으니 양쪽 존 모두로 트래픽이 가게 된다.

이제 Mesh에 로컬리티를 활성화했다. 화면에도 표시했지만 로컬리티를 활성화하면 아래와 같은 설정이 동적으로 적용된다.
1# istio-system/istio ConfigMap · data.mesh
2localityLbSetting:
3 enabled: true
4---
5apiVersion: networking.istio.io/v1
6kind: DestinationRule
7metadata:
8 name: outlier-detection
9 namespace: istio-system
10 labels:
11 app.kubernetes.io/managed-by: traffic-viewer
12spec:
13 host: "*.cluster.local"
14 trafficPolicy:
15 connectionPool:
16 http:
17 http1MaxPendingRequests: 2000
18 http2MaxRequests: 2000
19 maxRetries: 200
20 outlierDetection:
21 consecutive5xxErrors: 200000
22 interval: 10s
23 baseEjectionTime: 30s
24 maxEjectionPercent: 20
로컬리티를 켜고 다시 테스트해보자.
/hello로 요청을 보내면 아까와 다르게 트래픽이 같은 존으로만 100% 가고 크로스 존 트래픽은 전혀 발생하지 않는다. C존에서만 트래픽이 발생하는 이유는 Istio Ingress Gateway가 C존에 1대만 떠 있기 때문이다.
마찬가지로 /auth 요청에 대한 테스트를 하면 똑같이 로컬리티가 잘 적용되는걸 볼 수 있다.
로컬리티는 당연히 잘 동작하겠지만 envoyExtAuthzGrpc를 써서 Authorization Policy로 위임한 경우에도 로컬리티가 잘 동작함을 확인할 수 있다. 전체 예제 코드는 GitHub에 올려두었다.
왜 EnvoyExternalAuthorizationGrpcProvider에서도 로컬리티가 잘 적용되는가?
envoyExtAuthzGrpc를 사용해 설정한 것은 EnvoyExternalAuthorizationGrpcProvider이다. 결과를 보면 간단하지만 처음에는 여기서 동작하지 않을 거라고 생각했다. 메시에 설정된 도메인을 쓰지 못하고 <service-name>.<namespace>.svc.cluster.local 같은 클러스터 도메인으로 설정해야 하고, Istio Sidecar에서 설정할 때도 이 클러스터 도메인을 써야 하기 때문이다. 그래서 Mesh를 안 탄다고 막연히 생각했다.
EnvoyExternalAuthorizationGrpcProvider 문서를 보면 다음과 같이 설명하고 있다.
Envoy
ext_authzgRPC 권한 부여 서비스를 구현하는 서비스를 지정합니다. 형식은[<Namespace>/]<Hostname>입니다. 서비스 레지스트리에서 서비스를 명확하게 식별할 수 없는 경우에만<Namespace>를 지정해야 합니다.<Hostname>은 Kubernetes Service 또는 ServiceEntry로 정의된 서비스의 정규화된 호스트 이름(FQDN)입니다.예:
my-ext-authz.foo.svc.cluster.local또는bar/my-ext-authz.example.com
이 문서에 따르면 Kubernetes Service나 ServiceEntry에 정의된 FQDN이어야 한다. 여기서는 ServiceEntry를 따로 정의하지 않았으니 서비스 FQDN인 클러스터 도메인을 사용한 것이다. 따로 테스트해보진 않았지만 ServiceEntry로 등록하면 원하는 도메인을 사용할 수 있을 거라고 생각한다.
extauth의 EndpointSlice를 보면 각 Pod에 Zone 정보가 들어가 있는 걸 확인할 수 있다.
1$ kubectl describe endpointslice -n extauth -l kubernetes.io/service-name=extauth-server
2Name: extauth-server-n7m8r
3Namespace: extauth
4Labels: endpointslice.kubernetes.io/managed-by=endpointslice-controller.k8s.io
5 kubernetes.io/service-name=extauth-server
6Annotations: endpoints.kubernetes.io/last-change-trigger-time: 2026-08-02T17:59:09Z
7AddressType: IPv4
8Ports:
9 Name Port Protocol
10 ---- ---- --------
11 http 8000 TCP
12 grpc 9000 TCP
13Endpoints:
14 - Addresses: 10.244.2.6
15 Conditions:
16 Ready: true
17 Hostname: <unset>
18 TargetRef: Pod/extauth-server-6bbc7f7478-2swnp
19 NodeName: istio-lab-worker4
20 Zone: zone-c
21 - Addresses: 10.244.5.4
22 Conditions:
23 Ready: true
24 Hostname: <unset>
25 TargetRef: Pod/extauth-server-6bbc7f7478-tbxhq
26 NodeName: istio-lab-worker2
27 Zone: zone-a
28Events: <none>
app-a Pod은 다음과 같이 존별로 하나씩 떠 있다.
1$ kubectl -n app-a get pod -o wide --no-headers | awk 'NR==FNR{zone[$1]=$2; next}{print $1"\t"$7"\t"zone[$7]}' <(kubectl get nodes -o jsonpath='{range .items[*]}{.metadata.name}{" "}{.metadata.labels.topology\.kubernetes\.io/zone}{"\n"}{end}') -
2app-a-6cd67bdcd7-dkwjf istio-lab-worker2 zone-a
3app-a-6cd67bdcd7-s95hf istio-lab-worker3 zone-c
zone-a에 떠있는 app-a-6cd67bdcd7-dkwjf Pod에서는 10.244.5.4에 떠있는 extauth 클러스터의 priority가 0이고 다른 Pod은 1로 되어 있는걸 볼 수 있다.
1$ kubectl exec -n app-a app-a-6cd67bdcd7-dkwjf -c istio-proxy -- pilot-agent request GET clusters | grep -E '8085\|\|extauth-server.*::(zone|priority)::'
22026/08/03 15:22:19 INFO GOMEMLIMIT is already set, skipping package=github.com/KimMachineGun/automemlimit/memlimit GOMEMLIMIT=1073741824
3outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.5.4:9000::zone::zone-a
4outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.5.4:9000::priority::0
5outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.2.6:9000::zone::zone-c
6outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.2.6:9000::priority::1
반대로 zone-c에 떠있는 app-a-6cd67bdcd7-s95hf Pod에서는 10.244.2.6에 떠있는 extauth 클러스터의 priority가 0이고 다른 Pod은 1로 되어 있는걸 볼 수 있다.
1kubectl exec -n app-a app-a-6cd67bdcd7-s95hf -c istio-proxy -- pilot-agent request GET clusters | grep -E '8085\|\|extauth-server.*::(zone|priority)::'
22026/08/03 15:22:44 INFO GOMEMLIMIT is already set, skipping package=github.com/KimMachineGun/automemlimit/memlimit GOMEMLIMIT=1073741824
3outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.2.6:9000::zone::zone-c
4outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.2.6:9000::priority::0
5outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.5.4:9000::zone::zone-a
6outbound|8085||extauth-server.extauth.svc.cluster.local::10.244.5.4:9000::priority::1
priority는 Envoy 문서에 따르면 0이 가장 우선순위가 높고 Istio 로컬리티의 페일오버 문서를 보면 다음과 같이 priority를 사용한다.
0: region, zone, subzone이 모두 일치1: region, zone 일치2: region만 일치3: 로컬리티 불일치4: 페일오버 정책에 정의되지 않은 리전
이 기준에 따르면 0이 나오면 안 되지만, 설정 후 빈칸이 없게 압축하기 때문에 제일 높은 우선순위가 0으로 올라온 것이다.
추가 자료: PreferSameZone
Kubernetes에는 최근에 spec.trafficDistribution 필드를 통해 트래픽 분산 제어 기능을 추가했다. 이 기능은 1.33에서 알파로 들어오고 1.34에서 베타가 되고 1.35에서 GA가 되었다. 그리고 초기에는 PreferClose라는 이름으로 도입되었지만 1.34부터 PreferSameZone으로 이름이 바뀌었다.
KEP-2433에서 시작된 이 Topology Aware Routing 기능은 KEP-4444에서 EndpointSlices의 TopologyAwareHints를 사용해서 kube-proxy 등이 같은 존의 엔드포인트로 트래픽을 보낼 수 있게 하는 기능이다.
1apiVersion: discovery.k8s.io/v1
2kind: EndpointSlice
3metadata:
4 name: envoyauth-server-5fsst
5 namespace: envoyauth
6spec: {}
7addressType: IPv4
8endpoints:
9 - addresses:
10 - 10.210.72.183
11 nodeName: ip-10-210-74-151.ap-northeast-2.compute.internal
12 zone: ap-northeast-2a
13 hints:
14 forZones:
15 - name: ap-northeast-2a
16 - addresses:
17 - 10.210.109.43
18 nodeName: ip-10-210-109-35.ap-northeast-2.compute.internal
19 zone: ap-northeast-2c
20 hints:
21 forZones:
22 - name: ap-northeast-2c
trafficDistribution을 지정하면 위처럼 hints.forZones 같은 힌트가 기록된다. 그러면 kube-proxy가 자신의 노드 zone 레이블과 forZones를 비교해서 모든 엔드포인트에 힌트가 있고 그중 자신의 zone과 일치하는 게 하나 이상 있으면 그 엔드포인트만 라우팅 대상으로 남긴다. 참고로 Istio가 이 힌트를 보진 않는다.
PreferSameZone 구성을 따로 테스트해보진 않았지만, 최신 Kubernetes를 사용한다면 Istio가 없더라도 Zone Aware Routing을 적용하는 데 이 기능을 검토해볼 수 있다.
Comments