AppConfig は実験基盤ではない — フィーチャーフラグ移行で調査・設計・実装がそれぞれ書き換えたもの
A/B テスト基盤の終了に伴い AWS AppConfig へ移行した記録。比較表では「主要機能をすべて満たす」だったが、AppConfig は設定配信サービスでユーザー単位の評価を持たない。split が範囲分割ではないと分かるまでの、調査 → 設計 → 実装で前提が入れ替わっていく過程を残す。
使っていた A/B テスト基盤(CloudWatch Evidently)がサービス終了した。Electron 製のデスクトップアプリで、A/B テストと段階的リリースをこの上で回していたので、代替は必須だった。
調査資料の結論は明快だった。AWS AppConfig が最も適している。既存の一時クレデンシャルを流用できる。IAM / STS / CloudWatch との親和性が高い。専業 SaaS より桁違いに安い。Evidently でやっていた主要機能も満たす。
しかし実装は「SDK を差し替える」では終わらなかった。調査で選んだ道具が、設計と実装で別物に見えていった理由を整理する。核心はここだ。
AppConfig は実験基盤ではない。設定配信サービスだ。ユーザー単位の評価は、サービス本体ではなく Agent にオフロードされている。
比較表では見えないこの境界が、設計と実装の本体だった。
この移行が解いていた問題
Evidently でやっていたことは、次の 4 つに落ちる。
| 要件 | 意味 |
|---|---|
| フラグによる A/B | 単一機能の ON/OFF、UI の 2 パターン比較 |
| 複数バリアント | 設定値の違う構成を並べ、どれが効くかを見る |
| 段階的開放 | 一部ユーザーにだけ出し、広げる |
| ID による固定化 | 同じユーザーは、何度来ても同じバリアントを受け取る |
デスクトップアプリで、これを止めたくない。調査の最低ラインは「上記が残ること」だった。
比較対象は 3 つに絞った。
| 候補 | 選んだ理由 |
|---|---|
| AWS AppConfig | AWS ネイティブ。既存 IAM / STS / CloudWatch と親和する。Evidently に近い |
| LaunchDarkly | フラグと実験に特化した SaaS。統計判定と SDK が揃っている |
| Optimizely | 多変量テストとパーソナライズまで視野に入る |
年間コストは、数十万 MAU 規模の想定で 2 桁違った。AppConfig が圧倒的に安い。機能の厚みは専業 SaaS 側にある。統計的有意性の自動判定、GUI でのターゲティング、SDK 側の sticky assignment。AppConfig はそこを持たない。分析は GA や CloudWatch に寄せる。ID 固定もカスタム実装が要る、と調査時点でも書いてあった。
それでも AppConfig を選んだ決め手は、機能の豊富さではない。既存運用からの差分が最小であることだった。一時クレデンシャルの仕組みを流用できる。新しい SaaS を契約し、ユーザー ID を社外へ出す審査を通す必要がない。技術的な理想より、組織制約の下で最短に価値を出す方を採った。
調査資料はここで「主要機能はすべて満たす」と結んだ。設計は、その一文の中身を分解するところから始まった。
調査が隠していた境界: AppConfig は誰を評価しないか
AppConfig 自体は、設定データを配るサービスだ。フラグ定義は持つ。しかし ユーザー ID を受け取って「この人は A」と決める責任は、サービス本体にはない。
AWS は評価をローカルに寄せている。
- Lambda なら AppConfig Agent の Lambda 拡張
- ECS / EKS ならタスク内のサイドカー(
localhost:2772)
Agent なしで AppConfig だけ叩くと、Context を送れない。ランダムな振り分けしかできない。Evidently で当たり前だった「このユーザー ID には常に variant A」は、ここに来て初めて設計対象になる。
構成はインフラ側の提案で、ECS サイドカーに寄った。API サーバーのタスクに Agent コンテナを足す。アプリは localhost に HTTP する。Electron から直接 AppConfig を叩かない。
[Electron アプリ]
| POST /feature-flag { featureFlagName }
V
[API サーバー]
| userId / currentDate を足す
V
[フラグ評価ライブラリ]
| bucket を計算し Context を組む
V
[AppConfig Agent サイドカー]
| localhost:2772 で評価
V
[AWS AppConfig]
調査の「構成変更が少ない」は、IAM と課金の話としては正しい。実行経路としては、新しいプロセスと新しい API が一本増える。見積は初期実装で 1〜2 か月規模だった。差分の小ささは、比較表の印象より実装の方が正直だ。
ここで、調査資料の「段階的開放」も読み替える必要が出た。
AppConfig のデプロイ戦略は、設定を Agent へ何 % ずつ配るか の話だ。線形、指数、ベイク時間、CloudWatch アラームでの自動ロールバック。これは設定配信の安全装置であって、ユーザー 10% に新 UI を出す話ではない。
ユーザー単位の段階開放は、マルチバリアントフラグのルール側にある。調査資料がデプロイ戦略を Evidently の段階開放と同列に置いていたのは、この取り違えだった。
教訓: 「◯◯ % ずつ」という同じ言葉が、配信先の話なのかユーザーの話なのかを、比較表は区別してくれない。
設計で分かった 2 種類のフラグ
AppConfig のフラグは、見た目は一つでも契約が二つある。
基本フラグ
設定した値が、取得した全員にそのまま付く。ON なら 100% ON。A/B もパーセンテージ分割もできない。「50% だけ ON」は、基本フラグでは実現しない。
このアプリではアップデート抑制(suppressUpdate)がこれだ。緊急時に更新チェックを止める。基本フラグを ON にしてデプロイすれば、全員に効く。
ただし「全員に同時に効く」ではない。ユーザーによる出し分けができないだけで、設定が Agent に届くまでは前述のデプロイ戦略(線形・指数・ベイク時間)に従って段階的に広がる。「出し分けの有無」と「配信の速さ」は別の軸だ。
マルチバリアントフラグ
複数の値を定義し、ルールと Context で振り分ける。A/B と段階開放はこちら。enabled は常に true になり、実際の値は variant を見る。
この区別を間違えると、コンソールで「50% ON」のつもりで基本フラグをいじり、全員に当たる。設計書が「パーセンテージ制御したいならマルチバリアントにせよ」と繰り返している理由はここにある。
クライアントが API に送るのはフラグ名だけだ。
{ "featureFlagName": "feature-name" }ユーザー ID と日付はサーバーが足す。Electron にユーザー識別子を持たせて Context を組み立てさせない。ログインセッションの ID と、指定日時(通常は今日)を Agent に渡す。日付は YYYY-MM-DD。スケジュール付きルール(この日からこの日まで variant A)は、この Context で評価する。
成功時はフラグの種類で形が変わる。
{ "enabled": true }{ "enabled": true, "variant": "A" }設計の境界はここまでで一度固まった。残ったのが、等分割できるかどうかだった。
split はバケットではない
Evidently や LaunchDarkly の割合ロールアウトは、ユーザーをハッシュして 連続した範囲 に載せる。0〜33 が A、33〜66 が B、残りが default。重複しない。割合を 10% から 50% に上げても、最初の 10% は残る。
AppConfig の split は、見た目が似ている。
(split by::$userId pct::33.33 seed::$userId)
これは「全体の 33.33% を選ぶ」だけだ。「先頭 33%」ではない。start:: がない。だから次の書き方は、直感に反する。
variantA: (split ... pct::33.33 ...)
variantB: (and (not (split ... 33.33 ...)) (split ... pct::50 ...))
「A に入らなかった人の半分 = 全体の 33%」にはならない。split を二度呼ぶと、別の抽選が交差する。重複と抜けが残りうる。variantA + variantB = 66% は保証されない。
seed を付ければ、同じユーザーは同じ結果を受け取る。sticky にはなる。しかし 非重複の範囲分割 にはならない。調査資料が「ID 固定はカスタム実装」と書いたとき、頭にあったのは sticky だった。設計で足りないと分かったのは、等分割の保証だった。
等分割を自分で持つなら、バケットを先に計算して Context に載せる。
variant1: (lt $bucket 33) // 0-32
variant2: (and (ge $bucket 33) (lt $bucket 66)) // 33-65
default: (ge $bucket 66) // 66-99
同じユーザー ID は同じバケット。範囲は排他。10 分割も整数比較で書ける。AppConfig の split に範囲を期待するより、ハッシュをアプリ側に置く方が契約が小さい。
教訓: 名前が同じでも保証は同じではない。split が保証するのは割合であって、範囲の排他ではない。
この計算は Electron にも API サーバーのハンドラにも置かなかった。社内ライブラリ(フラグ評価ライブラリ)が、呼ぶたびにユーザー ID からバケットを作り、Agent へ載せる。
足りない契約は、API サーバーではなくライブラリに置いた
API サーバーの仕事は薄い。ログイン中のユーザー ID と日付を取り、GetFeatureFlag(ctx, name, userId, date) を呼ぶ。フラグ名のバリデーション、バケット計算、Agent の URL、エラーの切り分けはライブラリ側だ。ハンドラに評価ロジックを置くと、同種のサーバーへ横展開するときにコピーが走る。モジュールにしたのは、足りない契約の置き場を一つにするためだった。
呼び出し側はバケットを渡さない。ライブラリが SHA-256 の先頭 4 バイトを整数化し、100 で割った余り(0〜99)を $bucket にする。同じユーザー ID は同じ番号。呼ぶ側がハッシュ関数を選ぶ余地はない。ばらつきと sticky の責任は、この関数に閉じている。
Agent へは公式の Context ヘッダーで送る。設計メモには X-Aws-AppConfig-Context に JSON を載せる案もあったが、実装はカンマ区切りの key=value に寄せた。
Context: userId=...,currentDate=2026-07-01,bucket=42
これが、Evidently の SDK が内側でやっていた sticky assignment の実体だ。調査資料が「カスタム実装」と書いた一行は、このヘッダーになった。
ライブラリは Agent 応答の形も持ち替える。AWS 側のバリアントキーは _variant。クライアントと API サーバーが見るのは variant。フラグ名は小文字化してマップを引く。タイムアウトは 5 秒。日付は YYYY-MM-DD 以外を受けない。Agent 切断、不正ステータス、JSON 破損、フラグ欠落、バケット計算失敗はコード(E101〜E106)で分かれる。API サーバーはそれをエラー文字列に載せ、クライアント側の独自エラーコードへ渡す。
つまりライブラリは「AppConfig を叩く薄いクライアント」ではない。調査で足りないと分かった契約(Context、等分割、失敗の種類)を、Go の境界に固定した層だ。API サーバーは身分を足す。Electron はフラグ名だけ送る。評価の中身は、どちらにも漏らさない。
実装が置いた経路
実装は 4 つの境界に分かれる。Renderer はフラグ名だけ知り、Main はキャッシュと API を持ち、API サーバーは身分と日付を足し、ライブラリがバケットと Context を組んで Agent に聞く。
Renderer useAppConfigFlag(featureFlagName)
→ preload IPC
Main 1 時間キャッシュを見る
→ なければ POST /feature-flag
API サーバー ログイン中のユーザー ID と日付を足す
→ フラグ評価ライブラリ
ユーザー ID から bucket を計算
Context: userId, currentDate, bucket
Agent localhost:2772 で評価
Renderer のフックは、取得した variant を画面に返し、同時に store へ残す。GA で「この CV はどのバリアントの人か」を後から付けるためだ。action 名はまだ SET_EVIDENTLY_TEST_MAP のままにしてある。配信基盤は変わっても、分析側の箱は残っている。移行が SDK の差し替えではなく、呼び先と評価主体を入れ替える作業だったことが、この名前に残っている。
Main の IPC は失敗して止めない。取得に失敗したら default variant を返し、トラブルシュートログに専用のエラーコードを送る。フラグ基盤が落ちても、アプリは開く。A/B より起動を優先する。監視はログ基盤でそのコードを見て、Agent 切断(connection refused)と取得失敗(retrieve failure)を分ける。
キャッシュはフラグ名単位で 1 時間。同じセッションで何度も Agent を叩かない。enabled: false のときは default を返し、その結果はキャッシュしない。基本フラグが OFF の状態を長く固定しないためだ。
アップデート抑制は、この IPC を通らない。自動更新モジュールが Manager を直接呼び、enabled を見る。基本フラグはユーザーによる出し分けができない(取得した全員に同じ値が返る)。その契約を、variant 用の経路に載せない。フラグの種類が二つあることは、呼び出し側の分岐としても残している。
クライアントが知っているフラグは、テスト用の mock と、本番のアップデート抑制だけだ。基盤を先に置き、個別の A/B はフラグ追加と UI 分岐で足す。初期の 1〜2 か月のあと、1 本あたり数日、という見積の分け方は、この切り方と一致する。
途中で別の SaaS(AB Tasty)の再検討も入った。Public API は結果の export とイベント import 用で、Electron の DOM を振り分けるロジックは公開されていない。Web タグ前提の SaaS を、デスクトップの UI 分岐に使うのは契約が違う。結論は再び AppConfig に戻った。調査の比較対象に載せていなかった候補を、設計のあとで落としている。比較表は閉じた世界ではない。
調査・設計・実装で、変わった前提
同じ「AppConfig を使う」でも、各段階の前提は違う。
| 調査 | 設計 | 実装 | |
|---|---|---|---|
| AppConfig とは | Evidently の安い代替 | 設定配信。評価は Agent | サイドカー + 社内ライブラリ |
| 段階的開放 | デプロイ戦略で足りる | ユーザー単位はフラグルール | 基本フラグは全開閉、A/B は variant |
| ID 固定 | カスタム実装が要る | Context にユーザー ID を載せる | クライアントは送らない。サーバーが足す |
| 等分割 | 比較表にない | split では非重複を保証できない | ライブラリが $bucket を毎回載せる |
| 失敗時 | 書いていない | 書いていない | ライブラリが E101〜E106。クライアントは default + エラーログ |
| 分析 | 外部ツールが必要 | GA 連携を想定 | store に variant を残し、GA ラベルへ |
調査が決めたのは道具だ。設計が決めたのは評価の場所だ。実装が決めたのは、落ちてもアプリを生かすこと、ユーザー識別子をクライアントに持たせないこと、足りない契約をライブラリに閉じることだ。
まとめ
- AppConfig は設定配信サービスであって、実験基盤ではない。ユーザー単位の評価は Agent(サイドカー / Lambda 拡張)側にオフロードされている。Agent なしで叩くと Context を送れず、ランダム振り分けしかできない
- AppConfig の「段階的デプロイ」は 設定を Agent へ何 % 配るか の話。ユーザーの何 % に出すかはマルチバリアントフラグのルール側。同じ「%」でも層が違う
splitが保証するのは割合だけで、非重複の範囲分割ではない。等分割が要るならバケットを自前で計算して Context に載せる- 基本フラグとマルチバリアントフラグは契約が別物。「50% だけ ON」は基本フラグでは表現できない
- 足りない契約(Context・等分割・失敗の種類)は、ハンドラに散らさずライブラリ 1 か所に閉じる。横展開でコピーが走らない
- フラグ基盤の失敗でアプリを止めない。default variant + エラーログに倒す
「主要機能はすべて満たす」は、調査としては正しい方向だった。満たし方が、Evidently の SDK を AppConfig の SDK に置き換えることではなかっただけだ。足りない契約を、社内ライブラリと API とキャッシュと監視で埋める作業だった。
意思決定の本体は「一番高機能な実験基盤を選ぶ」ことではない。既存の制約の中で、誰がユーザーを評価し、何を保証し、何を落とすか を段階ごとに書き換えることだった。調査資料の結論は出発点で、形を決めたのは、そのあとに見つかった境界だ。