Sansan Builders Box

Sansanのものづくりを支える技術やデザイン、プロダクトマネジメントの情報を発信

GAEアプリの開発フローにCloud BuildでのCI/CDをいい感じに組み込む

関西支店で新規事業開発室に所属する加藤です。私のチームでは、Google Cloud Platform (GCP) で主にGoogle App Engine (GAE) を使ってシステムを構築しています。

GAEはコマンド1つで簡単にデプロイできますが、チームの開発者が増えるにつれて、デプロイ用の設定を共有するのが大変になってきました。 デプロイにも時間がかかって、リリース作業に負荷を感じるようになりました。

そこで、GAEアプリケーションの開発フローに、Cloud BuildによるContinuous Integration (CI) / Continuous Delivery (CD) を組み込み、デプロイを自動化しました。

公式ドキュメントや各種ブログに個別の方法は記載されていますが、開発フローに組み込もうとした時にいくつか考えることがあったので、まとめておきます。

前提

Google Cloud Platform (GCP)

GCPはGoogleのクラウドサービスです。 リソースはプロジェクトという単位で分離されます。

Google App Engine (GAE)

GAEはGCPのPaaSです。 1つのプロジェクトに複数のサービスを作成できます。 1つのサービスの複数のバージョンをデプロイでき、バージョン間でトラフィックの処理割合を設定できます。 これにより、Blue-Greenデプロイメントを比較的容易に実現できます。

アプリケーションのランタイムは大きくStandard環境とFlexible環境に分けられます。 歴史的な経緯はありますが、現在ではデプロイ時間や起動時間が短く、柔軟性も高い第2世代のStandard環境を使うと、GAEのメリットを最大限に享受できます。

Flexible環境では、トラフィックを処理しないバージョンでも、明示的に停止しない限りは最低1インスタンス(デフォルトでは2インスタンス)が起動し続けます。思わぬ課金に繋がる可能性があるので、Flexible環境でデプロイを自動化する場合はご注意ください。

Cloud Build

Cloud BuildはGCPのCIサービスです。GCPと連携するのが容易であるのはもちろんですが、次の点が他のCIサービスと比べて特徴的かと思います。

  • YAMLファイルでビルドステップを記述するが、個々のステップが独立したコンテナ実行に対応する。
    • コンテナイメージのpullに時間がかかりがちというデメリットはあるものの、ビルドに必要なツールを詰め込んだコンテナイメージを作る必要がないのが便利です。
  • ステップのwaitForを使って並列化するのが容易。

デプロイするアプリケーション

実際にはいくつかのアプリケーションをCI/CDしてますが、本稿ではGAE Java 11ランタイム(第2世代のStandard環境)で実行している、Kotlinで書かれたAPIのデプロイにフォーカスします。 一部Flexible環境も使っています。

CI/CDの全体像

CI/CDの全体像は次の通りです。 GCPで開発・ステージング・本番の3つのプロジェクトを使っており、GitHubの任意のブランチへのpushをトリガーに開発環境のCloud Buildでビルド・テストを実行します。 これが通れば、ステージング環境のApp Engineに(masterブランチの場合は本番環境にも)デプロイし、Slackにトラフィック移行用のコマンドを通知します。

なお、本稿での「デプロイ」は、あくまでリリース可能な状態にすることを意味し、デプロイしただけではユーザーからのリクエストを受け取りません*1。 明示的にトラフィック移行を行うことで、初めてユーザーからのリクエストを受け取ります。

f:id:ktx33:20190927162508p:plain

このようなフローを実現するために考えたことを5つ紹介します。

1. アプリケーションのシークレット管理

デプロイ自動化の前提として、DB接続用のパスワードのようなアプリケーションが必要とする秘密の情報(本稿ではシークレットと呼びます)をどう管理するか検討する必要がありました。

自動デプロイを始める前は、開発者のローカルPCからコマンドでデプロイしており、Gitで無視したファイルにシークレットを保持して app.yaml から include していました。CD環境にそのようなファイルを配置したくないことと、そもそもApp EngineにデプロイしたファイルはGCPコンソールでアプリケーションのソースを表示した時に見えてしまうことから、この機会に見直すことにしました。

sopsというシークレット管理ツールも検討しましたが、復号したデータをアプリケーションから読み込むのに一手間必要でした。別の方法を探したところ、ちょうどBerglasというGCPの中の人が作っているツールが見つかったので、これを使うことにしました。

詳しくはREADMEに記載されていますが、大まかには次のように使えます*2

  1. berglas create でシークレットを暗号化してCloud Storageのオブジェクトに保存
  2. berglas grant でそのオブジェクトへのアクセス権限をサービスアカウントなどに付与する
  3. berglas exec --local で環境変数のうち値が berglas://[BUCKET]/[SECRET] 形式のものを復号した値に置き換えて、任意のプログラムを実行する

これによって app.yaml は次のようになり、シークレットがないのでリポジトリにコミットできるようになりました。

runtime: java11
instance_class: F2
service: awesome-api
entrypoint: ./berglas exec --local -- java -jar *.jar
env_variables:
  # シークレットはBerglas (https://github.com/GoogleCloudPlatform/berglas) で暗号化する。
  JAVA_TOOL_OPTIONS: "-Xmx128m"
  JDBC_URL: berglas://proj-secrets-stg/awesome-api/jdbc-url

なお entrypoint で使用している berglas コマンドはApp Engineの実行環境に存在しないので、ビルド時にLinux版のコマンドを含めています。

2. ビルドを開始するトリガー

GitHubへのプッシュをトリガーにビルド・テストを行います。GCPのCloud Buildコンソールを見ると、GitHub連携の設定があるため、そこから設定したくなりますが、GitHub Marketplaceに公開されているGoogle Cloud Buildアプリを使うと楽に設定できます。

github.com

設定方法は次のページに解説があります。

cloud.google.com

Google Cloud Buildアプリを使うと、特に設定しなくてもコミットステータスがつくため、Pull Requestページでビルドが通ったかどうかすぐわかります。

f:id:ktx33:20190926165410p:plain

一方、 cloudbuild.yaml という設定ファイル名や、ビルド対象のブランチなど細かい設定はできません。 ビルド内のシェルスクリプトでもある程度対応できるので困っていませんが、細かな設定が必要な場合は、Cloud Buildコンソールから連携を設定すると良いでしょう。

設定時にハマったところとしては、GitHubのリポジトリのAdmin権限が無いと設定できないところです。 例えば既にリポジトリA, BがCloud Buildに接続されている状態で、新しくリポジトリCを接続しようとしたとします。 接続するユーザーがリポジトリA, CのみAdmin権限を持つ場合、GCP側の設定画面にはA, Cのみが表示されます。 このまま保存すると、Bの接続は解除されてしまい、Bのプロジェクトはビルドに失敗してしまいます。

3. 別プロジェクトへのデプロイ

上述のように開発・ステージング・本番の3プロジェクトを使っており、GitHubへのブランチのpushをトリガーにして、ステージング環境に(masterブランチの場合は本番環境にも)デプロイします。

Cloud Buildで別プロジェクトにデプロイするには、 gcloud app deploy コマンドの --project オプションで別プロジェクトを指定する方法があります。ただし、 gcloud app deploy はデプロイが完了するまで待つので、デプロイに時間がかかるとビルド時間が伸びてしまいます。特にFlexible環境のデプロイは時間がかかるため、最初に試した時は10分でタイムアウトしてしまいました。タイムアウト時間は設定で延長できるものの、コミットステータスがGreenになるまで時間がかかってしまいます。

そこで、デプロイ用のCloud Build設定ファイルを別に作成し、 gcloud builds submit コマンドでデプロイ用のビルドを行うようにしました。このコマンドもデフォルトではビルド完了まで待ちますが、 --async オプションをつければビルドを開始するだけとなります。 設定を分けると、後述するSlackへの通知など、デプロイ以外の処理をしたくなった時にも対応しやすいです。

なお、この構成にするためには次の設定が必要でした。

  • ステージング・本番プロジェクトで、App Engine Admin APIを有効化
  • ステージング・本番プロジェクトで、開発プロジェクトのCloud Buildサービスアカウントに次の権限を追加
    • Cloud Build 編集者
    • ストレージのオブジェクト作成者
    • ストレージ オブジェクト閲覧者
  • ステージング・本番プロジェクトのCloud Buildサービスアカウントに次の権限を追加
    • AppEngine デプロイ担当者

4. サービスのバージョンID

App Engineではデプロイ時にバージョンIDを指定しない場合、タイムスタンプがバージョンIDになります。 しかし、いろいろなブランチを自動でデプロイしていると、どのバージョンがどのブランチかわからなくなってしまいます。

そこで、次のルールでバージョンIDをつけることにしました。masterは本番リリースにも使うので、ロールバックできるようタイムスタンプ付きのものにし、その他のブランチはバージョンID数の増加を緩やかにするよう毎回上書きします。

  • masterブランチ: master-<タイムスタンプ>
  • その他のブランチ: <ブランチ名の英数字以外を-に置き換えたもの>

しかし、しばらく運用しているとブランチ名が長い場合に次のエラーが出て、デプロイに失敗しました。

ERROR: (gcloud.app.deploy) INVALID_ARGUMENT: Combined version and service (module) name is too long. The combined length must be less than 48 characters. Note that for internal reasons, each hyphen or sequence of hyphens in the version or service name counts as one extra character.

私たちのサービス名の場合は、23文字がバージョン名の上限になったので、23文字に切り詰めるようにしました。 単純にブランチ名を切り詰めると最後に-が出現する可能性があるので、その場合は削除します。 最終的にステップは次のようになりました。

  - name: 'gcr.io/cloud-builders/gcloud'
    entrypoint: 'bash'
    # あとで使うためにバージョンIDをファイルに書き出す。
    # バージョンIDはブランチ名(の英数字以外をハイフンに置き換えたもの)とし、masterの場合はタイムスタンプを含める。
    # 長すぎるとデプロイに失敗するので23文字に制限し、最初と最後に-が出現した場合は消す。
    args: ['-c', 'if [[ "${BRANCH_NAME}" == "master" ]]; then echo master-`date "+%Y%m%dt%H%M%S"` > VERSION_ID; else echo "${BRANCH_NAME}" | sed -E "s/[^a-z0-9]+/-/g" | cut -c 1-23 | sed -E "s/(^-+|-+$)//g" > VERSION_ID; fi']

サービス名はあまり長くしない方が良いという知見が得られました。

5. デプロイ完了通知とトラフィック移行

リリースは特定のタイミングで行いたいので、自動で行うのはデプロイのみで、トラフィック移行は手動で実施します。 手動といっても、GCPのコンソールから作業するのは手間なので、デプロイが完了したタイミングでトラフィック移行用のコマンドがSlackに通知されるようにしました。 リリース時はコマンドをコピペして実行するだけです。

f:id:ktx33:20190927132920p:plain

ステージングと本番で通知するチャンネルを分けていますが、取り違えると怖いのでメッセージの色でわかるようにしています。

設定は次のような感じです。Slack Incoming Webhooksで attachmentsblocks と組み合わせて color を使う場合、goodなどのキーワードを指定しても色が変わらず、カラーコードを指定する必要があるというのがちょっとしたハマりどころでした。

steps:
  - name: 'gcr.io/cloud-builders/gcloud'
    args: ['app', 'deploy', '--project=proj-stg', '--no-promote', '--version=${_VERSION_ID}']
  - name: 'gcr.io/cloud-builders/curl'
    args:
      - '-X'
      - 'POST'
      - '-H'
      - 'Content-type: application/json'
      - '--data'
      - |
        {
          "attachments": [
            {
              "color":"#daa038",
              "blocks": [
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "Service: <https://console.cloud.google.com/appengine/versions?project=proj-stg&serviceId=awesome-api|awesome-api> Version: `${_VERSION_ID}`"
                  }
                },
                {
                  "type": "section",
                  "text": {
                    "type": "mrkdwn",
                    "text": "https://${_VERSION_ID}-dot-awesome-api-dot-proj-stg.appspot.com/ has been deployed. Run the following command to migrate traffic: ```\ngcloud app services set-traffic --project=proj-stg awesome-api --splits ${_VERSION_ID}=1\n```"
                  }
                }
              ]
            }
          ]
        }
      - 'https://hooks.slack.com/services/*******'

まとめ

これらの取り組みによって、アプリケーションのリリース作業の所要時間が減り、心理的にも楽にできるようになりました。 リリースの負荷を最小限にして、素早くフィードバックサイクルを回していきたいです。

また、次のような課題もあるので、引き続き改善していきます。

  • ビルドが失敗した時の通知がないので、ビルド失敗に気づきやすくしたい。
  • 複数のPull Requestをほぼ同時にマージした場合に、ビルドが並列実行されてSlackへの通知順がずれることがあるので、なんとかしたい。
  • トラフィックを切り替えた時にもSlack通知があると嬉しい。
  • アプリケーションの設定が間違っていて起動していない時に気づけるよう、デプロイ後にスモークテストを実施したい。
  • ずっと運用してるとバージョン数が上限を超えるので、古いバージョンをいい感じに削除したい。

宣伝

10月23日開催のSansan Builders Boxにおいて、「新規事業の開発メンバーが1人→n人に増えるのを支えた技術」というタイトルで話します。 この記事のCI/CDの取り組みのほかにも、開発スピードを落とさずに品質を保つために取り組んできたことをご紹介します。 ご興味ありましたら、ぜひお越しください!

jp.corp-sansan.com

参考文献

*1:コマンドで言うと、 gcloud app deploy を --no-promote オプション付きで実行しています。

*2:Berglasは開発途上のツールなので、今後変わるかもしれません。v0.1.3の情報です。

© Sansan, Inc.