メルカリWeb版のPull Request環境の構築方法について

こんにちは。メルカリのweb platformチームの@urahiroshiです。 この記事は、Mercari Advent Calendar 2021 の9日目の記事です。

今回は、メルカリWeb版のアプリケーションの開発に利用している検証環境の実現方法について記載します。 メルカリWeb版の開発では、フロントエンドアプリケーションのリポジトリに対してPull Requestを作成すると、そのコードベースの検証環境(以下ではPR環境と記載します)が自動で構築され、変更箇所に対するマニュアルテストや自動テストに利用されています。

ステージング環境やInternalリリース(https://engineering.mercari.com/blog/entry/2019-10-30-105936/ に記載しているTrialリリースに相当します)を用いた本番環境でのテストも実施しているのですが、ステージング環境や本番環境で問題が見つかると手戻りが大きくなり、また原因箇所の特定に時間がかかる場合も多いため、Pull Requestをマージする前にPR環境でのテストを実施しています。

最近のWebアプリケーションのホスティングサービスでは、このようなPR環境を自動的に用意してくれるものも多いですが、メルカリではPR環境も極力ステージング環境に近い構成を持たせるため、Kubernetes, GCP上に自前で構築しています。以下の記事では、どのようにPR環境を構築しているかと、構築方法の変遷について紹介したいと思います。

PR環境のContinuous Deployment

まず、PR環境をどのようにデプロイしているかという点について記載します。 メルカリWeb版では、フロントエンドのHTTPサーバ(HTMLを返すサーバ)がGoogle Cloud Storageの場合とKubernetesクラスタ上のPodである場合があり、それぞれに対してデプロイ処理は異なります。

Google Cloud Storageの場合は、PR環境共通のbucketを一つ用意し、その中でPull Requestごとに異なるパスになるようにビルド成果物をデプロイします。例えば、#1234のPull Requestの場合は、 gs:///1234/ 配下にデプロイするようにします。 よくGitのbranch名を利用してデプロイするような設定をしがちなのですが、同名のbranchから複数のPull Requestを作られる可能性があることや、branch名の中に / が利用された場合の対処を考慮する必要があることから、Pull Request番号を採用しました。

また、HTMLから参照されるJavaScriptなどのファイルはwebpackのビルド時にcontent hashを付与したファイル名で生成しており、これらのファイルはPR環境ごとに区別する必要がないため、Pull Requestごとパス階層を持たせず、別のGoogle Cloud Storageに保存しています。

PR環境のContinuous Deployment

Server Side RenderingなどのためHTTPサーバから動的に生成したHTMLを返している場合は社内製のPull Request Replication ControllerというKubernetes custom controllerを利用しており、Pull Request作成時にdocker imageのビルドとpushを行った後に、そのdocker imageを参照するKubernetesのリソースを作成しています。詳細については、 https://www.slideshare.net/VishalBanthia1/kubernetes-controller-for-pull-request-based-environment を参照ください。

なお、 Renovate など自動的にパッケージを更新するためのサービスを利用している場合は、大量にPull Requestが作られて不要なリソースを消費してしまうため、GitHubのラベルを通じてPull Request環境の作成可否を制御するなどの処理をCI/CDのジョブに対して追加しています。

ドメインによるPR環境へのルーティング

PR環境のデプロイができた後は、ブラウザからPR環境にアクセスするためのルーティングが必要になります。 最初に作成したのは、ドメインによるルーティング(厳密にはHostヘッダによるルーティング)を行う方法です。例として prXXX.example.com (XXXにはPull Request番号が入る)のようなドメインを利用し、そのドメインに対するリクエストがあった場合は開発環境用のproxyサーバを介して、Pull Request番号に対応するPR環境へのルーティングを行うというものです。 proxyサーバとしてはNGINXを利用しており、Google Cloud Storageのbucketに接続する場合は以下のような設定により実現できます (実際の設定は、privateなbucketを利用するため別のサーバに接続していたり、keep alive接続を行ったりするため、より複雑になっているのですが、そのあたりは割愛します)

server {
    server_name ~^pr(?<pr_number>[0-9]+).example.com;
    …
    location / {
        rewrite ^/(.+)$ /$pr_number/$1 break;
        proxy_pass https://$bucket_name;
    }

NGINXはKubernetes Cluster上にデプロイしており、開発者やテスト担当者からのリクエストはCDNやIngressを介してNGINX Podにルーティングしています。

ドメインによるPR環境へのルーティング

また、開発者がすぐにアクセスできるように、対象のPull Request上にPR環境のURLをコメントする処理をCI/CDに追加しています。

URLをPull Request上にコメントする

CookieによるPR環境へのルーティング

ドメインによるPR環境へのルーティングは多くの場合でうまくいくのですが、いくつか問題があるケースがありました。

  • OAuth 2.0やOpenID Connectに準拠した認証機能を利用している場合、外部ドメインの認可サーバーに対してあらかじめリダイレクト先のURLを登録する必要があり、PR環境がドメインごとに異なるとその都度登録が必要になってしまう
  • デプロイ先が異なる複数のwebアプリケーションをパスによって切り替えている場合、それぞれのアプリケーションのPR環境を組み合わせて動作確認したいケースがあり、それに対応しようとするとドメインの組み合わせが増え、ルーティングの設定が複雑になる

こういった問題を解決するために、ドメインではなくCookieを利用してPR環境へのルーティングを行えるように変更を加えました。 具体的には、 pr.example.com というPR環境共通のドメインを用意し、リクエストに appA=123 のようなCookieを付与することでPR環境を切り替えるというものです。

CookieによるPR環境へのルーティング

Cookieの付与を簡単にするため、 /__set_pr__?appA= のようなCookie設定用のパスも用意しました。NGINXの設定例は以下のようになります。

server {
    server_name pr.example.com;
    …
    location /__set_pr__ {
        add_header Set-Cookie "appA_pr=$arg_appA; Path=/; Secure" always;
        rewrite ^.*$ / redirect;
    }
    location / {
        rewrite ^/(.+)$ /$cookie_appA_pr/$1 break;
        proxy_pass https://$bucket_name;
    }

Pull Request上にコメントするURLも以下のように変更します。

URLをPull Request上にコメントする

修正1. 任意のパスでCookieを設定できるようにする

上記のような設定のPR環境では、 /hoge のようなパスをテストしたい場合にはまず /__set_pr__ を呼び出してCookieをセットした後に /hoge を呼び出す、というステップが必要で、「PR#123の /hoge の動作確認をしたい」という場合にそれを一つのURLで表現できないという問題がありました。これは開発者間で特定のPR環境の問題を共有する際にも手間がかかりました。 そこで、 pr.example.com 配下の任意のURLに対して、 appA= のようなquery stringによりPR環境を切り替えられるようにし、また画面遷移しても同じPR環境が参照され続けるようにCookieが付与されるように変更しました。

    location / {
        set $appA_pr $cookie_appA_pr;
        if ($arg_appA ~ "([0-9]+)") {
            set $appA_pr $1;
        }
        add_header Set-Cookie "appA_pr=${appA_pr}; Path=/; Secure" always;

この変更により、PR#123の /hoge を参照したいという場合は、https://pr.example.com/hoge?appA=123 という一つのURLにより実現できます。

query stringによりPR環境を切り替える

修正2. キャッシュへの対応

PR環境のHTMLについては、Cache-Controlヘッダによりキャッシュされないように設定を行っているのですが、Service Workerによるキャッシュを行っている場合にPR環境を切り替えるためのSet-Cookieヘッダが返らないという問題がありました。

具体的には、デフォルトで任意のページに対して Workbox のキャッシュ戦略でStale-While-Revalidateを適用しており、ブラウザがHTMLのキャッシュを保持しているとキャッシュされたコンテンツが返されてしまうものです。キャッシュが返された後にHTTP requestが発生してCookie自体は更新されるため、Cookieを参照すると一見環境が更新されたように見えるのですが、キャッシュされたHTMLを利用してページを表示しているため、表示されているページのバージョンは古いものとなってしまいます。

これには、Workboxの設定でCookieが更新されるようなURLに対してはNetwork Onlyのキャッシュ戦略を適用するよう修正を行いました。

なお、修正1, 2に対する別の解決方法として、もともと利用していたPR環境用のドメイン(https://prXXX.example.com)を利用し、このドメイン配下のパスにアクセスされた場合にSet-Cookieヘッダを返した上で対応する https://pr.example.com 配下のパスにリダイレクトするという方法も考慮できるかと思います。この場合、Set-Cookieを返すURLはドメインが異なるので、Service Workerによるキャッシュの問題は生じません。query stringを利用した場合と比較すると変更コストは大差がなく、一方でリダイレクトが増えるというデメリットを加味して採用していませんが、Workboxのキャッシュ戦略を含めたWebフロントエンドの設計と、PR環境のインフラ構成をより疎結合にできるというメリットもあります。

その他の課題

CookieによるPR環境のルーティングでは、自分がどのPR環境に接続してるかがURLから判別できず、また同一ブラウザ内ではCookieの値が共有されるため、ブラウザ上で複数のタブを開いて複数のPR環境を並行して確認することが難しいという課題があります。また、以前に利用していたPR環境のCookieが残っていると、意図せず他のPR環境に接続してしまう可能性も考えられるため、アプリケーションやブラウザの拡張機能を用いて、現在表示しているPR環境の情報を表示するといった対応についても検討しています。

ドメインによるルーティングがステートレスであるのに対して、CookieによるPR環境のルーティングはステートフルであり、複雑性が増すことが根本的な問題として挙げられます。 メルカリWeb版の場合、ドメインによるルーティングを用いるとOAuth 2.0やOpenID Connectに準拠した認証機能を利用するのに制約が生じるためCookieによるルーティングを選択しましたが、こうした機能による制約がない場合はドメインによるルーティングを実施することをお勧めします。

おわりに

私たちweb-platformチームでは、このようにwebの開発で必要となる開発基盤やネットワーク基盤の構築を行っています。また、インフラの構築だけでなく、メルカリでのログイン/サインアップ画面の実装など、webアプリケーションに共通で必要となる機能の提供なども行っており、webのプロダクト開発チームがより生産的に活動できるとともに、高品質でセキュアなwebアプリケーションを提供できるようにサポートしています。 現在チームメンバーを積極募集中ですので、興味がある方はぜひご応募ください! https://mercari.wd3.myworkdayjobs.com/ja-JP/mercari_external/job/Roppongi/Software-Engineer–Web-Platform_JR-000000022-1

明日の記事はgloriaさんの "How is Security Testing Different from Typical Software Testing?" です。引き続きお楽しみください。