【書き起こし】The World Is at Your Pull Request! – How to Make a Dynamic QA Environment on Kubernetes and Istio - Yuki Ito【Merpay Tech Fest 2021】

Merpay Tech Fest 2021は、事業との関わりから技術への興味を深め、プロダクトやサービスを支えるエンジニアリングを知れるお祭りで、2021年7月26日(月)からの5日間、開催しました。セッションでは、事業を支える組織・技術・課題などへの試行錯誤やアプローチを紹介していきました。

この記事は、「The World Is at Your Pull Request!」の書き起こしです。

伊藤雄貴氏:「The World Is at Your Pull Request!」というタイトルで、メルペイの伊藤が発表させていただきます。このセッションでは、メルペイの開発で出てきた、「QA環境が枯渇してしまい、QAをスケールすることができない」という課題をKubernetes、Istioといった技術を用いてどのように解決していくか、というナレッジを皆さんにシェアできればと思います。

はじめに、簡単に自己紹介をさせてください。私は伊藤と申します。株式会社メルペイのArchitectチームに所属しています。Architectチームは、会社全体のマイクロサービスのあるべき姿を考えたり、基盤となる技術であるKubernetesやIstio、gRPCなどの活用方法を標準化したり、また、そのための必要なライブラリを書いたりするようなチームになっています。それと同時に、メルカリ側のMicroservices PlatformチームというKubernetesを始めとしたプラットフォームを管理するチームにも所属し、CI/CDの環境を拡張するようなKubernetesのコントローラーを書いていたりもしています。

Agenda

このセッションのAgendaはこのようになっています。、まず、弊社がどのような環境でサービスを運用しているのかという点と、そこで出てきた問題、おもにQA環境が枯渇してしまっているという問題の背景を解説します。次に、その問題をどのようにして解決していったのか、具体的にはカスタムKubernetes Controllerを作成することでどのようにして解決していったのか、という2部構成でお話します。

Introduction

まず、弊社はマイクロサービスというアーキテクチャでサービスを運用しています。いろいろなビジネスのドメインごとに個々のコンポーネントとしてマイクロにサービスを実装することで、全体としてのシステムを構築しています。

これは余談になりますが、先日開催された「Go Conference」で、私がメルカリのスポンサーブースを運営しており、そこでは手元のPCでメルカリっぽいマイクロサービスを動かすことができる、というリポジトリを用いてハンズオンを行っていました。手元のPCにKubernetesクラスを構築して、そこにIstioをインストールし、gRPCで通信するGoのサーバーが動く、というメルカリっぽいマイクロサービスを手元で試すことができる環境になっていますので、興味のある方はぜひ試してみていただければと思います。

話を戻しまして、弊社は、おもにGoogle Cloud Platform(GCP)を用いてサービスを構築しています。このGCPから提供されているマネージドなKubernetesのエンジン、Google Kubernetes Engineの上に各マイクロサービスを載せており、各マイクロサービスが必要に応じてGCP上のリソース、例えばデータベースであるCloud Spannerや、メッセージングのミドルウェアであるCloud Pub/Subなどを利用している、というアーキテクチャになっています。

Kubernetesは、コンテナでアプリケーションを運用するためのプラットフォームとなるソフトウェアです。弊社は、このKubernetesの上でマイクロサービスを構築しています。

Kubernetesのクラスタについてですが、弊社はProduction環境用に1つのクラスタ、開発環境用に1つのクラスタ、といったように2つのクラスタを運用しています。QAやテストにおいては、開発環境用のクラスタで行っています。

その中でマイクロサービスをどのようにデプロイしているかというと、まず、マイクロサービスごとにNamespaceをつくっています。ここでは、マイクロサービスa、b、c、dというような個々のマイクロサービスごとにKubernetesのNamespaceを提供する形になっています。この個々のNamespaceに、個々のマイクロサービスのためのDeploymentやServiceを構築しており、それぞれをつないでマイクロサービスとして運用している、というような形になっています。

このように、個々のマイクロサービスごとに、1つのServiceと1つのDeploymentが存在する、という形式なので、QAに利用できる環境も1つしかありませんでした。この時にどのような問題が発生してしまっていたかという点を解説します。

例えばある1つのマイクロサービスについて、Feature1、Feature2、Feature3という、3つの機能開発を並列させていること。例えばバグフィックスをやりつつ、新しい機能をつくっている、というケースを想定してください。

このとき、個々のFeatureは、基本的には1つのPull Requestとして実装しています。例えば、Pull Request1がFeature1、Pull Request2がFeature2、といったようにPull Requestベースで実装が進んでいきます。例えばPull Request2をQAするときには、1つしか存在しないQA環境にPull Request2のソースコードから生成されたアプリケーションのコンテナイメージをデプロイします。

このときに、Pull Request1とPull Request3のQAも同時に行いたい場合、1つしかないQA環境をPull Request2が占有してしまっている状態なので、この場合だとPull Request1と3は、まず2のQAの完了を待たなければなりません。当然ですが、このようにQAを行う場合、QAにかかる時間の増加やQA自体がスケールしないといった点が問題になります。

これを解決するために、例えばPull Request1、2、3を1つのRelease branchにマージして、このマージしたbranchを単一のQA環境にデプロイするという選択肢もあります。しかし、この方法にも問題点があると感が手ています。例えばFeature1と3はQAをパスしたが、Feature2にはバグがあるというケースを想定してください。この場合の選択肢としては次の2つになると考えられます。

Pull Request2の機能のバグを直すまでPull Request1と3はリリースせずに待つか、或いは、Release branchからPull Request2をRevertするか、という選択肢です。この両方の選択肢がどちらも問題を持っていると考えています。まず、Pull Request2を直さないとデプロイできないというのは、Pull Request1と3に関してもっと早くお客さまに価値を届けられる、もっと早くデプロイできるのに2を待たないといけないということが機会損失になると思っています。

Revertを用いる方式については、Revertを通常フローに組み込むこと自体危険なことだと思っています。また、Revertを用いる場合は本来であればRelease branchからPull Request2をリバートしたものを再度QAする必要がありますが、これを行うとまた時間がかかってしまいます。

このような問題がメルペイでは頻繁に発生しており、この問題をどのようにして解決していったかということが、セッションで共有する内容になります。

この問題を解決するにあたり、なにをゴールとして据えたかというと、例えばPull Request1、2、3のような個々の機能があるときに、それに対応したQA環境を1、2、3といったように構築して、それぞれが並列でQA活用でき、QAが終わったものからデプロイできるという、ダイナミックでオンデマンドにQA環境を構築することが可能になる環境を実装する、ということをゴールとして定めました。具体的にこれをどのようにして解決していったかということをきょうのセッションではメインとして話していこうと思います。

Custom Kubernetes Controllers

Pull Request Replication Controller

これを解決するためにわれわれは、カスタムのKubernetesのコントローラーを実装して、動的にQA環境を増やせるような仕組みをつくりました。具体的にどのようなコントローラーを書いていったかという点を、ステップ・バイ・ステップで説明していこうと思っています。

われわれは、2つのカスタムKubernetes Controllerを作成し、それぞれを連携させることによってこの問題にアプローチしました。まずは、Pull Request Replication Controllerと呼ばれる、Pull Requestに対してDeployment、やServiceを複製するコントローラーについて解説していきます。

ハイレベルなアーキテクチャはこのようになっており、Pull Request Replication Controllerという、われわれが書いたカスタムのKubernetes ControllerをKubernetesクラスタにデプロイしています。このコントローラーはGitHubの状態を取得して、Pull Requestが存在する場合に、そのPull Requestに対応したDeploymentを動的hに複製する、というコントローラーになっています。このコントローラーがどのような動きをしていくかというのをステップ・バイ・ステップで見ていこうと思います。

まず、こちらが一番最初の状態となります。コピー元となる、今まで使っていた単一のQA環境がオリジナルとして存在しています。これにPull Request Replication Controller専用のAnnotationを1個付与することで、Pull Request Replication Controllerの動作が始まります。

具体的にどのようなAnnotationこのようになっており、Annotationにそのマイクロサービスが対応しているGitHubのリポジトリーのURLを記載します。これによって、このQA環境がどのリポジトリーのPull Requestを見て複製すればいいかというのをこのコントローラーに伝えています。

このAnnotationをオリジナルとなるDeploymentに付与することで、Pull Request Replication Controllerは「Annotationで指定されたリポジトリーにPull Requestが作成された時、QA環境を複製する必要がある」というのを認識することができます。

具体的にはこのような実装になっており、まずはじめに、後述するKubernetesのAPIクライアントを利用してDeployment、正確にはDeploymentの管理対象となるReplicaSetを全て取得して、先ほど解説した特殊なAnnotationが付いているもののみを集めます。

ここで利用しているGoのライブラリが、このclient-goというライブラリになっていて、KubernetesのAPIクライアントとしてのGoのライブラリになっています。例えばPodを作成したり、Podを取得したり、われわれの用途だとReplicaSetを取得・作成するために用いています。このような操作をGoで行うときは、必ずと言っていいほど利用するようなライブラリになっており、今回のPull Request Replication Controllerでも、これをメインに使っているという形になります。

これによってPull Request Replication ControllerがオリジナルとなるServiceやDeploymentを取得します。その次にPull Request Replication Controllerは、先ほど解説したAnnotationの内容、つまりGitHubのリポジトリーのURLを使ってそのリポジトリーに対して、今Pull Requestはいくつなのか、どういうPull Requestがあるのか、という情報を取得します。

その後、開発者がPull RequestをつくったときにPull Request Replication Controllerがどのような動きをするかを解説します。Kubernetes ControllerはActualとDesiredの状態、つまりKubernetesリソースをどのような状態にしたいかということをDesiredな状態として、今現在の実際のKubernetesのリソースの状態をActualな状態として定義し、その理想の状態と現在の状態の差分を埋め続けるように必要な処理を実行し続ける、というアーキテクチャになっています。われわれのPull Request Replication Controllerの場合は、Pull Requestに対応したDeploymentとServiceが複製されている状態がDesiredな状態となります。

初期の状態だと、Pull Requestをつくっても、それに対応したDeploymentは当然複製されていません。なので、Pull Request Replication Controllerは先ほど紹介したclient-goを用いて、コピー元となるDeploymentの情報を取得して、必要な部分だけを書き換えたものを新しい環境としてServiceとDeployment、ReplicaSetを複製するというな動きになっています。

この状態でPull Request2をつくると、Pull Request2に対応する環境が存在しないので、Pull Request2に対する環境を複製する必要がある、というのをPull Request Replication Controllerが認識し、Pull Request2に対応する環境を複製し始めます。

さらに、例えばPull Request1がマージ・クローズされたときに、Actualな状態として存在しているPull Request1のQA環境というのは、もはや必要のないものなので削除する必要があります。

そのような差分を埋めるように、不要になったPull Request1の環境をDeleteするという仕組みになっています。このような一連の動作がPull Request Replication Controllerの基礎となっています。

このように、Pull Request Replication Controllerは、開発者がつくったPull Requestを定期的に監視して、Pull Requestに対応する環境を必要に応じて作成し、不要になったら削除していくというようなコントローラーになっています。

具体的な実装ですが、これは先ほども紹介したとおりclient-goを用いて、例えばReplicaSetのCreateやServiceのCreateメソッドを実行することで、最終的にはKubernetesのリソースとして作成しています。

このようにしてPull Request Replication Controllerを用いて、Pull Requestに対して動的にQA環境を複製することが可能となっています。

しかし、QA環境の枯渇問題はこれだけでは解決しません。あるマイクロサービスXが複製されたマイクロサービスAを呼び出している時に、マイクロサービスXは、動的に複製されたマイクロサービスAの環境は認識できません。KubernetesのServiceが複製されると、そのServiceに対する新しいドメインがKubernetes内で1つ発行されますが、呼び出元となるマイクロサービスXが認識しているマイクロサービスAのアドレスは、依然としてオリジナルの環境のものになっています。この状態だと、環境を複製しても複製した環境にリクエストをルーティングすることができません。

Service Routing Controller

これを解決するためにService Routing Controllerというカスタム Kubernetes Controllerを実装しました。このコントローラーは、複製したQA環境に対して必要に応じてダイナミックにトラフィックをルーティングできる仕組みを提供します。次に、このコントローラーの内容を再びステップ・バイ・ステップで見ていこうと思います。

この図が、ハイレベルなアーキテクチャになります。弊社はIstioというサービスメッシュのスタックを導入しており、このIstioの機能、具体的には Vertual Serviceという機能を用いることで、トラフィックをサービスメッシュレイヤーでインターセプトし、必要に応じて複製環境に向ける、という仕組みを構築しています。

Istioは、オープンソースのサービスメッシュの技術です。認証・認可や、今回のわれわれのユースケースであるトラフィックのルーティングというような、ビジネスドメインによらない共通課題を、サービスメッシュというアプリケーションとは別のレイヤーに切り出して解決する、という思想で作られたソフトウェアになります。

こちらがIstioのアーキテクチャになります。クラスタにIstioを導入すると、サービスAとサービスB、われわれのマイクロサービスだとGoで書かれたアプリケーション同士が直接通信していたところが、アプリケーションに対してIstioのネットワークProxy(Envoy)が強制的に注入されて、すべてのトラフィックがこのProxy経由で送受信される形になります。このProxyのレイヤーでトラフィックのルーティングや、認証・認可のような処理を制御することが可能となります。

Istioがない状態というのは、アプリケーションとアプリケーション、が直接通信している形になります。

ここにIstioが導入されると、すべてのトラフィックがIstioによって注入されたネットワークProxyであるEnvoyのコンテナ経由で行われることになります。これにより、このEnvoyのレイヤーでトラフィックをハイジャックして、トラフィックを強制的に必要なアドレスに向け直したり、というようなことが可能となります。

IstioではEnvoyという、C++で実装されたL7、L4で動作するネットワークProxyがアプリケーションに注入されます。Envoyは「ネットワークレイヤーの課題はアプリケーションからは透過的であるべきである」という思想を掲げており、Istioはそれを体現したサービスメッシュになっていると考えられます。

Istioが提供している機能の1つにVertual Serviceというものがあり、Service Routing Controllerではこれを利用しています。

Vertual Serviceは、Istioが提供している新しいKubernetesのリソース(Custom Resource)として提供されています。VirtualServiceリソースをクラスタに反映すると、specの「hosts」フィールドに指定したアドレスに対するトラフィックに対して様々な処理を差し込むことが可能となります。この例のVirtualServiceは、オリジナル環境のServiceのアドレス(microservice-a.microservice-.svc.cluster.local)に向けたトラフィックに対して、ある特殊なヘッダー(service-router-microservice-a-microservice-a)が特定の値(microservice-a-pr1.microservice-a)のときのみ、トラフィックをハイジャックしてPull Request1に対応する環境に向け直すためのものとなっています。これを用いることで、もともとのトラフィックがオリジナルのService、オリジナルのDeploymentに向いていたとしても、トラフィックをハイジャックして、特定のヘッダーが特定の値であるあるときのみ、任意の環境に向けるということ可能になっています。

Vertual Serviceを用いてトラフィックを制御する場合、Pull Request1の環境ができたときにその環境のためのルーティングルールを追加しなければなりません。これを毎回、Pull Requestが動的に増えるたびに書いたり、不要になったときに手動で消したりするのは現実的ではありません。、このVertual Serviceの特殊なルーティングルールを、QA環境が複製されるときに動的にプロビジョンするのが、このService Routing Controllerの責務となっています。

これをステップ・バイ・ステップで解説していきます。まず初期状態としては、Service Routing Controllerがクラスタにデプロイされており、オリジナルとなるようなサービスもデプロイされている状態となります。

この状態で、先程解説したPull Request Replication Controllerによって環境が複製されます。Pull Request Replication Controllerは、環境を複製するときにService Routing Controllerのための特殊なAnnotation(mercari.com/service-router-origin-service)を付与します。

このAnnotationには、複製されたServiceからコピー元のServiceのアドレスを特定できるように、コピー元のServiceの名前が指定されています。

Service Routing Controllerは、このAnnotationが付与されたServiceを監視しています。

具体的にはこのような実装になっています。ここではclient-goを使ってServiceを取得して、先程述べたAnnotationの値を取得する実装になっています。

Pull Request Replication Controllerではclient-goをそのまま利用していましたが、Service Routing Controllerではcontroller-runtimeというライブラリ利用しています。ここれを用いると、例えばリソースが作成・削除されたタイミングで自動的にGoの関数呼び出す、という実装が書きやすくなります。

先ほど説明したとおり、Service Routing Controllerはコピー元のServiceが指定されたAnnotationを持っているServiceを監視し、そのようなService、つまりQA環境が複製されたときに自動で対応するVertual Serviceにルーティングルールを追加します。

実装はこのようになっています。controller-runtime経由でclient-goやIstioのためのAPIクライアントが呼ぶことで、Vertual Serviceを更新していく形になっています。

これが、ルーティングルールが追加される前のVirtual Serviceです。オリジナルの環境に対するルーティング先として、その環境に対するアドレスがそのままデフォルトのルーティング先として指定されています。

このVirtualService、Service Routing Controllerが自動でルールを追加します。具体的には、Service Routing Controller用のヘッダーがあるときのみ、オリジナル環境に対するルーティング先をオーバーライドしてPull Request1の環境に対応するアドレスにトラフィックを流すというルールが追加されます。

これが、Service Routing Controllerの責務となっています。これによってVertual Service越しでアクセスするときは、何もヘッダーを付けない場合は今までどおりのオリジナルの環境にリクエストが流れますが、例えばPull Request1に対するヘッダーを付けたときのみ、Pull Request1の環境に対して動的にトラフィックがルーティングされる、といったように必要に応じて動的にPull Requestを元に複製された環境にトラフィックを当てることが可能となっています。

しかし、まだ解決するべき問題が残っています。クラスタの外からQAのためのリクエストを流すときに、例えばマイクロサービスXを経由して、マイクロサービスAの複製環境にトラフィックを送りたい、というケースを想定してください。QAのためのリクエストには先程解説したようなルーティングのためのヘッダーが付与されますが、マイクロサービスXは、当然、この特殊なヘッダーのことを認識していません。つまり、マイクロサービスXがマイクロサービスAにアクセスするときにこのヘッダーが伝搬されません。Vertual Serviceでは、ヘッダーを使ってルーティング先を分岐させているので、ヘッダーが伝搬されないと、Vertual Serviceに対して自動でルーティングルールを追加してもアクセスするときにヘッダーがないので、結局オリジナルの環境にリクエストが流れてしまいます。

これをどのように解決しているかについて説明します。われわれのサービスでは、クラスタにアクセスするときにはAPI Gateayというマイクロサービスを必ず経由するようになっています。このAPI Gateayのレイヤーで認証・認可を行っていますが、認証後に認証情報を伝搬するためのJWT(JSON Web Token)を発行しています。この認証用のJWTはすべてのマイクロサービスで伝播されるようにつくられていますこれをもとに、このJWTの中にQA環境のルーティング用の情報も格納しています。

具体的なJWTのペイロードはこのようになっています。既存の認証用JWTに、service_routersというクレームを新しく追加して、例えば「マイクロサービスAはPull Request1に対する環境にルーティングしたい」という情報をJWTに詰め込めるようにしています。

このJWTを使って、どのマイクロサービスのどの環境にルーティングしたいかという情報を伝搬していますが、先程述べたようにVertual Serviceはヘッダーのの値によってルーティング先を決めています。つまり、このJWTに格納されたルーティングの情報をヘッダーに変換する必要があります。

これをどのように行っているかというと、すべてのトラフィックはEnvoyを必ず経由するので、EnvoyにカスタムのFilterを追加して、トラフィックが送られるときにVertual Serviceのルールが評価される前に、Authorizationヘッダーに付いている認証用のJWTから、先程説明したルーティング用のクレームを抽出し、それをリクエストヘッダーに置き換えて、最終的にVertual Serviceで利用できるようにしています。

このカスタムFilterはIstioが提供しているEnvoyFilterというカスタムリソースを用いて各Envoyに配布しています。EnvoyFilterリソースではinlineCodeというフィールドとしてLuaのスクリプトを記述することができます。このLuaで書かれたカスタムFilterがルーティングルールをJWTからヘッダに変換する処理を担っています。

現在は、EnvoyがWebAssemblyを使えるようになったので、このLuaで書いているFilterを、WebAssemblyとして再実装することを試しています。

これによって、マイクロサービスAに関してPull Request1の環境にトラフィックを流す、というような情報をヘッダーとして付与してトラフィック送ったときに、それがJWTに入って各マイクロサービスに伝搬されていき、そのルーティングルールがEnvoyのカスタムFilterでJWTからHTTPヘッダーに変換され、最終的にはIstioのVirtualServiceを用いてルーティングを分岐する、ということが可能になっています。

Summary

最後に、解説したQA環境の複製の仕組みを総括します。

まず、QA環境が枯渇するという問題に対しては、Pull Request Replication ControllerというPull Requestに応じて動的にt環境を複製するコントローラーをつくることによって解決しています。

さらに、この複製された環境に対して、必要なときにのみ動的にトラフィックを送信できる仕組みをつくるために、IstioのVertual Serviceに対して自動でルーティングルールを追加するService Routing Controllerというコントローラーを実装しました。

これによって、必要に応じてPull Requestから動的に生成された環境にトラフィックを送信することが可能となり、1つのマイクロサービスに対して複数機能を並列してQAを行うことがが可能となりました。

このコントローラーはOSSとして公開できていませんが、いずれは公開できるように実装を進めています。

以上となります。ありがとうございました。