
はじめに
【デジスマ ブログリレー 1日目】
こんにちは、デジスマチームの 伴 です。
デジスマ診療は、患者さんの予約や問診、決済まで一連の機能をワンストップで提供することで患者さんだけでなく受付、医師もラクになる新しい医療体験を提供しています。
www.youtube.com
普段は主にインフラ周りを担当しており、Kubernetes のメンテナンスなどをしていますが、現在は API の Go 化も進めています。
本記事では、Kotlin (Spring Boot / JVM) から Go へのサーバー実装書き換えを安全に進めるために、私たちのチームで Istio の VirtualService をどのように活用しているかを紹介します。
本番稼働中のサービスの実装を別言語に丸ごと差し替えるのはそれなりにリスクがありますが、Istio VirtualService のmirror・match・weight をうまく組み合わせることで、「ミラーリングによる同一性チェック → パスごとのカナリアリリース → 100%切り替え」という流れを、アプリケーションコードにほぼ手を入れずに進めています。
本記事が同じような移行を検討している方の参考になれば幸いです。
目次
- はじめに
- 目次
- 移行の背景
- なぜ Istio VirtualService を使うのか?
- VirtualService の機能おさらい
- フェーズ1: ミラーリングで同一性を検証する
- フェーズ2: match と weight でカナリアリリース
- 現状と所感
- We are hiring !!
移行の背景
私たちのサービスは Kotlin (Spring Boot / JVM) で実装し、Kubernetes 上で長らく安定運用してきました。Kotlin (Spring Boot / JVM) は型安全性や豊富なエコシステムに支えられ、ここまでサービスを育ててくれた頼れる構成です。一方で、サービス規模の成長に伴い、JVM の暖気特性(起動時間、起動直後のレイテンシ、暖気対策の積み重ねによる構成の複雑化)が、次に伸ばせそうなポイントとして見えてきました。そこで、起動が速く定常的なリソース効率も良い Go で書き直し、サービス基盤をさらにスケールしやすい形に進化させるプロジェクトを進めています。
しかし、本番稼働中のサービスを別言語の実装に差し替えるのは、当然ながら大きなリスクを伴います。新実装でも同じ挙動を維持できるか、そして問題が見つかったときにいかに速く戻せるかが、移行の成否を分ける鍵になります。この2点を支えてくれているのが Istio の VirtualService です。
なぜ Istio VirtualService を使うのか?
Kotlin 実装と Go 実装は、別 Deployment / 別 Service として並走させ、その手前にいる Istio の VirtualService で振り分けを制御しています。
VirtualService を使うことで、
- 同じリクエストを Kotlin と Go の両方に同時に流す(
mirror) - 特定のパスやメソッドの組み合わせだけを Go に流す(
match) - 流量を 5% / 50% / 100% と段階的に増やす(
weight)
といった切り替えを、アプリケーションコードにほぼ手を入れずに、マニフェストの書き換えとデプロイだけで実現できます。本番トラフィックを使った検証と段階的な切り替えに非常に向いていました。
ミラーリング自体は専用の OSS ツールでも実現できますが、検討時点でメンテナンスが活発でないものが多く、新規導入のコストも小さくありませんでした。一方、私たちのクラスタには既に Istio が導入済みで、VirtualService 1つでmirror・match・weight をまとめて扱えるため、追加コンポーネントを増やさずに移行を進められる Istio を選択しました。
VirtualService の機能おさらい
具体的な実例に入る前に、今回使った機能を簡単に整理します。
mirror — リクエストを複製してもう一方にも流す
mirror を指定すると、本来の宛先 (route) に加えて、リクエストのコピーを別の宛先にも非同期で送信します。ミラー先のレスポンスはクライアントには返らないため、ミラー先で何が起きてもユーザー影響はありません。「本番トラフィックを使って新実装の挙動を確認する」のに最適です。
mirrorPercentage を指定するとミラーする割合も調整できます。ミラーリングでは Go 側でもリクエスト処理が走るため、リクエスト数の多いエンドポイントでは Go 側の負荷や Datadog に流れるログ・メトリクスの量が大きく膨らみがちです。そのため、観測に必要十分なサンプル量を見ながら mirrorPercentage で流量を絞る運用をしていました。
match — パスやヘッダーで振り分け先を変更する
match で URI のプレフィックスや正規表現、HTTP メソッド、ヘッダーなどの条件を指定して、その条件にマッチしたリクエストだけ別の route に転送できます。「まずはこの GET エンドポイントだけ Go に切り替えたい」というユースケースにそのまま使えます。
weight — 同一ルート内で振り分け比率を制御する
route に複数の destination を書いて、それぞれに weight を指定すると、その比率でトラフィックが振り分けられます。weight: 5 から始めて 50、100 と段階的に上げていく、いわゆるカナリアリリースが素直に実現できます。
フェーズ1: ミラーリングで同一性を検証する
最初のフェーズでは、Kotlin 実装にトラフィックを流しつつ、同じリクエストを Go 実装にもミラーで流しました。VirtualService の設定例は次のようになります(この後の YAML 例では、my-api を Kotlin 版、my-api-go を Go 版の Service 名として記載しています)。
apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: my-api spec: http: - route: - destination: host: my-api.my-ns.svc.cluster.local mirror: host: my-api-go.my-ns.svc.cluster.local mirrorPercentage: value: 100.0 timeout: 10s
これだけで本番のリクエストがそっくりそのまま Go 側にも流れます。あとは Go 側が「Kotlin 側と同じ挙動をしているか」を確認していくフェーズです。
なお、ミラーリングではリクエストが Kotlin 側と Go 側の両方で実行されるため、DB を更新したり外部 API を呼んだりする副作用のあるエンドポイントには使えません。フェーズ1 のミラーリング対象は副作用のない READ 系のエンドポイントに限定し、WRITE 系のエンドポイントはフェーズ2 のカナリアリリースで段階的に切り替える方針としました(実物の VirtualService では match で READ 系のパスにのみミラーをかけています)。
同一性チェックでみていたもの
Kotlin 版・Go 版の挙動を、次のような観点で突き合わせていきました。
- HTTP ステータスコードの一致: 同じリクエストに対して同じステータスを返しているか
- レスポンスボディの一致: JSON の構造・値が一致しているか
- エラーログの有無: Go 側だけで予期しないエラーが出ていないか
- DB へのクエリ / 更新内容: 同じクエリ・同じ更新内容になっているか
- レイテンシ: Kotlin 版と Go 版でレスポンスタイムに極端な差が出ていないか
これらは主に Datadog 経由で確認しました。Istio で mirror されたリクエストは、ミラー先のサービス側から見ると http://my-api.my-ns.svc.cluster.local-shadow/foo のように、Host 名に -shadow サフィックスが付いた形で記録されます。これを利用すると、Datadog 上で「Go 側が受けたミラーリクエスト」だけをフィルタして集計できるため、Kotlin 側に流れている本来のリクエストと並べて、ステータスコードの分布、ERROR ログの発生数、実行された SQL の種類、レイテンシ分布などが揃っているかを比較できます。
差分が見つかったときは、Datadog 上のトレース情報をもとに該当リクエストの再現条件を絞り込み、Go 側のコードを修正したうえで、再度ミラーリングで挙動を比較する、というサイクルを回しました。本番切り替え前にこのサイクルを繰り返すことで、後段のカナリアリリースを安心して進める土台になりました。
実際に拾えた不一致
ミラーリングをやっておいてよかったと心から思った差分の例として、次のようなものがありました。
- 日時の精度差: Kotlin / Go の両方で同じ OpenAPI 定義から型を生成していますが、使っているコードジェネレータが言語ごとに異なるため、片方だけ日時がミリ秒精度から秒精度に丸められてしまっていた
- 空値のキーが欠落: 期待されるレスポンスは「キーは残したまま値が空文字列・空配列」だったが、片方の実装ではキー自体がレスポンスから欠落していた
どちらも机上の互換性確認では見つけにくく、本番切り替え後に発覚していたら少し厄介なタイプの差分です。とくに後者は、開発時のテストでは想定しきれない少し特殊なパラメータパターンのリクエストで初めて顕在化したもので、本番トラフィックをそのまま流して比較できるミラーリングだからこそ拾えた差分でした。
フェーズ2: match と weight でカナリアリリース
ミラーリングで「同じ挙動になっている」確信が持てたら、次は実際にユーザーリクエストの一部を Go 側に流していくフェーズです。VirtualService の設定例は最終的に次のような姿になりました(実物の抜粋・簡略化版です)。
apiVersion: networking.istio.io/v1 kind: VirtualService metadata: name: my-api spec: http: # カナリア中: 一部パスを 50/50 で振り分け - match: - uri: prefix: "/v1/createFoo" - uri: regex: "/v1/foos/[^/]+/images" route: - destination: host: my-api.my-ns.svc.cluster.local weight: 50 - destination: host: my-api-go.my-ns.svc.cluster.local weight: 50 timeout: 10s # 100% 移行済みのパス: 全部 Go へ - match: - uri: prefix: "/v1/foos/search" - uri: prefix: "/v1/getFoos" route: - destination: host: my-api-go.my-ns.svc.cluster.local timeout: 10s # それ以外はまだ Kotlin - route: - destination: host: my-api.my-ns.svc.cluster.local timeout: 10s
ポイントは、
- エンドポイント単位で切り替えを進められること(
matchのuri.prefix/uri.regexで柔軟に指定できる) - 同じパスでも比率を変えて段階的に Go の比率を上げられること(
weight) - 切り替えが終わったパスは、
weightを消して Go のみの destination に縮約してしまうこと
特に「READ 系のエンドポイントから先に倒していく」「副作用のある WRITE 系は最後まで慎重にカナリア比率を上げる」など、エンドポイントの性質ごとに進め方を変えられるのが嬉しいポイントでした。
現状と所感
執筆時点では、1つのマイクロサービスの API がほぼ Go 化できた段階です。同じ仕組みを使いまわしながら、これから他の API の Go 化にも順次取り組んでいく予定です。
振り返って思うのは、
- 本番トラフィックをそのまま使ってミラー検証できたので、Kotlin / Go の挙動が一致していることを移行前に確認できた
- 切り替えの粒度をエンドポイント単位 × 比率で持てるので、「問題が見つかったら即座に戻す」が現実的な選択肢になり、心理的負担がかなり軽い
- 振り分けのためのロジックを VirtualService 側に閉じ込められるので、Kotlin / Go それぞれの実装に「移行のための分岐コード」が入り込まずに済む
Istio / Service Mesh のトラフィック制御機能は、これまで知識としては知っていましたが、今回のような大きな移行で実際に使ってみて、トラフィックの振り分けを自在に変えられる土台が手元にあることのありがたみを実感しました。同じように「別言語へのリプレース」「フレームワーク刷新」みたいな話を抱えているチームには、おすすめできる構成です。
We are hiring !!
エムスリーでは一緒にプロダクト開発をするエンジニアを絶賛募集中です。 デジスマの仕事に興味をお持ちいただけたならぜひ詳細をご確認ください。