はじめに
こんにちは!AI事業本部 アプリ運用カンパニーでバックエンドエンジニアをしている26卒内定者の荻原瑛史です。
本記事では、CyberAgent内定者バイト期間中にトランクベース開発(Trunk‑Based Development)をバックエンドチームへ導入し、それを支えるFeature Flag管理ツールをOpenFeature仕様に準拠して内製した取り組みを紹介します。
トランクベース開発とは
トランクベース開発は各開発者が作業を小さな単位に分割し、少なくとも1日に1回メインブランチへマージを行うことで継続的インテグレーションを実現します。長期間存続するブランチを排除し、マージコンフリクトとレビューコストを大幅に削減できる点が最大のメリットです。
しかしながら未完成の機能がそのままmainブランチにマージされると本番環境に反映されてしまうため、Feature Flagで機能の切り替えを行います。
Feature Flag とは
Feature Flag は機能のオン/オフを切り替える仕組みで、コード内にフラグを埋め込むことで新機能を本番環境にデプロイしつつ「オフ」にしておき、準備が整った段階でスイッチを切り替えて公開することができます。 たとえば以下のコードではfeatureFlag
がTrueのときにif文内が実行され、Falseのときにelseの中が実行されます。
if featureFlag {
// featureFlagがTrueのときここが実行される
} else {
// featureFlagがFalseのときここが実行される
}
また、これらの仕組みはA/Bテストや有料会員向け機能の限定公開など、さまざまなユースケースにも応用可能です。
内製Feature Flagを決めた背景
当該プロダクトではフロントエンドで既にFeature Flagを運用していましたが、バックエンド側では未導入でした。トランクベース開発を採用するには「未完成機能が本番に反映されない仕組み」が必須である一方、既存のFeature Flag as a Service(FFaaS)はコストや可用性リスクが高く、かつ現時点ではシンプルなオン/オフ制御のみが求められていました。
そのため今回は環境変数でフラグの値を解決するようなツールを内製することに決めました。
OpenFeature対応のツールに
OpenFeatureはCNCF(Cloud Native Computing Foundation)のインキュベーティングプロジェクトとして開発された、フィーチャーフラグ用のオープン仕様APIです。ベンダーに依存しない統一インターフェースを提供するため、LaunchDarklyやDevCycleなどフラグ管理ツール間で切り替えが容易になります。一度実装すればコードの修正を最小限に留めながら、将来的なFFaaS導入も視野に入れることができ、ベンダーロックインのリスク低減に寄与します。
実装
OpenFeatureに対応するツールを作るには以下のinterfaceを実装する必要があります。(参考)
type FeatureProvider interface {
Metadata() Metadata
BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx FlattenedContext) BoolResolutionDetail
StringEvaluation(ctx context.Context, flag string, defaultValue string, evalCtx FlattenedContext) StringResolutionDetail
FloatEvaluation(ctx context.Context, flag string, defaultValue float64, evalCtx FlattenedContext) FloatResolutionDetail
IntEvaluation(ctx context.Context, flag string, defaultValue int64, evalCtx FlattenedContext) IntResolutionDetail
ObjectEvaluation(ctx context.Context, flag string, defaultValue interface{}, evalCtx FlattenedContext) InterfaceResolutionDetail
Hooks() []Hook
}
たとえばBooleanEvaluation
はフラグの入力に対してBool値でフラグ値を返すメソッドの実装は以下のように行いました。今回はcontextによる解決は一旦無視しているのでevalCtxは使用していません。
func (f *FeatureFlagProvider) BooleanEvaluation(ctx context.Context, flag string, defaultValue bool, evalCtx of.FlattenedContext) of.BoolResolutionDetail {
var (
flagValue = os.Getenv(flag)
isEnabled = flagValue == enable
value = defaultValue
variant = defaultVariant
reason = of.DefaultReason
)
if isEnabled {
value = true
variant = matchVariant
reason = of.TargetingMatchReason
}
return of.BoolResolutionDetail{
Value: value,
ProviderResolutionDetail: of.ProviderResolutionDetail{
Reason: reason,
Variant: variant,
},
}
}
同様にして全てのinterfaceを実装し、OpenFeatureに対応したツールを実装しました。
Feature Flagの利用
実際に内製したツールを実際に使うためには以下の3つの変更が必要です。
- 環境変数に値をセットする 例)
ENABLE_FEATURE_1= True
- Feature Flagを定義する 例)
const EnableFeature1 FeatureFlag = True
- 実際に使用する
例)func (c *Controller) Feature1(ctx context.Context, req hoge.Request) (hoge.Response error) { if c.openftrClient.BooleanValue(ctx, openftr.EnableFeature1) { res, err := c.Usecase.Feature1(ctx, req) err != nil { return nil, err } return res, nil } return nil, nil }
運用上における課題と対策
Feature Flagは一般的に「肥大化」や「消し忘れ」が発生しやすい傾向にあります。また、FFaaSを導入していないため、リッチなUIでフラグの状態を確認できず、エンジニア以外のメンバーが現状を把握しにくいという独自の課題がありました。そこで、フラグを追加する際に以下の情報をコメントとして付与するルールを導入しました。
- where: どのサービスで利用するか (ex. api-gateway)
- status:
- Active: 本番環境または開発環境で全面的に有効化されているフラグ
- Development: 開発環境でテスト中のフラグ
- Disabled: 無効化されているが、再利用可能・想定されている状態のフラグ
- note: そのフラグについての補足情報
- expire(optional): そのフラグが無効化される予定の日時
コード上での例
const (
TestFrag1 FeatureFlag = "TEST_FEATURE_FLAG1"
// where:api-gateway
// status:Active
// expire:2024-12-15
// note:テスト1用のフラグ
// ----------------------------------------
TestFrag2 FeatureFlag = "TEST_FEATURE_FLAG2"
// where:api-gateway
// status:Development
// expire:2025-03-01
// note:テスト2用のフラグ
// ----------------------------------------
TestFrag3 FeatureFlag = "TEST_FEATURE_FLAG1"
// where:api-gateway
// status:Disabled
// expire:2025-04-01
// note:テスト3用のフラグ
// ----------------------------------------
...
)
また、前田 拓さんが以前に取り上げていた「reminder-lintでFeature Flagsの削除漏れを防ぐ」の記事を参考に管理の方法を考え、Flagの状態を週に一度Slackに通知するようなツールを実装しました。
Flag Reporterの実装
Reportツールの目的は不要になったフラグが残っていないか検知をすることです。
私のチームでは月曜日にスプリントプランニングを行うため、その直前にSlackにレポートを通知し、使われていないフラグや放置されているフラグについて議論する場所を設けようと考えています。
レポートではコメントを元にFlagのステータスごとの数と、Flagのexpireが切れているものを理由と共に通知するようにしました。以下は先程例で挙げたFeature Flagに対して実行した結果です。TestFlag1、TestFlag2はexpireが切れているため、それぞれ理由と共に表示されています。(実行日時は2025/03/25)
以下は実装の一部分です。ここではコードを解析し、必要な情報を取り出しています。
type FlagReport struct {
Flag string `json:"flag"`
Reason []string `json:"reason"`
}
func ParseFile(filename string) (map[string]int, []FlagReport, error) {
statusCounts := map[string]int{
"Active": 0,
"Development": 0,
"Disabled": 0,
}
reportsMap := make(map[string]reporter.FlagReport)
fset := token.NewFileSet()
src, err := os.ReadFile(filename)
if err != nil {
return nil, nil, err
}
lines := strings.Split(string(src), "\n")
node, err := parser.ParseFile(fset, filename, src, parser.ParseComments)
if err != nil {
return nil, nil, err
}
// 宣言と最も近いコメントを自動的に判定
cmap := ast.NewCommentMap(fset, node, node.Comments)
for _, decl := range node.Decls {
// GenDecl(宣言)のみを対象とする
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.CONST{
continue
}
// 1つのconstブロックから各定数を取り出す
for _, spec := range genDecl.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || len(vs.Names) == 0 {
continue
}
flagName := vs.Names[0].Name
commentText := ""
if groups, ok := cmap[vs]; ok {
for _, group := range groups {
// コメントが定数の後にある場合はコメントを取得
if group.Pos() >= vs.End() {
commentText += group.Text() + "\n"
}
}
}
// constの後に続くコメントを取得
if commentText == "" && vs.Comment != nil {
commentText = vs.Comment.Text()
}
// コメントがない場合は次の行のコメントを取得
if commentText == "" {
pos := fset.Position(vs.End())
commentText = reporter.GetTrailingComment(lines, pos.Line+1)
}
// statusとexpireを正規表現で抽出
status, expire := reporter.ParseComment(commentText)
if status != "" {
statusCounts[status]++
}
var reasons []string
if expire != "" {
if t, err := time.Parse("2006-01-02", expire); err == nil && time.Now().After(t) {
duration := time.Since(t)
daysAgo := int(duration.Hours() / 24)
reasons = append(reasons, fmt.Sprintf("Expired %d days ago", daysAgo))
}
}
if len(reasons) > 0 {
reportsMap[flagName] = reporter.FlagReport{Flag: flagName, Reason: reasons}
}
}
}
var reports []reporter.FlagReport
for _, r := range reportsMap {
reports = append(reports, r)
}
return statusCounts, reports, nil
}
まとめ
この記事では、OpenFeature準拠の内製Feature Flag管理ツールをGoバックエンドに組み込み、環境変数ベースのフラグ評価と週次Flag ReporterによるSlack通知を実装したことについて紹介しました。運用開始直後のためこれから多くの課題に直面すると思いますが、その都度得られる学びを活かし、少しずつ改善を重ねていきたいと思います。