SRE兼スクラムマスターのブログ

チーム開発が好きなエンジニアブログです。検証した技術やチームの取り組みを書いていきます。

KEDA による イベント駆動オートスケール と 使い方を考えてみる

背景

MicrosoftAKS を利用して kubernetes を運用しており、podのauto scaleはHorizontal Pod Autoscalerの機能を利用しています。 Horizontal Pod Autoscalerは、簡単に説明すると指定したmetricをpodから取得して、閾値に達した場合、podをauto scaleする仕組みです。 これはkubernetesでcontainerを安定し運用するための便利な仕組みです。しかし、kubernetesのリソースに対して監視するため 外部リソースの状態に応じてpodをscaleする場合は、別の仕組みを検討する必要があります。

KEDAとは

KEDA (Kubernetes Event-driven Autoscaling)は、 kubernetes clusterに追加できる単一目的の軽量コンポーネントで、CNCFのSandboxプロジェクトにホストされているkubernetesベースのイベント駆動オートスケーラーです。宣言的に定義されたイベントに応じて、kubernetesの任意のコンテナーのスケーリングを促進できる。Horizo​​ntal Pod Autoscalerなどのkubernetesコンポーネントと連携して動作し、上書きや重複なしに機能拡張できます。

keda.sh

KEDAの役割

KEDAはkubernetes内で2つの重要な役割を果たします。

  • Agent kubernetes deploymentをアクティブ/非アクティブ化を管理します。

  • Metrics kubernetes metricsサーバーとして機能します。キューの長さやストリームラグなどのイベントデータをHorizo​​ntal Pod Autoscalerに公開してscale outを促進します。ソースから直接イベントを消費するのはDeploymentの役割であり、これにより、豊富なイベントの統合が維持され、キューメッセージの完了や破棄などのジェスチャーをそのまま使用できます。

Architecture

公式の説明からKEDAはetcdや外部リソースを監視して標準のHPAや対象podに命令します。

https://keda.sh/img/keda-arch.png

対象ソース

AWSやAzureなど幅広く対応しています。また、「External」リソースを利用することで柔軟なevent操作ができます。 詳細はこちら https://keda.sh/docs/concepts/#event-sources-and-scalers

デプロイ

今回はYAML Manifestを使ってdeployします。 ※HelmやOperatorも可

  • KEDAのrepositoryをcloneし、CRDを作成する
kubectl apply -f ./deploy/crds
  • scaleとtriggerを行うCRDがあることを確認する
$ kubectl get crds
NAME                                 CREATED AT
scaledobjects.keda.k8s.io            2020-04-21T01:43:13Z
triggerauthentications.keda.k8s.io   2020-04-21T01:43:13Z
  • CRDを作成したらKEDA operatorとmetrics serverを作成する
kubectl apply -f ./deploy
  • デフォルトでは「keda」namespaceに作成される
$ kubectl get all -n keda
NAME                                         READY   STATUS    RESTARTS   AGE
pod/keda-metrics-apiserver-f465ccb68-wdk5l   1/1     Running   0          23h
pod/keda-operator-8fdf64d5-pkwf9             1/1     Running   0          23h

NAME                             TYPE        CLUSTER-IP       EXTERNAL-IP   PORT(S)             AGE
service/keda-metrics-apiserver   ClusterIP   10.105.111.250   <none>        443/TCP,80/TCP      23h
service/keda-operator-metrics    ClusterIP   10.110.125.60    <none>        8383/TCP,8686/TCP   23h

NAME                                     READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/keda-metrics-apiserver   1/1     1            1           23h
deployment.apps/keda-operator            1/1     1            1           23h

NAME                                               DESIRED   CURRENT   READY   AGE
replicaset.apps/keda-metrics-apiserver-f465ccb68   1         1         1       23h
replicaset.apps/keda-operator-8fdf64d5             1         1         1       23h

これでKEDAのdeployは完了です。

動作確認

KEDAのdeployが完了したので実際外部リソースをtriggerしてpodのauto scaleを確認します。 今回はnginxのpodをAzure storage accountのblob container file uploadイベントをhookしてpodをscaleさせます。

簡単なサンプルですがこちらに載せておきます。 https://github.com/JunichiMitsunaga/keda-example

  • Azureにresource groupとstorage accountを作成する
az group create --location japaneast --name keda-example
az storage account create --name kedaexample --resource-group keda-example
  • connection stringを取得する
az storage account show-connection-string --name kedaexample
  • 取得したconnection stringでk8sのsecretを作成する

01-secret.yaml

apiVersion: v1
kind: Secret
metadata:
  name: account-secret
  namespace: keda-scale
data:
  connectionString: ***
  • auto scale用のpodをdeployする
kubectl apply -f .deploy/
  • 「keda-scale」namespaceにpodがdeployされていることを確認する
$ kubectl get all -n keda-scale                         
NAME                              READY   STATUS    RESTARTS   AGE
pod/scale-nginx-898f5b6d4-x8897   1/1     Running   0          46s

NAME                          READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/scale-nginx   1/1     1            1           47s

NAME                                    DESIRED   CURRENT   READY   AGE
replicaset.apps/scale-nginx-898f5b6d4   1         1         1       46s
  • KEDAによるscaleを実施するために「ScaledObject」をデプロイする
kubectl apply -f keda-hpa/
  • 「ScaledObject」がdeployされていることを確認する
$ kubectl get ScaledObject -n keda-scale
NAME                      DEPLOYMENT    TRIGGERS     AGE
azure-blob-scaledobject   scale-nginx   azure-blob   28s

ScaledObjectをdeployするとscale条件にマッチしてないため該当のpodは0スケールされます。 ※この仕組みは地味にうれしいですね

$ kubectl get po -n keda-scale
No resources found in keda-scale namespace.
  • triggerを満たしてauto scaleを確認する 今回のtriggerはblob containerにfileを2つ以上uploadすること

1つ目のfileをuploadする 条件を満たしていないのでscaleされない

$ kubectl get po -w -n keda-scale

2つ目のfileをuploadする 条件を満たすのでpodがscale outされる

$ kubectl get po -w -n keda-scale
NAME                          READY   STATUS    RESTARTS   AGE
scale-nginx-898f5b6d4-zhgdm   0/1     Pending   0          0s
scale-nginx-898f5b6d4-zhgdm   0/1     Pending   0          0s
scale-nginx-898f5b6d4-zhgdm   0/1     ContainerCreating   0          0s
scale-nginx-898f5b6d4-zhgdm   1/1     Running             0          4s

fileを削除して条件を満たさないように変更する

$ kubectl get po -n keda-scale -w
NAME                          READY   STATUS    RESTARTS   AGE
scale-nginx-898f5b6d4-ftffd   1/1     Running   0          16s
scale-nginx-898f5b6d4-ftffd   1/1     Terminating   0          70s
scale-nginx-898f5b6d4-ftffd   0/1     Terminating   0          72s
scale-nginx-898f5b6d4-ftffd   0/1     Terminating   0          72s
scale-nginx-898f5b6d4-ftffd   0/1     Terminating   0          75s
scale-nginx-898f5b6d4-ftffd   0/1     Terminating   0          75s

以上よりAzure Blob Storageのeventからpodをscaleできた。

運用を考えてみる

リソース監視からのauto scale

背景で述べているようにkubernetesの標準機能はmemoryやcpuをtriggerにするため、Databaseのような外部リソースやpod内のthread、connection数といった他のmetricをtriggerにするのが困難でした。KEDAを利用することでAzure Monitorなどといった監視サービスと併用することでpodのconnection数が上限をtriggerにscale outするなどの運用方法が考えられます。 ただし、scale outする場合の他のリソースへの影響を考慮するため キャパシティプランニングする必要はあります。

Serverlessの実行

Serverlessアプリケーションは、AWS LambdaやAzure FunctionsなどのFaaSやAzure Container Instance(ACI) によるpodの起動など、さまざまな方法が挙げられます。もちろん、これらの仕組みでも十分に実現できますが、KEDAを利用することでkubernetes cluster内に一元管理できるためコストが楽になると感じました。 とくに長時間のbatch処理は「Azure Queue Storage → KEDAによるAuto Scale」といったシンプルな実装で管理でき、タイムアウト意識する必要も減るため有用な仕組みになりそうです。

所感

触ってみた印象としてKEDAは、kubernetes cluster内で実現するFaaSのイメージです。とくにkubernetesを普段運用している方は、扱いやすいものだと思います。 プロダクトを安定させる使い方や、新機能を効率よく動かす使い方など、プロダクトの課題に沿った形で柔軟に使えそうなので、auto scale や batch処理なの運用設計に困っている人がいれば是非触ってみてほしいです。

初心者の 負荷試験入門 2

はじめに

本記事は初心者の負荷試験入門シリーズの第2弾です!

前回の 初心者の負荷試験入門 その1 では、負荷試験のざっくりとした目的や指標、性能改善をまとめました。負荷試験を実施するにあたってどのような指標が必要なのかは何となくわかりましたが、実際にどのようなToolを利用してシステムに負荷をかけていくのかはまだ見ていないので今回はそれをまとめてみようと思います。

負荷試験ツール

ひとえにツールといっても負荷試験のツールにはいくつか種類があるようで、主に「攻撃ツール」、「モニタリングツール」、「プロファイリングツール」の3種類のツールを利用する。 ツールの役割はこんな感じ 攻撃ツール:システムに負荷を与えるツール(大量リクエストの送信など) モニタリングツール:システムのリソース状況を観測して可視化するツール プロファイリングツール:ミドルやアプリケーション内部を解析して可視化するツール

攻撃ツールの選択

攻撃ツールとは

システムを利用する側の動きをシミュレーションすることにより、対象のシステムを高負荷状態にできるツールのことを指します。 攻撃ツールを利用することでシステムに大量のリクエストが発生し、Dos攻撃を受けたかのような状態となります。

攻撃ツールに求められる要件

負荷試験を行うために攻撃ツールに必要とされる主な要件は下記の4つになります。 1.リクエストを正しくシミュレーションできること 2.攻撃の強さを制御できること 3.対象のシステムに対して十分な負荷を発生させられること 4.攻撃ツールの設置場所、起動場所を選択できること

1.リクエストを正しくシミュレーションできること 攻撃ツールによってはシナリオを組んだ攻撃が出来ないツールも存在するため、負荷試験の要件にそぐわない場合はこれらのツールを選定から外した方がよさそうです。

2.攻撃の強さを制御できること クライアントの同時起動数、リクエスト間隔、最大スループット数を調整することで攻撃の強さを設定できます。

3.対象のシステムに対して十分な負荷を発生させられること 攻撃ツールにより、どこまでの高負荷状態を効率よく発生させられるかが異なる為、自分たちのシステムの特性に合ったツールを選択する必要があります。

4.攻撃ツールの設置場所、起動場所を選択できること 攻撃ツールによって起動できる攻撃サーバに制約がある為、攻撃サーバを設置する場所が制限されるものがあります。

攻撃ツール用語

ツールによって多少用語が異なることがありますが、たいていの攻撃ツールは下記表のような概念が存在しています。用語を理解していればツールが変更になったとしても同じように負荷試験が行えるようです。

用語 説明
クライアント HTTPリクエストを同時に1つだけ発行できるリクエスト生成機
クライアントの同時起動数 攻撃ツール上で利用される攻撃用クライアントの数
Ramp-Up 期間 攻撃開始後にすべてのクライアントが起動するまでのウォームアップ期間
シナリオ クライアント毎に設定されたHTTPリクエストの発生パターン
シナリオ実行回数 クライアントがシナリオに沿ったリクエストを実施する回数
スループット システムが単位時間あたりに処理できるリクエストの数
レイテンシ 攻撃ツールがリクエストを出してからレスポンスを受け取るまでの期間

攻撃ツールの例

具体的な攻撃ツールを一部紹介しています。(各ツールの詳細については割愛します…調べたら記述予定)

  • Apache JMeter 特徴
    • リクエスト毎に動的にパラメータを変更可能
    • 複数のURLを組み合わせたシナリオを実施できる
    • GET,POST,PUT,DELETEメソッドの試験が可能 etc
  • Locust 特徴
    • シナリオをPythonスクリプトで記述できるため、柔軟なシナリオが作成できる
    • 結果表示がシンプル
    • 必要なサーバリソースが少ないた為、攻撃サーバで負荷をかけやすい etc
  • Gatling 特徴
    • developerフレンドリーなシナリオファイル
    • 見やすいレポートHTML etc

攻撃ツールにはそれぞれ特徴があり、負荷試験の対象となるシステムによって使い分けることが必要です。 とくに同じシステムに対する負荷試験でも利用するツールによって大きく結果がことなることがあるため、場合によっては複数のツールで攻撃を行い結果を比較してみることも必要かもしれません。

モニタリングツール/プロファイリングツール

攻撃ツールの中にはスループットの監視ツールや可視化ツールが含まれているものも多いですが、攻撃ツールから観測できるレイテンシはシステム全体のレイテンシのみであり、個別のサブシステム等のレイテンシは観測できません。

サブシステムにおける詳細なモニタリングやプロファイリングを補助するためのツールとして以下があります。 - topコマンドとnetstatコマンド - AWS管理コンソール(AWSを利用している場合) etc

そしてこれらのツールを利用してモニタリングしておきたい項目が以下の表になります。

サブシステム 監視すべきもの
ネットワーク 転送量
ハードウェアやOS CPU,メモリ,プロセス数,SWAP
TCP 外部へのコネクション状況
ディスク R/W 転送データ量
サーバミドルウェア コネクション数
アプリケーション プロファイラーにて監視

今後について

負荷試験に利用するツールについて調べたが主に攻撃ツールの特性による試験の向き不向き等が見えてきました。モニタリングについては文章ベースなので今後、実際にサンプルアプリケーションを作成して負荷試験を試してみる中でモニタリングのやり方を学習していこうと思います。

そのほか、こんなツールがあるよなど情報があればぜひご連絡頂ければと思います。

初心者の 負荷試験入門 1

はじめに

  • Webアプリケーションについて負荷試験が必要になり、初心者ながら負荷試験についての基礎事項を調べてまとめておこうと思います
  • この分野に詳しい人の話をぜひ聞いてみたいものです…

負荷試験って目的とは

  • 負荷試験の目的とは、システムの可用性を高めることだと思います。そしてそれはオンプレミスの場合とクラウドの場合で異なる
    • オンプレミスの場合

      • ユースケースを想定して、システムの応答性能を推測するため
      • 高負荷時のシステム改善を実施するため
      • 目的の性能を提供するために必要なハードウェアをあらかじめ選定するため
    • クラウドの場合

      • ユースケースを想定して、システムの応答性能を推測するため
      • 高負荷時のシステム改善を実施するため
      • 目的の性能を提供するために必要なハードウェアをあらかじめ選定するため
      • システムがスケール性を持つことを確認するため
      • システムのスケール特性を把握するため

負荷試験の指標

  • スループット
    • 単位時間に処理を行う量
      • webシステムでは「1秒間に処理を行うHTTPリクエストの数」を示すことが多く、rps(Request Per Second)と呼ぶ
    • 「ネットワーク上を流れるデータ転送速度」として利用する場合もある
      • ネットワーク帯域と呼ぶ
  • レイテンシ
    • 処理時間には2種類が存在する
      • 「ユーザーから見た処理時間」
        • ユーザーがリクエストを送ってレスポンスを受け取るまでの時間
      • 「システムから見た処理時間」
        • Webシステムがリクエストを受け取ってからレスポンスを返すまでの時間
      • 2種類の処理時間には以下のような関係が成り立つ
      • 「ユーザーから見た処理時間」=「システムから見た処理時間」+「データがネットワークを往復する時間」
      • ブラウザを利用する場合は、ブラウザがデータを受け取ってから画面描画が完了するまでをレイテンシと呼ぶ場合もある

システムの性能改善

  • スループットの改善
  • レイテンシの改善
    • 待ち時間を含めた各サブシステムの処理時間の総和であるため、「大きな処理時間を要している処理」から順々に改善できる点がないかを検討する
    • 処理時間にとって最大の改善個所はアプリケーションの実装部分であることが多い
      • 非効率なアルゴリズム
      • 無駄なI/O
      • データベースのインデックス不足
    • アプリケーションの実装部分である場合は、プロファイラーを利用して処理の詳細な処理時間を確認することが可能
    • 重要な点としてレイテンシは「待ち時間を含めた時間」であるため、スループットが上限に近づくと必然的に待ち時間も増加するため、裏を返せばスループットが向上すればレイテンシも改善することもありうる

今後について

  • ひとまず負荷試験を行う上での用語や性能改善については調べられたので、今後は負荷試験に利用するtoolを調べてみることにする

WireMock を使って通信が発生する サービステスト を実装する

背景

マイクロサービスにより構築されたwebアプリケーションのテストコードではサービス間の通信が発生するケースがある。 これまではmockingjay-serverを利用して外部サービスのmockを作成しサービステストを実装していたが、エラー発生ケースのmock定義が非常に煩雑になると感じた。 且つ、マイクロサービス間で頻繁に呼ばれるAPIに関してはエラーケースを定義することで他のテストケースへの影響も発生した。 これらのことから、テストケース間で依存関係を与えない方法でサービステストが実装出来ないかを調査したところWireMockが候補に挙がったためシンプルではあるが実装例を残しておく。

実行環境

導入手順

本検証ではmavenを利用しているため、公式サイトの手順に沿って下記の依存性をpomに記入する。 後はmvn installで依存ライブラリをインストール出来る。

        <dependency>
            <groupId>com.github.tomakehurst</groupId>
            <artifactId>wiremock</artifactId>
            <version>2.17.0</version>
            <scope>test</scope>
        </dependency>

注意点

  • 他の依存ライブラリでjettyを利用しているものが存在する場合、jettyのバージョンの関係でwiremockが起動しないケースが発生した。
java.lang.NoClassDefFoundError: org/eclipse/jetty/util/thread/Locker

    at org.eclipse.jetty.server.Server.<init>(Server.java:94)
    at com.github.tomakehurst.wiremock.jetty9.JettyHttpServer.createServer(JettyHttpServer.java:118)
    at com.github.tomakehurst.wiremock.jetty9.JettyHttpServer.<init>(JettyHttpServer.java:66)
    at com.github.tomakehurst.wiremock.jetty9.JettyHttpServerFactory.buildHttpServer(JettyHttpServerFactory.java:31)
    at com.github.tomakehurst.wiremock.WireMockServer.<init>(WireMockServer.java:74)
    at com.github.tomakehurst.wiremock.junit.WireMockClassRule.<init>(WireMockClassRule.java:32)
    at com.github.tomakehurst.wiremock.junit.WireMockClassRule.<init>(WireMockClassRule.java:40)
    at spark.unit.service.ServiceTests.<clinit>(ServiceTests.java:26)
    at sun.misc.Unsafe.ensureClassInitialized(Native Method)
    at sun.reflect.UnsafeFieldAccessorFactory.newFieldAccessor(UnsafeFieldAccessorFactory.java:43)
    at sun.reflect.ReflectionFactory.newFieldAccessor(ReflectionFactory.java:156)
    at java.lang.reflect.Field.acquireFieldAccessor(Field.java:1088)
    at java.lang.reflect.Field.getFieldAccessor(Field.java:1069)
    at java.lang.reflect.Field.get(Field.java:393)
    at org.junit.runners.model.FrameworkField.get(FrameworkField.java:73)
    at org.junit.runners.model.TestClass.getAnnotatedFieldValues(TestClass.java:230)
    at org.junit.runners.ParentRunner.classRules(ParentRunner.java:255)
    at org.junit.runners.ParentRunner.withClassRules(ParentRunner.java:244)
    at org.junit.runners.ParentRunner.classBlock(ParentRunner.java:194)
    at org.junit.runners.ParentRunner.run(ParentRunner.java:362)
    at org.junit.runner.JUnitCore.run(JUnitCore.java:137)
    at com.intellij.junit4.JUnit4IdeaTestRunner.startRunnerWithArgs(JUnit4IdeaTestRunner.java:68)
    at com.intellij.rt.execution.junit.IdeaTestRunner$Repeater.startRunnerWithArgs(IdeaTestRunner.java:51)
    at com.intellij.rt.execution.junit.JUnitStarter.prepareStreamsAndStart(JUnitStarter.java:237)
    at com.intellij.rt.execution.junit.JUnitStarter.main(JUnitStarter.java:70)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:147)
Caused by: java.lang.ClassNotFoundException: org.eclipse.jetty.util.thread.Locker
    at java.net.URLClassLoader.findClass(URLClassLoader.java:381)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:424)
    at sun.misc.Launcher$AppClassLoader.loadClass(Launcher.java:331)
    at java.lang.ClassLoader.loadClass(ClassLoader.java:357)
    ... 30 more
  • 対応策①
    • 下記の依存ライブラリをpomに追加する
<dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-server</artifactId>
            <version>9.3.14.v20161028</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-servlets</artifactId>
            <version>9.3.14.v20161028</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.eclipse.jetty</groupId>
            <artifactId>jetty-servlet</artifactId>
            <version>9.3.14.v20161028</version>
            <scope>test</scope>
        </dependency>
  • 対応策②
    • wiremock-standaloneの依存ライブラリをpomに追加する
        <dependency>
            <groupId>com.github.tomakehurst</groupId>
            <artifactId>wiremock-standalone</artifactId>
            <version>${wiremock.version}</version>
            <scope>test</scope>
        </dependency>

サンプル実装

今回はマイクロサービスで構築されたアプリケーションを想定してサービステストコードを実装する。

前提条件

  • コードのロジックにおいて外部サービスの呼び出しが発生すること
  • 外部サービスの呼出し結果により処理が変更される

    処理イメージ

    image.png

  • 上記処理の場合、Aサービスの「/user/insert」に対するサービステストを実装する場合、テスト結果がBサービスに依存することがわかる。

  • 従来のテストではmockサーバーにBサービスの正常系・異常系を定義することでCDCテストが可能になるが、異常時にURIやリクエストパラメータの定義が固定値になりがちである。 ※個人的な経験上です…
  • wiremockを利用することによってテストメソッド単位でBサービスのmockを定義できるため、mockサーバーに定義することなくかつ他のテストに依存することなくテストコードが実装できる。
  • 実装例ではメソッド毎にBサービスの結果を変更することでテストメソッド内のみでmockを利用している。

実装例

    // wiremockをポートを指定して定義する
    @Rule
    public WireMockClassRule wireMockRule = new WireMockClassRule(8082);

       @Test
    public void TEST_Bサービス正常動作時() throws UnirestException {
        // Bサービスの正常動作をmockとする
        wireMockRule.stubFor(get(urlEqualTo("/mock/sample"))
                .willReturn(
                        aResponse()
                                .withStatus(200)
                                .withHeader("Content-Type", "application/json")
                                .withBody("OK")));

        HttpResponse<String> res = Unirest.post("http://localhost:8081/user/insert")
                .header("Content-Type", "application/json")
                .asString();

        assertEquals("response codeが200であること:", 200, res.getStatus());
        assertEquals("response bodyがOKであること:", "OK", res.getBody());
    }

    @Test
    public void TEST_Bサービス異常動作時() throws UnirestException {
        // Bサービスの異常動作をmockとする
        wireMockRule.stubFor(get(urlEqualTo("/mock/sample"))
                .willReturn(
                        aResponse()
                                .withStatus(400)
                                .withHeader("Content-Type", "application/json")
                                .withBody("B Service Error")));

        HttpResponse<String> res = Unirest.post("http://localhost:8081/user/insert")
                .header("Content-Type", "application/json")
                .asString();

        assertEquals("response codeが400であること:", 400, res.getStatus());
        assertEquals("response bodyがB Service Errorであること:", "B Service Error", res.getBody());
    }
  • 各テストコードが成功していることが分かる image.png

感想

当初の想定通りテストケース間で依存を与えずにサービステストの実装を行えた。かつ導入手順もシンプルである為非常に扱いやすい。 mock管理のフレームワークといえばswaggerが有名であるが、今回のようなテスト目的のみであればWireMockが導入コスト面から言ってもおすすめである。

課題

  • テストに利用するAPIの定義が変更された場合(URIが変更されるなど)、該当するAPIがどのサービスから呼び出されているか管理できていないと発見が遅れること。

※外部のmockサーバーに管理している場合は、mockを用いたテスト時にテストが失敗するため検知できる。 ※今回のように利用する場合は、サービス間の関連図を作成しておく必要があると思う。

その他

WireMock サーバーの導入手順

背景

マイクロサービスにより構築されたwebアプリケーションのテストコードではサービス間の通信が発生するケースがある。今回はサービス間の通信が発生するテストの実装を実装するためmockツールを検証したので導入手順を残しておく。

利用ツール

WireMock thoughtworksのTechnology Radarでも紹介されており、この1年間でも進化が著しいとのことなので、さっそく試してみる。

WireMock

HTTPベースのAPIのシミュレータ、mockサーバー。 http://wiremock.org/ https://github.com/tomakehurst/wiremock

WireMockの起動

WireMockはjarファイルで提供されており、起動についてはこれを実行する。 http://repo1.maven.org/maven2/com/github/tomakehurst/wiremock-standalone/2.17.0/wiremock-standalone-2.17.0.jar

$java -jar wiremock-standalone-2.12.0.jar

http://localhost:8080でmockサーバーが起動する

image.png

WireMock API登録

レスポンスマッピングAPIベース

Chrome Postmanを使用して検証を実施 特定のリクエストに対して指定したレスポンスを返す設定を行う。 なお、マッピングにはWireMockの/__admin/mappingsAPIを使用する。 POST : /__admin/mappings - /helloのPOST APIを定義する image.png

  • 登録された結果が返却される image.png

  • 設定した結果が返却される。 image.png

  • 登録されたAPIはwiremock-standalone-2.12.0.jarと同一階層のmappingsに保存される。 image.png

  • mapping配下 image.png

レスポンスマッピング fileベース

  • wiremock-standalone-2.12.0.jarと同一階層のmappingsにjson fileとして配置する。 image.png
   {
    "scenarioName": "findUser",
    "request": {
        "url": "/user/find",
        "method": "GET"
    },
    "response": {
        "status": 200,
        "body": "Taro",
        "headers": {
            "Content-Type": "application/json"
        }
    }
}
  • __admin/mappings/reset APIでWireMockの定義を更新する。
  • APIを呼び出しを行うと登録されていることが確認できる。 image.png

WireMock API確認

  • WireMockのAPIについては公式のAPI docsを確認する、またはWireMockを起動後にブラウザで__admin/docsを開きSwagger UIの管理画面で確認できる。 image.png

関連

参考文献

http://wiremock.org/docs/running-standalone/

Azure 上に GitOps 対応の AKS クラスターを自動構築する

背景

GitOpsというキーワードが出てきており、職場でもGitOpsを使った検証環境の運用が始まっている。

しかし、GitOps環境を構築するにあたってIaCは進めているものの、まだ人手によるOperationがなくなっていないのも事実です。

そこで、Azure上にkubernetes clusterの構築からGitOpsの仕組みを導入するまでを自動化する仕組みとして

Bedrockを検証したので手順や気づきについてまとめていきます。

GitOpsとは

Weaveworks社が提唱した,Kubernetesの運用ベストプラクティス

詳しくはGitOpsを参照

記事はボリュームがあるので 要点をまとめると下記のような感じです。

  • システムの状態がGit管理されている
  • コードとして宣言的なインフラストラクチャの実現 ⇔ Kubernetesも宣言的な仕組み
  • Git上で承認された場合は、環境にも適用される
  • Git = 環境を保証し、差異があったらアラートする

GitOpsを実現するために、 現在はFluxというOSSを使ってkubernetes clusterの状態とgit管理しているmanifestの間で 同期を取る仕組みを実現している。

fluxについて過去にチュートリアル記事を書いたので参考になればと思います。 cabi99.hatenablog.com

現在はFluxを使って下図の環境を構築しています。 この環境をpipeline以外、すべて自動で構築します。

f:id:JunichiMitsunaga:20200127125902p:plain
Azure DevOps PipelinesとFlux連携

Bedrockとは

Microsoftが提供するGitOpsワークフローを使用してKubernetesクラスターを運用可能にするterraformテンプレートです。 GitOpsワークフローは、Fabrikateの定義を中心に展開されており、構成から構成を分離する、より高い抽象化レベルで展開を指定できます。 今回の検証ではBedrockを使ってAKSクラスターを自動構築することを優先したのでFabrikateについては検証しません。

github.com

手順

前提条件

helm: v2.13.1 → 3系だとまだ動かないっぽい

terraform: v0.12.20 → テンプレートは古いバージョン対応だったのでupgradeコマンドでスクリプトを更新しました

Azure CLI

$ az version
{
  "azure-cli": "2.0.80",
  "azure-cli-command-modules-nspkg": "2.0.3",
  "azure-cli-core": "2.0.80",
  "azure-cli-nspkg": "3.0.4",
  "azure-cli-telemetry": "1.0.4",
  "extensions": {}
}

おおまかな流れ

bedrockのテンプレートで実行している内容を簡単に書くと以下になります。

  1. azure cli でresource groupを作成する
  2. terraformでvnet,subnetを作成する
  3. terraformでを作成する
  4. terraformでaks clusterを作成する
  5. terraformのnull_resourceを使ってshellでflux agentをインストール/起動する

詳細

1.azure cli でresource groupを作成する

az group create -n gitOpsCluster -l  japaneast

f:id:JunichiMitsunaga:20200215170328p:plain

2.vnetとsubnetをterraformで作成する ※利用するfileはbedrockの以下path

/bedrock/cluster/azure/vnet

参考までにmain.tf variables.tfはresource_group_name以外、ほぼデフォルト値

resource "azurerm_virtual_network" "vnet" {
  name                = var.vnet_name
  location            = var.location
  address_space       = [var.address_space]
  resource_group_name = var.resource_group_name
  dns_servers         = var.dns_servers
  tags                = var.tags
}

resource "azurerm_subnet" "subnet" {
  count                = length(var.subnet_names)
  name                 = var.subnet_names[count.index]
  virtual_network_name = azurerm_virtual_network.vnet.name
  resource_group_name  = azurerm_virtual_network.vnet.resource_group_name

  address_prefix    = var.subnet_prefixes[count.index]
  service_endpoints = var.subnet_service_endpoints[count.index]
}

f:id:JunichiMitsunaga:20200215184059p:plain

3.aksクラスターを作成し、fluxをinstallする ※利用するfileはbedrockの以下path

/bedrock/cluster/azure/aks-gitops

参考までにmain.tf variables.tfはfluxの設定値やkey情報以外はほぼデフォルト値

module "aks" {
  source = "../../azure/aks"

  resource_group_name      = var.resource_group_name
  cluster_name             = var.cluster_name
  agent_vm_count           = var.agent_vm_count
  agent_vm_size            = var.agent_vm_size
  dns_prefix               = var.dns_prefix
  vnet_subnet_id           = "/subscriptions/${var.azure_subscription_id}/resourceGroups/${var.resource_group_name}/providers/Microsoft.Network/virtualNetworks/${var.virtual_network_name}/subnets/${var.agent_subnet_name}"
  ssh_public_key           = var.ssh_public_key
  service_principal_id     = var.service_principal_id
  service_principal_secret = var.service_principal_secret
  service_cidr             = var.service_cidr
  dns_ip                   = var.dns_ip
  docker_cidr              = var.docker_cidr
  kubeconfig_filename      = var.kubeconfig_filename
  network_policy           = var.network_policy
  network_plugin           = var.network_plugin
  oms_agent_enabled        = var.oms_agent_enabled
}

module "flux" {
  source = "../../common/flux"

  gitops_ssh_url       = var.gitops_ssh_url  #Azure DevOpsのgitで管理しているリポジトリ
  gitops_ssh_key       = var.gitops_ssh_key #fluxがAzure DevOpsにアクセスするためのkey
  gitops_path          = var.gitops_path #fluxが同期したいマニフェストのpath
  gitops_poll_interval = var.gitops_poll_interval
  gitops_label         = var.gitops_label
  gitops_url_branch    = var.gitops_url_branch #fluxが同期したいbranch
  enable_flux          = var.enable_flux
  flux_recreate        = var.flux_recreate
  kubeconfig_complete  = module.aks.kubeconfig_done
  kubeconfig_filename  = var.kubeconfig_filename
  flux_clone_dir       = "${var.cluster_name}-flux"
  acr_enabled          = var.acr_enabled
  gc_enabled           = var.gc_enabled
}

module "kubediff" {
  source = "../../common/kubediff"

  kubeconfig_complete = module.aks.kubeconfig_done
  gitops_ssh_url      = var.gitops_ssh_url
}

Azure DevOpsでssh_keyを発行する手順はこちらを参照

Connect to your Git repos with SSH - Azure Repos | Microsoft Docs

注意点

fluxで同期を行うmanifestをkustomizeを使って管理している場合はfluxのoptionに注意する必要があります。 bedrockでfluxをインストールするのはhelmを利用しているのですが、「manifestGeneration」optionが変数として受け渡す設計がなされていないので自分たちで修正する必要があります。

/cluster/common/flux/deploy_flux.sh
#!/bin/sh
while getopts :b:f:g:k:d:e:c:l:s:r:t:z: option
do
 case "${option}" in
 b) GITOPS_URL_BRANCH=${OPTARG};;
 f) FLUX_REPO_URL=${OPTARG};;
 g) GITOPS_SSH_URL=${OPTARG};;
 k) GITOPS_SSH_KEY=${OPTARG};;
 d) REPO_ROOT_DIR=${OPTARG};;
 e) GITOPS_PATH=${OPTARG};;
 c) GITOPS_POLL_INTERVAL=${OPTARG};;
 l) GITOPS_LABEL=${OPTARG};;
 s) ACR_ENABLED=${OPTARG};;
 r) FLUX_IMAGE_REPOSITORY=${OPTARG};;
 t) FLUX_IMAGE_TAG=${OPTARG};;
 z) GC_ENABLED=${OPTARG};;
 *) echo "Please refer to usage guide on GitHub" >&2
    exit 1 ;;
 esac
done

KUBE_SECRET_NAME="flux-ssh"
RELEASE_NAME="flux"
KUBE_NAMESPACE="flux"
CLONE_DIR="flux"
REPO_DIR="$REPO_ROOT_DIR/$CLONE_DIR"
FLUX_CHART_DIR="chart/flux"
FLUX_MANIFESTS="manifests"

echo "flux repo root directory: $REPO_ROOT_DIR"

rm -rf "$REPO_ROOT_DIR"

echo "creating $REPO_ROOT_DIR directory"
if ! mkdir "$REPO_ROOT_DIR"; then
    echo "ERROR: failed to create directory $REPO_ROOT_DIR"
    exit 1
fi

cd "$REPO_ROOT_DIR" || exit 1

echo "cloning $FLUX_REPO_URL"

if ! git clone -b "$FLUX_IMAGE_TAG" "$FLUX_REPO_URL"; then
    echo "ERROR: failed to clone $FLUX_REPO_URL"
    exit 1
fi

cd "$CLONE_DIR/$FLUX_CHART_DIR" || exit 1

echo "creating $FLUX_MANIFESTS directory"
if ! mkdir "$FLUX_MANIFESTS"; then
    echo "ERROR: failed to create directory $FLUX_MANIFESTS"
    exit 1
fi

# call helm template with
#   release name: flux
#   git url: where flux monitors for manifests
#   git ssh secret: kubernetes secret object for flux to read/write access to manifests repo
echo "generating flux manifests with helm template"
#   ここにmanifestGenerationがないため、kustomizeでmanifestをgenerateする場合はoptionを追加する必要があります。
#   こんな感じ「--set manifestGeneration=true」
if ! helm template . --name "$RELEASE_NAME" --namespace "$KUBE_NAMESPACE" --values values.yaml --set image.repository="$FLUX_IMAGE_REPOSITORY" --set image.tag="$FLUX_IMAGE_TAG" --output-dir "./$FLUX_MANIFESTS" --set git.url="$GITOPS_SSH_URL" --set git.branch="$GITOPS_URL_BRANCH" --set git.secretName="$KUBE_SECRET_NAME" --set git.path="$GITOPS_PATH" --set git.pollInterval="$GITOPS_POLL_INTERVAL" --set git.label="$GITOPS_LABEL" --set registry.acr.enabled="$ACR_ENABLED" --set syncGarbageCollection.enabled="$GC_ENABLED"; then
    echo "ERROR: failed to helm template"
    exit 1
fi

# back to the root dir
cd ../../../../ || exit 1


echo "creating kubernetes namespace $KUBE_NAMESPACE if needed"
if ! kubectl describe namespace $KUBE_NAMESPACE > /dev/null 2>&1; then
    if ! kubectl create namespace $KUBE_NAMESPACE; then
        echo "ERROR: failed to create kubernetes namespace $KUBE_NAMESPACE"
        exit 1
    fi
fi

echo "creating kubernetes secret $KUBE_SECRET_NAME from key file path $GITOPS_SSH_KEY"

if kubectl get secret $KUBE_SECRET_NAME -n $KUBE_NAMESPACE > /dev/null 2>&1; then
    # kubectl doesn't provide a native way to patch a secret using --from-file.
    # The update path requires loading the secret, base64 encoding it, and then
    # making a call to the 'kubectl patch secret' command.
    if [ ! -f "$GITOPS_SSH_KEY" ]; then
        echo "ERROR: unable to load GITOPS_SSH_KEY: $GITOPS_SSH_KEY"
        exit 1
    fi

    secret=$(< "$GITOPS_SSH_KEY" base64 -w 0)
    if ! kubectl patch secret $KUBE_SECRET_NAME -n $KUBE_NAMESPACE -p="{\"data\":{\"identity\": \"$secret\"}}"; then
        echo "ERROR: failed to patch existing flux secret: $KUBE_SECRET_NAME "
        exit 1
    fi
else
    if ! kubectl create secret generic $KUBE_SECRET_NAME --from-file=identity="$GITOPS_SSH_KEY" -n $KUBE_NAMESPACE; then
        echo "ERROR: failed to create secret: $KUBE_SECRET_NAME"
        exit 1
    fi
fi

echo "Applying flux deployment"
if ! kubectl apply -f  "$REPO_DIR/$FLUX_CHART_DIR/$FLUX_MANIFESTS/flux/templates" -n $KUBE_NAMESPACE; then
    echo "ERROR: failed to apply flux deployment"
    exit 1
fi

aksのデプロイが完了するとfluxの同期によりapplicationがデプロイされます。

今回はaksチュートリアルにあるvoteアプリを使いました。

クイック スタート:Azure Kubernetes Service クラスターをデプロイする | Microsoft Docs

デプロイされたらexternal-ipを取得してアクセス出来ます。

$ kubectl get svc -o wide
NAME               TYPE           CLUSTER-IP     EXTERNAL-IP     PORT(S)        AGE   SELECTOR
azure-vote-back    ClusterIP      10.0.111.235   <none>          6379/TCP       10m   app=azure-vote-back
azure-vote-front   LoadBalancer   10.0.55.117    23.102.68.224   80:31559/TCP   10m   app=azure-vote-front
kubernetes         ClusterIP      10.0.0.1       <none>          443/TCP        22m   <none>

アクセスすると無事表示されました。 f:id:JunichiMitsunaga:20200215193713p:plain

デフォルトですと「default」namespaceにfluxのpodがdeployされるので別で管理したい場合はoptionを変更したほうが良いかもしれません。

$ kubectl get po --all-namespaces
NAMESPACE     NAME                                        READY   STATUS    RESTARTS   AGE
default       azure-vote-back-bc5b86f5f-dpskl             1/1     Running   0          14m
default       azure-vote-front-ccfd8667f-rt8rc            1/1     Running   0          14m
flux          flux-6455b87976-7thxx                       1/1     Running   0          15m
flux          flux-memcached-5ff94b674c-t9qcm             1/1     Running   0          15m
kube-system   azure-cni-networkmonitor-bjxvq              1/1     Running   0          22m
kube-system   azure-ip-masq-agent-pltrz                   1/1     Running   0          22m
kube-system   azure-npm-79bxx                             2/2     Running   0          17m
kube-system   coredns-544d979687-dzrxw                    1/1     Running   0          26m
kube-system   coredns-autoscaler-546d886ffc-s8ssf         1/1     Running   0          26m
kube-system   dashboard-metrics-scraper-867cf6588-9llkt   1/1     Running   0          26m
kube-system   kube-proxy-nvxvm                            1/1     Running   0          22m
kube-system   kubernetes-dashboard-7f7676f7b5-dnrcc       1/1     Running   0          26m
kube-system   metrics-server-75b8b88d6b-spllf             1/1     Running   1          26m
kube-system   tunnelfront-6dcfb8d9d9-vrtvr                1/1     Running   0          26m

気になったこと

FluxのDocker Image

検証していて気になったこととして、FluxのDocker imageのリポジトリです。 もともとfluxはFlux projectの「fluxcd」リポジトリからpullしていると想定していましたが 実際はweaveworksのリポジトリからでした。

リポジトリを比較するとfluxcdの方がversionは新しいようです。

weaveworks
weaveworks/flux:1.14.2

fluxcd
fluxcd/flux:1.17.1

この違いに関しては

weaveworksの「Flagger」というプロダクトがFluxを利用したcanary デプロイを実現する仕組みがあり対応しているFluxをweaveworks自身が管理しているのではないかと推測しています。

所感

今回はbedrockを使ってAzure上にGitOps対応のAKSクラスターを手動で作成しました。

これらの工程はAzure DevOpsのpipelinesのようなCIツールで自動化することが可能なので自動化できればエンジニアは 特に意識せずにテスト環境を構築できるようになります。

fluxを使ってclusterのgitOpsは実現できていますが、terraformのコードと環境が同期を取っているわけではないので、 実際に環境に関してもgitOpsを行いたい場合はFabrikateを導入してCI pipelineを構築することが必要になります。

検証してみた感想としてあまり抽象化しすぎると、管理や保守の面で結果的に属人化してしまう恐れもあるので テスト環境のような使い捨ての環境を作る場合は、今回のようなterraformスクリプトを自動化するpipelineのみでも十分だと感じました。

Feature Toggleについて整理してみました

はじめに

みなさんの現場はデリバリーチームとオンコールチームに分かれていますでしょうか?

分かれている現場ではリリースのタイミングは調整が出来ていますか?

我々のチームはデリバリーチームとオンコールチームに分かれているのですが、テスト環境などの関係上 リリースのタイミングの調整に時間がかかりがちで、そのたびにmaster branchのコンフリクトに悩まされてしまったり merge待ちなどが発生してデリバリーのリードタイムが伸びてしまうことがあります。

そこで今回は本番コードに潜在的にプロダクトのコードを埋め込んでも 影響が出ない仕組みが実現できる「Feature Toggles」について調べてみたので整理してみようと思います。 ※この記事ではFeature Togglesの具体的な実装については記載しません

Feature Togglesとは

Feature Togglesは別名:Feature Flagとも呼ばれており、チームがコードを変更することなくシステムの振る舞いを変更することができる仕組みであり、さまざまな用途が存在しているようです。 ちなみに私が初めてFeature Togglesを知ったのは Martin Fowlerのblog記事でした。 今回の記事もこちらで記載されている情報やFeature Togglesを実現するProductの内容を自分なりに噛み砕いて整理しています。 martinfowler.com

Togglesを実装したり、管理したりする場合、数が増えていくと複雑になる傾向があるので カテゴリ分けを考えることは重要であり、複雑性をコントロールをする事が推奨されています。

トグルのカテゴリ分け

調べてみるとFeature Togglesにはいくつかカテゴリがあり、自分達の課題にある内容も Feature Togglesのカテゴリの中に存在していました。

Release Toggles

継続的デリバリーを実践するチームのためにトランクベースの開発を可能にするために使用される機能フラグです。Release Togglesを使用すると 進行中の機能をmaster branchなどにmerge出来るようにし、いつでも本番環境に展開出来るようになります。

https://martinfowler.com/articles/feature-toggles/chart-1.png

Release Togglesは、一般的に1週間から2週間以上にわたっては存在しないことが推奨されています。 ただし、プロダクト中心のトグルについては長期間埋め込まれていることもあります。 Release Togglesを用いたトグル戦略は非常に静的で、トグルコンフィグレーションの変更して新しいリリースをトグルで展開することの決定は、大抵受入れ易いものになります。

Experiment Toggles

A/Bテストやカナリアテストを実現するために使用されます。 仮説検証のプロダクトや非機能要件の実装を一部のユーザーにのみ公開して動作を追跡することで、効果を比較できます。 この手法は、通常、データ駆動型の最適化に使用されます。 また、この手法を採用する場合はシステム監視の仕組みやユーザーインタビューのようなデータを収集する術をあらかじめ用意していることが望ましいです。

https://martinfowler.com/articles/feature-toggles/chart-2.png

Experiment Togglesは、統計的に有意なデータを収集するために十分な期間の同じ構成を維持する必要があります。 実験の内容にもよって、一般的に時間や週単位が推奨されています。また、システムに変更を加えてしまうと実験の結果が無効になるリスクが存在するので、これ以上長く期間を設定することは有用ではありません。

Ops Toggles

システムの動作の運用面を制御するために使用されます。 パフォーマンスへの影響が不明確な新機能をロールアウトする際にOps Togglesを導入して、Opsチームが必要に応じて本番環境でその機能を迅速に無効化または低下できるようにします。 この仕組みを導入することによってシステム障害時のMTTRが向上すると考えられます。 ※MTTRは障害から回復するまでの平均時間なので、トグルをoffにしたときに「回復した」と定義して良いか、少し迷っています…

Ops Togglesは短い期間でしか存在しておらず、新機能の運用面で安定したらフラグを廃止します。 個人的にはOps作業のボリュームからOps Togglesの上限を設けて、カンバン開発のWIP制御と連動するとDevOpsが良い感じになりそうな予感がします。

ちなみに、Ops Togglesのテクニックとして システムが異常に高い負荷に耐えている場合、Opsチームが重要でないシステム機能を正常値に低下させることができる例外的に長寿命の「Kill Switches」という概念が存在しているようです。 例えば、システムに高負荷がかかっている場合は、機能的には重要ではないが表示コストがかかる項目を一時的にoffするといったイメージです。 これらのOps Togglesを長寿命で管理して手動切り替えられるで管理される「Circuit Breaker」として利用することもあります。

https://martinfowler.com/articles/feature-toggles/chart-3.png

Permissioning Toggles

特定のユーザのみ機能を変えたり、プロダクトのエクスペリエンスを変えたりするのに使われます。 個人的には、Experiment Togglesとの違いはプロダクトとして完成しているかしていないかだと捉えました。 Experiment Togglesはプロダクトが正しいものなのかを実験するためのもの、Permissioning Togglesは正しいものと判断できた機能を 特定のユーザーに提供する仕組みだと思います。

https://martinfowler.com/articles/feature-toggles/chart-4.png

公開する機能を管理する方法として使う場合には、Permissioning Togglesは、ほかのカテゴリの機能トグルに比べてとても長期間生存するものになります。 切り替えは、いつもリクエスト毎になるので、このトグルは非常に動的なものである必要があります。

トグルの管理

トグルのカテゴリについて紹介しましたので、ここでは各トグルの管理方法について整理します。 トグルはカテゴリ毎に、静的/動的および長期間/短期間の2軸で考えることが出来ます。

静的トグルと動的トグル

https://martinfowler.com/articles/feature-toggles/chart-6.png

Martin Fowlerのblog記事では静的トグルは「Release Toggles」のみとなっています。 Release Togglesの用途は、本番環境に実装途中の機能を統合するための仕組みなので動的に切り替えられる必要がなく 設定ファイル等にon/offを設定すれば良さそうです。

動的トグルは、トグルの切り替えのためにリリースするのは非常にコストがかかる作業になるので 別の仕組みを提供する必要があります。 例を挙げると下記のような方法が考えられます。

  • システムの利用しているユーザーのIDを読み込んでon/offを切り替える切り替える
  • 別システムを利用してDBでトグル情報を管理してダッシュボードからOpsチームがon/offを切り替える
  • システムにアクセスする時のリクエストをオーバーライドして、リクエストパラメーターやHTTP headerに特定の値を埋め込んでon/offを切り替える…etc

短期間トグルと長期間トグル

https://martinfowler.com/articles/feature-toggles/chart-5.png

短期間トグルは、トグル判定を行うコード上にif/elseのようにハードコーディングしても良いと思います。 短期間のため、リリースサイクルの過程でif/elseの記述がなくなると予想されるからです。

function reticulateSplines(){
  if( featureIsEnabled("use-new-SR-algorithm") ){
    return enhancedSplineReticulation();
  }else{
    return oldFashionedSplineReticulation();
  }
}

一方で長期間トグルの場合はif/elseのコードがいつまでも残っているのは保守の観点からも リスクが発生します。blog記事でも保守可能な実装技術を採用する必要があるの述べられています。

実装テクニック

ここから先の記述ではFeature Toggles の切り替え分岐部分をトグルポイント、切り替え判定の値取得部分をトグルルーターと表現します。 トグルポイントはFeature Togglesの数とともにはコードベース全体に増殖する傾向があります。 トグルポイントの実装が増殖した場合は、保守が非常に煩雑になり本番障害へのリスクも増加します。 ここではトグルポイントを実装する際の実装パターンを紹介していきます。

決定点と決定ロジックの分離

Feature Togglesを実装する際の注意点はトグルポイントの処理と決定ロジックの統合をしないことです。 例として下記のコードを紹介を紹介します。

  
  const features = fetchFeatureTogglesFromSomewhere();

  function generateInvoiceEmail(){
    const baseEmail = buildEmailForInvoice(this.invoice);
    if( features.isEnabled("next-gen-ecomm") ){ 
      return addOrderCancellationContentToEmail(baseEmail);
    }else{
      return baseEmail;
    }
  }

if/else時の判定にnext-gen-ecommの値を取得して切り替えているので 一見、シンプルで可読性の高い実装のように見えますが非常に保守が大変になります。 まず、トグルルーターを呼び出すのが、他のメソッドでも必要にあった場合に すべてのメソッドに同様の処理を施す必要があります。 結果的に非常に冗長したコードになり開発時のコストを増加させる危険があります。

そこでトグルルーターを別オブジェクトとして実装します。

  function createFeatureDecisions(features){
    return {
      includeOrderCancellationInEmail(){
        return features.isEnabled("next-gen-ecomm");
      }
      // ... additional decision functions also live here ...
    };
  }

FeatureDecisionsオブジェクトを導入しているので、機能切り替えの決定ロジックの収集ポイントとして機能します。 これによってトグルルーターの保守が簡潔になります。

そして先ほどのトグルポイントの実装は下記のようになります。

  const features = fetchFeatureTogglesFromSomewhere();
  const featureDecisions = createFeatureDecisions(features);

  function generateInvoiceEmail(){
    const baseEmail = buildEmailForInvoice(this.invoice);
    if( featureDecisions.includeOrderCancellationInEmail() ){
      return addOrderCancellationContentToEmail(baseEmail);
    }else{
      return baseEmail;
    }
  }

切り替えの決定が抽象化されているので開発者はその中身をあまり気にせずに実装を続けることが可能になります。

条件の回避

上記で紹介したトグルポイントをif/elseで実装するケースは、短期間トグルには有効なテクニックですが 長期間トグルではアンチパターンとされています。 より保守可能な代替手段は、ある種の戦略パターンを使用してコードを実装することが推奨されています。

  function identityFn(x){ return x; }
  
  function createFeatureAwareFactoryBasedOn(featureDecisions){
    return {
      invoiceEmailler(){
        if( featureDecisions.includeOrderCancellationInEmail() ){
          return createInvoiceEmailler(addOrderCancellationContentToEmail);
        }else{
          return createInvoiceEmailler(identityFn);
        }
      },
  
      // ... other factory methods ...
    };
  }

上記の例ではfactoryパターンを採用した場合の実装になります。 この実装では「featureAwareFactory」というオブジェクトを実装しており その中で、メールの送信に必要なオブジェクトをトグルポイントで作り分けています。

このように実装することによってトグルポイントがコードベースに散りばめられることや コードの修正漏れに対するリスクの軽減になります。

所感

Feature Togglesの記事について読み込んで整理してみました。 結果としてもともとのissueであったチーム間のコンフリクトやリリースのリードタイムはFeature Togglesを導入することで解決できそうなことが分かりました。 特に私たちのチームでは「Release Toggles」と「Ops Toggles」が有効に機能しそうです。 リリース戦略を考えるうえで強力な手法なのでこれを気にしっかり導入してみようと思います。 また、将来的にはOutcome Deliveryのために「Experiment Toggles」を導入してより「正しいもの」を見つけるための仕組みとして強力な手法になりそうな予感です。

一方、課題として見えたことは 各カテゴリのトグルを単一管理するとOps作業が大変になり運用障害が起こりうること、運用していくうえでダッシュボードのように トグルの状態を可視化できる仕組みが求められる可能性が高いこと、Feature Togglesが単一障害点にならないように設計する必要があることなどなど やることはたくさんありそうな気がしました。

ダッシュボードや堅牢性を担保する場合は、自分たちで実装するのもありですが下記の製品を検証してみようと思います。 launchdarkly.com

簡単に説明するとFeature Togglesを管理してくれる製品です。 java, javascript, node, pytho..etcなどの対応言語が多いこととdatadogなどの監視システムとの親和性も高そうなので期待しています。

参考

今回の記事は基本的に下記のblogを参考にしました。 個人的には非常によくまとめられているので導入が進んでいくにつれて読み直すと気づきも多くなりそうです。 https://martinfowler.com/articles/feature-toggles.html