Outsider's Dev Story

Stay Hungry. Stay Foolish. Don't Be Satisfied.
RetroTech 팟캐스트 44BITS 팟캐스트

Istio External Authorization의 로컬리티 로드 밸런싱 확인하기

수많은 마이크로서비스가 있을 때 일부 서비스에 공통 인증이 필요한 상황을 가정해 보자. 비교적 간단한 JWT 토큰을 검증하는 정도라면 Istio의 RequestAuthentication을 사용할 수 있겠지만 더 복잡한 인증 로직이 있다면 이를 사용할 수 없다.

내부에 인증 서버가 이미 구축돼 있다고 할 때, 인증이 필요한 서비스는 인증 서버를 거치도록 해야 한다. 각 서비스에서 인증 서버로 리다이렉트하거나, 요청을 처리하기 전에 인증 서버에서 인증 여부를 확인하도록 구현할 수도 있다. 하지만 모든 서비스가 이를 구현하면 작업 비용이 많이 들고 구현 수준도 달라져 좋지 않다. 그런 면에서 가능하다면 인프라 차원에서 처리하는 것이 훨씬 좋다.

Istio는 이러한 경우에 External AuthorizationAuthorization 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에서 extensionProvidersenvoyExtAuthzGrpcenvoyExtAuthzHttp를 지정하면 클러스터에서 외부 인증을 사용할 수 있다. 위 예시는 envoyExtAuthzGrpc를 사용한다.

여기서 gRPC와 HTTP는 Envoy가 인증을 위임할 때 사용하는 프로토콜이므로, 실제 서비스 트래픽이 HTTP인지 gRPC인지는 상관없다. 두 구현체는 동작이 다르다. 일반적으로 인증 서버는 gRPC로 더 많이 구현하는 것으로 보인다. 대표적인 차이점은 envoyExtAuthzGrpc가 기본적으로 모든 헤더와 호출한 서비스 정보를 전달한다는 점이다. 반면 envoyExtAuthzHttp는 헤더를 설정에 명시해야 전달하며 호출한 서비스 정보는 전달하지 않는다.

이렇게 설정한 envoyExtAuthzGrpcenvoyExtAuthzHttpAuthorization 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의 Istio 프록시가 요청을 받고 AuthorizationPolicy로 extauth-server에 인증을 위임한 뒤 auth-backend로 연결한다.

  1. demo-app.example.com/api로 GET 요청이 들어온다.
  2. istio-proxy가 받아서 AuthorizationPolicy를 확인하고 demo-app으로 요청을 보내기 전에 extauth-server.extauth.svc.cluster.localextauth-server로 보낸다.
  3. extauth-serverauth-backend에 요청을 보내 인증을 확인한다. (사실 이 과정은 필요 없고 일반적인 서버 간 호출일 뿐이지만 인증 로직의 복잡성을 표현하려고 추가했다.)
  4. 인증에 성공하면 demo-appistio-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.enabledTrue로 설정하면 전역에서 로컬리티가 활성화된다. 다만 이 설정만으로 로컬리티 로드 밸런싱이 동작하지는 않는다.

로컬리티는 다음과 같은 조건을 계산하면서 적용된다.

localityLbSetting은 DestinationRule 설정이 MeshConfig보다 우선하며, locality·distribute·OutlierDetection 여부로 분기된다.

녹색 박스로 표시된 조건에 도달해야 실제로 로컬리티 로드밸런싱이 적용된다고 보면 된다.

distribute

조건 중 첫 번째에 해당하는 distributeDestinationRule에서 설정할 수 있다. 아래와 같이 레이블별로 가중치를 지정하면 그 가중치에 따라 로컬리티 로드 밸런싱이 동작한다.

 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

두 번째 조건인 outlierDetectionDestinationRule에서 설정한다. 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

브라우저의 GET /hello 요청이 ingress gateway를 거쳐 app-a로 전달되고, app-a가 같은 경로로 app-b에 요청해 응답받는다.

이 두 앱은 app-aapp-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로 인증을 위임한다.

브라우저 요청이 ingress gateway를 거쳐 app-a Envoy sidecar에서 extauth로 검증된다. 거부 시 403, 허용 시 app-b로 전달된다.

이제 이러한 동작을 시각화하는 앱을 적용해보자.

  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/에 접속하면 다음과 같은 화면을 볼 수 있다.

Istio request path 대시보드에서 zone-a와 zone-c의 Pod 배치, 6/6 준비 상태, 아직 없는 요청 기록을 표시한다.

배포된 서비스의 상태를 시각화했다. 앞에서 말한 대로 app-aapp-b, extauth가 있고, 모두 replica 2로 topologySpreadConstraints를 사용해 a존과 c존에 하나씩 배치했다.

/hello 버튼을 누르면 10개의 요청을 보내고 메트릭을 수집한다. 요청이 같은 존으로 갔는지, 다른 존(a존 -> c존 또는 c존 -> a존)으로 갔는지는 비율로 쉽게 확인할 수 있다. /hello 요청은 app-a를 거쳐 app-b까지 호출한 뒤 응답을 돌려준다. 아직 로컬리티를 활성화하지 않았으므로 크로스 존 트래픽이 발생한다.

이번에는 extauth로 인증을 위임하는 /auth 경로로 똑같이 테스트를 했다. 앞에서도 설명했듯이 /auth 요청은 app-aistio-proxy에서 extauth로 요청을 보냈다가 app-aapp-b를 호출한 뒤에 응답을 돌려주게 된다. 이 경우에도 로컬리티가 활성화되어 있지 않으니 양쪽 존 모두로 트래픽이 가게 된다.

Istio request path 대시보드에 전역 locality YAML과 zone-a·zone-c 노드별 app-a, app-b, extauth Pod 배치가 표시되어 있다.

이제 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_authz gRPC 권한 부여 서비스를 구현하는 서비스를 지정합니다. 형식은 [<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

priorityEnvoy 문서에 따르면 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을 적용하는 데 이 기능을 검토해볼 수 있다.

Valid HTML5 Valid CSS WCAG 2.1 AA tested