Istio 를 통한 path(url) 기반 Local Rate Limit 적용

몇 년 전인지는 기억나진 않지만 Rate Limit 적용은 항상 애플리케이션 쪽에서 처리하는 것이 당연하다는 것이 주된 의견이었다. 그래서 그때 당시 Bucket4J 를 통해서 Spring 쪽에서 처리하고 했던 기억이 있다.

이제는 당연하게도 Istio와 같은 Service Mesh쪽에서 처리하는 것이 응당 맞다고 생각되는 것이 개발 세상이 이제 점점 더 클라우드향으로 이동된다는 느낌이다.

강력한 오픈 소스 서비스 메시인 Istio는 마이크로서비스 아키텍처를 운영/관리 하기 위한 굉장히 다양한 기능을 제공한다. 하지만 너무 많은 기능을 담은 탓 일까.. 기능이 너무 많고 설정하기 너무 어렵고 너무 불편하다 😂

만들고 있는 서비스에서 Rate Limit을 적용하려 했고, 다양한 경우의 수에 의해서 하나의 인스턴스에서 특별한 Path를 가진 놈만 제한을 가하려 하니, 공식 문서 참조 만으로는 해결이 잘 안되었고 몇일은 고생한 기억이 있어서 Istio 및 Envoy에 대한 간략한 정리와 삽질기를 한번 풀어 써볼까 한다.

Rate Limit이란

Rate Limit란 서비스 혹은 API를 운영함에 있어서 특정 시간 동안 허용되는 최대 요청 횟수를 제한하는 기능이고, 대부분 안정적인 서비스 운영을 위해 특정 시스템의 과부하 등을 조절하거나 제어하기 위해 사용한다.

한국말로 하자면 요청 제한 혹은 호출 제한, 속도 제한 등으로 불리우기는 하는데 어느 것 하나 표현이 제대로 안되는 것 같아서 앞으로도 그냥 Rate Limit으로 기술한다.

이러한 제한 형태 서비스를 구현하기 위한 알고리즘은 아래와 같은 것들이 많이 사용되고, Istio에서는 Token Bucket 을 사용한다. 간단한 알고리즘 설명은 위키 페이지를 참조하면 좋겠다.

  • Token Bucket Algorithm #envoy에선 이 알고리즘 사용
  • Leaky Bucket Algorithm
  • Fixed Window Counter
  • Sliding Window Log

Istio의 Rate Limit

앞서 얘기했지만 Istio를 통해 들어오는 트래픽을 동적으로 제어하고 관리하는 기능은 굉장히 종류가 많다. 트래픽에 대한 관리, 보안, 정책, 관측 등의 모든 처리는 Envoy Proxy를 통해 동적으로 설정 되고 관리된다.

출처: https://istio.io/latest/about/service-mesh/

그래서 먼저 Rate Limit 설정을 서술하기전에 Envoy가 어떤식으로 트래픽을 관리하는지 알아볼 필요성이 있어서 리서치 해본 김에 살펴보고 넘어가야할 일부 중요한 처리 과정을 아래와 같이 정리해보았다.

출처: 권혁 블로그

트래픽이 Istio를 통해 유입이 되면 Sidecar를 통해 배포된 Envoy Proxy는 요약하자면 다음과 같은 동작을 수행한다. 전체 요청에 대한 라이프사이클 문서에 상세한 설명은 담겨 있으니 디테일한 부분이 궁금하신 분들은 참조하시면 좋겠다.

  • Listener Filter는 리스너에 구성된 주소와 포트에 상응하는 요청이 들어오면, 리스너 내부적으로 리스너 필터로 전달하여 커넥션 메타데이터를 조작하고 네트워크 필터로 전달
  • Network Filter를 순회하면서 트래픽 특성에 따른 적합한 Filter를 찾고 해당 Filter가 요청을 처리. 제공되는 빌트인 필터 종류가 어마무시하고 자세한 필터 정보는 레퍼런스를 참조
  • HTTP 요청인 경우 HTTP Connection Filter를 통해 트래픽을 처리
  • 마지막 Route 를 통해 Upstream endpoint로 연결
  • 전체 요청에 대한 상세한 설명은 공식 문서(Life of a request) 잘 설명되어 있으므로 상세한 설명이 필요한 경우 참조

여기서 Rate Limit 처리를 담당 하는 부분은 Network Filter 중에 하나이며, 뜯어보면 아래와 같은 프로세스로 처리된다고 볼 수 있다.

Network Filer, HTTP Filter 2가지에서 모두 Rate Limit 설정을 진행할 수 있고, 원하는 구현 방식에 따라 해당 Filter에 설정을 하면 된다.

출처: 권혁 블로그

Envoy는 Global과 Local의 두 가지 Rate Limit을 지원하고, 각각의 네트워크 필터 혹은 HTTP 필터를 통해 Rate Limit을 설정하고 관리 할 수 있다. 각각의 형식에 따라 구성해야할 추가적인 외부 서비스 등이 있으므로 간략히 설명해 본다.

Global Rate Limit

Global Rate Limit은 전체 메시의 Limiting를 제공하기 위해 Global gRPC Rate Limit Service를 사용한다. Istio 외부에서 Rate Limit을 처리하기 위해 Redis를 기반으로 한 Go로 짜여진 서비스이고 해당 설정을 사용하려면 Redis와 함께 외부 Rate Limiter를 설치해야 한다.

출처: 권혁 블로그

Local 방식과의 차이점은 각각 인스턴스의 Envoy Proxy를 사용하는 것이 아니라 Ingress Gateway의 Envoy를 통해 전역으로 설정하는 것이 가장 큰 차이점이라 볼 수 있다. 나머지 이슈나 차이점 정리해보자면 다음과 같다.

  • 별도의 Global Rate Limit Service를 구현 해야함
    • 샘플은 제공됨, sample
    • 설치 자체는 어렵진 않는데 샘플 자체는 2022년 08월 16일 구현체로 설치하는데 수정된지 꽤 되어 보이긴 하니 참조
    • redis backend가 필요
  • Local Rate Limit과 병행하여 사용 가능
  • Istio Ingress Gateway의 Envoy에서 전역으로 설정된 Rate Limit을 처리

이번 포스팅의 주제는 Local Rate Limit 쪽이므로 Global 관련된건 다음에 시간이 나면 별도의 포스팅으로 다뤄 볼 생각이다.

Local Rate Limit

Envoy 자체적으로 트래픽 요청(L4, HTTP)의 Local Rate Limit을 지원다. 이를 통해 다른 외부 서비스를 호출하지 않고도 프록시 자체 인스턴스 수준에서 Rate Limit을 적용할 수 있다.

출처: 권혁 블로그

내가 Local로 선택한 이유도 Redis 등의 별도의 백엔드 데이터베이스를 구성하고 따로 운영해야하는 부담이 있어서 Istio/Envoy 만으로 구성할 수 있기를 선호해서 나는 Local 방식을 선택했지만 어떻게 구성하여 운영할 지는 주어진 환경에 따라 선택하면 될 것 같다.

구성을 위한 사전 정보는 살펴봤으므로 이제 본격적으로 Local Rate Limit을 설정해 보자.

All you need is "EnvoyFilter"

사실 설정할 것은 많지 않다. Envoy Filter 를 적정한 포맷으로 작성하면 된다. 근데 이 예제가 정말 거지 같다. 공식 문서 샘플도 정말 정말 거지 같다. 도저히 그것만 보고는 따라 만들 수 없을 정도로 거지 같다.

그래도 뭐 차근차근 일단 전체 서비스를 걸려면 공식 문서에 나와 있는 것처럼 HTTP_FILTER 전에 RATE_LIMIT 필터를 추가해야 한다.

$ kubectl apply -f - <<EOF
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
  name: filter-local-ratelimit-svc
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      app: productpage
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: "envoy.filters.network.http_connection_manager"
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.local_ratelimit
          typed_config:
            "@type": type.googleapis.com/udpa.type.v1.TypedStruct
            type_url: type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
            value:
              stat_prefix: http_local_rate_limiter
              token_bucket:
                max_tokens: 4
                tokens_per_fill: 4
                fill_interval: 60s
              filter_enabled:
                runtime_key: local_rate_limit_enabled
                default_value:
                  numerator: 100
                  denominator: HUNDRED
              filter_enforced:
                runtime_key: local_rate_limit_enforced
                default_value:
                  numerator: 100
                  denominator: HUNDRED
              response_headers_to_add:
                - append: false
                  header:
                    key: x-local-rate-limit
                    value: 'true'
EOF

여기서 중요하게 봐야할 설정 값은 아래와 같다.

  • stat_prefix
    • promethus 를 통해서 관련된 메트릭을 수집할 수 있는데 그때 사용되는 prefix 정보다.
  • token_bucket
    • 이 예제는 1분에 총 4개의 요청만 받고 나머지는 제한 하는 설정으로
    • max_tokens: 4 는 버킷에 담을 수 있는 최대 토큰 수이고, 버킷에 초기 설정에 포함되는 토큰의 수이기도 하다.
    • tokens_per_fill: 4 는 각 토큰 채움 간격 동안 버킷에 추가되는 토큰 수다. 지정하지 않으면 기본값은 max_tokens 값이다.
    • fill_interval: 60s 는 토큰이 버킷에 추가되는 채움 간격이다. 각 채움 간격 동안 버킷에 토큰이 추가될 때마다 tokens_per_fill 개수가 추가된다. 이 경우 1분이 지나면 4개의 토큰이 다시 채워지는 경우이다. 버킷에는 최대 토큰 수보다 많은 토큰이 포함되지 않는다. 예를 들어 기간 동안 2개가 남았다고 해서 2+4가 아니라 4개가 다시 지정되는 방식이다.

이제 특정한 포트라던가 path 설정을 추가하려면 HTTP_ROUTE 과 함께 설정을 해야 한다. 아래 예제는 공식문서에 나와있는 9080 으로 들어오는 모든 요청에 대해서 Rate Limit을 거는 예제이다.

metadata:
  name: filter-local-ratelimit-svc
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      app: productpage
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: "envoy.filters.network.http_connection_manager"
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.local_ratelimit
          typed_config:
            "@type": type.googleapis.com/udpa.type.v1.TypedStruct
            type_url: type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
            value:
              stat_prefix: http_local_rate_limiter
    - applyTo: HTTP_ROUTE
      match:
        context: SIDECAR_INBOUND
        routeConfiguration:
          vhost:
            name: "inbound|http|9080"
            route:
              action: ANY
      patch:
        operation: MERGE
        value:
          typed_per_filter_config:
            envoy.filters.http.local_ratelimit:
              "@type": type.googleapis.com/udpa.type.v1.TypedStruct
              type_url: type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
              value:
                stat_prefix: http_local_rate_limiter
                token_bucket:
                  max_tokens: 4
                  tokens_per_fill: 4
                  fill_interval: 60s
                filter_enabled:
                  runtime_key: local_rate_limit_enabled
                  default_value:
                    numerator: 100
                    denominator: HUNDRED
                filter_enforced:
                  runtime_key: local_rate_limit_enforced
                  default_value:
                    numerator: 100
                    denominator: HUNDRED
                response_headers_to_add:
                  - append: false
                    header:
                      key: x-local-rate-limit
                      value: 'true'
EOF

이제 이렇게 설정하면 9080으로 들어오는 모든 요청에 대해서 limit이 걸리게 된다.

사실 path 기준으로 limit을 설정하려고 했던 이유는 서비스의 health check url은 15초에 한번 probe를 체크하는데 계속 사용되는데 이게 계속 token 갯수에 잡히다 보니 특정 인스턴스에서 해당 url을 제외하고 내가 원하는 url만 걸고 싶어서 찾기 시작했던 설정이었다.

근데 나머지 설정을 어떤식으로 MERGE, INSERT 해야하는지 친절하게 설명된 문서는 거의 전무 했고 Envoy 문서를 위주로 해당 정보를 찾았다.

일단 Envoy Filter를 설정하는 방법은 아래에 나와 있긴 하다. 근데 큰 도움은 되지 않는다.

Envoy Filter
Customizing Envoy configuration generated by Istio.

여기서 HTTP_ROUTE 설정에 몇가 설정만 추가하면 되는것이었는데 사실 찾기 쉽진 않았다. 부디 다른 분들은 해당정보를 찾아 헤매는 시간이 많이 줄길 바라며..

주요한 설정은 HTTP_ROUTEroute 설정에 다음과 같은 액션을 아래와 같이 추가하고

route:
  rate_limits:
    - actions:
      - header_value_match:
          descriptor_value: "infer"
          expect_match: true
          headers:
            - name: :path
              string_match:
                safe_regex:
                  google_re2: {}
                  regex: "^/v2/models/[a-zA-Z0-9-_]+/versions/[0-9]+/infer"

이 예제는 정규표현식으로 특정 URL만 descriptor로 분류 해서 header에 infer 라는 값을 넣기 위함이다.

그리고 HTTP_ROUTElocal_ratelimit 설정에 해당 descriptor 설정을 추가하면 된다!

envoy.filters.http.local_ratelimit:
  '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
  stat_prefix: [[${param.statPrefix}]]
  token_bucket:
    max_tokens: 2147483647
    tokens_per_fill: 2147483647
    fill_interval: 60s
  filter_enabled:
    runtime_key: test_enabled
    default_value:
      numerator: 100
      denominator: HUNDRED
  filter_enforced:
    runtime_key: test_enabled
    default_value:
      numerator: 100
      denominator: HUNDRED
  response_headers_to_add:
    - append: false
      header:
        key: x-local-rate-limit
        value: "true"
  descriptors:
    - entries:
        - key: header_match
          value: infer
      token_bucket:
        max_tokens: [[${param.maxTokens}]]
        tokens_per_fill: [[${param.tokensPerFill}]]
        fill_interval: [[${param.fillInterval}]]

여기서는 기본으로 설정될 값을 지정하고 descriptors 설정에 header_matchinfer 인 경우 특별한 설정을 할 수 있게 지정하면 된다.

전체 설정은 아래와 같다.

kind: EnvoyFilter
metadata:
  name: filter-local-ratelimit-svc
  namespace: istio-system
spec:
  workloadSelector:
    labels:
      app: productpage
  configPatches:
    - applyTo: HTTP_FILTER
      match:
        context: SIDECAR_INBOUND
        listener:
          filterChain:
            filter:
              name: envoy.filters.network.http_connection_manager
      patch:
        operation: INSERT_BEFORE
        value:
          name: envoy.filters.http.local_ratelimit
          typed_config:
            '@type': type.googleapis.com/udpa.type.v1.TypedStruct
            type_url: type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
            value:
              stat_prefix: [[${param.statPrefix}]]_all
    - applyTo: HTTP_ROUTE
      match:
        context: SIDECAR_INBOUND
        routeConfiguration:
          vhost:
            name: "inbound|http|8000"
            route:
              action: ANY
      patch:
        operation: MERGE
        value:
          route:
            rate_limits:
              - actions:
                  - header_value_match:
                      descriptor_value: "infer"
                      expect_match: true
                      headers:
                        - name: :path
                          string_match:
                            safe_regex:
                              google_re2: {}
                              regex: "^/v2/models/[a-zA-Z0-9-_]+/versions/[0-9]+/infer"
          typed_per_filter_config:
            envoy.filters.http.local_ratelimit:
              '@type': type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
              stat_prefix: [[${param.statPrefix}]]
              token_bucket:
                max_tokens: 2147483647
                tokens_per_fill: 2147483647
                fill_interval: 60s
              filter_enabled:
                runtime_key: test_enabled
                default_value:
                  numerator: 100
                  denominator: HUNDRED
              filter_enforced:
                runtime_key: test_enabled
                default_value:
                  numerator: 100
                  denominator: HUNDRED
              response_headers_to_add:
                - append: false
                  header:
                    key: x-local-rate-limit
                    value: "true"
              descriptors:
                - entries:
                    - key: header_match
                      value: infer
                  token_bucket:
                    max_tokens: [[${param.maxTokens}]]
                    tokens_per_fill: [[${param.tokensPerFill}]]
                    fill_interval: [[${param.fillInterval}]]

그러면 이제 내가 원하는 특정 API만 설정이 가능하다. 만세~!

그래서 결국 만들어낸 서비스내 구현

프로메테우스를 통한 모니터링

stat_prefix 설명을 통해 간단히 얘기했지만 Filter 걸린 요청 정보는 아래와 같은 metric으로 조회가 가능하다.

모든 통계값은 Prometheus의 경우 Counter 형식으로 저장된다.

Name Type Description
enabled Counter Limiter가 받아낸 전체 리퀘스트 갯수
ok Counter token bucket이 정상적으로 처리한 전체 건수
rate_limited Counter 이 통계는 특정 시간 동안 Rate Limit에 걸려 Envoy가 처리하지 않고 차단한 요청의 수를 나타냄
enforced Counter 제한이 걸린 전체 리퀘스트 갯수 (e.g.: 429 returned)

이렇게 [stat_prefix]_http_local_rate_limit_ok 조회 가능하다.

Summary

지금까지 Istio Envoy Proxy의 Local Rate Limit 설정 기능 중에 Path 기반으로 설정하는 방법에 대해 주저리 주저리 설명을 해보았다.

부디 같은 고민을 하는 분들의 삽질 시간을 많이 줄여 주길~

포스팅 글과 관련하여 질문이나 피드백 등은 자유롭게 링크드인 프로필에 연락주세요~

LinkedIn https://www.linkedin.com/in/hkwon77