TypeScriptとOpenFeatureで作る機能フラグシステム:柔軟なトグル管理とカナリアリリース

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

課題の提示:リスクの高いデプロイに伴う苦痛

木曜日の午後3時15分。あなたのチームは、チェックアウトサービスの大規模なリライト版を本番環境へデプロイしたばかりです。その10分後、PagerDutyがけたたましく鳴り響きます。新しい決済パイプライン内のインデックスが貼られていないクエリがPostgreSQLのコネクションプールを逼迫させ、60本ある接続すべてを占有してしまっています。レイテンシは4,500ミリ秒を超えて急上昇。ロールバックを実行しようにも、CIによる再ビルド、スモークテストの実行、Podのロールアウト完了までに12分間待たなければなりません。そのダウンタイム中、チェックアウトの失敗によって毎分約8,000ドルの売上が失われ続けています。

この障害パターンは、「デプロイ」と「リリース」を混同するという古典的なアーキテクチャの罠を浮き彫りにしています。バイナリをプッシュした瞬間に新しいロジックが全ユーザーへ自動的に公開される状態では、障害の影響範囲(ブラストレイディウス)は即座に100%となってしまいます。ステージング環境が、本番環境のトラフィックパターンや複雑なテナントデータ、突発的な同時実行数のスパイクを正確に再現できることは滅多にありません。

根本原因:結合されたデプロイとベンダーロックイン

なぜ開発チームはこの壁に突き当たるのでしょうか? 主に2つの技術的課題が原因として挙げられます。

  • ランタイム制御の欠如: 機能のアクティベーションを環境変数やHelmの値に依存していると、スイッチを切り替えるためだけにPodの完全な再起動が必要になります。再起動には数分かかりますが、インシデント対応にはミリ秒単位の俊敏性が求められます。機能制御はコンテナのライフサイクルから独立して存在していなければなりません。
  • SDKのロックイン: 多くのチームは、Redisのキーや社内Postgresテーブルを参照する単純なif/else判定からフラグ運用を始めます。要件が高度になるにつれて、LaunchDarkly、Split、Unleashといったエンタープライズ向けプラットフォームへの移行を検討します。しかし、ベンダー固有のSDK呼び出しコードを50ものマイクロサービス全体にばらまいてしまうと、深刻な技術的負債を生み出します。後からプロバイダーを変更しようとしても、数か月におよぶリファクタリング作業に追われることになります。

OpenFeatureは、このアーキテクチャ上の結合を解消します。Cloud Native Computing Foundation(CNCF)がホストするOpenFeatureは、ベンダーに依存しない仕様と標準SDKを提供します。TypeScriptをはじめとする主要なランタイムにおいて、ビジネスロジックを背後にあるフラグベンダーから完全に切り離すことができます。

基本概念:OpenFeatureの仕組み

OpenFeatureは、責務を4つの主要なプリミティブに分割して管理します。

  • Evaluation Context(評価コンテキスト): フラグの評価時に渡されるランタイム属性です。これにはtargetingKey、tenantId、メールドメイン、国コード、アプリのバージョンなどのコンテキスト情報が含まれます。
  • Client(クライアント): フラグの値(真偽値、文字列、数値、JSONペイロード)をリクエストする、開発者向けAPIです。
  • Provider(プロバイダー): OpenFeature APIとフラグストレージエンジンの間を取り持つ変換アダプターです。ストレージエンジンには、インメモリーディクショナリ、FliptやGO Feature Flagといったオープンソースエンジン、各種SaaSプラットフォームなどを利用できます。アプリケーションコード内のフラグ評価処理を1行も変更することなく、プロバイダーを動的に差し替えることが可能です。
  • Hooks(フック): フラグの解決前、解決中、または解決後に実行される、合成可能なライフサイクルインターセプターです。OpenTelemetry、Prometheus、Datadogなどへテレメトリデータを送信する処理に最適です。

この抽象化標準を採用することで、ゼロダウンタイムリリースの実現が容易になります。インフラの設定に触れることなく、きめ細やかなトラフィック制御を行えるようになります。

実践演習:TypeScriptによるカナリアロールアウトシステムの構築

それでは、OpenFeatureを使用して、確定的(決定論的)なパーセンテージベースのカナリアロールアウトを実行する、実行可能なTypeScriptサービスを構築してみましょう。

1. プロジェクトの初期化

新しいNode.jsワークスペースを準備し、必要なパッケージをインストールします。

mkdir ts-openfeature-canary
cd ts-openfeature-canary
npm init -y
npm install @openfeature/server-sdk murmurhash
npm install --save-dev typescript @types/node @types/murmurhash ts-node
npx tsc --init

ここでは、ユーザー識別子を0から99まで均等に分散・ハッシュ化するためにmurmurhashを使用します。これにより、同じユーザーには常にまったく同じフラグ結果が返るという、スティッキーなコホート割り当てが保証されます。

2. カスタムカナリアプロバイダーの作成

本番システムでは通常マネージドプロバイダーを利用しますが、ローカルのインメモリプロバイダーを独自に実装してみることで、コンテキストに応じた評価が内部でどのように動作しているかを深く理解できます。

以下の実装をcanaryProvider.tsとして保存します。

import {
  Provider,
  ResolutionDetails,
  EvaluationContext,
  ProviderMetadata,
  StandardResolutionReasons,
} from '@openfeature/server-sdk';
import murmurhash from 'murmurhash';

export interface FlagRule {
  defaultValue: boolean;
  canaryPercentage?: number; // 0から100までの整数
  targetUsers?: string[];
}

export class CanaryFlagProvider implements Provider {
  readonly metadata: ProviderMetadata = {
    name: 'CanaryFlagProvider',
  };

  private flags: Map<string, FlagRule> = new Map();

  constructor(initialFlags: Record<string, FlagRule>) {
    for (const [key, rule] of Object.entries(initialFlags)) {
      this.flags.set(key, rule);
    }
  }

  // プロセスを再起動せずに実行時に動的にフラグを更新
  public updateFlag(key: string, rule: FlagRule): void {
    this.flags.set(key, rule);
  }

  async resolveBooleanEvaluation(
    flagKey: string,
    defaultValue: boolean,
    context: EvaluationContext
  ): Promise<ResolutionDetails<boolean>> {
    const rule = this.flags.get(flagKey);

    if (!rule) {
      return {
        value: defaultValue,
        reason: StandardResolutionReasons.DEFAULT,
      };
    }

    const targetingKey = context.targetingKey || (context.userId as string);

    // 1. 明示的な許可リストの判定
    if (targetingKey && rule.targetUsers?.includes(targetingKey)) {
      return {
        value: true,
        reason: StandardResolutionReasons.TARGETING_MATCH,
      };
    }

    // 2. 決定論的なパーセンテージバケットの算出
    if (targetingKey && typeof rule.canaryPercentage === 'number') {
      const hash = Math.abs(murmurhash.v3(`${flagKey}:${targetingKey}`));
      const bucket = hash % 100;
      const isIncluded = bucket < rule.canaryPercentage;

      return {
        value: isIncluded,
        reason: isIncluded
          ? StandardResolutionReasons.TARGETING_MATCH
          : StandardResolutionReasons.DEFAULT,
      };
    }

    return {
      value: rule.defaultValue,
      reason: StandardResolutionReasons.STATIC,
    };
  }

  async resolveStringEvaluation(flagKey: string, defaultValue: string) {
    return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT };
  }

  async resolveNumberEvaluation(flagKey: string, defaultValue: number) {
    return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT };
  }

  async resolveObjectEvaluation<T>(flagKey: string, defaultValue: T) {
    return { value: defaultValue, reason: StandardResolutionReasons.DEFAULT };
  }
}

3. アプリケーションロジックへのOpenFeatureの組み込み

作成したカスタムプロバイダーをOpenFeatureのシングルトンに登録し、ユーザーコンテキストを評価します。app.tsを作成します。

import { OpenFeature } from '@openfeature/server-sdk';
import { CanaryFlagProvider } from './canaryProvider';

async function main() {
  // 1. ロールアウトルールの設定:トラフィックの20% + 専用QAバイパス
  const flagConfigs = {
    'v2-checkout-engine': {
      defaultValue: false,
      canaryPercentage: 20,
      targetUsers: ['[email protected]'],
    },
  };

  const provider = new CanaryFlagProvider(flagConfigs);
  await OpenFeature.setProviderAndWait(provider);

  const client = OpenFeature.getClient('payment-service');

  const testUsers = [
    { id: '[email protected]', name: '社内QAエンジニア' },
    { id: 'usr_live_83910', name: 'Sarah(テナントA)' },
    { id: 'usr_live_12049', name: 'Marcus(テナントB)' },
    { id: 'usr_live_44921', name: 'Elena(テナントC)' },
    { id: 'usr_live_99214', name: 'David(テナントD)' },
  ];

  console.log('=== ステージ1: 20% カナリアデプロイ ===');
  for (const user of testUsers) {
    const isEnabled = await client.getBooleanValue(
      'v2-checkout-engine',
      false,
      { targetingKey: user.id }
    );
    console.log(`[評価] ${user.name.padEnd(24)} -> フラグ有効: ${isEnabled}`);
  }

  // 2. モニタリング指標でエラー率0%を確認後、フラグを100%へ引き上げ
  console.log('\n=== ステージ2: 100% ロールアウトへの昇格 ===');
  provider.updateFlag('v2-checkout-engine', {
    defaultValue: true,
    canaryPercentage: 100,
  });

  for (const user of testUsers) {
    const isEnabled = await client.getBooleanValue(
      'v2-checkout-engine',
      false,
      { targetingKey: user.id }
    );
    console.log(`[評価] ${user.name.padEnd(24)} -> フラグ有効: ${isEnabled}`);
  }
}

main().catch(console.error);

4. 実装コードの実行

ts-nodeを使ってスクリプトを直接実行します。

npx ts-node app.ts

ステージ1では、QAテスターおよび顧客IDの正確に20%がtrueと評価されます。それ以外のユーザーは、安定稼働しているレガシーコードパスを実行します。フラグを100%に昇格させると、コンテナの再起動やPodの入れ替えを発生させることなく、インメモリですべてのトラフィックが即座に切り替わります。

まとめ:標準運用としてのデカップリング

デプロイとリリースを切り離すことで、ストレスの多いリリース作業を予測可能で低リスクなルーチンワークへと変えることができます。OpenFeature仕様を採用すれば、ベンダーロックインを回避しながら運用の柔軟性を最大限に高めることが可能です。

システムの規模が拡大してもフラグアーキテクチャを健全に保つための3つのベストプラクティスを紹介します。

  1. トグルを短命な負債として扱う: リリーストグルには有効期限(SLA)を設定しましょう。ある機能が7日間連続で100%ロールアウト状態を維持できたら、フラグの分岐条件コードを削除し、不要になったコードパスを整理します。
  2. 決定論的なフォールバックを徹底する: フラグ管理システムはネットワークの瞬断などの影響を受ける可能性があります。デフォルト値は、常に最も安全なフォールバックパス(通常はfalse)になるよう設計してください。
  3. トグルをアラートと直接連動させる: フラグの評価イベントをメトリクス基盤へストリーミングします。カナリアステージ中にエラー率が0.5%を超えた場合は、自動キルスイッチを作動させて即座にフラグを無効化できるようにします。
Share: