OpenFeature を使ったアプリケーション開発

Keita Sato

2025.4.23

はじめに

はじめましての方も、そうじゃない方も、こんにちはこんばんは。
Sreake 事業部 佐藤慧太(@SatohJohn)です。

皆さん、アプリケーションのコードを変更せずに機能の有効無効を切り替えることができる Feature Flag をつかって開発されたことはありますでしょうか?

私はあまり利用したことがなかったのですが、ふと PoC 環境でアプリケーションを作成している際に、同時に複数の機能を試しており、Flagで管理するということをやっていることに気づきました。
このようなとき、なるべく車輪の再発明をしたくないというのが世の常です。

色々調べていくと https://openfeature.dev/ にたどり着きました。

Feature Flag について

一応前提として、Feature Flag について簡単に触れますが、Feature Flag (フィーチャーフラグ) または Feature Toggle (フィーチャートグル) とは、コードを変更せずにソフトウェアの機能を有効/無効にできる技術です。
コード内に条件分岐を設け、設定によって機能の動作を切り替えることで実現します。

これにより、以下のようなことが可能になります。

  • 新機能の段階的なリリース: 特定のユーザーグループに対して新機能を徐々に公開する (カナリアリリース、段階的ロールアウト)。
  • A/Bテスト: 異なる機能のバージョンを同時に公開し、ユーザーの反応を比較する。
  • 緊急時のロールバック: 問題が発生した場合、機能を即座に無効化する。
  • 開発中の機能の統合: まだ完成していない機能をメインブランチに統合し、Feature Flag で隠蔽する。
  • 有料機能の制御: 特定のライセンスを持つユーザーにのみ機能を有効にする。

Feature Flag の詳細な説明については様々な人がされており、そのメリットと、デメリットについては十分に議論がされていると思います。
きちんと使いこなせれば、アプリケーションのリリース速度を加速させていくことができる良い考え方だと思います。以下から Feature Flag を使っているプロジェクトに対して、OpenFeature は良いぞという前提で記載します。

OpenFeature について

OpenFeature https://openfeature.dev/specification/ は CNCF プロジェクトとして2023-11-21 に Incubating のステータスになりました。そして、以下の特徴をもっています。

  • ベンダー中立性: Feature Flag を取り扱うサービスは色々でてきていますが、特定のベンダーやツールに依存しない API を提供することで、開発者はツールを自由に選択し、必要に応じて簡単に切り替えることができます。
  • 標準化: 統一された API を提供することで、開発者はツールごとの実装の違いを意識することなく、機能フラグを管理できます。
  • 柔軟性: 様々な機能フラグ管理ツールや自社開発のソリューションとの連携をサポートすることで、開発者は既存のシステムに OpenFeature を容易に統合できます。
  • ベンダーロックインの軽減: 標準化された API を提供することにより、OpenFeature はベンダーロックインを軽減し、開発者は必要に応じて機能フラグプラットフォーム間を簡単に切り替えたり、複数のプラットフォームを統合したりできます。

Feature Flag の使い方として以下のようなパターンが考えられると思います。

  • カナリアリリース: 新機能を一部のユーザーに限定して公開し、フィードバックを収集することで、リスクを最小限に抑えながら新機能をリリースできます。
  • A/B テスト: 異なるバージョンの機能をユーザーに提供し、効果を比較することで、最適な機能を決定できます。
  • パーソナライズ: ユーザーの属性や行動に基づいて機能をカスタマイズすることで、ユーザーエクスペリエンスを向上させられます。
  • キルスイッチ: 問題が発生した場合に、特定の機能を即座に無効化することで、システムへの影響を最小限に抑えられます。

単純に使ってみる

OpenFeature は Provider を使って flag 管理システムへのアクセスを抽象化しています

まずは、利用方法のイメージを簡単に掴むために flagd を使ったローカル環境を考えてみましょう。

Flagd 以下のように説明されており OpenFeature の機能を掴むには一番手っ取り早いです。

flagd is a feature flag daemon with a Unix philosophy. Think of it as a ready-made, open source, OpenFeature-compliant feature flag backend system.

Docker compose でやってみましょう。構成は以下のとおりです。

.
├── app
│   ├── Dockerfile
│   ├── main.ts
│   ├── package.json
│   ├── pnpm-lock.yaml
│   └── tsconfig.json
├── compose.yaml
└── flagd
    └── flags.flagd.json

compose.yaml

services:
  server:
    build:
      context: ./app
    environment:
      NODE_ENV: production
      FLAGD_HOST: flagd
      PORT: 3333
    develop:
      watch:
        - target: ./main.ts
          path: ./app/main.ts
          action: sync+restart
    ports:
      - 3333:3333
  flagd:
    image: ghcr.io/open-feature/flagd:latest
    volumes:
      - type: bind
        source: ./flagd/flags.flagd.json
        target: /etc/flagd/flags.flagd.json
    ports:
      - 8013:8013
    command: ["start", "--uri", "file:/etc/flagd/flags.flagd.json"]

アプリケーションの方で以下の OpenFeature の依存を追加します

pnpm install @openfeature/server-sdk @openfeature/flagd-provider

今回は単純に flag を使って機能(画面)を切り替えるような構成で考えます。

import express from "express";
import Router from "express-promise-router";
import { OpenFeature } from "@openfeature/server-sdk";
import { FlagdProvider } from "@openfeature/flagd-provider";

OpenFeature.setProvider(new FlagdProvider({
  host: process.env.FLAGD_HOST || "localhost"
}));
const client = OpenFeature.getClient();
const app = express();
const routes = Router();
app.use((_, res, next) => {
  res.setHeader("content-type", "text/plain");
  next();
}, routes);

routes.get("/", async (_, res) => {
  const showWelcomeMessage = await client.getBooleanValue(
    "welcome-message",
    false
  );
  if (showWelcomeMessage) {
    res.send("Express + TypeScript + OpenFeature Server");
  } else {
    res.send("Express + TypeScript Server");
  }
});

const port = process.env.PORT || 3333;
app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

flagd は json ファイルを参照します。variants (値の種類)、 state (利用できるか)、 defautVariant (デフォルトで利用される variants) があり、基本ほかの flag 管理システムでも同じような値を持っています。

今回、flagd.json の中身は以下を想定しています。

{
    "flags": {
        "welcome-message": {
            "variants": {
                "on": true,
                "off": false
            },
            "state": "ENABLED",
            "defaultVariant": "off"
        }
    }
}

アクセスすると以下のように表示されると思います。

その後、flagd.json の defaultVariant 値を変更すると flagd 内で変更を感知して、アクセスした際の結果が変化します。

ちなみに、variants に存在しない値にすると

error runtime/runtime.go:146 default variant: 'test' isn't a valid variant of flag: 'welcome-message' {"component": "runtime"}

という形で flagd 内でエラーがでて、前回の設定していた値のままになります。

起動時にエラーの場合は、application 側の client で指定している default value の値になります。エラーが起きた際に詳しく内容を把握されたい方は、 https://openfeature.dev/specification/types#error-code を参考にしてください。

今回 value について、サンプルでは2値でやっていますが、配列や構造体、日付なども指定可能です。

もしうまくいかないなどでエラー内容を確認したい場合は getXXXValue ではなく getXXXDetails を使いエラーの内容を取得することができます。このときにも value から flag の値を取得することは可能です。

  const { value: showWelcomeMessage, errorCode, reason, errorMessage } = await client.getBooleanDetails(
    "welcome-message",
    false
  );

または、Custom の Hook を作成し、ログを取得する方法が考えられます。

import { EvaluationDetails, FlagValue, Hook, HookContext } from "@openfeature/server-sdk";

export class MyHook implements Hook {
  before(hookContext: HookContext) {
    // code to run before flag evaluation
    console.log("Before hook called");
  }

  after(hookContext: HookContext, details: EvaluationDetails<FlagValue>) {
    // code to run after successful flag evaluation
    console.log("After hook called");
  }

  error(hookContext: HookContext, err: Error) {
    // code to run if there's an error during before hooks or during flag evaluation
    console.log("Error hook called", err);
  }

  finally(hookContext: HookContext, details: EvaluationDetails<FlagValue>) {
    // code to run after all other stages, regardless of success/failure
    console.log("Finally hook called");
  }
}

作成した Hook を以下のように client に登録します。

  client.addHooks(new MyHook());

これにより、データの取得時で flag の状態を別途見ることができます。

ここまでで単純な OpenFeature の利用について理解していただけたと思います。

Tracking してみる

OpenFeature には 結果を tracking することができる仕様があります。こちらのユースケースは以下が考えられます。

  • AB テストの結果として、どちらが利用されているのか、効果を測定したい
  • 新機能の効果を測定したい、速度を測定したい

ただし、こちらの tracking は使える provider が限られていますので注意が必要です。例えば、残念ながら flagd には現在 Tracking の機能はありません。
そのため、これからは DevCycle を利用します。 DevCycle 自体の詳しい説明は省きますが、flagd のような flag 管理と、時間帯での設定など細かい設定ができるような SaaS になります。

利用する Provider を変更する際の修正は以下、少しだけです。

DevCycle の設定 (flagd.json といっしょになるように修正

アプリケーション側

依存関係の追加 @devcycle/nodejs-server-sdk のインストール pnpm i @devcycle/nodejs-server-sdk

provider の変更 (devcycleClient.getOpenFeatureProvider())

import express from "express";
import Router from "express-promise-router";
import { OpenFeature } from "@openfeature/server-sdk";
import { initializeDevCycle } from "@devcycle/nodejs-server-sdk";

const devcycleClient = initializeDevCycle("dvc_xxxxxxxxxxxxx")
OpenFeature.setProviderAndWait(await devcycleClient.getOpenFeatureProvider());

// OpenFeature.setProvider(new FlagdProvider({
//   host: process.env.FLAGD_HOST || "localhost"
// }));

top level await が使えない環境であればサーバ起動時に実行するなどでもよいでしょう

DevCycle の仕様として user_id または targetingKey がコンテキストに必要になります そのためコンテキストとして、認証後などに以下のように user_id を追加しましょう。

const context = {
  user_id: "john-doe",
}
client.setContext(context)

または、 OpenFeature の client 作成時に user_id を渡すという手もありかと思います

const client = OpenFeature.getClient(context);

を指定してもよいのですが、そうすると各 flag 取得時に呼び出すことになり、flagd からの実装変更が大きいため user_id がよいと思います。

以上です。以下のように DevCycle に設定があるのでアプリケーション起動中に on,off を切り替えると、 Flagd と同じように切り替えできることが確認できると思います。

また、DevCycle ではどれぐらい Feature Flag にアクセスが来たかというのも確認ができます。

それでは具体的に Tracking を利用します。

最初に DevCycle での設定を行います。track の値は DevCycle 上で Metrics として計測されます。

Metrics は名前などを設定できますが、今回大切な項目は Event Type Type Optimize For の値になります。https://docs.devcycle.com/platform/experimentation/creating-and-managing-metrics/ を参照

  • Event Type
    • 今回 OpenFeature から呼び出す key に当たります
  • Type
    • 計測したい方法に依存します
  • Optimize For
    • 結果が減っていると良いのか、増えているのが良いのかを指定します
      以下のように DevCycle の Docsに説明があります。

DevCycle は、望ましい最適化に応じて、メトリックをプラスまたはマイナスとして表します。多くの場合、ツールは常に増加が有益であると想定します。ただし、ほとんどのエンジニアリング アプリケーションでは、その逆が当てはまります。レイテンシ、ロード時間、サーバー負荷、処理時間などは、減らすべきメトリックです。DevCycle は、メトリックが望ましい方向に改善しているかどうかを明確にし、いずれかの方向に大きな影響があった場合はすぐに通知します。

例えば 実行速度等の場合、数値としては減っている方が有益なので、type を average にしつつ decrease を選択します。ABテストでどちらが 優位的に使われているかは type を sum で計算しつつ increase を使うなどが考えられます。

実装としては、OpenFeature の track を利用していきます。key は Metrics で指定した Event Type と一致させる必要があります。

    client.track("welcome-message", context, {
      value: 1
    });

こちらが実行されると DevCycle の Metrics の画面から確認することができます。バッチ処理的に一括でデータが送られるということなので若干データのラグがありますので、すぐ反映されずとも焦らず待っていてください。

こちらの track ですが気をつけなければ行けないのは、呼び出したタイミングでの key 値 と context に従った flag値 で tracking されるというところです。そのため、以下のパターンでずれる可能性があります。

👉
1. flag を user 毎に振り分けるよう設定しており、A さんの処理中に B さんの処理が行われた場合
2. flag を allUser などで設定しており、処理開始から完了(evaluationとtrackingの間で)までに flag の値が変わった場合
1の場合、DevCycle では ランダマイズのパターンとして context の user_id を使っているなど、context を正しく設定している限りは問題はありません。
2の場合、こちらはずれる可能性があります。もしこのパターンで計測をしたいことがあれば気をつけておくべきです。(この際に厳密に計測したいユースケースが思いつかないですが

Cloud Run + Gemini で動かす

最後 Cloud Run のアプリケーション上でやってみます。ただし、このままだと面白くはないので、ちょっとしたユースケースを作りたいと思います。

例えば、LLM の回答を受け取りつつ、その結果についてユーザの評価(5段階)を受け取る機能があるとします。このときに、Gemini のどのモデルを使うべきかを検証したいというケースです。たくさんのユーザが一気にアクセスしてきたことを想定して、Cloud Run でスケールするようにします。

そのため、Feature Flag としてはモデルを切り替えることができるフラグと以下の trace をとっていきます。

  • user 毎にランダムに出し分ける
    • flag name: model-evaluate
  • モデルの応答速度を計測する
    • event type: model-call-time
    • type: average per user
    • Optimize For: decrease
  • コスト(response token数)を計測する
    • event type: model-used-token
    • type: average per user
    • Optimize For: decrease
  • レスポンスに対するユーザの評価を登録する
    • event type: model-user-rating
    • type: average per user
    • Optimize For: increase

サンプルのコードとしては以下のようになるはずです

import express from "express";
import Router from "express-promise-router";
import { OpenFeature } from "@openfeature/server-sdk";
import { initializeDevCycle } from "@devcycle/nodejs-server-sdk";
import {GoogleGenAI} from '@google/genai';

const devcycleClient = initializeDevCycle(process.env.DEV_CYCLE_SDK_KEY || "devcycle_sdk_key")
OpenFeature.setProviderAndWait(await devcycleClient.getOpenFeatureProvider());

OpenFeature.setLogger(console)
const ai = new GoogleGenAI({
  vertexai: true,
  project: process.env.GOOGLE_PROJECT_ID,
  location: "us-central1",
});

const app = express();
app.use(express.json());
const routes = Router();
app.use((req, res, next) => {
  const body = req.query
  const context = {
    user_id: body["uid"] as string ?? "john-doe",
  }
  res.setHeader("content-type", "text/plain");
  res.locals.context = context;
  next();
}, routes);

routes.get("/", async (_, res) => {
  res.send("ok");
})

routes.post("/call", async (req, res) => {
  const client = OpenFeature.getClient(res.locals.context);
  const { query } = req.body
  const model = await client.getStringValue(
    "model-evaluate",
    "gemini-2.0-flash-lite",
  );

  const start = process.hrtime.bigint()
  const {text} = await ai.models.generateContent({
    model,
    contents: query,
  });
  const end = process.hrtime.bigint();
  client.track("model-call-time", undefined, {
    value: Number(end - start), // ナノ秒
  })
  ai.models.countTokens({
    model,
    contents: text ?? "",
  }).then((tokens) => {
    client.track("model-used-token", undefined, {
      value: tokens.totalTokens ?? 0,
    })
  })
  res.send(text);
});

routes.post("/rate", async (req, res) => {
  const client = OpenFeature.getClient(res.locals.context);
  const { rate } = req.body
  client.track("model-user-rating", undefined, {
    value: rate
  })
  res.send("ok");
});

const port = process.env.PORT || 3333;
app.listen(port, () => {
  console.log(`Server running at http://localhost:${port}`);
});

ユーザを50件ぐらいとりあえずたくさん作って、アクセスしてみましょう。ユーザの評価についてはわからんので一旦適当にしておきます。

#!/bin/bash

names=()
for i in $(seq -w 1 50); do
    names+=("john$i")
done

for name in "${names[@]}";
do
    curl -X POST -H "Content-Type: application/json" -d '{"query": "株式会社スリーシェイクについて教えて下さい"}' https://xxxxxxxxxxx.asia-northeast1.run.app/call?uid="$name" > /dev/null 2>&1 &
    
    rate=$((RANDOM % 5 + 1))
    curl -X POST -H "Content-Type: application/json" -d '{"rate": '"$rate"' }' https://xxxxxxxxxxx.asia-northeast1.run.app/rate?uid="$name" > /dev/null 2>&1 &
done

実験結果としては以下のようになりました。

model-call-time

B が一番早く返却されることがわかりました。

また、C (flash-lite) よりも B (flash)のほうが結果が早く返ってくる傾向にあるのがわかりました。

個人的には A(2.5 pro) より D(2.0 flash thinking) のほうが若干早く変えることがあるということにも驚きました。もしかすると、両方とも experimental なのでほかで利用されてて若干反応が遅かったなどのほかからの影響も考えられるので、やるとしても、もう少し時間とかを変えながらやったほうが良いかもしれません

model-used-token

この数値自体はあまり意味が無いかもですが、やはり D(thinking model) は token 数を大量に消費していますね。また、B(flash) は基本的に若干レスポンスのtoken数が少なめのようです。

model-user-rating

評価値をランダムにしているため全く意味はないですが、このような形で取得できます。ちなみに、DevCycle で赤くなっているのは、「ベースの結果のほうが、ほかの結果より良かった」ことを表しています。

まとめ

簡単に Feature Flag を利用することができました。また、OpenFeature を使うことで Provider の実装を差し替えるだけで Flagd から DevCycle への利用の変更が済んでいるのも魅力です。

そして、trace を使うと計測が簡単にできることがわかりました。AB テストなどでの計測に利用する分には十分な機能があると感じます。

Feature Flag を導入するうえで良い反面、コードの複雑性が上がってしまうデメリットもあります。そうならないよう、AB テストでの Flag は結果が出たら削除するなど、きちんとリファクタを心がけるようにしましょう。

また、今回 DevCycle での計測も見てみましたが、それ以外を利用したいなどある場合、別途自分たちで OpenFeature の仕様に合うように自作 Provider を作成してみるのも良いかもです。

ブログ一覧へ戻る

お気軽にお問い合わせください

SREの設計・技術支援から、
SRE運用内で使用する
ツールの導入など、
SRE全般についてご支援しています。

資料請求・お問い合わせ