メルカリShops の CI/CD と Pull Request 環境

こんにちは!ソウゾウの Software Engineer の @dragon3 です。 連載:「メルカリShops」プレオープンまでの開発の裏側の8日目を担当させていただきます。

この記事では、メルカリShops 開発において、日々バリバリに利用されている CI/CD 環境と Pull Request 毎のデプロイ環境について紹介します。

CI/CD 環境

メルカリShops では、CI/CD (テスト・ビルド・デプロイ)やその他自動化のために GitHub Actions を使っており、ほとんどのワークフロー・ジョブを Self-hosted runners で実行しています。

Self-hosted runners は、専用の VPC ネットワーク 内の GCE インスタンス上で動かしており、Managed Instance Group 等を使い、そのプロビジョニングや起動・停止等は自動化されており、スケジュールベースの autoscaling により、平日営業時間中はアクティブに利用されるのでジョブの待ちが起きないくらいの多めのインスタンス数で、深夜や土日は利用が少ないのでコストを抑えるためにもインスタンス数少なめにするような調整も自動的に行います。

この Self-hosted runners 環境は、ほぼ同じ構成で、開発環境と本番環境それぞれに独立して存在しています。

Self-hosted runners

Self-hosted runners はその名前のとおり、自前で構築・運用するマシン上で GitHub Actions のワークフロー・ジョブを実行できる仕組みです。GitHub が提供する runners(GitHub-hosted runners)ではできないような、たとえば CPU やメモリを増やしたり等のカスタマイズを行なうことができます。メルカリShops では、大きく以下の2つの理由でこの Self-hosted runners を利用しています。

理由1: ビルドパフォーマンスを最大にするため

1日目の記事の通り、メルカリShops では Bazel をビルドシステムとして利用しています。Bazel は「ビルドキャッシュが有効に使える」時にそのすばらしいスピードを発揮できます。逆に言えば、ビルドキャッシュが無い場合、例えばリポジトリを clone して初めてビルドする場合等、ものすごく時間がかかります。これは Bazel の特徴のひとつであるビルドの再現性のために Sandbox 環境を作るため、依存関係だけではなく、言語環境も含めてすべてダウンロードして配置する必要があるからです。つまり、いかに「ビルドキャッシュが有効に使える」状態にするかが、ビルドパフォーマンスを最大にするための肝になります。

上記の理由から、Self-hosted runners を GCE インスタンス上で動かし、ビルドキャッシュを意図的にその GCE インスタンス上に残しておく戦略をとっています。

なお、GitHub-hosted runners で利用できる actions/cache を試したこともあるのですが、私達の場合はそのキャッシュサイズが数GBを越えるため、リストアするだけでも5分ほどかかったり、そのサイズ制限がリポジトリあたり5GBまであることから、採用しませんでした。

さらに、Bazel にはリモートキャッシュという、別々のマシン上でビルドした結果を共有できる仕組みがあり、これも利用することで 各 Self-hosted runner 間のビルドキャッシュもできるだけ利用してさらなるビルドパフォーマンスの向上させています。

現時点では Go 言語で実装されているサービスが 40 程ありますが、CI では変更された部分にかかわらず、毎回これら全てを対象にして、以下のようにテストとビルド・コンテナイメージ作成とプッシュをしています。

# テスト
bazel coverage //go/...

# ビルド・コンテナイメージ作成とプッシュ
bazel run //go/services:push

Bazel のビルドキャッシュシステムにより、すべてが再ビルド・テストされるわけではなく、必要な部分のみ行われます。これにより現在、テストとビルド・コンテナイメージ作成とプッシュすべてで、平均5〜6分で完了できています。

Bazel 以外にも、一部 yarn や docker コマンドによるビルドやコンテナイメージ作成を行うワークフローがありますが、これらも Bazel と同様の戦略で、yarn cache や docker BuildKit を使ったキャッシュをマシン上に残し、パフォーマンスを向上させています。

ちなみに、はじめから Self-hosted runners を使っていたわけではなく、メルカリShops の開発開始直後は、GitHub-hosted runnners とリモートキャッシュの構成だったのですが、開発がどんどんアクティブになるにつれ、日に日に CI の時間が伸びていき、一時は20分越えの状態となっていました。

理由2: CI/CD 環境をよりセキュアにするため

CI/CD (テスト・ビルド・デプロイ)では、各ジョブ毎に様々な種類の権限やクレデンシャルが必要になります。たとえば、GCP のリソースにアクセスするための Service Account JSON キーや外部APIアクセスのためのキーなど、GitHub Actions の環境変数や Secrets に設定しジョブ内で利用するのが一般的です。

メルカリShops では、これらをよりセキュアに取り扱うために、GitHub 上には一切保存せず、JSON キーも一切使わず、Self-hosted runner を実行する GCE インスタンス用の Service Account に必要な IAM Role を付与し権限をコントロールしています。また、外部APIアクセスのためのキー等は、GCP Secret Manager に保管され、必要な場合にのみ取得・利用されます。

また、上述の通り、Self-hosted runner は専用の VPC ネットワークに閉じており、直接外からのアクセスはできなくなっていますし、Self-hosted runner から外部に通信する場合も、Cloud NAT 経由となっているため、ネットワーク的にもセキュアにコントロールされた環境になっています。

開発環境と本番環境

さて、ここまで説明してた Self-hosted runners の環境ですが、全体像で説明したとおり、開発環境と本番環境それぞれに独立して存在しています。メルカリShops(およびメルカリ・メルペイ)では、開発環境と本番環境でそれぞれ GCP プロジェクトから分けており、相互に接続することは無いため、開発環境のための CI/CD は開発環境内の Self-hosted runners で実行され、本番環境の CI/CD は本番環境の Self-hosted runners で実行、という構成になっています。

それぞれの Self-hosted runner がどちらの環境のものかを見わけるために、Self-hosted runner に環境名のラベル(developmet or production)を付けています。

これによって、各ジョブをどちらの環境で動かすかを、以下のようにワークフローの “runs-on” で指定できるようになっています。

jobs:
  test:
    runs-on: [self-hosted, development]
jobs:
  build-prod:
    runs-on: [self-hosted, production]

また、GitHub Actions の Environments 機能が提供する Protection Rule を利用し、本番環境へのデプロイジョブを実行するためには他のメンバーからの承認が必要となるようにしています。以下の例では、ジョブに “environment: production” を付けることで、そのジョブの実行には承認が必要となります。

jobs:
  deploy-prod:
    runs-on: [self-hosted, production]
    environment: production

ワークフロー

1日目の記事での monorepo とそのリポジトリ構成の紹介の通り、メルカリShops では言語ごとにルートディレクトリを分割しています。これにより、言語ごとに実行したいワークフローを、比較的わかりやすく定義することができます。例えば、Go 言語共通のワークフローがあった場合、paths の指定で定義できます。新しい Go の service が追加された時にも、ワークフロー定義はそのまま利用できます。

name: go
on:
  push:
    path:
      - "go/**"

これをベースに、ディレクトリ階層毎で必要なワークフローがあれば定義していく方針にしており、ネーミングもそのままディレクトリ名を使ったものにしています。以下、実際のワークフローのいくつかの例です。

ワークフロー名 内容
go Go コード全般のテスト・ビルド・デプロイ
typescript Typescript コード全般の lint や fmt
typescript/apps/graphql-server GraphQL サーバーのテスト・ビルド・デプロイ
python/projects/echo Python 実装の echo サービスのテスト・ビルド・デプロイ
terraform/fastly Fastly 用の Terraform 設定の plan や apply
docs/adr ADR (Architecture Decision Records) の Markdown から HTML への変換

Pull Request 毎のデプロイ環境

たとえば、ある機能追加において複数のコンポーネントの変更が必要だった場合に、それらを1つの Pull Request に含められ、機能変更の依存管理がしやすいのが monorepo の良い点のひとつであると1日目の記事で紹介しました。さらに、この Pull Request による変更をマージする前にデプロイし、End-to-End でテストできる環境があるとさらに便利です。

メルカリ・メルペイでは、Pull Request Replication Controller という社内製の Kubernetes Controller を使い、各マイクロサービスチーム毎のリポジトリの Pull Request 毎のデプロイ環境が作られる仕組みがあります。しかしメルカリShops では、メルカリ・メルペイとは異なる技術スタックを選択し、Kubernetes ではなく、Cloud Run を利用していることからこの仕組みは使えません。そのため、Cloud Run の Revision Tag 等を利用し、 別の仕組みで Pull Request 毎のデプロイ環境を実現しています。

この Pull Request 毎のデプロイ環境にアクセスするには、ブラウザの拡張機能等を使い、カスタムヘッダを付けてリクエストします。(カスタムヘッダ名は実際のものとは異なります)

各 Pull Request 毎の環境にはほぼすべてのコンポーネントがデプロイされており、End-to-End のテストが可能になっています。(一部未対応のコンポーネントはありますが)

Pull Request 番号を Revision Tag に設定しデプロイ

Cloud Run には Revision Tag という機能があります。任意の Revision に Tag を付けることで、専用の URL が割り当てられます。たとえば、”echo” という Cloud Run サービスの場合、その URL は、https://echo-XXXXXXXXXX-an.a.run.app ですが、pr123 という Tag が付いた Revision には、https://pr123---echo-XXXXXXXXXX-an.a.run.app という専用の URL でアクセスできるようになります。

各 Pull Request 毎の環境内部のすべてのコンポーネント間の通信に、この専用の URL を使うことで独立した環境を作ることができます。

ある Pull Request でこの環境が必要になった場合は、”Pull Request Environment” ラベルを付けることで、専用の GitHub Actions ワークフローが実行され、その Pull Request の変更内容を含んだサービス・コンポーネントはもちろん、それ以外の変更がないものもすべて、Revision Tag とコンポーネント間の通信のURLを切替えるための環境変数を設定してデプロイされます。

カスタムヘッダでリクエストをルーティング

Cloud Run の URL はあくまで内部の通信のために利用しており、ブラウザ等からの End-to-End のテストには直接利用できません。そのため、カスタムヘッダを見てルーティングする仕組みを、Google Load Balancer の URL mask routing と Fastly 上でリクエストの Host ヘッダ置き換えを組み合わせて実現しています。

Google Load Balancer の URL mask routing は、詳細は公式ドキュメントを参照いただきたいのですが、<tag>.<service>.example.com のような URL mask を設定すると、Cloud Run サービスの特定の Revision Tag にリクエストをルーティングできる仕組みです。

ルーティングの流れは以下のようになります。

  1. リクエストを受け取った Fastly 上で、カスタムヘッダがあれば、Host ヘッダを <tag>.<service>.example.com の形式に置き換え、Google Load Balancer ににリクエストを送信する
  2. Google Load Balancer URL mask 設定により、置き換えられた Host ヘッダにマッチする Cloud Run サービスの指定 Tag にリクエストを送信する
  3. リクエストを受け取った Tag 付き Cloud Run サービスは、同じ Tag が付いたコンポーネントと通信し、処理をおこない、レスポンスを返す

これによって、カスタムヘッダを指定することによりPull Request 毎の環境へアクセスし End-to-End のテストが可能となっています。

おわりに

メルカリShops 開発において、日々バリバリに利用されている CI/CD 環境と Pull Request 毎のデプロイ環境について、ちょっと駆け足になりましたが、紹介しました。

これらの環境や仕組みは、もちろん最初からあったわけではなく、「CIに時間がかかりすぎる、速くしたい」とか「Pull Request マージ前にテストしたい」とか、日々の開発で困ったことをみんなで共有し、それらを解決していくことで、現在こういうかたちになっています。ここが最終形ではなく、今後も改善し続けていきますし、もしかすると、数ヶ月後にはもっと良い全く違ったかたちに変化しているかもしれません。

~~

メルカリShopsではメンバーを募集中です。メルカリShopsの開発に興味を持ったり、チャレンジしてみたいという方がいれば、ぜひこちらも覗いてみてください。またカジュアルに話だけ聞いてみたい、といった方も大歓迎です。こちらの申し込みフォームよりぜひご連絡ください!

また、2021/08/18 から 2021/09/28 にかけて「ソウゾウ TECH TALK」というイベントが開催されます。テーマを分け、技術的な知見を共有しあうことを目的とした勉強会です。興味のある方はぜひご参加ください!

~~

明日は「メルカリShops での monorepo 開発体験記」というタイトルで Software Engineer の @ogataka が登場予定です。お楽しみに!