Carpe Diem

備忘録

Envoyで自動リトライ

概要

ネットワーク越しの通信は基本的に不安定であるため、外部APIとの通信で発生したエラーを都度クライアントに返すとエラー率が上がってしまいユーザ体験が悪くなってしまします。

そこでクライアントに返す前に何度かリトライすることで、エラー率を下げるようにするという実装を行うことが多いです。
例えばGCPSDKではgax-goというライブラリがそこを保証してくれています。

google-cloud-go/invoke.go at 20e9b2acf7766594da827da62858d83cb03fed81 · googleapis/google-cloud-go · GitHub
gax-go/invoke.go at ce75229dd089591008bbaf03b1518474f9830646 · googleapis/gax-go · GitHub

しかしこういった処理は本来アプリケーションで表現したいドメイン部分ではないので、できればEnvoyやIstioといったソリューションで解決したいです。

今回はEnvoyでの設定方法を説明します。

環境

  • Envoy 1.22.0

構成

以下のように前段にEnvoyを挟んでバックエンドのリトライを自動化します。

backend service

バックエンドサービスはエラーが出やすいよう、以下のようにランダムでresponse codeを変えるようにします。

func (h *handler) hello(w http.ResponseWriter, r *http.Request) {
        dur := rand.Intn(1000)
        time.Sleep(time.Duration(dur) * time.Millisecond) // 処理を表現するためのsleep
        n := rand.Intn(4)                                 // エラーレスポンスを返すためのランダム値
        switch n {
        case 0:
                w.WriteHeader(http.StatusOK)
                fmt.Fprintln(w, "Hello World")
        case 1:
                w.WriteHeader(http.StatusBadRequest)
                fmt.Fprintln(w, "Bad Request")
        case 2:
                w.WriteHeader(http.StatusInternalServerError)
                fmt.Fprintln(w, "Internal Server Error")
        case 3:
                w.WriteHeader(http.StatusServiceUnavailable)
                fmt.Fprintln(w, "Server Unavailable")
        }
}

動作検証

では動作検証していきます。検証する上で正しくリトライされたのか確認できるよう、以下のstatsをGrafanaで可視化しておきます。

ref: https://www.envoyproxy.io/docs/envoy/latest/configuration/upstream/cluster_manager/cluster_stats

retryがない場合

envoy.yaml

特に追加の設定はしません。

結果

期待通りEnvoy〜Backendのリクエスト数及びエラー数と、クライアント〜Envoyのリクエスト数及びエラー数が一致しています。

retry_policyを付けた場合

Envoyでリトライの設定をするには

Network Filter chain > HTTP Connection Manaager > route_config

retry_policyをつける必要があります。

envoy.yaml

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 8080 }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                stat_prefix: ingress_http
                codec_type: AUTO
                access_log:
                  - name: envoy.access_loggers.file
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.access_loggers.file.v3.FileAccessLog
                      path: /dev/stdout
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: backend_service
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route:
                            cluster: backend_service
                            retry_policy:  # here
                              retry_on: "5xx"
                              num_retries: 3
                              per_try_timeout: 10s
                http_filters:
                  - name: envoy.filters.http.router
                    typed_config:
                      "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router

各パラメータの説明は以下です。

パラメータ 説明 デフォルト値
retry_on HTTPリトライ条件
gRPCリトライ条件
-
num_retries 再試行回数 1
per_try_timeout リトライ1回あたりのタイムアウト routes.route.timeoutと同値。

ポイント

retry_policyと同一階層にrouteのtimeoutの設定があります。
これはリトライを含めたtimeout値になるため、

routes.route.timeout > per_try_timeout * num_retries

にしないと意味がありません。

また前述の通り未指定の場合はroutes[].route.timeoutの値がセットされるため、実質リトライはされません。

結果

Envoy→Backendへのリクエスト数 > クライアント→Envoyのリクエスト数

となったことから、リトライが行われていることが分かります。
ただ思ったよりも5xx系エラーが多いです。その理由を次で説明します。

retry_overflowが多いのはなぜ?

これはCircuit Breakerのretry_budgetが影響しているためです。

Circuit Breakerは未指定であればデフォルトの値が設定されますが、ここに含まれるretry_budgetはアクティブなリクエストの内20%はretryによるリクエストであってもよいとなっています。

したがってupstreamへのretry割合が増えるとこのCircuit Breakerに引っかかってretry overflowになってしまいます。

retry_policy + retry_budget

先程retry_budgetによってリトライが制限されたので、今回は検証のためバジェットを100%にしてみます。

envoy.yaml

先の設定に加えて、cluster側にCircuit Breakerの設定を追加します。

  clusters:
    - name: backend_service
      connect_timeout: 0.25s
      type: STRICT_DNS
      lb_policy: ROUND_ROBIN
      circuit_breakers: # here
        thresholds:
          - retry_budget:
              budget_percent:
                value: 100.0
      load_assignment:
        cluster_name: backend_service
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: backend
                      port_value: 8000

結果

期待通りCircuit Breakerによるリトライの抑制は無くなりました。結果5xx系エラーも先程よりかなり減りました。
リトライ上限を超えたものに関してはretry_exceededとなって5xxエラーが返っています。

今回は検証のため100%にしましたが、これによってアクティブなリクエストが全てリトライで埋まる可能性もあります。
なので実際はretry_overflowのstatsを確認しながら調整するのが良いでしょう。

より細かいリトライ条件

先程のretry_onでは5xxという5xx系エラー全般を対象としましたが、Envoyではより細かい設定が可能です。

例えば以下のような条件があります。

リトライ条件 説明
5xx 5xx系エラーの場合
retriable-4xx 409のようなリトライしても問題ない4xx系エラーの場合
retriable-status-codes 指定したstatus codeの場合
retriable-headers 特定のレスポンスヘッダを持っていた場合

ref: https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/router_filter#config-http-filters-router-x-envoy-retry-on

例としてretriable-status-codes500の場合のみリトライするように変更してみます。

envoy.yaml

routes:
  - match: { prefix: "/" }
    route:
      cluster: backend_service
      retry_policy:
        retry_on: "retriable-status-codes"  #here
        num_retries: 3
        per_try_timeout: 10s
        retriable_status_codes:  # here
          - 500

retry_onretriable-status-codesにし、retriable_status_codesフィールドにstatus codeを書きます。

結果

期待通り500のときのみリトライしてくれました。1件だけretry_limit_exceedにはなりましたが、それ以外はretry_successになりました。

その他

サンプルコード

今回のサンプルコードはこちら

github.com

まとめ

Envoyの自動リトライ機能の使い方を説明しました。

検証するまではCircuit Breakerがリトライを抑制すると考えていなかったので、サービス導入する前に知見を得られて良かったです。

参考