こんにちは、FANTECH本部の前田(@arabian9ts)です。
サイバーエージェントでは、「変化対応力」を武器に「開発力倍増」に取り組むため、開発生産性の向上を目指しており、FANTECH本部でも多くの技術的な仕組み作りをしています。
今回は、海外展開を行っているプロダクトでのFeature Flagsの課題と、内製システムについて事例をご紹介します。
この1年間で、Feature Flagsも業界内で大きく盛り上がってきた印象があり、ソリューションも多く存在します。
そんな中、内製したシステムを本番環境で運用し始めて、ちょうど1年が経過したので、実際に運用してみての感想も綴りたいと思います。
業界でのFeature Flagsの動向
サイバーエージェントではBucketeerというFeature Flags、A/BテストツールをOSSで開発しているチームがあります。
他にも、CNCFのプロジェクトでもOpenFeatureがインキュベーションプロジェクトに入りましたし、DevCycleやUnleashといったソリューションも出てきました。
Firebase Remote Configも、リアルタイム反映をサポートしたことで、ネイティブアプリでのFeature Flagsの選択肢としてかなり有力になりました。
このように、非常に選択肢が増えてきており、それらのインターフェースの標準化も進んでいる状況です。
Feature Flagsを利用する上で障壁だったこと
私のチームでは、もともとBucketeerを利用していました。
社内でホスティングされており、開発チームも社内にあることから、機能リクエストも相談しやすい環境だったためです。
しかし、当時Bucketeerやその他ソリューションを含め、マルチリージョンでのレプリケーションに対応したFeature Flagsは台頭していなかったと記憶しています。
私のチームのプロダクトは海外展開をしていたため、ファーストビューやコア体験に関わる処理にはFeature Flagsを適用することをためらっていました。
これが、当時内製を考えた理由の1つです。
メトリクス上では、アメリカのユーザーに対して600msの待ち時間を与えてしまう状況でした。
(今では、DevCycleがEdge Flagsを提供しているため、マルチリージョンでの導入ハードルは下がったように思います。)
また、「FanTechで日本の閉塞感を打破する」サーバーサイドエンジニアの開発思想で記載した通り、私のチームは複数の事業・プロダクトを共通のコード資産で同時開発・運用しています。
プロダクトによって機能のON/OFFを切り替えたり、一部のプロダクト固有のロジックをコードベース外で管理したいケースがありました。
つまり、単純な機能のON/OFFに限らず、柔軟に条件を記述できることが求められていたことも、既存のソリューションを選択しがたい理由の1つでした。
汎用データ配信サーバー(wings)
以上の理由から、私のチームではFeature Flagsシステムを内製したのですが、汎用データ配信サーバーを設計思想として開発しました。
柔軟にデータを配信できる仕組みを作ることで、そのユースケースの1つであるFeature Flagsを実現しています(実際に、Feature Flags以外の利用事例があります)。
汎用データ配信サーバーを設計するにあたり、以下の課題解決を考えなければなりません。
1. マルチリージョンでのパフォーマンスが担保されること
2. 展開リージョン数がコストの係数にならないこと
3. 柔軟な条件判定が可能であること
以降、このシステムはwingsと呼びます。
マルチリージョンでのパフォーマンス
真っ先に思いつくのは、マルチリージョンでレプリケーションが可能なDBを利用することだと思います。
しかし、今回設計するのは読み取りヘビーなシステムであり、書き込みの整合性を犠牲にすることで、選択肢は広がります。
当時は、SQLiteをオブジェクトストレージにバックアップしてくれるLitestreamが話題になっていました。
バックアップの機能があればもちろんリストア機能もあるため、このリストア機能を利用し、クラウド上のインスタンス内にバックアップを復元できれば、中央集権的なDBを持たなくても良いことになります(バックアップの更新を検知する手段は別途必要)。
今回は、このリストア機能をGoのコードから呼び出すことで、インスタンス内部にSQLiteをリストアして利用することとしました。
これにより、DBへのアクセスがローカルホスト内で完結するようになり、非常に応答速度の早いシステムに仕上がりました。
また、中央集権的なDBやレプリケーションを行わないことで、展開するリージョンを増やしてもコストが上がりにくい仕組みになりました。
アーキテクチャはCloud Run上に構築されたPrimaryと、各リージョンのReplicaによって構成されます。
なお、Litestreamの作者が開発している、LiteFSがこのようなレプリケーションを想定して開発されています。
Litestreamでも、Live Read Replicationという機能のベータ実装が行われましたが、こちらは正式にリリースされず、LiteFSへと引き継がれました。
wingsでは、ほとんど書き込みが発生せず、読み取りヘビーな特性から、LitestreamでリストアされたSQLiteをローカルホスト内で利用できれば良かったため、LiteFSの採用までは進めていません。
柔軟な条件判定が可能であること
Feature Flagsのソリューションの多くは、事前定義された複数のバリアントと、どのバリアントを返却するかを設定します。ユーザーIDやその他条件によって、動的に返却するバリアントを選択するものもあります。
一方、wingsでは返却するバリアントの条件判定をGoogle CELで記述できるように設計しました。
例えば、本番環境かつユーザーIDが 'ABC'
の場合は、以下の式で表現できます。
env == 'prod' && userId == 'ABC'
リクエスト時に、CEL式で使用するシンボルに合わせてデータを送信することで、wings側で式を評価することができます。
例えば、上記の式を評価するために、次のようなJSONをリクエストで送信することになります。
{
"env": "prod",
"userId": "XYZ"
}
userId
が 'ABC'
ではないことから、先程の式の評価結果は false
となります。
評価結果が真偽値となるCEL式を利用し、バリアントの識別子と条件判定のマッピングを Targeting
として表現します。
message Targeting {
message Rule {
string variant = 1; // バリエーション識別子
string expr = 2; // CEL式
}
repeated Rule rules = 1; // 複数のターゲティングルール
}
複数のターゲティングルールを順序集合として表現し、ファーストマッチで返すバリアントを決定することにします。
対応するデータの定義は、マッチするルールが存在しない場合のデフォルトバリアント識別子と、複数のバリアント定義を保持します。
message Value {
string id = 1;
string default_variant = 2;
map<string, Evaluation> variants = 3;
Targeting targeting = 4;
}
message Evaluation {
oneof value {
BooleanValue boolean_value = 1;
StringValue string_value = 2;
JSONValue json_value = 3;
IntegerValue integer_value = 4;
}
}
message BooleanValue {
bool value = 1;
}
message StringValue {
string value = 1;
}
// ...
リクエストを処理する際は、以下のようなGoコードで、どのバリアントの値を返却するか判定します。
func (v *Value) getVariant(input map[string]any) (variantKey string, err error) {
var match bool
for _, rule := range v.Targeting.Rules {
match, err = rule.evaluate(input)
if err != nil {
return variantKey, err
}
if match {
variantKey = rule.Variant
break
}
}
if !match {
variantKey = v.DefaultVariant
}
return variantKey, nil
}
Terraform Providerの実装
TerraformはIaCソリューションの1つですが、多くはインフラ管理に使用されていると思います。
一方、Terraform Providerは平たく言うと外部APIのラッパーであり、APIコールをIaCするためのツールとしても捉えることができます。
また、Terraformはプラガブルな設計になっており、Terraform Providerは意外とあっさり実装することができます(※)。
(実装方法については、また別のブログで紹介できればと思うので、今回は触れません。)
具体的には、以下のようなリソース定義をすることで、Terraformを通してFeature Flagsを作成できます。
ある機能を、開発環境では有効にし、本番環境では特定のユーザー(デバッガーなど)にのみ有効にする例です。
resource "wings_value" "enable-test-feature" {
value_id = "enable-test-feature"
default_variant = "off"
boolean_value {
variant = "on"
value = true
}
boolean_value {
variant = "off"
value = false
}
targeting {
variant = "on"
expr = "env == 'prod' && userId == 'ABC'"
}
targeting {
variant = "on"
expr = "env == 'dev'"
}
test {
variables = jsonencode({
"userId" : "ABC",
"env" : "prod",
})
expected = "on"
}
test {
variables = jsonencode({
"userId" : "XYZ",
"env" : "prod",
})
expected = "off"
}
test {
variables = jsonencode({
"userId" : "XYZ",
"env" : "dev",
})
expected = "on"
}
}
実際の運用では、CEL式を誤って記述するリスクがあるため、 test
を記述できるようになっています。
Terraform Apply時にすべての test
を評価し、PASSした場合にのみApplyが成功します。
※ PipeCD Terraform Providerの開発とサイバーエージェントのインナーソース事例でも、Terraform Providerの実装事例を紹介しています。また、提供しているサービスによっては、Terraformのライセンスに注意する必要があります。
活用事例
今回構築したwingsは、純粋にFeature Flagsとしても利用していますが、柔軟な条件式を活かして、トリッキーな利用をしています。
いくつか面白い利用方法を紹介しようと思います。
配信エンコーダーの事前起動台数の決定
ライブ配信をする場合に、エンコーダーの起動には時間がかかります。
それまで配信を待つのは配信者にとってストレスになったり、配信に対するハードルが上がったりする課題があります。
そのため、配信が多い時間帯になると、配信エンコーダーを事前に何台か起動する処理が存在します。
この処理は、1時間おきにエンコーダーの使用状況を確認し、すぐに配信できる状態を目指してエンコーダーを事前にプールします。
何台のエンコーダーを起動しておけばいいのか、何時から何時までの間に配信が多いのか、これらの情報はプロダクトや配信者によって変わってきます。
そのため、これらの情報は外部から注入できる状態が好ましく、wingsを利用して台数を決定しています。
resource "wings_value" "encoder-pool-size" {
value_id = "encoder-pool-size"
default_variant = "zero"
integer_value {
variant = "zero"
value = 0
}
integer_value {
variant = "one"
value = 1
}
integer_value {
variant = "two"
value = 2
}
targeting {
variant = "one" # 1台プールしておく
expr = "env == 'prod' && product == 'ABC' && nowJST().getHours() >= 19 && nowJST().getHours() < 23" # プロダクトABCでは、19~23時に配信が多い(仮)
}
targeting {
variant = "two" # 2台プールしておく
expr = "env == 'prod' && product == 'XYZ' && nowJST().getHours() >= 17 && nowJST().getHours() < 21" # プロダクトXYZでは、17~21時に配信が多い(仮)
}
}
nowJST()
は、JSTで現在時刻のTimestampを返すマクロです。
CELの標準実装にはないため、以下のようにマクロを定義して組み込んでいます。
func nowJST() cel.EnvOption {
return cel.Function("nowJST",
cel.Overload("nowJST",
[]*cel.Type{},
cel.TimestampType,
cel.FunctionBinding(func(v ...ref.Val) ref.Val {
t := time.Now().UTC().In(time.FixedZone("JST", 9*60*60))
return &types.Timestamp{Time: t}
}),
),
)
}
このように、 nowJST()
を利用することで、時限式で評価結果が変化する式を書けるため、時限式での機能リリース・クローズが可能となります。
他にも、必要になった機能はマクロ実装として組み込めるため、プラガブルな実装になっています。
マルチリージョンでのJSON変形関数
ここまでに記載していない内容で、wingsはTransformという機能を備えています。
これは、事前定義したバリアントに対して、レスポンスの変形をCEL式で書くことができる機能です。
resource "wings_value" "transform-timestamp-json" {
value_id = "transform-timestamp-json"
default_variant = "default"
json_value {
variant = "default"
value = jsonencode({})
transform {
expr = "{\"timestamp\": input.timestamp, \"unixtime\": input.timestamp != '' ? unix(timestamp(input.timestamp)) : 0}"
}
}
}
この例では、入力として受け取ったタイムスタンプの文字列表現を、UnixTimeに変換して返却します。
入力JSON
{
"input": {
"timestamp": "2024-03-29T10:00:00Z"
}
}
出力JSON
{
"timestamp": "2024-03-29T10:00:00Z",
"unixtime": 1711706400
}
Feature Flagsってなんだっけ?となる使い方ではありますが、先述の通り、外部に一部のロジックを括り出したかった課題感から、こういった幅広い利用方法をサポートしています。
パフォーマンス
実際に海外展開しているサービスのある時点でのRTTです(表示リージョンはリクエスト元)。
リクエスト元とwingsが同一リージョン内であれば、50ms以内で安定してレスポンスを得ることができています。
なお、wingsはCloud Runで動作しており、1vCPU、128MiBメモリで300~350RPSを処理可能です。
運用面での工夫ポイント
IaC・CI/CD管理
Terraformでのコード管理については先述しましたが、複数のプロダクトを同時開発している私のチームでは、TerraformのApplyにもかなりの労力がかかります。
そのため、wingsのTerraform Applyは、PipeCDを用いたGitOpsとなっています。
wingsのTerraformコードは、普段機能開発をしているリポジトリ内に含まれており、Pull Requestを出すとPlan結果がコメントされ、マージするとApplyされます。
クライアントコードの自動生成
Feature Flagsを運用していると、コード上に記載したフラグIDを間違えていないか、不安になるときがあります。
Feature Flagsを管理しているシステムからIDをコピペしようとして、誤った値を設定してしまうなど、管理上の潜在的な課題があります。
そこで、wingsはFeature FlagsをIaC管理していることを活かし、Terraformのリソース定義を解析してクライアントコードを自動生成する仕組みを導入しました。
以下は、生成されたコードの例です。
type Client interface {
EncoderPoolSize(ctx context.Context, input map[string]any, opts ...Option) (int64, error)
EnableTestFeature(ctx context.Context, input map[string]any, opts ...Option) (bool, error)
TransformTimestampJson(ctx context.Context, input map[string]any, opts ...Option) (json.RawMessage, error)
}
func (c *client) EncoderPoolSize(ctx context.Context, input map[string]any, opts ...Option) (v int64, err error) {
m, err := structpb.NewStruct(meta)
if err != nil {
return
}
o := newOption(opts...)
ctx, cancel := context.WithTimeout(ctx, o.timeout)
defer cancel()
gOpts := []grpc.CallOption{
grpc_retry.WithCodes(codes.Unavailable),
grpc_retry.WithMax(3),
grpc_retry.WithBackoff(grpc_retry.BackoffLinear(time.Millisecond * 100)),
grpc_retry.WithPerRetryTimeout(time.Second),
}
req := &evaluator.ResolveRequest{Id: "encoder-pool-size", Metadata: m}
resp, err := c.eval.IntegerValue(ctx, req, gOpts...)
if err != nil {
err = fmt.Errorf("flags error encoder-pool-size: %w", err)
return
}
v = resp.GetEvaluation().GetIntegerValue().GetValue()
c.logger.Debug("flags client calls encoder-pool-size", zap.Int64("value", v))
return
}
// ...
自動生成のモチベーションは、クライアントコードを書く労力の削減ではなく、IDの取り回しが自動化されることでコピペミスを防止できることにあります。
単純にDRYにするだけなら、型パラメータなどで共通化された処理を書くことができると思います。
また、クライアントコードの自動生成と、TerraformでのIaC管理は相性が良いです。
IDEでクライアントの呼び出し元を調べれば、使用しなくなったFeature Flagsを探し出すことができます。
Terraformリソース定義を削除しないと、クライアントコードが生成されてしまうので、Feature Flagsの削除忘れにもつながります。
逆に、誤って使用されているFeature FlagsのTerraformリソース定義を消してしまった場合は、クライアントコードが生成されなくなるので、その時点でビルドが失敗するようになり、ミスに気づけます。
まとめ
私のチームで内製した汎用データ配信サーバーである、wingsをご紹介しました。
マルチリージョンでのパフォーマンスと、条件判定の柔軟性が当初の課題でしたが、それらを解決するシステムを構築し、大きな事故なく本番運用開始から1年が経過しました。
時限式での動的なレスポンスの変更、JSONの変形機能など、Feature Flagsのスコープからはみ出した利用もしていますが、過度な利用は認知負荷や複雑性の増加につながるので、便利なものとして適度に利用していこうと思います。
他にも、Feature Flagsの管理方法など、もし参考になる箇所があれば幸いです。