現在、React + NestJSのフルTypeScript構成でサービスを開発していて、バックエンドはECS on Fargateに載せています。
これまでは、「リリース前のQA段階だけセッションタイムアウトを短くしたい」「β版機能を一部ユーザーだけに公開したい」といった調整を、環境変数の上書き→再デプロイで乗り切ってきました。
ただ、小さなフラグ変更でもCI/CDが走るたびに3~5分待たされるのがしんどくなってきたので、カミナシ社の事例を参考にAWS AppConfigを採用して、フィーチャーフラグ(機能フラグ)をコンソールから即時切り替えられるようにしました。
AIに聞けば簡単にできるだろうと思っていたら予想外にハマったので、メモとして記事に残します。
フィーチャーフラグを導入した理由
- QA時だけセッションタイムアウトを短くしたい
- β版機能を社内ユーザーのみに公開したい
- 緊急ロールバックをコード変更なしで行いたい
AppConfigを挟めば、これらをコンソールのクリックだけで完結できます。
全体構成
ECSタスクにAppConfig Agentのサイドカーコンテナを追加し、フラグ設定を定期的に(デフォルト45秒)ポーリングします。
アプリケーション側はlocalhost:2772
へHTTPリクエストを送るだけです。設定管理をサイドカーコンテナに丸投げするので、アプリケーション側のAWS SDK依存をゼロにできます。
AppConfigの設定手順
アプリケーションと設定プロファイルの作成
まずは以下2つを作成します。
- アプリケーション:
feature-flags
- 設定プロファイル:
feature-flags-config
基本フラグ(Basic Flag)の作成
ON/OFFだけ切り替えたい場合は基本フラグを使います。今回はisDebug
を作ります。
マルチバリアントフラグ(Multi‑Variant Flag)の作成
ユーザーの属性(今回の場合はsessionId
)単位でフラグのON/OFFを切り替えたい場合は、マルチバリアントフラグを使います。右サイドバーのRule builderで柔軟に条件を設定することができます。
環境(dev/stg/prod)の作成
環境ごとにフラグ値を変えたいので、アプリケーションの環境タブでそれぞれの環境(dev, stg, prod)を作成します。
例えば、devを作成すると以下のようになります。
設定プロファイルのデプロイ
環境を作成したら、設定プロファイルをデプロイします。右上の"Start Deployment"をクリックします。
EnvironmentとHosted configuration versionとDeployment strategyを設定してデプロイします。
Deployment strategyではカナリアデプロイも行うことができます。
例えばCanary10Percent20minutes
であれば、以下のフローで設定値が反映されていきます。
- 0分時点: 新しい設定を全体の10%のトラフィックにのみ適用
- 10分間のベーキングタイム: エラーや異常をモニタリング。問題があればロールバック可能
- 10分後〜20分間で指数的に100%まで展開: たとえば残りの90%を段階的に(30%→60%→100%など)適用
- 20分後: 全体に新しい設定が反映される
ECSタスク定義へのAppConfig Agent追加
公式ドキュメントを参考に、タスク定義に以下を追記します。
{
"name": "aws-appconfig-agent",
"image": "public.ecr.aws/aws-appconfig/aws-appconfig-agent:2.x",
"cpu": 128,
"memoryReservation": 256,
"essential": true,
"environment": [
{
"name": "PREFETCH_LIST",
"value": "/applications/feature-flags/environments/dev/configurations/feature-flags-config"
}
],
"portMappings": [
{
"containerPort": 2772,
"protocol": "tcp"
}
]
}
アプリ本体+サイドカーの合計がタスク上限に収まるようにcpu/memoryを調整します。
また、環境変数からいろんな設定をいじれます。
今回はPREFETCH_LIST
で事前にフェッチする設定パス(/applications/<App>/environments/<Env>/configurations/<Profile>
)を指定しています。
ポーリング時間はPOLL_INTERVAL
で変更できます(デフォルト45秒)。
アプリケーション(NestJS)でフラグを読みこむ
サービス実装(抜粋)
フィーチャーフラグの読み込みを行うFeatureFlagsService
を実装しました。
FeatureFlagsService
@Injectable()
export class FeatureFlagsService {
async evaluateFlag<T = boolean>(flagName: string, context: Record<string, string | number | boolean> = {}, defaultValue: T): Promise<T> {
const environment = process.env.ENV || 'dev'
const isDevelopment = environment !== 'prod'
try {
if (environment === 'local') {
const envFlagName = `FEATURE_FLAG_${flagName
.replace(/([A-Z])/g, '_$1')
.toUpperCase()
.replace(/^_/, '')}`
const envValue = process.env[envFlagName]
if (envValue !== undefined) {
const result = this.parseEnvValue<T>(envValue, defaultValue)
return result
}
return defaultValue
}
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), 5000)
const headers: Record<string, string> = {}
if (Object.keys(context).length > 0) {
const contextHeader = Object.entries(context)
.map(([key, value]) => `${key}=${String(value)}`)
.join(',')
headers.Context = contextHeader
}
const response = await fetch(
`http://localhost:2772/applications/feature-flags/environments/${environment}/configurations/feature-flags-config`,
{
signal: controller.signal,
headers
}
)
clearTimeout(timeoutId)
if (!response.ok) {
this.warnDevelopment(isDevelopment, `[FeatureFlags] AppConfig response not ok: ${response.status} for flag ${flagName}`)
return defaultValue
}
const config = await response.json()
this.logDevelopment(
isDevelopment,
`[FeatureFlags] AppConfig response for ${flagName} with context ${JSON.stringify(context)}:`,
JSON.stringify(config, null, 2)
)
if (flagName.toLowerCase() in config || flagName in config) {
const flagKey = flagName in config ? flagName : flagName.toLowerCase()
const flagValue = config[flagKey]
this.logDevelopment(isDevelopment, `[FeatureFlags] Flag value for ${flagName}:`, JSON.stringify(flagValue, null, 2))
if (typeof flagValue === 'object' && flagValue !== null && 'enabled' in flagValue) {
this.logDevelopment(isDevelopment, `[FeatureFlags] flagValue.enabled:`, flagValue.enabled)
const result = flagValue.enabled as T
return result
}
}
this.warnDevelopment(isDevelopment, `Unexpected AppConfig response format for ${flagName}`)
return defaultValue
} catch (error) {
this.warnDevelopment(isDevelopment, `Feature flag evaluation error for ${flagName}:`, error)
return defaultValue
}
}
/**
* 環境変数の値を適切な型に変換する
*/
private parseEnvValue<T>(envValue: string, defaultValue: T): T {
if (typeof defaultValue === 'boolean') {
return (envValue.toLowerCase() === 'true') as T
}
if (typeof defaultValue === 'number') {
const parsed = Number(envValue)
return (isNaN(parsed) ? defaultValue : parsed) as T
}
return envValue as T
}
/**
* 開発環境でのみログ出力するヘルパー関数
*/
private logDevelopment(isDevelopment: boolean, message: string, ...args: Array<unknown>): void {
if (isDevelopment) {
console.log(message, ...args)
}
}
/**
* 開発環境でのみ警告出力するヘルパー関数
*/
private warnDevelopment(isDevelopment: boolean, message: string, ...args: Array<unknown>): void {
if (isDevelopment) {
console.warn(message, ...args)
}
}
}
ローカル環境
ローカル環境ではサイドカーコンテナがないので、FEATURE_FLAG_<FLAG_NAME>
でオーバーライドできるようにしています。
if (environment === 'local') {
const envFlagName = `FEATURE_FLAG_${flagName
.replace(/([A-Z])/g, '_$1')
.toUpperCase()
.replace(/^_/, '')}`
const envValue = process.env[envFlagName]
if (envValue !== undefined) {
return this.parseEnvValue<T>(envValue, defaultValue)
}
return defaultValue
}
Context情報の送信
AppConfigで特定のsessionId
のユーザーだけフラグをONにする、といったマルチバリアントフラグの設定を行いましたが、このsessionId
のようなContext情報をAppConfigに送信するには、ヘッダーにContextとして含める必要があります。
以下のように動的にヘッダーを生成する処理をいれています。
const headers: Record<string, string> = {}
if (Object.keys(context).length > 0) {
const contextHeader = Object.entries(context)
.map(([key, value]) => `${key}=${String(value)}`)
.join(',')
headers.Context = contextHeader
}
以下が実際の使用例です。
await featureFlagsService.evaluateFlag('isBetaUI', { sessionId: 1 }, false);
// => Context: sessionId=1 ヘッダーが付与される
フロントエンドでフラグを切り替えてUIを出し分ける
Controller
バックエンドでフラグを評価するだけでもかなり便利ですが、新機能のUIを一部のユーザーのみに見せたいケースではフロントエンド側でも判定が必要です。
そこで、複数フラグをまとめて返すAPI(FeatureFlagsController
)を用意して、Reactから呼び出せるようにしました。
FeatureFlagsController
@Controller('v1/feature-flags')
export class FeatureFlagsController {
constructor(private readonly featureFlagsService: FeatureFlagsService) {}
/**
* 複数の機能フラグの状態を取得
*
* @param query - フラグ名のリストを含むクエリパラメータ
* @returns 各フラグの状態を含むオブジェクト
*/
@ApiOperation({ description: '複数の機能フラグの状態を取得' })
@ApiOkResponse({ description: '機能フラグの状態を返却' })
@ApiNotFoundResponse()
@ApiInternalServerErrorResponse({ description: 'サーバーエラー' })
@SiteRoles(SITE_ROLE.admin, SITE_ROLE.owner, SITE_ROLE.member)
@UseGuards(UserAuthGuard)
@Get()
async getFeatureFlags(@Query() query: GetFeatureFlagsDto): Promise<{ flags: Record<string, boolean> }> {
const flagNames = query.flags ? query.flags.split(',') : []
const flags: Record<string, boolean> = {}
const context: Record<string, string | number | boolean> = {}
if (query.siteId !== undefined) {
context.siteId = String(query.siteId)
}
for (const flagName of flagNames) {
flags[flagName] = await this.featureFlagsService.evaluateFlag(flagName, context, false)
}
return { flags }
}
/**
* 特定の機能フラグの状態を取得
*
* @param flagName - フラグ名
* @param query - デフォルト値を含むクエリパラメータ
* @returns フラグの状態
*/
@ApiOperation({ description: '特定の機能フラグの状態を取得' })
@ApiOkResponse({ description: '機能フラグの状態を返却' })
@ApiNotFoundResponse()
@ApiInternalServerErrorResponse({ description: 'サーバーエラー' })
@SiteRoles(SITE_ROLE.admin, SITE_ROLE.owner, SITE_ROLE.member)
@UseGuards(UserAuthGuard)
@Get(':flagName')
async getFeatureFlag(
@Param('flagName') flagName: string,
@Query() query: GetFeatureFlagDto
): Promise<{ flagName: string; enabled: boolean }> {
const defaultValue = query.defaultValue === 'true'
const context: Record<string, string | number | boolean> = {}
if (query.siteId !== undefined) {
context.siteId = String(query.siteId)
}
const enabled = await this.featureFlagsService.evaluateFlag(flagName, context, defaultValue)
return {
flagName,
enabled
}
}
}
TanStack Queryのカスタムフック
また、フロントエンド(React)側ではフィーチャーフラグの取得用に、TanStack Queryで以下のようなカスタムフックを実装しました。
useFeatureFlag / useFeatureFlags
/**
* 単一フラグを扱う薄いラッパー
*/
export const useFeatureFlag = (
flagName: string,
ctx: Partial<FeatureFlagContext> = {},
) => {
const { data, isLoading, error } = useFeatureFlags([flagName], ctx)
return {
enabled: data?.flags[flagName] ?? false, // ネットワーク遅延中は安全側(false)
loading: isLoading,
error,
}
}
/**
* 複数フラグをまとめて取得
*/
export const useFeatureFlags = (
flagNames: string[],
ctx: Partial<FeatureFlagContext> = {},
) =>
useQuery({
queryKey: ['feature-flags', [...flagNames].sort(), ctx],
queryFn: () => fetchFeatureFlags(flagNames, ctx),
enabled: flagNames.length > 0,
staleTime: 1000 * 60 * 5,
retry: false,
})
/** HTTP 呼び出し部分だけ分離 */
const fetchFeatureFlags = async (
flagNames: string[],
ctx: Partial<FeatureFlagContext>,
): Promise<GetFeatureFlagsResponseDto> => {
const res = await apiClient.get<GetFeatureFlagsResponseDto>(
'v1/feature-flags',
{ params: { flags: flagNames.join(','), ...ctx } },
);
if (res instanceof Failure) throw res
return res.value;
}
さいごに
AppConfigを導入したことで、「フラグを変えるだけの再デプロイ」というムダな待ち時間がゼロになり、リリース前後の心理的ハードルが大幅に下がりました。
一方で、マルチバリアントフラグのルールが環境ごとに異なる場合など、設定プロファイルのデプロイミス(devの設定をprodに反映してしまうなど)には注意が必要です。
このあたりの運用ルールをどう設計していくかが今後の課題ですね。
参考