はじめに
WINTICKET バックエンドチームのマネージャーの鍛冶(@kj455)です。
WINTICKET では、これまでWeb向けに「DarkCanary 環境」を導入し、クローズドなリリース前動作確認や、便利な開発環境を整備してきました。
そして今回、同様の仕組みをサーバーサイドにも導入しました。本記事では、導入背景から実現方法、運用についてご紹介します。
DarkCanary 環境とは
DarkCanary 環境は、特定の条件を元にアクセス可能なクローズドなテスト環境です。通常のユーザーには見えない状態で、同一ドメイン上に構築されたテスト用の環境へトラフィックを振り分けることで、実際のインフラや設定を利用した動作検証が可能になります。
導入背景
従来の課題
WINTICKET のバックエンド開発では、以下の問題がありました。
ローカル開発・PR 作成時点では動作確認が行えない
WINTICKET サーバーはマイクロサービスアーキテクチャを採用していますが、様々な背景でローカルでのサーバー起動による動作確認が行えない状態にあります。
結果として開発者は各コンポーネントの単体テストによる動作の担保を試みますが、実際のアプリケーションフローやサービス間の連携に起因する問題は、 main ブランチへのマージと開発環境へのデプロイを経なければ確認できませんでした。その結果、複雑な機能や基盤変更が加わると、単体テストでは見落とされがちなバグが発生し、問題の調査のためにログを追加する修正 PR を作成、原因究明後にさらに別の修正PRを出すという非効率的な開発サイクルに陥っていました。
本番リリース前の動作確認に制限がある
従来の運用では、ステージング環境での検証とカナリアリリースを通じて本番環境へのデプロイを行っていたものの、特定の機能を本番環境で一部のユーザーに限定して検証する柔軟性には欠けていました。ユーザー固有の API に対しては、フィーチャーフラグを用いれば個々のユーザに応じた動作切替が可能でしたが、CDN キャッシュが有効な API の場合、そのアプローチは使えず、十分な検証が難しい状況にありました。
これらの課題を受けて、DarkCanary 環境をサーバーサイドにも導入し、開発者体験の向上と安全なリリースフローを確立することを目指しました。
実現方法
WINTICKET のサーバーアプリケーションは GKE 上で稼働しており、Istio(Cloud Service Mesh)の機能を利用して、特定の HTTP ヘッダーを検知し DarkCanary 環境へルーティングする仕組みを実現しています。以下は、その主要な手法と実装方法です。セキュリティ観点については後述します。
1. Istio VirtualService の設定
Istio の VirtualService を利用し、リクエストの特定の HTTP ヘッダー(例: X-DarkCanary)に基づいて、リクエストを DarkCanary 環境に振り分けます。例えば、以下の YAML サンプルでは、X-DarkCanary ヘッダーが enabled の場合、リクエストを DarkCanary 用サーバーへルーティングし、その他の場合は通常のサーバーへ送るよう設定しています。
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: foo-server
spec:
hosts:
- "foo-server"
http:
- match:
- headers:
X-Dark-Canary:
exact: "enabled"
route:
- destination:
host: foo-server.dark.svc.cluster.local # DarkCanary用の名前空間のサーバーへルーティング
- route:
- destination:
host: foo-server.default.svc.cluster.local # 通常の名前空間のサーバーへルーティング
2. HTTP ハンドラおよび gRPC インターセプターの実装
上記の Istio の設定に加え、特定の HTTP ヘッダーがマイクロサービス間で伝播する仕組みを作る必要があります。以下では、クライアントからのリクエストを受信する HTTP サーバー側の処理例と、後続で処理を行う各マイクロサービスに共通して適用する gRPC インターセプターの実装例を示します。
HTTPサーバー
ユーザーからの HTTP リクエストを受信した際、DarkCanary 用の HTTP ヘッダー(例: X-Dark-Canary)を抽出し、その情報を gRPC メタデータに変換して、 Outgoing context に追加します。これに加え、後続の gRPC クライアントインターセプタの処理によって DarkCanary 用のヘッダーが付与されます。以下に実装例を示します。
func DarkCanaryPropagationHandler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
val := r.Header.Get("X-Dark-Canary")
md := metadata.New(map[string]string{
"X-Dark-Canary": val,
})
mdctx := metadata.NewOutgoingContext(ctx, md)
next.ServeHTTP(w, r.WithContext(mdctx))
})
}
gRPCインターセプター
ここでは、Context から DarkCanary 用のヘッダー値を抽出し、他マイクロサービスへのリクエストを行う際にメタデータの OutgoingContext として付与します。 gRPC メタデータは HTTP ヘッダーとして扱えるため、 Istio(Envoy)のヘッダーマッチ機能によって正しく認識されます。以下に実装例を示します。
func ClientUnaryDarkCanaryInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, method string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption,
) error {
// このメタデータに DarkCanary 用のヘッダーが含まれる
if md, ok := metadata.FromIncomingContext(ctx)
ctx = metadata.NewOutgoingContext(ctx, md)
}
return invoker(ctx, method, req, reply, cc, opts...)
}
}
3. 全体のフロー
上記の実装を総合すると、処理全体としては以下のようになります。
- リクエスト受信:
クライアントからのHTTPリクエストがサーバーに到達すると、HTTP ハンドラが DarkCanary 用ヘッダーを抽出し、コンテキストに格納します。 - マイクロサービス間の通信への伝播:
他マイクロサービスへのリクエストを行う際に、コンテキストに含まれる gRPC メタデータを OutgoingContext へと詰め替えてリクエストを行います。これらの gRPC インターセプターは全てのマイクロサービスにおいて適用します。 - ルーティングの決定:
Istio の VirtualService 設定により、DarkCanary 用のヘッダーが付与されているもののみを DarkCanary 用のサーバーへとルーティングします。
これにより、リクエストのライフタイムにおいて DarkCanary 用ヘッダー値に基づいた一貫したトラフィックルーティングを実現することができます。
運用
WINTICKET では、GitHub の Pull Request(PR)を中心とした開発フローにおいて、GitHub Actions(GHA)を活用し、DarkCanary リリースを自動化しています。以下に、リリースの流れ、動作確認方法、及びセキュリティ対策について説明します。
DarkCanaryリリースの流れ
PR 作成時
-
- ラベル付与によるトリガー
PR に特定のラベルを付与することで、DarkCanary リリース用の GHA が起動し、デプロイが開始されます。 - デプロイ結果のフィードバック
デプロイ完了後、デプロイされたマイクロサービスや適用された YAML 設定などの情報がPRにコメントされます。
- ラベル付与によるトリガー
-
- Artifact の自動クリーンアップ
DarkCanary 用にビルドした Docker イメージは ArtifactRegistry にプッシュされますが、長期で保存する必要はないため、クリーンアップポリシーにより一定期間経過後に自動削除されるように設定しています。
- Artifact の自動クリーンアップ

PRマージ/クローズ時
-
- 開発環境
PRがマージまたはクローズされると、DarkCanary リソースを削除する GHA が起動し、DarkCanary 環境に展開されたリソースをクリーンアップします。 - ステージング・本番環境
ステージングや本番環境では、通常のリリースプロセスに連動して DarkCanary 環境も追従する形で自動デプロイするようにしています。この対応の背景としては、あらかじめ VirtualService に DarkCanary 用のルーティング設定を行い、後から DarkCanary 用の Service と Deployment をデプロイした場合にDarkCanary 環境へのトラフィックが500エラーになる、という事象があります(2025年3月時点)。本番環境ではできる限りエラーは発生させたくないため、常に DarkCanary リソースをデプロイしておく運用方針を採用しています。
- 開発環境
動作確認方法
開発者向け
DarkCanary リリース環境での動作検証を容易にするため、各マイクロサービスがリクエストを処理した際に、その識別情報(例:サーバー名)をレスポンスヘッダーに付与する仕組みを導入しています。これにより、レスポンスを確認するだけで、どのサービスがどの環境でリクエストを処理したかを把握可能です。
以下の 2 つの仕組みを組み合わせています。
1. Istio によるレスポンスヘッダー付与
Istio の VirtualService では、各マイクロサービスのレスポンスにサーバー情報を付与する設定を行っています。たとえば、以下のように設定することで、通常のサーバーが処理を行なったのか、DarkCanary 環境のサーバーが処理を行なったのかをレスポンスヘッダーで区別できます。
apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
name: example-service
spec:
hosts:
- "example-service"
http:
- route:
- destination:
host: example-service
headers:
response:
add:
server: "example" # 通常サーバー用ヘッダー
- match:
- headers:
X-Dark-Canary:
exact: "enabled"
route:
- destination:
host: example-service-dark
headers:
response:
add:
server: "example-dark" # DarkCanaryサーバー用ヘッダー
この設定により、クライアントに返されるレスポンスヘッダーには、どのサーバー(通常 or DarkCanary)がリクエストを処理したかの情報が含まれます。しかし、この方法だけでは、クライアントにレスポンスを返す直前のサーバーの情報のみが伝わり、内部でやり取りされるマイクロサービス間の通信には反映されません。これらを伝播させるために、以下の gRPC インターセプターを実装しました。
2. マイクロサービス間通信でのレスポンスヘッダー情報の集約
1 つのリクエストは、最終的にクライアントへ返されるサーバーだけでなく、内部の複数のマイクロサービス間で複数の gRPC 通信を伴います。そして、それらのレスポンスそれぞれに対してサーバー情報が上記の設定で付与されています。
つまり、リクエスト処理中に行ったすべての gRPC 呼び出しのレスポンスヘッダーを確認し、最終的なレスポンスを返す際に、それらのヘッダー情報を集約してクライアントに返す必要があります。
これを実現するための gRPC インターセプターを実装し、全てのマイクロサービスに適用します。以下に実装例を示します。
// サーバー側インターセプター:リクエスト開始前に OriginMap を初期化し、レスポンス時にヘッダーにセットする
func NewOriginCollectorServerUnaryInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo,
handler grpc.UnaryHandler,
) (interface{}, error) {
originMap := NewOriginMap() // 実体は sync.Map
ctx = NewContextWithGRPCOrigin(ctx, originMap)
resp, err := handler(ctx, req) // ハンドラ内部で gRPC 通信が行われる際に、下記 ClientInterceptor の処理が呼ばれ、originMap に値が入っていく
md := metadata.MD{}
md.Set(headerOrigin, originMap.Values()...)
grpc.SetHeader(ctx, md) // originMap の値を全てヘッダーに詰めて返す
return resp, err
}
}
// クライアント側インターセプター:受信したレスポンスヘッダーを集約する
func NewOriginCollectorClientUnaryInterceptor() grpc.UnaryClientInterceptor {
return func(ctx context.Context, fullMethod string, req, reply interface{},
cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption,
) error {
originMap := ExtractGRPCOriginMap(ctx) // ServerInterceptorで詰めた originMap を取得
newHeader := &metadata.MD{}
opts = append(opts, grpc.Header(newHeader))
err := invoker(ctx, fullMethod, req, reply, cc, opts...)
originMap.Add(newHeader.Get(headerOrigin)...) // レスポンスヘッダーの特定の値を originMap に詰める
return err
}
}
HTTP レスポンスに集約ヘッダーを付与
最終的に、クライアントへ返す HTTP レスポンスに、マイクロサービス間で集約したサーバー情報をヘッダーとして付与する必要があります。以下は、その実装例です。
// レスポンスヘッダーを溜める用の Map を context に詰めておく
func NewOriginMapHTTPMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
originMap := NewOriginMap() // 実態は sync.Map
ctx := NewContextWithGRPCOrigin(r.Context(), originMap)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// HTTP サーバー内で行った gRPC 通信の結果を HTTP レスポンスヘッダーに付与する
func addServedByHeader(ctx context.Context, w http.ResponseWriter) {
const headerServedBy = "X-Served-By"
originMap := trace.ExtractGRPCOriginMap(ctx) // 上記ミドルウェアで詰めた Map を context から取得
for _, origin := range originMap.Values() {
w.Header().Add(headerServedBy, origin) // レスポンスヘッダーに付与して返す
}
}
この仕組みにより、最終的にクライアントへ返すレスポンスにはそのリクエストを処理したすべてのサーバー情報がヘッダーに含まれます。これにより、開発者はリクエストがどの経路でどのサーバーによって処理されたかを一目で把握でき、トラブルシューティングやデバッグを効率的に行うことができます。
以下に概略図を示します。

非開発者向け
Web/App に組み込まれた専用の UI を利用することで、サーバーの DarkCanary 環境へのアクセス設定が簡単に行えます。UI から設定を有効にすると、Web/App からの HTTP リクエストに自動的に DarkCanary 用ヘッダーが付与され、エンジニアだけでなくビジネスメンバーも本番リリース直前の動作確認が安全かつ容易に実施できます。

セキュリティ
DarkCanary 用認証ヘッダー
WINTICKET では、WAF として CloudArmor を採用し、多層的なセキュリティ対策により悪意ある攻撃からシステムを保護しています。今回の DarkCanary 機能を有効にする場合には追加の認証ヘッダーを必須とすることで、社内限定のアクセスを保証します。また、WINTICKET では CDN を利用して API キャッシュを行っており、DarkCanary 用レスポンスと通常レスポンスが混在しないよう、キャッシュの分離設定も実施しています。
GHA でのWorkload Identity 連携と権限制御
DarkCanary リリースは GHA を用いて行っていますが、本番環境への書き込みを伴うため、DarkCanary 用名前空間に対してのみ権限を付与したサービスアカウントの Workload Identity 連携を行っています。これにより、万が一サービスアカウントが悪用された場合でも、本番リソースへの影響を最小限に抑えることが保証されています。
今後の展望
PR ごとの DarkCanary 環境の作成
現状、WINTICKET サーバーの DarkCanary 環境は、開発、ステージング、本番環境それぞれに対して、各マイクロサービスごとに一つしか構築できません。これは、Istio の VirtualService が「同一ホスト名に対しては1つのマニフェストしか有効にならない」という制約に起因しています。
しかし、この問題は Kubernetes GatewayAPI の HTTPRoute 機能を Cloud Service Mesh で利用できれば解決できます。HTTPRouteでは、同一ホストに対して複数のマニフェストを定義できるため、各PRや開発チームが自由にDarkCanary環境を作成して動作確認を行えるようになり、リリース時のコンフリクトを気にする必要がなくなります。ただ、2025年3月現在、既存 Istio API を利用している場合はGatewayAPIへの移行が行えない旨がドキュメントに記載されており、将来、この制約が解消され次第、HTTP Route を用いてより柔軟な DarkCanary 環境の整備を行う予定です。
非同期処理との連携
現時点では同期的な API リクエストを DarkCanary 環境に振り分けることを想定しています。しかし API リクエストによってトリガーされる非同期処理がある場合、それらのジョブ群も DarkCanary 向けに切り離されている状態が理想的です。今後の拡張として、非同期フローまで含めて DarkCanary 化を進められれば、より強力なテスト環境になると考えています。
終わりに
本記事では、WINTICKET サーバーに導入した DarkCanary 環境についてご紹介しました。この仕組みにより、以下のような課題を解決できています。
-
- PR時点での限定的なプレビュー環境の提供
- main にマージしなくても、動作確認ができるようになった
- 今後 GatewayAPI を利用できれば、他 PR とのコンフリクトを気にせず独立したプレビュー環境も実現可能
- 本番ドメイン上での安全な動作確認
- Web・サーバー・アプリ、すべてのプラットフォームで一気通貫した社内限定の検証環境が整備され、クローズドなテストが可能
- PR時点での限定的なプレビュー環境の提供
実際に、開発チームにおいて DarkCanary 環境が利用され、「事前に動作確認できて安心」・「デバッグがしやすくなった」などの声が上がっています。
今後もチームの開発組織の課題を解決できるような技術的取り組みを行い、高品質かつ高速でプロダクトをアップデートできる仕組みを整えていきたいです。