0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ECSにAppConfigでフィーチャーフラグを仕込んで即切り替えできるようにした

Posted at

現在、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依存をゼロにできます。

image.png

What is AWS AppConfig Agent?

AppConfigの設定手順

アプリケーションと設定プロファイルの作成

まずは以下2つを作成します。

  • アプリケーション: feature-flags
  • 設定プロファイル: feature-flags-config

スクリーンショット 2025-07-12 13.44.27.png

基本フラグ(Basic Flag)の作成

ON/OFFだけ切り替えたい場合は基本フラグを使います。今回はisDebugを作ります。

スクリーンショット 2025-07-12 13.49.23.png

マルチバリアントフラグ(Multi‑Variant Flag)の作成

ユーザーの属性(今回の場合はsessionId)単位でフラグのON/OFFを切り替えたい場合は、マルチバリアントフラグを使います。右サイドバーのRule builderで柔軟に条件を設定することができます。

スクリーンショット 2025-07-12 13.57.55.png

環境(dev/stg/prod)の作成

環境ごとにフラグ値を変えたいので、アプリケーションの環境タブでそれぞれの環境(dev, stg, prod)を作成します。

スクリーンショット 2025-07-12 13.59.34.png

例えば、devを作成すると以下のようになります。

スクリーンショット 2025-07-12 14.01.19.png

設定プロファイルのデプロイ

環境を作成したら、設定プロファイルをデプロイします。右上の"Start Deployment"をクリックします。

スクリーンショット 2025-07-12 14.03.26.png

EnvironmentとHosted configuration versionとDeployment strategyを設定してデプロイします。

Deployment strategyではカナリアデプロイも行うことができます。
例えばCanary10Percent20minutesであれば、以下のフローで設定値が反映されていきます。

  1. 0分時点: 新しい設定を全体の10%のトラフィックにのみ適用
  2. 10分間のベーキングタイム: エラーや異常をモニタリング。問題があればロールバック可能
  3. 10分後〜20分間で指数的に100%まで展開: たとえば残りの90%を段階的に(30%→60%→100%など)適用
  4. 20分後: 全体に新しい設定が反映される

スクリーンショット 2025-07-12 14.04.11.png

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に反映してしまうなど)には注意が必要です。

このあたりの運用ルールをどう設計していくかが今後の課題ですね。

参考

0
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?