Istio で実現する A/B テスト基盤

本記事は リクルートライフスタイル Advent Calendar 2019 1日目の記事です。

CETチーム の寺下です。

本日は 2019 年 12 月 1 日ですが、12 月といえば Advent Calendar ですね。 リクルートライフスタイルも Advent Calendar をやっていきます。

さて、初日となる本日は Istio 検証で得られた知見について書いていきます。

我々のチームでは 2015 年頃から基盤開発に Kubernetes を利用しており、 最近では Observability や Traffic Management のために Kubernetes 上に Istio の導入も行っています。

Istio は Kubernetes 上のトラフィック管理やテレメトリを行ってくれる Service Mesh であり、 例えば Istio の Traffic Management を利用することで Canary Release や A/B テスト等が可能です。 その他にも様々な機能がありますが、本記事内での紹介は割愛致します。

我々のチームで開発している基盤でも A/B テストの機能を Istio で実現しようと思ったのですが、検証を進める中で、本番導入に際して考慮しなければならない点がいくつか見つかりました。 そこで、本記事では Istio の Traffic Management 機能を A/B テストに利用しようとした場合の注意点や、その回避策について紹介したいと思います。

なお、本記事の内容は以下の Version で検証を行いました。

  • Kubernetes: 1.13.11-gke.14
  • Istio: 1.3.2

Istio の機能を使った AB Testing

まず初めに、 Istio での基本的な Traffic Management について簡単に紹介します。 重要なのは Virtual ServiceDestination Rule の2つです。

Virtual Service

Virtual Service は Traffic Routing の設定を行うリソースです。

特定の Host へのリクエストが発生した際に、 HTTP Header や パス等のマッチングルールに基づいて、 リクエストのルーティング先を書き換えたり HTTP Header の操作をすることが可能です。

また、以下のように Weight-based routing の機能を利用することで、各バージョンへのトラフィックの割合を指定することが可能です。 以下の例では v1 に 75% , v2 に 25% のトラフィックが流れます。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# https://istio.io/docs/reference/config/networking/virtual-service/#HTTPRouteDestination
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: reviews-route
spec:
  hosts:
  - reviews.prod.svc.cluster.local
  http:
  - route:
    - destination:
        host: reviews.prod.svc.cluster.local
        subset: v2
      weight: 25
    - destination:
        host: reviews.prod.svc.cluster.local
        subset: v1
      weight: 75

出典: https://istio.io/docs/reference/config/networking/virtual-service/#HTTPRouteDestination

他にも多くの機能がありますが、詳細は Istio / Virtual Service を参照して下さい。

Destination Rule

Destination Rule は特定の Service に対する設定を定義するリソースです。

Subset を定義して単一 Service 内に複数のバージョンの Deployment を共存させたり、ロードバランシングの設定ができます。

また、Consistent Hash-based load balancing 機能を利用することで、HTTP Header/Cookie/IP の値によって振り分け先を固定(Soft Session Affinity)することもきます。 以下の例では、 Cookie の値によって振り分け先を固定しています。

1
2
3
4
5
6
7
8
9
10
11
12
13
# https://istio.io/docs/reference/config/networking/destination-rule/#LoadBalancerSettings
apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: bookinfo-ratings
spec:
  host: ratings.prod.svc.cluster.local
  trafficPolicy:
    loadBalancer:
      consistentHash:
        httpCookie:
          name: user
          ttl: 0s

出典: https://istio.io/docs/reference/config/networking/destination-rule/#LoadBalancerSettings

こちらも詳細については Istio / Destination Rule を参照して下さい。

Problems

A/B テストの基本機能としては、トラフィックの割合を指定しつつ、同一クライアントが同一のバージョンに割り振られ続けることが求められます。 これらの機能は上述した Weight-based routing と Consistent Hash-based load balancing によって実現できそうに見えるのですが、 以下のような問題点がありました。

Consistent Hash-based load balancing は厳密ではない

前述した通り、Istio では ConsistentHash Hash-based load balancing 機能によって、 IP アドレスや HTTP Header 等の値を基準に同一のPodにルーティングをしてくれます。 しかしながら Consistent Hash という名前から読み取れるように、 内部のルーティングには Consistent Hash 法 が利用されているものと思われます。 そのため、Pod の Replica 数の増減に対してある程度はロバストなものの、 Pod のスケーリングが行われると一部のルーティングの再振り分けが発生してしまいます。

多くの A/B テストのユースケースでは、 UX や分析の観点で一定期間内では同一のクライアントに対して同一のパターンを割り振ることを求められます。 このようにパターンの再振り分けが許容できない場合は Consistent Hash-based load balancing による Soft Session Affinity は利用できません。

Weight-based Routing と Soft Session Affinity が同時に効かない

前述した Weight-based Routing は指定した Weight に基づいて Traffic を分ける機能です。 これと Consistent Hash-based load balancing を組み合わせることで、 一部のクライアントだけを特定のパターンに振り分けることが可能に見えます。

しかしながら、これは本稿執筆時点では これら2つの機能を同時に利用することができず、 同一クライアントがリクエストの度に異なるサービスを引き当てることが起こりえます。

この問題に関しては Issue として上がっており、いずれ対応されるかもしれませんが、現時点では難しいようです。

複数テストの相互作用を制御できない

A/B テストは一定期間内には 1 件だけを実行するのが原則かとは思いますが、 実時間の制約等により、複数の独立した A/B テストを並行で走らせたいケースも存在します。

複数のテスト間の相互作用を排除しようとした場合、 例えば「API α で A パターン に割り当てられたクライアントは、 API β では必ずデフォルトのパターンを割り当てられるようにする」といった制御が必要になります。 こういった制御も現時点での Istio 単体の機能としては実現困難です。

なお、A/B テストの相互作用についての議論は以下の資料を参考にさせていただきました。

Solutions

以上のような問題点から、現状の Istio 単体では A/B テストの機能の実現は難しかったのですが、 補助的な Reverse Proxy を実装・導入することで、この点を解決することができました。

Reverse Proxy

Reverse Proxy 自体は Go で実装しています。Go 標準の net/http/httputil.ReverseProxy を利用することで簡単に Reverse Proxy を実相することができます。

実装した Reverse Proxy の処理概要について説明します。

まず最初に Request Header を読み取ります。この Request Header には事前に Istio 経由で設定が埋め込まれています。 Istio の設定は Route, TestGroup という2種類の独自リソース定義を元に動的に生成され、A/B テスト設定を Request Header に注入するような Virtual Service の設定になっています。

Reverse Proxy は Request Header 経由で A/B テストの設定を読み取り、どのクライアントをどのパターンに振り分けるかを計算します。 クライアントの ID と A/B テストの乱数シードが同時に入力に与えられれば Reverse Proxy 自体は Stateless に振り分け先を決定できます。

振り分け先が決定できたら Request Header に値を書き込み、そのまま Istio にリクエストを戻します。 その後は Istio によって HTTP Header のパターンマッチが行われ、 A/B テストの設定を反映したルーティングが行われます。

全体としては、以下のような構成になっています。 Istio の Gateway 通過時、 Request Header に A/B テストの設定が埋め込まれ、それを Reverse Proxy の入力としています。 Reverse Proxy の出力もまた HTTP Header のみあり、ルーティングの処理自体は Istio に委ねられています。

tg

Example

以下は Route, TestGroup リソースの設定例です。 Kubernetes の Custom Resource のような書き方にしていますが、実際には現時点ではこれらのリソースは Kubernetes の CRD ではなく、Helm の values として扱っています。

Route の設定例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
kind: Route
metadata:
  name: advent-calendar-api
spec:
  testGroupName: sample-test:20191201
  backends:
    default:
      name: advent-calendar-service-v1
    # A/B テスト用の別バージョンの service
    variants:
    - name: advent-calendar-service-v2
      slot: A
    - name: advent-calendar-service-v3
      slot: B

TestGroup の設定例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
kind: TestGroup
metadata:
  name: sample-test:20191201
spec:
  routes:
  - name: advent-calendar-api
    slots:
    - name: A
      percent: 10
    - name: B
      percent: 20
  # 同時に実行したい他のAPI
  - name: merry-christmas-api
    slots:
    - name: A
      percent: 10

Route の設定では advent-calendar-api という API に対してのルーティングの設定と、A/B テストの設定を行っています。 advent-calendar-api のデフォルトのサービスは advent-calendar-service-v1 であり、 A/B テスト用に advent-calendar-service-v2, advent-calendar-service-v3 もルーティング可能な設定にしています。

なお、上記の例では backends としてサービスのみを指定しており、サービス間 の A/B テストを行う設定になっています。 Subset を利用してサービス内のバージョン指定することも実装上は可能ですが、今回は簡単のため Subset については考えないこととします。

TestGroup では advent-calendar-api の他に merry-christmas-api という Route を指定していますが、これは TestGroupRoute が 1 : N に対応しているためです。 前述した「複数テストの相互作用を制御できない」という問題に対処するため、複数テストを同一グループにまとめて振り分けの計算を行います。

具体的には「(Client ID + TestGroup name Hash 値) mod 100」を計算することで、相互作用を考慮した振り分け先が決定できます。 以下の図のように同一 TestGroup に属する複数の Route において、同時にデフォルト( X パターン)以外に振り分けられないことを保証できます。

hash

今後の課題

今回は Istio の補助的な Reverse Proxy を実装しましたが、Istio の機能だけで完結できるとスッキリすると思います。 Istio の Envoy Filter を利用すれば近いことができるのではないかと思うため、今後検証を進めていきたいです。

また、Route, TestGroup に関しては CRD のようなフォーマットにしていますが、CRD にはなっていません。 現状は Helm 経由でデプロイ時に頑張っているのですが、ここを CRD + Custom Controller にできると CI/CD 周りをスッキリさせられる印象です。

まとめ

本記事では Istio の機能を利用して A/B テストを行おうとした際の注意点とその回避策について紹介いたしました。

弊社では Kubernetes や Istio を使った基盤を開発し、ビジネスを加速していく仲間を絶賛募集していますので、 ご興味をお持ちいただけましたら、お気軽に @shotat_jp までお声がけ下さい!