Zenn Tech Blog
🐙

Zennのバックエンドを Google App Engine から Cloud Run へ移行しました(無停止!YES!)

2022/03/25に公開

Zennは、Next.js + Ruby on Rails(APIモード)を Google Cloud の App Engine へデプロイして稼働していました。最近、Rails の実行環境を App Engine Flexible から Cloud Run へ移行したので、その記録を残します。

ロードバランサーのバックエンドサービスを付け替えることで実現

最初に、どうやって移行したかです。Zennのバックエンドはもともとロードバランサーで構成されていました。以下の図のように、ロードバランサーの Backend Service より背後を切り替えることにより実現しています。Cloud Run とそこにアクセスするための Serverless NEG はあらかじめ稼働させておくことで、ダウンタイムなしで切り替えられました。


参考:負荷分散 | Google Cloud https://cloud.google.com/load-balancing/docs/https/setting-up-https-serverless?hl=ja

Cloud Run 移行モチベーションと結果

なぜ移行したかったかと、結果どうだったかについて述べます。移行のモチベーションは以下のようなものでした:

  • App Engine Flexible 環境だとバースト(急激なアクセス増加)に弱いのをなんとかしたい
  • コンテナベースのデプロイサイクルにしておきたい
  • 盛り上がっているサービス(Cloud Run)の恩恵にあずかりたい

バーストに弱いのをなんとかしたい

App Engine Flexible 環境だと、VMベースの実行環境ですので、負荷の増大を検知してから新しいインスタンスがサービスインするまで数分かかるという状況でした。

Cloud Run へ移行した結果 👇

まだ評価途中ですが、インスタンスが立ち上がるまでの時間は数分だったのが10秒程度に改善しています。バーストがおきたときに、ユーザーからみたときのレスポンスに悪影響がでなければ万々歳です。とはいえ、アプリケーションサーバーがスケールするようになった場合、おそらく次に悲鳴をあげるのはDB(Cloud SQL)です。今後もチューニングは必要になるでしょうが、少なくともアプリサーバーのスケール速度に気を使わなくてよくなったというのは、大きな収穫です。

コンテナベースのデプロイサイクルにしておきたい

アプリケーションをビルド・デプロイする方法は多岐に渡りますが、昨今はコンテナイメージからデプロイして動かす流れをサポートするプラットフォームが増えています。

どのクラウドサービスとを使うか、オンプレでいくのか、現場により事情はさまざまだとは思います。が、アプリケーションをコンテナ単位に落とし込んでおくことで、エコシステムの恩恵を受けられそうだという考えがありました。

Cloud Run へ移行した結果 👇

ローカル開発では docker compose を使っていたという背景もあり、コンテナ化はスムースにできました。振り返ると、バックグラウンドジョブや独自のログドライバーのデーモンを入れず、Cloud Tasks, Cloud Scheduler, Cloud Logging といった Google Cloud のサービスを活用していたことが有利に働きました。これらのサービスはCloud Runへ移行した後も同じように使えるためです。SidekiqKuroko2などを使っていた場合、移行の難易度が一段上がっていたと思います。

勢いのあるサービス(Cloud Run)の恩恵にあずかりたい

バーストに強くする目的であれば App Engine Standard を使っても良いのでは、という話もありますが、Zennはもともと Standard 環境から Flexible 環境へ移行したという経緯があります。

catnoseさんが調査した App Engine Standard の課題が現在はどうなっているか不明ですが、また同じような問題に直面するのは避けたい、ということで、もともとチラチラと気にしていた Cloud Run を使ってみたいよねという気持ちを発揮させました。こちらの記事でも catnose さんが過去 Cloud Run を試みたのがみてとれます。

https://zenn.dev/catnose99/articles/zenn-dev-stack#cloud-runは断念

Cloud Runは断念

開発環境にはDockerを使っているため、はじめはCloudRun(フルマネージド)にまとめてデプロイしていました。デプロイ体験は素晴らしかったのですが、実際にアプリを操作していると、バックエンドのレスポンスがまれに15秒以上遅延する現象が発生してしまいました。

2020年9月の時点では最小インスタンスによる常時ウォームアップができなかったみたいですね。最小インスタンスのサポートによって、アプリの運用が現実的になったと思います。Cloud Run は日々改善が進んでおり、これからどんな新しい機能が追加されていくのか私も楽しみです。

移行の流れ

前提

冒頭で述べたとおり、ロードバランサーのバックエンドをすげかえる戦略が固そうだったので、前提としてアプリケーションがロードバランサーで構成されている必要がありました。この点はすでにクリア済でした。Google Cloud でプロダクトを運用するときは、ドキュメントに沿ってロードバランサーを構成しておくことをオススメします。


ロードバランサを作成する - https://cloud.google.com/load-balancing/docs/https/setting-up-https-serverless?hl=ja

ロードバランサー構成のTerraform化

このたぐいの切り替え作業を、Google Cloud 管理コンソールで行うのはとても怖いことです。検証環境とまったく同じ手順とはいえ、人間の手でやるとミスがつきものですし、うまくいかなかったので切り戻しましょうとなったときの手作業が失敗し二次災害を引き起こす現場を見てきました。

App Engine で稼働している現状の構成を把握する目的もあり、ロードバランサーを Terraform 化することにしました。なお、Terraform化にあたりロードバランサーを作り直す必要はなく、既存のリソースをImportする機能があるためそちらを使っています。

試行錯誤の様子👇
https://zenn.dev/waddy/scraps/2a374b06992c63

ロードバランサーの構成をTerraform化することで、以下のようなメリットがあります。

  • 現在の構成を宣言的に記述できるようになった
  • Cloud Run への切り替えでやろうとしてることをプルリクエスト・レビューできる
  • 実際に切り替える時は、terraform applyするだけ

ついでにいうと、一度検証環境でインフラリソースを作成すれば、変数をちょっと変えれば同じ構成が本番で再現できるというのも大きなメリットです。

検証環境をCloud Runに

次に、検証環境、本番環境のうち検証環境を Cloud Run で稼働するようにしました。ただ、検証中にアプリケーションの修正も当然発生します。アプリケーションのビルド自体は App Engine も Cloud Run も行うようにしておき、アプリの修正を動作確認するときは App Engine へ向き先を戻して対応しました。

Cloud Tasks と Cloud Scheduler のターゲットを変更

Cloud Tasks と Cloud Scheduler はどちらも特定のエンドポイントに対してリクエストすることでタスクを実行するサービスですが、エンドポイントは大きく分けて3種類あります:

  1. App Engine ターゲット
  2. HTTP(S) ターゲット
  3. Pub/Sub

もともと App Engine ターゲットを利用していましたが、Cloud Run 移行にあたってはHTTPSエンドポイントに対してリクエストすることになるため、リクエストターゲットの変更も仕込んでおきました。ここで後述するサービス間認証の課題に直面します🥲

本番環境にあらかじめCloud Runリビジョンとバックエンドサービスを作っておく

「あとは切り替えるだけ」の状況をつくります。Cloud Run リビジョン、Serverless NEG、バックエンドサービスをあらかじめ作っておきます。もちろん、ロードバランサーの向き先を切り替えさえしなければCloud Runへトラフィックが流れることはありません。

Cloud Run リビジョン

Cloud Build + gcloud run deploy で最初のリビジョンを作成しました。デプロイコマンドは以下のようなものです。

# 利用するオプションによってはbetaは不要ですが、面倒なので常につけています
gcloud beta run deploy backend-rails-api \
    --quiet \
    --project=$PROJECT_ID

Serverless NEG

Terraformで以下のように定義して作成します。

# rails Cloud Run Serverless NEG
resource "google_compute_region_network_endpoint_group" "cloudrun_api_neg" {
  name                  = "${var.project_id}-cloudrun-rails-api-group"
  network_endpoint_type = "SERVERLESS"
  region                = var.target_neg_region
  cloud_run {
    service = "rails-api"
  }
}

バックエンドサービス

Terraformで以下のように定義して作成します。前で定義した Serverless NEG を使います。

# ロードバランサーのバックエンド: Cloud Run Rails API
resource "google_compute_backend_service" "cloudrun_rails_api_backend" {
  load_balancing_scheme           = "EXTERNAL"
  name                            = "${var.project_id}-cloudrun-api"
  port_name                       = "http"
  protocol                        = "HTTP2"

  backend {
    # ここで Serverless NEG を参照 
    group = google_compute_region_network_endpoint_group.cloudrun_api_neg.self_link 
  }
}

検証OK=>切り替え日を決めて実行

検証環境での動作確認が終わったら、ロードバランサーのバックエンドを App Engine から Cloud Run へ切り替えます。


Terraformの変更を適用する直前の様子

terraform applyすることで、Cloud Run へ切り替わりました。

トラフィックが Cloud Run へ流れてくることを確認します。


Cloud Runへトラフィックが流れ始めている様子

直面した課題と、解決策(未解決を含む)

Cloud Run 移行へあたっては検証中に多くの課題に直面しました。記録を残しておきます。

Cloud Run で Railsサーバーの静的アセットが配信されない

Cloud Run で動かしたとき、Railsサーバーで持っている静的ファイル(ユーザーには配信しない、管理用途です)が配信できずに404となる事象がありました。ビルド後のDockerイメージにはファイルが入っているにもかかわらず、です。

App Engine Flexible 環境ではデフォルトで設定される環境変数が、Cloud Run では設定されていないためでした。App Engine Flexible 環境では、RAILS_SERVE_STATIC_FILESがデフォルトでtrueになります。


Ruby ランタイム  |  Ruby の App Engine フレキシブル環境に関するドキュメント  |  Google Cloud https://cloud.google.com/appengine/docs/flexible/ruby/runtime?hl=ja-jp

私がZennの開発にジョインした頃からすでにFlexible環境で動作していたため、この環境変数について気にしたことをなかったのが敗因です。Cloud Run にデプロイする際、RAILS_SERVE_STATIC_FILES=trueと明示することで解決しました。

Cloud Scheduler から Cloud Run へリクエストすると401になる

先述のとおり、Cloud Scheduler によるスケジュールタスクは、App Engine ターゲットから Cloud Run への HTTPS ターゲットへ変更しました。このとき、HTTPSターゲットではサービス間認証のためにクレデンシャルを作成して渡します。作成自体はSDKがやってくれるので、開発者がやることは以下ふたつの情報をリクエストヘッダへ設定することです:

  • サービスアカウントのメールアドレス
  • オーディエンス(起動したいCloud RunのルートURL)

https://cloud.google.com/scheduler/docs/http-target-auth?hl=ja#creating_a_scheduler_job_with_authentication

問題はここで渡すオーディエンスのドメインでした。Google Cloud のサービス間認証では、認証で利用するオーディエンスにカスタムドメインを指定することはできません

https://cloud.google.com/run/docs/authenticating/service-to-service?hl=ja


もう少しでかく書いてほしいし、Cloud Scheduler のドキュメントでも言及して!

Cloud Run のデフォルトで割り当てられるURLは一度サービス(最初の Cloud Run リビジョン)を作成することではじめて目に見えるため、

  1. 適当なコンテナでCloud Runサービスを作成する
  2. デフォルトURL(https://rails-api-47skgkalboe2-uc.a.run.appみたいなURL)を取得する
  3. デフォルトURLを使って各Cloud Schedulerサービス間認証のパラメータを仕込む
  4. Cloud Runの適当なコンテナを本命のコンテナに置き換える
  5. Cloud Scheduler から Cloud Run が認証ありで起動できる

この手順を踏まねばなりません。初見でわからず、aud値にカスタムドメインのまま設定してしまい、401が返ってくる現象につながりました。なお、HTTPSターゲットのリクエスト先自体はカスタムドメインでもOKです。サービス間認証で利用するリクエストヘッダのオーディエンスは、必ずデフォルトドメインでなければなりません。

DBマイグレーションをどこでどうやるか

App Engine Flexible は、SSHを利用してインスタンスにログインできます。これを利用して、DBマイグレーションが必要なときは、新しくデプロイしたインスタンスからbundle exec rails db:migrateを実行していました。しかし、Cloud Runへデプロイする場合、いまのことろSSHで入りシェルを実行する、といった芸当はできません。DBマイグレーションをどこでやるか考える必要がありました。結局、Cloud Build からデプロイに利用したDockerイメージを使ってDBマイグレーションを実行する方式に落ち着きました。

本番環境へのデプロイが走ると、Cloud Buildトリガーを起動するコマンドがSlackへ通知されるようになっており、そのコマンドを Cloud Shell から打つことでDBマイグレーション用のCloud Buildが起動します。

Cloud Build からの DBマイグレーション実行については、こちらの記事も参照ください。

https://zenn.dev/waddy/articles/prisma-migration-to-cloud-sql#cloud-build-から-cloud-sql-へマイグレーション実行
https://cloud.google.com/ruby/rails/run#automation_with

運用などで Rakeタスク を実行したい場合どうするか

DBマイグレーションについてはピンポイントで db:migrate を Cloud Build から実行する形でOKですが、DB洗い替えのRakeタスクなどどうしても一時的なシェルやrailsコマンドを実行したいシーンがあります。そんなときのために、App Engine Flexible を停止状態で残しておき、コマンドを実行したいときにSSHでログインして実行するという方針にしています。App Engineへ最新版をデプロイすることについては既存の資産がそのまま使えるため大きな手間ではありません。また、普段は停止状態としているため料金が余計にかかることもありません。

チームとしての落とし所としてはまあよかろうとは思いつつも、せっかくコンテナベースにしたところ、VMベースのアプリケーションが残っている状態です。こちらの記事で GKE Autopilot モードによる運用インスンタンスの管理を行っていたのが気になっており、私も試してみたいと思っています。

https://zenn.dev/nownabe/articles/rails-on-google-cloud#googke-kubernetes-engine-autopilot

Cloud Runで新しいリビジョンを使うとき、コールドスタートが発生しないようにする

Cloud Run への移行を考えるときに、まっさきに解決したかった課題がこれでした。新しいリビジョンにトラフィックを流すとき、最初のアクセスでコールドスタートが発生するのではないか?という懸念です。

この課題は最小インスタンスを1以上にしつつ、新しいリビジョンにタグを付けることで解決できます。こちらの記述を見てください。

リビジョンと最小インスタンス数

最小インスタンスで開始するのは、リビジョンでアドレス指定が可能な場合に限ります。次のいずれかに該当する場合は、リビジョンでアドレス指定が可能です。

  • 一定の割合のトラフィックを受信している
  • リビジョンタグが割り当てられている

スーパーファインプレー機能です。以下のように、新しくリビジョンをつくるとき、タグをつけることによって、トラフィックを流さずかつ最小インスンタンスが立ち上がった状態にできます。

gcloud betarun deploy backend-rails-api \
    --no-traffic \ # トラフィックは流さないが
    --tag=latest \ # タグをつけることで
    --min-instances=1 \ # この分のインスタンスが立ち上がる
    --quiet \
    --project=$PROJECT_ID \

ただし、切り戻しなど、リビジョンタグが着いていない状態でトラフィックを流すとコールドスタートが発生するため、切り戻すまえにタグを付けるなどの措置が必要になります。この点注意です。

検証環境と本番環境で、同じコンテナイメージを使いたい

未解決です

検証環境でビルドしたイメージを動作確認し、OKとなったら同じイメージを使って本番にデプロイできると最高です。

  • ビルド済・検証済のイメージを本番で使うので安心感がある
  • ビルドフェーズをスキップできるため、本番での Cloud Run リビジョン作成は約1分で終わる
    • イメージをビルド・プッシュすると10分程度かかる😒

ビルド時の環境依存要素は排除し、実行時の環境変数や設定ファイルを検証/本番で切り替えます。ビルドはこれでOKなのですが、デプロイ時に課題があります。本番デプロイ時、「検証済のコンテナイメージタグはこれだよ」ということを細かい情報をCloud Build GitHub トリガーへ伝えることができません。Cloud Buildでバージョン番号などを追跡できる機能などがあれば話は変わってきますが(要望も上がっているようです)、いまのところそのような仕組みはありません。

リリースフローを調整したり、GitHub Actions 側で無理やり Cloud Build トリガーを起動すればできなくもないですが、デプロイ事情でソースコード運用をこねくりまわすことは避けたかったため、泣く泣く断念しました。いまは本番環境でもビルドステップを踏んでいます。ここはCloud BuildやArtifact Registryのエコシステムが改善することを期待したいです。

おわりに

この記事自体は私が書いていますが、チーム全体で助け合いながらCloud Runへ移行しました。引き続き、新しいサービスは積極的に試しつつ、Zennを安定して運営し続けるため、ちからをあわせて努力します。

Zenn Tech Blog
Zenn Tech Blog

Discussion