サービス分割時の複雑性に対処する: テスト戦略の話

技術部の taiki45 です。

現在のクックパッドでは、cookpad.com 内のデータを利用するようなプロダクトでも、cookpad.com を提供しているアプリケーション(本体アプリケーション)とは別に新規のアプリケーションとして設計・実装しています。また、すでに本体アプリケーションの一部として実装されているプロダクトについても、トレードオフを考慮しながら場合によっては、本体アプリケーションから独立した別のアプリケーションとして設計・実装することが増えてきています。これらの本体アプリケーションや、新規にあるいは本体アプリケーションから独立させて設計・実装したアプリケーションのことを「サービス」と呼んでいます。また、この本体アプリケーションから独立させることを「サービス分割」と呼んでいます。

制御できないほどの巨大な複雑なまとまりを制御するために、その巨大なまとまりと単純なまとまりに独立・切り出して分散系へと切り出す方法があります。サービス分割をすることはこのやり方に沿っていて、小さなアプリケーションとして開発できるメリットや、独立したチームが独立したプロダクトのライフサイクルを持てるといったメリットを享受できます。一方で、分散系ではそれらを構成する要素間の関係をいかにして担保するかという従来とは異なった側面の複雑性が問題になってきます。その関係が正しく動作しないと、系全体として正しく動作しなくなるためです。例えば、サービスディスカバリの問題が発生することや、サービス間の連携が不安定になり複雑になるといったデメリットなどがあります。サービス分割のメリットを享受し、開発速度を保つためには、このデメリットを解消していく必要があります。

今回は、分散環境化で生じる問題の1つとしてサービス間のインテグレーションテストにおける問題、その解決へのアプローチとして Consumer-Driven Contract testing パターン、そしてクックパッドでの取り組みを紹介します。

問題

クックパッドでは各サービス間の連携には、Protocol Buffers や Thrift を用いた RPC を利用するのではなく、JSON over HTTP を利用する方針にしています。この理由としては、現状そこまでパフォーマンスがボトルネックになっていないことがあり、また、デバッギングの容易性や Rails の RESTful な思想との親和性や既存のミドルウェアの再利用といった開発効率のほうを優先しているからです。

クックパッドでは、「機能やデータを提供する側」(Provider)と「そのデータを利用する側」(Consumer)の結合部分において Provider に拡張の余地を残しています。したがって、Provider はレスポンスを変更することがあります。例えば、「レシピ名」、「作者」、「レシピの説明」のようなレシピデータを1件返すような架空の API のレスポンスを考えてみます。

{
  "name": "丸ごと野菜とステーキ アボカドソース添え",
  "author": {
    "id": 8510522,
    "name": "taiki45"
  },
  "description": "ステーキと丸ごと野菜だけでもおいしいのですが、少しさっぱりとしたアボカドマスタードソースが絡むことでとてもおいしいです。"
}

この時、トップレベルに新たに id キーを追加することも、あるいは author オブジェクトに新たに created_at キーを追加することもどちらも Consumer にとって非破壊的変更になるように、Consumer が利用するクライアントライブラリは未知のキーを無視するように実装されています。

今まではこのようなサービス間の結合部分を、主に WebMock あるいは vcr が提供するテストダブルを利用して、ほとんど固定されたレスポンスを使用してテストしていました。ここでの問題は、Provider がレスポンスを変更する際に誤って非互換な変更を含めてしまうことに対する対処です。非互換な変更の例としては、「Consumer が利用しているキーを削除してしまう」や「値の型を変更する」などです。

固定されたレスポンスを使う方法では、Provider が間違って破壊的変更を追加した際に、テストを実行した段階では破壊的変更を検知できません。そのため、Provider がその変更をリリースして初めてサービス間連携が壊れた事に気がつく事態になってしまいます。そのような事故を減らすために、今は大きな変更の前には Provider と Consumer が調整しています。

しかし、組織とサービスの独立性を高め、お互いに非同期かつ高い効率で、サービス開発するには、サービス間連携が壊れないことをテスト実行時に検証する仕組みを導入し、Provider と Consumer の調整コストを減らすことが必要です。

破壊的変更を自動的に検知する容易な方法として、Consumer 側でのインテグレーションテストにおいて実際の Provider サーバーを起動してテストする方法があります。まず、この方法ではテスト実行時間が増加します。さらに、Provider 側で変更を加える時、Consumer 側の時間がかかるインテグレーションテストを全て通さなければならなくなります。そのため、サービス間の依存が複雑になるにつれ、テストのコストが上がっていき、クックパッドが実現したい継続的デリバリーの形に支障をきたしてしまいます。

クックパッドでの取り組み

このような問題に対して、クックパッドではまず VCR を拡張して、Provider が CI を回す毎にレスポンスデータを生成して Consumer に渡し、Consumer はそのレスポンスデータを元にインテグレーションテストを実行する方法を開発しました。詳しい内容は「マイクロサービス時代を乗り越えるために、Rack::VCRでらくらくアプリケーション間テスト」から参照できます。この方法では常に最新の Provider のレスポンスデータで Consumer はテストを実行できます。

この方法はある程度うまくいったのですが、いくつか問題がありました。1つ目に、Provider に変更があるとその Provider に依存する全ての Consumer の検証をパスしないと安全に Provider の変更をリリースできないことです。これはサービス間の依存グラフが複雑な状況ではテスト時間が問題になってしまいます。2つ目は、Consumer が要求するリクエスト・レスポンスの組を Provider の側で記述する必要がある点です。サービス開発効率という面では、Provider と Consumer 間の調整コストはもっと減らしたい。

上記のような問題を解決するために調査をしてみると、この形は実は Integration Contract Testing 戦略と呼ばれているものに近いことが分かりました。この戦略は Provider が提供する機能や Consumer が必要とする機能を Contract としてくくりだし、コンポーネント単体でそれらの Contract をテストする手法です。

Contract は入力・出力のスキーマ、呼び出しが起こす副作用、パフォーマンスなどの特徴から成り立ちます。Contract は大別すると3種類にわけられます:

  • Provider Contract: Provider が提供する機能全体をカバーする Contract。
  • Consumer Contract: 個々の Consumer が Provider に要求する機能をカバーする Contract。
  • Consumer-Driven Contract: ある Provider への Consumer Contract 全ての集合。

この中でも Consumer-Driven Contract に着目します。この Contract を利用して以下のようなプロセスを実施することで、Provider-Consumer 間の結合が破壊されていないことを検証できます。

  • Consumer がある特定のリクエストに対応する期待するレスポンスを定義する。
  • Provider と Consumer はその Contract について合意する。
  • Provider は自身が継続的にその Contract を守れているか検証する。

このようなテスト手法は Consumer-Driven Contract testing パターンと呼ばれています。このパターンは他のインテグレーションテストの手法に比較して、次の点でメリットがあります:

  • インテグレーションテストがコンポーネント単体で完結できる。
    • 2つ以上のサービス間の結合のテストのための調整コストが低くなる。
  • Provider は Consumer-Driven Contract を守っている限り、安心して変更を行える。
    • どの Consumer も要求しないような Provider Contract に対して Provider は責任を持つ必要がない。
  • Consumer の開発者が自身が依存する Contract を作成・メンテナンスできる。
    • Contract が守られなかった場合、主に影響を受けるのは Consumer 側なので、Provider よりも Consumer のほうが Contract に対して関心が高い。したがって Consumer が Contract の作成・メンテナンスに責任を持つほうがより適切である。

実際に Consumer-Driven Contract testing パターンを実現するには次のような仕組みが必要です:

  • Consumer が Contract を定義して、それを Provider に届ける仕組み。
  • Provider が Contract を取得して、それを検証する仕組み。
  • (Provider が状態を持ったり、他のサービスに依存している場合) Consumer が特定の Contract での Provider の状態を記述し、Provider がそれを再現できる仕組み。

このような仕組みを実装してるツールとして代表的なものに Pact があります。

クックパッドでは、現在はこの Pact を検証すると同時に、Consumer が Contract を書く手間を自動生成ツールで代替できないか等を検討しています。

エンジニア募集中!!

クックパッドではこのような挑戦的な問題解決に取り組める環境があり、問題解決に取り組む仲間を募集しています。興味のある方はぜひ一度遊びに来てみてください!!

テストエンジニア | クックパッド株式会社 採用情報