17
Help us understand the problem. What are the problem?

posted at

軽量feature flag導入の手引き

何か

Kyashでサーバサイドのエンジニアをしているhirobeです。

業務でサーバサイドのアプリケーションへのfeature flag導入を提案および設計をしました。
どのような設計や実装にするか、なぜそうしたかを整理します。

なお、アプリケーションの特性としては以下を念頭に入れていただければと思います。

  • 新規プロダクトではなく、5年以上動いている既存プロダクトに導入する
  • サーバサイドアプリケーションのみをスコープとする
  • 20~30のマイクロサービスから構成されている
  • マイクロサービス間はREST/gRPC/SQSで通信されている

そもそもなぜ導入するのか?

目的を整理しておきます!

featureブランチからmainブランチにmergeされるまでの期間が長いために、以下のような問題がありました。

  • 複数の機能開発の修正がconflictする可能性があり、またそれに気づくのが遅れる可能性がある
  • ^に関連して、複数の機能開発がお互いに及ぼす影響を把握しにくい
  • QA中のコードフリーズ期間は本番反映が基本的にできない
  • mainブランチに長期間mergeされないのは心理的にも気持ちの良いものではない

数ヶ月などの長い期間存在するfeature branchをリリース直前に一度にmainにmergeしていました。feature flagで新機能をOFFにすることで、短いサイクルでmainにmergeして本番環境にdeployしてしまう運用が可能になります。

このような運用をトランクベース開発と呼びます。シンプルな運用でスケールしやすく、GoogleやFacebookが実践している手法です。

また、そのような顕在化している問題に加えて、 LeanとDevOpsの科学GoogleのDevOps Reserarch and Assessment (DORA) で検証されているFour Keys(リードタイム・デプロイ頻度・変更失敗率・復元時間)を指標として追おう! 特にデプロイ頻度を最優先としよう!

という目標がTechチーム内で立てられたというのも導入のきっかけになっています。その目標に対して、現在のブランチ、デプロイの運用は、最も重視するデプロイ頻度の向上を阻害している大きな原因となっていました。

参考にした情報

参考にした情報です。

  • https://martinfowler.com/articles/feature-toggles.html
    • 圧倒的に有名
    • 実装の工夫等が体系的にまとまっている
    • とても良い!あえて難点を上げるならマイクロサービスを念頭に置いた記述はなかった
    • 以降本文に出てくる記事は全てこれを指す
  • https://codezine.jp/article/corner/869
    • 最近のサイバーエージェントさんの取り組み
    • どちらかというとfeature flagを管理するツールについて詳しい
    • 1つ目の記事はツールについての説明はないので、ツールを検討する人は目を通すとよさそう

feature flagで実現したい機能

feature flagをどこまで作り込むべきか。

ABテストや、オペレーションチームの運用によるフラグ、サーキットブレーカーに似たような機能を提供するフラグまで多種多様にあリますが、現状必要ではなく、それらはサポートしないことにしました。

以下の1は必須、2は可能な限り作るという方針で行くことにしました。

  1. リリースフラグ
    • デプロイするが機能はOFFにするためのもの
    • リリースするときにONにする
  2. 一部ユーザに有効な機能
    • 社員のuserIDをホワイトリストとして登録
    • リリース前に本番環境でテストできる

「一部ユーザに有効な機能」の他の実現方法として、「cookieやheaderに特定の値があればONにする」というアイデアが記事に書いてありました。この方法であれば、動作確認をしたい人はホワイトリスト等への登録依頼等をせずに、いつでも好きな時にONの状態を本番環境で確かめることができます。特にWEBなら親和性が高そうですが、悪意のある人に狙われるリスクがあることは考慮した方が良さそうです。

featureフラグを管理するマイクロサービスを作るか

作らない! 各マイクロサービスでフラグ情報を管理する。

マイクロサービスを作ったときのメリット/デメリットは以下で、バランスを鑑み、作らないことにしました。

  • メリット
    • マイクロサービスに情報が集約される
      • 必然的にマイクロサービス専用DBでフラグが管理されるだろう
    • 管理画面を作成することによって、QAチームやOPSメンバ等が主に開発環境で気軽にフラグを変更できる
      • とはいえ、現状そういう運用は必要なさそうではある
  • デメリット
    • 機能開発においての開発工数が増える
      • DB更新して、呼び出す側のマイクロサービスがフラグ情報を取得できるようにして、場合によっては管理画面も作成して、、
    • 障害の原因となるファクターが増える
      • それなりに多くのリクエストが届く可能性が想定されますが、捌ききれなかった場合、処理遅延や障害につながるでしょう
    • 厳密には、マイクロサービス間の通信でフラグ情報を渡す仕組みが必要になる(後述)

最後の「マイクロサービス間の通信でフラグ情報を渡す仕組みが必要」の部分だけ補足します。

featureフラグを管理するマイクロサービスにて、機能をOFF->ONに変更した瞬間を想定します。

マイクロサービス間の通信でA->Bという通信があり、A、Bそれぞれfeatureフラグを管理するマイクロサービスにフラグ情報を問い合わせるとします。この場合、「Aでは機能ONと判断したけど、BではOFFと判断していた」瞬間はどうしても起こり得て、その間のリクエストは失敗するかもしれないし成功するかもしれないし、整合性がおかしな挙動になるかもしれない。いずれにしろ、可能な限り避けた方が良いでしょう。

これを避けるためには、BFFなどの通信の入り口でのみfeatureフラグを管理するマイクロサービスにフラグ情報を問い合わせて、その後のマイクロサービス間での通信はその情報を常に渡してあげる必要があります。(もっと賢い方法をご存知の方は教えてください)

新規Product開発をゼロから設計するのであれば、最初からこのように実装してもいいかもしれないですが、既存Productの場合、マイクロサービス間の通信(REST/gRPC/SQSを採用している)の全てのinterfaceにflag情報を追加していく必要があり、(厳密にはheader等に入れることになりそうではあるものの)辛すぎます。

一方、各マイクロサービスで管理する場合、普段デプロイを行う順序と同様に、依存される側のサービスを依存する側のサービスより先にフラグ更新していけばいいだけです。

そもそもサーバサイドのマイクロサービスにおいて、feature flagが必要になる機会は少ないと思います。なぜなら多くの機能開発においては新規にAPIを作ることの方が多く、新規APIであればそもそもリリース後にのみ叩かれるのでfeature flagは不要だからです。もちろん、新規APIでも既存モジュールを内部で利用しており、そこで条件分岐が発生しうる場合もあることには注意しましょう。

いずれにしろ、各マイクロサービスでflagを管理することで管理コストが増えるといったことはないと予想しています。

設定ファイル or DB管理

設定ファイルでやる!

専用のマイクロサービスを作らず、外部のツール(optimizelyとか)も利用しない場合、主に、設定ファイル or DB管理でフラグ情報を管理することになります。

DBで管理する場合、各マイクロサービスのDBにfeature_flags等のテーブルを作成することになるが、設定ファイルとDBを比較したときにDBを採用したときのメリットが少なくとも私達の用途ではありませんでした。

であれば、実装が容易で障害可能性を増やすことがない設定ファイルの方がいいだろうとシンプルな方に倒してます。

参考までに記事ではetcd等の分散ストアも紹介されていました。場合によっては管理画面も提供されていたり、良さそうではあったがawsにいい感じのマネージドサービスはない気はします。

However nowadays there are a breed of special-purpose hierarchical key-value stores which are a better fit for managing application configuration - services like Zookeeper, etcd, or Consul. These services form a distributed cluster which provides a shared source of environmental configuration for all nodes attached to the cluster. Configuration can be modified dynamically whenever required, and all nodes in the cluster are automatically informed of the change - a very handy bonus feature.

Some of these systems (such as Consul) come with an admin UI which provides a basic way to manage Toggle Configuration.

チームに相談したら、AWSのParameter Storeも案には上がりました。設定ファイルで運用してみて問題があれば検討するかもしれません。

feature flagを消す運用を徹底させる

feature flagを消さないと不要なロジックが残り、負債が残ってしまいます。機能リリースをしたら確実にフラグを消す運用を徹底させたいところです。

記事では3つ紹介されており、1つめは元々やるつもりでしたが、2つ目と3つ目は興味深かったです。

Some teams have a rule of always adding a toggle removal task onto the team's backlog whenever a Release Toggle is first introduced. Other teams put "expiration dates" on their toggles. Some go as far as creating "time bombs" which will fail a test (or even refuse to start an application!) if a feature flag is still around after its expiration date. We can also apply a Lean approach to reducing inventory, placing a limit on the number of feature flags a system is allowed to have at any one time. Once that limit is reached if someone wants to add a new toggle they will first need to do the work to remove an existing flag.

まとめると、

  • プロジェクトのバックログに「feature flagを消す」を最初から入れておく
  • 各のフラグに期限も設け、その期限をすぎていたらテストがfailするようにする
  • フラグ数に上限を設け、過ぎていたら追加できないようにする

次章では、実際に上記の仕組みを実装してみます。

実装

今までの章の内容を考慮してGoでの実装イメージを作る!

大事な観点としては以下があると思います。

  • 実装が容易
  • フラグに依存する側がフラグに対して知っている情報が少ない
  • テストを書きやすい
    • テストコードを書く場合、ONとOFF両方をテストすべき
  • リリース後に処理を消しやすい
  • リリース後にフラグを消し忘れしにくい

feature_config.go

feature flagを管理するモジュールです。外部からはinterfaceのみに依存させます。
go generateでmockを生成してます。外部のテストはmockで簡単にテストできます。

feature_config.go
//go:generate mockgen -source=$GOFILE -destination=./mock_$GOFILE -package=$GOPACKAGE

package main

type FeatureConfig interface {
	// プロジェクト名 + ONでフラグ名とする
	ProjectAOn() bool
	ProjectBOn(userID uint64) bool
}

// 設定ファイルを元に環境変数が設定されているので、環境変数からフラグ設定を読み取る
type envConfig struct {
	ProjectAOn bool `required:"true" envconfig:"ProjectAOn" default:"false"`
	ProjectBOn bool `required:"true" envconfig:"ProjectBOn" default:"false"`
}

type featureConfig struct {
	envConfig envConfig
}

func (f featureConfig) ProjectAOn() bool {
	return f.envConfig.ProjectAOn
}

func (f featureConfig) ProjectBOn(userID uint64) bool {
	var whitelistUserIDs = []uint64{100, 111}
	for _, wuid := range whiteListUserIDs {
		if wuid == userID {
			return true
		}
	}
	return f.envConfig.ProjectBOn
}

func NewFeatureConfig(envConfig envConfig) FeatureConfig {
	return &featureConfig{envConfig: envConfig}
}

feature_config_test.go

既出の消し忘れを防ぐ仕組みを愚直に実装しただけです。実際の閾値は運用しながら調整していけばいいと思います。メンバに相談したところ、期限を緩めに設定した場合、フラグ関連の処理を消し忘れてテストが失敗したときにチームメンバが他プロジェクトで忙しくしている可能性がある。であればリリース直後で記憶が新しく、余裕もある状態で対応した方がいいのではという話になり、短めに設定してます。具体的には、

  • リリースしてから1週間以内にフラグ処理を消さないとテストが失敗する
  • 1マイクロサービス最大フラグ3つまで

としています。

feature_config_test.go
package main

import (
	"reflect"
	"testing"
	"time"
)

func TestFeatureFlagExistsTooLong(t *testing.T) {
	tests := []struct {
		name  string
		until time.Time
	}{
		{
			name:  "ProjectAOn",
			until: time.Date(2023, 6, 6, 23, 59, 59, 0, time.Local),
		},
		{
			name:  "ProjectBOn",
			until: time.Date(2022, 6, 20, 23, 59, 59, 0, time.Local),
		},
	}

	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			if !hasMethodName(tt.name) {
				t.Fatalf("flag %v no longer exists. you may forgot to delete the test code", tt.name)
			}
			if tt.until.Before(time.Now()) {
				t.Fatalf("flag %v should be deleted", tt.name)
			}
		})
	}
}

func TestFeatureFlagExistTooMany(t *testing.T) {
	t.Run("Count of FeatureFlag", func(t *testing.T) {
		it := reflect.TypeOf((*FeatureConfig)(nil)).Elem()
		if it.NumMethod() > 3 {
			t.Fatalf("too many feature flags")
		}
	})
}

func hasMethodName(name string) bool {
	it := reflect.TypeOf((*FeatureConfig)(nil)).Elem()
	for i := 0; i < it.NumMethod(); i++ {
		if it.Method(i).Name == name {
			return true
		}
	}
	return false
}

charge_impl.go

feature flagによって条件分岐が必要な処理の実装例です。
初期化において、feature flagへの依存の追加が発生し、リリース後には削除が発生します。実際にはDIツールで自動で管理されているので、コードへの影響は少ないはずです。

charge_impl.go
package main

// 本当はinterfaceは別ファイルに定義されている想定だが、便宜上同じファイルに定義
type Charge interface {
	Charge(userID uint64, amount uint64)
}

type chargeService struct {
	featureConfig FeatureConfig
}

func (s *chargeService) Charge(userID uint64, amount uint64) {
	if !s.featureConfig.ProjectBOn(userID) {
		// 既存処理はここ
	} else {
		//
	}

	// charge実行
}

// DIツールで依存注入
// テストはfeatureConfigのmockを使えばいい
func NewChargeService(featureConfig FeatureConfig) Charge {
	return &chargeService{featureConfig: featureConfig}
}

ほとんどの場合、上記のような単純なif文の分岐で問題ないはずですが、複数のfeature flagが同一モジュールに影響を及ぼし、なおかつお互いの挙動に副作用がある場合は慎重に対応を考える必要があります。
記事には、strategyパターンを採用する案が載ってましたが、たかだか2ヶ月ほどの寿命のコードであることからコピペして異なる関数にしてしまうという割りきった方法でも個人的にはありだと思います。

何より、そのようなことが発生しないようにプロジェクトを計画すべきですね。

まとめ

feature flagは多様な使われ方がされていますが、リリースを管理したいだけであればかなり簡単に導入できるのではと思います。実際、feature flagの導入の提案をしてから1ヶ月以内に機能開発に実際に取り込まれています。現在は運用に慣れていないこともあり、feature branchで開発し、毎週決まった曜日にfeature branchからmainへのPRを作り、デプロイするという安全側に倒した運用にしています。ゆくゆくはもう少しデプロイ頻度を上げるべくチームに馴染む運用を模索していけたらなと思います。

feature flagを導入したことによって、障害発生頻度が増えたり、エンジニアの負担が増えたら本末転倒なのでその辺りは気をつけていきたいです。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
17
Help us understand the problem. What are the problem?