Mercari Engineering Blog

We're the software engineers behind Mercari. Check out our blog to see the tech that powers our marketplace.

マイクロサービス環境でのメルカリWebのリリースフロー

メルカリJP Webチームの@urahiroshiです。 Webチームでは、メルカリWebのマイクロサービス化や機能開発を行なっています。メルカリWebのマイクロサービス化の概要については、昨年のTech Conferenceの資料がよくまとまっているので、そちらを参照していただけると良いかと思います。 https://speakerdeck.com/mercari/mtc2018-web-application-as-a-microservice-3a161f5c-07fa-4dca-99e9-bd0e8feeeddf

現在、メルカリWebのトップページはマイクロサービス環境から配信されており、他のページも随時マイクロサービス環境に移行していく予定です。 この記事では、マイクロサービス環境で導入した、メルカリWebの新しいリリースフローについて記載します。

まずメルカリWebのマイクロサービス環境について記載しますが、Google Container Engine (以下、GKE)上にWeb Gateway, Server Side Rendering(以下、SSR), GraphQL, session serviceの4つのマイクロサービスが配置されています。ブラウザからのリクエストは、Web Gatewayを通じて、SSRサービス, GraphQLサービスが処理するという流れになっています。このWeb Gatewayは、KubernetesのIngress (HTTP/HTTPSロードバランサ)という役割を担っているサービスであり、今回記述するリリースフローに密接に関わっています。

f:id:urahiroshi:20191023151508p:plain
メルカリJP Webのマイクロサービスの概要

Web Gateway, NGINX Ingress Controllerについて

Ingressの機能の実装方式はIngress Controllerと呼ばれ、GKE環境でのデフォルトのIngress ControllerはGCEロードバランサ(GLBC)となります。しかし、webページを配信する場合は後述する問題があり、Web GatewayはNGINX Ingress Controllerを利用しています。

Kubernetesでは、Rolling Updateというデプロイ方式を取ることができます。例として、バージョン10のアプリケーションが動作している状態で、バージョン11のアプリケーションをデプロイする場合を考えます。 このときKubernetesでは、バージョン11のPod(アプリケーションを実行するホスト)を増やしつつ、バージョン10のPodを減らしていくという挙動になります。この場合、一定時間はバージョン10のPodと11のPodが両方リクエストを受け付けられる状態となります。

f:id:urahiroshi:20191023151911p:plain
リクエストはIngressを通ってPodに渡され(Service等は省略)、複数のバージョンが同時にリクエストを受ける時間が生じる

このような状態だと何が問題となるでしょうか? メルカリJP Webを含む多くのwebサイトでは、HTMLを取得するだけでは完全にwebページは表示できず、JavaScriptやCSS取得のため追加のリクエストを送信する必要があります。メルカリ JP WebのHTML, JavaScript, CSSは同一のコードベースで管理されており、JavaScriptやCSSは、それが作成されたタイミングと同じリビジョンのHTMLを前提として記述されます。 すなわち、ユーザーからのリクエストがランダムにバージョン10のPodとバージョン11のPodに渡された場合、取得したHTMLとJavaScript, CSSの内部的なリビジョンにずれが生じ、予期せぬ挙動となるおそれがあります。

f:id:urahiroshi:20191023154100p:plain

これを防ぐには、以下のような3通りの対策が考えられます。

  1. JavaScriptやCSSはすべてGoogle Cloud Storageなどから配信し、コンテントハッシュを付加することで特定のリビジョンのコンテンツが取得できるようにする
  2. Blue Greenデプロイにより、一度のタイミングでバージョン11のPodに切り替わるようにし、バージョン10と11のPodが同時にリクエストを受ける状況を回避する
  3. Session Affinityにより、一度接続したユーザーのリクエストを、常に同じPodが受けるようにする

1の対策については、メルカリJP WebではService Workerを利用しようと考えており、Service Workerは同一ドメインから配信するという制約があることや、 バージョンごとにURLを変更すると問題となるケースがあるため適用できませんでした。

2の対策では、デプロイ方法が限定され、Canaryリリース(一部のPodに新しいバージョンのアプリケーションをリリースし、一定時間モニタリングを行った上でリリースするPodを増やしていく)が利用できません。そこで、我々は3のSession Affinity方式を利用することとしました。

GLBCにはSession Affinityの機能がなかったため*1、その機能を持ち、Kubernetesプロジェクトが公式にメンテナンスしているNGINX Ingress Controllerを採用することとしました。従来のwebサーバのリバースプロキシがNGINXであり、既存のリバースプロキシと同等の設定を行いやすいことも採用理由の一つでした。

また、NGINX Ingress Controllerの代表的な機能に、Canary機能があります。 これは、リクエストに対して特定の条件(リクエストの割合、HTTPヘッダの値、Cookieの値など)に一致した場合、リクエストを送信先のPod(Kubernetes Service)を切り替えるという機能です。 これを利用することで、アプリケーションの社内向け先行リリースや、Canaryリリースなどを容易に実現することができます。

メルカリJP Webのリリースフロー

以上に記載したWeb Gateway (NGINX Ingress Controller)を利用して、webチームでは3-stepsリリースと呼んでいるリリースフローを構築しています。このワークフローはSpinnakerというツールで構築していますが、Spinnakerについては https://tech.mercari.com/entry/2017/08/21/092743 を参照してもらうとして、本記事では説明を割愛します。

f:id:urahiroshi:20191025133056p:plain
リリースフロー

まず、対象のサービスを管理するGitリポジトリのmasterブランチが更新されると、CIにより自動的にDockerイメージが更新され、リリースフローが走ります。 最初にTrialリリースが行われますが、これは前述したNGINX Ingress ControllerのCanary機能を用いて、特定のHTTPヘッダやCookieの値がある場合のみ、新しいDockerイメージが配置されたPodにアクセスできる状態にしています。Kubernetes上では、Trialリリース用Podと他のPod (Canaryリリース, 100%リリース用Pod)に対するServiceを分離することで実現しています。

f:id:urahiroshi:20191023154447p:plain

ここでQAチームによる手動テストや自動テストを実行し、機能的なバグがないかを検証します。検証が終わるとCanaryリリースのフェーズに移ります。

Canaryリリースでは、ユーザーがアクセスするPodの一部のみ新しい更新を適用します。例えば全体として50台のPodが動作している中で、そのうち1台のPodを更新した場合、2%のユーザーのリクエストが新しいPodに渡されます。このPodに対するパフォーマンスモニタリングやエラートラッキングを行い、パフォーマンス上の問題がないか、新しいエラーが発生していないかなどの確認を行います。パフォーマンスに懸念がある場合は、CanaryリリースのPod数を上昇させることもできます。 (Trailリリース、Canaryリリース用のPodには、環境変数を通じてパフォーマンスモニタリングやエラートラッキング用のタグを割り当て、通常のPodと区別してモニタリングできるようにしています) なお、前述したSession Affinityの挙動により、一度Canaryリリース用Podに接続したユーザーは継続してCanaryリリース用のPodに接続します。このため、画面をリロードするたびランダムにUIが変更する、といった問題の発生を抑えることができます。

ここで「なぜNGINX Ingress ControllerのCanary機能を使わないのか」と疑問に思われた方もいるかと思います。 実は、執筆時点のNGINX Ingress Contollerの挙動では、Canary機能とSession Affinityの機能を併用して利用することができず、同一ユーザーのリクエストがランダムにCanaryリリース用Podと通常のPodに振り分けられることになってしまいます。この状態では冒頭で記述したHTMLとJavaScript, CSSのリビジョンにずれが出る問題が生じかねないため、NGINX Ingres ControllerのCanary機能は使用せずにCanaryリリースを行なっています。

さて、Canaryリリースの検証も終わると、100%リリースを行います。100%リリースの後も一定時間モニタリングを行い、問題ないことを確認します。

もし各リリースのフェーズで問題が見つかった場合は、ロールバックのワークフローを実行します。このワークフローでは、まずKubernetes上で各Podを更新前の状態に戻し、その後に更新元のPull Requestに対するRevert Pull Requestを作成するという手順を行なっています。 Revert Pull Requestを作成することはSpinnakerの標準機能ではできないのですが、gitのコミットハッシュをDockerのイメージタグ内に入れており、このコミットハッシュの値からRevert Pull Requestを作成する仕組みを別途作成しています。

おわりに

以上が、マイクロサービス環境におけるメルカリJP Webのリリースフローでした。

以前のリリースフローでは、本番環境に対する変更は一気に100%適用されてしまうため、安全にリリースを行うためには事前のテストを入念に行う必要がある上に、環境要因の予期せぬトラブルが生じることもありました。 マイクロサービスの環境では相互依存するサービスがさらに増えているため、検証/開発環境と本番環境の差異はより大きく、環境要因のトラブルのリスクが高まると考えられます。一方で、プロダクトの競争力を維持するために、リリースのスピードを落とさないことも求められます。リリースの速度と、プロダクトの安定性をともに落とさないための仕組みとして、このようなリリースフローを導入しました。

導入後の課題として、リリース後のモニタリングに人手がかかりすぎているなどの点があるため、今後はモニタリングの自動化などにより力を注いでいきたいと思っています。

*1:追記: 設計時点の話であり、現在はGLBCにもSession Affinityの機能があります。