GoでHeadless browserを使いClient Side Renderingを Cloud Run で動かす
はじめに
この記事は、Go言語でヘッドレスブラウザを実装したサーバ(サーバレスなので実際にはコンテナという表現が正しいかも知れません)を作り、Google Cloud Platform (GCP) の Cloud Run (Knative を使用) でセキュアに実行する内容となります。
ハンズオンではありませんが、少し手を加えれば動くものが作れるレベルの資料としています。
一応、なるべく幅広い読者層に分かりやすく伝えるため、人によって冗長的な説明に感じるところもあると思います。その場合は本題に入る「Go で ヘッドレスブラウザ を実装したサーバを用意するの」 の章から読み進めて下さい。
Goals
- Go で CSR (Client Side Rendering / クライアントサイドレンダリング) に対応する方法が知れる
- たとえば CSR 対応のテストやスクレイピングなどの環境を作る知識が身に付きます
- Google Cloud Platform のサーバレスサービスの使い分けを理解する
- Buildpacks を使ったビルドから Cloud Run へのデプロイまで一通りの方法、概要を理解する
- Go + Cloud Run から Secret Manager を使う方法を理解する
CSR (Client Side Rendering) とは何か
CSR は、SSR (Server Side Rendering), SSG (Static Site Generation) などのサーバ側でHTMLを生成や保管するのと対照的に名前のとおり JavaScript がクライアントのブラウザで実行され、HTMLを生成して描画(レンダリング)します。
CSR は、SPA(Single Page Application) も含まれます。
SPAで動的コンテンツをサーバとの通信を発生させずにクライアント内でHTML更新処理を行わせてネットワークレイテンシーを省くことでパフォーマンスを大幅に向上させたり、SPA以外でも基本的にクライアント内で描画処理を行うので、サーバの負荷集中を下げるなどの利点もあります。
これらの話は、本記事の対象外なのでここまでにしますが、気になる方は調べてみてください。
さて、細かい説明は割愛しますがCSRは利点ばかりではなく、欠点もいくつかあります。
初期表示が遅いことやSEOに弱いなどもありますが、そういったユーザーやビジネス以外にも欠点があります。
それは「JavaScriptが動く環境ではないとHTMLが構築されない」ため、サーバサイドでHTMLが構築されることを期待したテストやスクレイピングなどは出来ません。
そのため、クライアントサイドでレンダリングされHTML構築後、ページが出来上がった結果に対応するテスト等を行う場合は、ヘッドレスブラウザを利用します。
ヘッドレスブラウザとは
一言で書くと、UIを持たないウェブブラウザです。
例えば w3m みたいなコマンドで実行できるブラウザがありますが、それよりも高機能で JavaScript も実行できます。
普段ウェブブラウジングで利用されているChrome, Firefox などのウェブブラウザはGUIが備わっていますが、UIが丸ごと無くなってコマンドラインやAPIによるブラウジング操作が可能な物です。
仕組み
それでは今回の仕組みを説明します。流れは以下のようになります。
- クライアントが HTTPS で Cloud Run 上の コンテナサーバにアクセスする
- クライアントは、単にトリガーであるので、人間、CI、Pub/Sub、Cloud Scheduler など問いません
- コンテナサーバがヘッドレスブラウザを使ってターゲットのサーバにアクセスする
- ヘッドレスブラウザが HTMLを生成する
- 取得した HTML からページのエレメントをパースし、必要な情報を取得する
- テスト or スクレイピング などを行う
GCP のマネージドサーバーレスプラットフォームである Cloud Run を使用します。
余談ですが、CSR ではなければヘッドレスブラウザは必要ありませんので、その場合は Cloud Functions などの FaaS (Function as a service) でも実行可能です。
Go で ヘッドレスブラウザ を実装したサーバを用意する
Cloud Run に置くコンテナサーバ
Cloud Run に置くコンテナサーバは、標準でHTTPSのエンドポイントが提供されます。
そこから下は、自分たちで用意する必要があります。
トリガーは様々です。HTTP の他に gRPCにも対応 しています。
ここでは以下の形で HTTP のサンプルコードを示します。
HTTP 8080 で Listen する例
package main
import (
"fmt"
"log"
"net/http"
"os"
"context"
"github.com/xxxxx/xxxxx/function"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
log.Print("starting server...")
http.HandleFunc("/", handler)
port := os.Getenv("PORT")
if port == "" {
port = "8080"
log.Printf("defaulting to port %s", port)
}
log.Printf("listening on port %s", port)
if err := http.ListenAndServe(":"+port, nil); err != nil {
log.Fatal(err)
}
}
func handler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
function.Function(ctx)
fmt.Fprintln(w, "completed.")
}
その他は公式ドキュメント を参照してください。
ここからヘッドレスブラウジングを実装します。
ヘッドレスブラウジング
go-rod/rod というパッケージが存在するのでそれを利用します。
このパッケージは、次の流れで処理を行います。
- 最初に DevTools エンドポイントへの接続を試し、見つからなければローカルのブラウザーの利用を試みます。それが無ければダウンロードして再接続をします
- JSON-RPC を使用して、DevTools プロトコル を通して DevTools エンドポイントと通信をし、ブラウザーを制御します
- JSON-RPCの型定義を使用して、高レベルのアクションを実行します。
上図は https://pkg.go.dev/github.com/go-rod/rod より抜粋
サンプルコード
今回は生成が想定されている HTML のタグやエレメントを見つけて検査をし、条件が一致したものを抽出するところまでとします。
サンプルコード
func SampleCode(ctx context.Context, conf *CfgFile) string {
var msg string
for _, keyword := range conf.Keywords {
endpoint := fmt.Sprintf("%s%s%s", conf.Endpoint, conf.Path.Search, keyword)
// ここでヘッドレスブラウザを立ち上げ。オプション指定無し = デフォルトの設定例。
browser := rod.New().MustConnect()
defer browser.MustClose()
// HTMLを生成。
page := browser.MustPage(endpoint).MustWaitLoad()
page.Timeout(5 * time.Second)
// 生成した HTML の各要素を検査し、必要な物をパース。
if page.MustElement("div.main").MustHas("li.listItem") {
// <section>タグの下の層にある <li class="listItem">タグにあるテキストを抽出して変数に代入する例。
msg += page.MustElement("section li.listItem").MustText()
// 「li要素の次の要素も存在する場合は取得」という条件の例。
if _, err := page.MustElement("section li.listItem").Next(); err == nil {
msg += page.MustElement("section li.listItem").MustNext().MustText()
}
}
}
return msg
}
解説
上記のコメントに書いたとおりなんですが、もう少し掘り下げて説明します。
if page.MustElement("div.main").MustHas("li.listItem") {
page の中で MustElement で期待するHTML要素のタグとクラス名を指定します。
この例だと、div.main
は <div class="main">
です。
その中に存在するリストの <li class="listItem">
が存在していた場合に true になります。
この div タグから 対象の li タグまでいくつ要素があっても検索します。
なので、例えば以下のような形でも Hit します。
<main>
<div>
<a></a>
</div>
<div>
<form>
</form>
</div>
<div class=main> /* 1. ここの中を検索 */
<div>
<section> /* 3. ここから再検索 (下記で説明)*/
<form>
</form>
<ul>
<li class="listItem"> /* 2. ここが Hit */
<div>
<p></p>
<ol>
<li></li>
</ol>
</div>
</li> /* 4. ここまでが対象 */
<li class="listItem">
<div>
<p></p>
<ol>
<li></li>
</ol>
</div>
</li>
</ul>
</section>
</div>
</div>
</main>
続く以下で、<section>
タグの中にある <li class="listItem">
を探して String にて返します。
page.MustElement("section li.listItem").MustText()
この最後に指定した要素が対象となります。
そして、MustText()
で、 その中のテキストのみ抽出します。
少し補足しておきますと、上記の例はその前の
if page.MustElement("div.main").MustHas("li.listItem") {
により、
<li class="listItem"> /* 2. ここが Hit */
まで検索されていたのを遡って上位タグの
<section> /* 3. ここから再検索 (下記で説明)*/
から再検索しています。
動的に変化するページが対象なので、このように生成された HTML 要素の内容に合わせてハンドリング出来た方が便利です。
次に以下で、上記と同じリストを指定していますが、これは条件によってリストが複数生成される場合の処理の例です。
ここまで説明すれば自明ではありますが、要するに次のリストがあればそのテキストを抽出する例になります。
if _, err := page.MustElement("section li.listItem").Next(); err == nil {
msg += page.MustElement("section li.listItem").MustNext().MustText()
}
今回の例はここまでにしますが、他にもさまざまな機能があります。
詳しく知りたい場合は、公式のサンプルコード をご覧ください。
(上記のサンプルコード以外です)
通常のHTML要素の取り出しの他にDOM操作やスクリーンショットなどさまざまな操作に対応しています。
コンテナイメージのビルド
今回の Cloud Run (Knative) までの最終的な形は以下のような形になります。
(他にも構成例はありますが、今回は取り上げませんので割愛します)
Development -> Buildpacks -> Google Cloud Registory
|
Cloud Run <------- pull ------------+
上記のとおり、Cloud Run で使うための コンテナイメージですが、 Buildpacks を使ってビルドします。
pack CLI があるので、それをインストールしておきます。
pack build ${IMAGE_NAME} --builder gcr.io/buildpacks/builder:v1 --path ${WORK_DIR}
上記のようなコマンドで自動的に Go のビルドと Dockerfile 無しでコンテナイメージ作成まで行ってくれます。
しかし、今回の場合は Buildpacks のイメージそのままだとヘッドレスブラウザを動かすための deb パッケージが足りません。(Buildpacks のイメージのOSは現在 Ubuntu です)
そこで、run.Dockerfile という名前で Dockerfile を用意して事前に必要な物を足してからそのイメージを元に Buildpacks でビルドします。
run.Dockerfile
FROM gcr.io/buildpacks/gcp/run:v1
USER root
RUN apt update && apt upgrade -y && \
apt install -y --no-install-recommends wget ca-certificates && \
wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && \
DEBIAN_FRONTEND=noninteractive apt install -y ./google-chrome-stable_current_amd64.deb && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
USER cnb
そして Google Cloud Registory (以下GCR) に push するため例えば以下のようにタグを作ります。
docker tag ${IMAME_NAME} asia.gcr.io/${GCP_PROJECT}/${IMAGE_NAME}
※ GCR以外も利用可能です。
コンテナーイメージをレジストリーに送る
事前に認証が通る状態にします。
GCR への認証については こちら をご覧ください。
認証が通れば以下だけです。
GCP_PROJECT=$(gcloud config get-value project)
docker push asia.gcr.io/${GCP_PROJECT}/$(IMAGE_NAME)
サービスアカウントを作成して IAM Role を割り当てる
Cloud Run に標準で付くサービスアカウントですが、Editor 権限であり、権限が多すぎます。
不要な権限は多いのにさらにこのあと説明する Secret Manager の権限を追加する必要があります。
公式ドキュメントでも標準で付くサービスアカウントの使用よりも適した権限で新規作成することが推奨されています。
手動で作成でも良いですが、Terraform の例は以下です。
resource "google_service_account" "example_sa" {
account_id = "example-sa"
display_name = "example-sa"
description = "Cloud Run"
project = GCP_PROJECT
}
resource "google_project_iam_member" "cloud_run" {
project = GCP_PROJECT
role = "roles/run.invoker"
member = "serviceAccount:${google_service_account.example_sa.email}"
}
resource "google_project_iam_member" "cloudrun_service" {
project = GCP_PROJECT
role = "roles/run.serviceAgent"
member = "serviceAccount:${google_service_account.example_sa.email}"
}
resource "google_project_iam_member" "secret_accessor" {
project = GCP_PROJECT
role = "roles/secretmanager.secretAccessor"
member = "serviceAccount:${google_service_account.example_sa.email}"
}
必要な 標準 Role は上記となりますが、それぞれ次のようになります。
-
roles/run.invoker
- Cloud Run を呼び出すために利用します
- 要するにクライアント/トリガー側で必要な権限です
- Cloud Run 自体には不要なので、さらにサービスアカウントを分けることも可能です
- Cloud Run を呼び出すために利用します
-
roles/run.serviceAgent
- Cloud Run のデプロイに必要な権限です
-
roles/secretmanager.secretAccessor
- このあと説明する
Secret Manager
へのアクセスで利用します
- このあと説明する
※カスタムロールを使うことでさらに権限を小さく出来ます。
カスタムロールで、より細かい権限に調整したい場合は、Cloud Run IAM permissionsも参考にしてください。
Secret Manager でシークレット情報を管理する
何か Token などのシークレット情報がある場合、Google Cloud 環境だと Secret Managr を使うのが便利です。
また、鍵(json ファイル)無しのサービスアカウントを割り当てるだけで利用できますので、パスワードどころか鍵さえも所有が不要となります。
そうすることでソースコード上に暗号化させる必要があるものを無くせるので非常に安全です。
※下記の「Test と ローカルの Docker による確認の例」の項目で、このサービスアカウントの鍵(json ファイル)を利用しますが、それはあくまでDocker の ローカルテスト用です。サービスの実行自体には不要ですので、ソースコードに含める必要は全くありません。
Secret Manager でシークレットを作成したら Resource ID を取得しておきます。
projects/PROJECT_NUMBER/secrets/SECRET_NAME/versions/1
みたいな形式になります。
1
のところは、だいたい latest
にしておくで良いはずです。
これは、のちほど使います。
そして Go 側では以下で簡単に Secret が取得出来ます。
config/secret-manager.go
package config
import (
secretmanager "cloud.google.com/go/secretmanager/apiv1"
"context"
"fmt"
secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
)
func AccessSecret(name string) (string, error) {
// Create the client.
ctx := context.Background()
client, err := secretmanager.NewClient(ctx)
if err != nil {
return "", fmt.Errorf("failed to create secretmanager client: %v", err)
}
// Build the request.
req := &secretmanagerpb.AccessSecretVersionRequest{
Name: name,
}
// Call the API.
result, err := client.AccessSecretVersion(ctx, req)
if err != nil {
return "", fmt.Errorf("failed to access secret version: %v", err)
}
return string(result.Payload.Data), nil
}
上記はほとんど公式のサンプルコードのままです。
取得側は以下のようにします。
if api.Token, err = config.AccessSecret(os.Getenv("TOKEN")); err != nil {
Knative マニフェストを使って Cloud Run へデプロイ
Cloud Run の根幹は Knative なので、以下のようにマニフェストを作成します。
以下のマニフェストで「外部からアクセスが出来る且つIAMで認証が必要な状態」となります。
GCP console で見るとこんな感じです。
なお、この IAM 権限、ロールの割り当てについては、上記の「サービスアカウントを作成して IAM Role を割り当てる」 の項目で割り当てたものです。
例えば、knative-manifests/${GCP_PROJECT}-service.yaml という名前で作成します。
この例の場合、GCP Project名が hoge-dev
とすると knative-manifests/hoge-dev-service.yaml
となります。
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
name: XXXX
annotations:
client.knative.dev/user-image: asia.gcr.io/GCP_PROJECT/IMAGE_NAME
run.googleapis.com/launch-stage: BETA
run.googleapis.com/ingress: all
run.googleapis.com/ingress-status: all
labels:
cloud.googleapis.com/location: asia-northeast1
spec:
template:
metadata:
annotations:
autoscaling.knative.dev/maxScale: '4'
spec:
containers:
- image: asia.gcr.io/GCP_PROJECT/IMAGE_NAME
env:
- name: TOKEN
value: projects/GCP_PROJECT_ID/secrets/SECRET_NAME/versions/latest
ports:
- containerPort: 8080
resources:
limits:
cpu: 1000m
memory: 1024Mi
serviceAccountName: example-sa@GCP_PROJECT.iam.gserviceaccount.com
timeoutSeconds: 120
以下の サービスアカウントは、先ほど作成したアカウントです。
serviceAccountName: example-sa@GCP_PROJECT.iam.gserviceaccount.co
そして以下は、一つ前の項目の Secret Manager で作成した Resource ID です。
(これはご覧のとおり、シークレット情報ではありませんので安全です)
env:
- name: TOKEN
value: projects/GCP_PROJECT_ID/secrets/SECRET_NAME/versions/latest
シークレットやマニフェストは Dev、 Prod それぞれの環境ごとに分けて用意します。
go test
や、手元の Docker run
の場合は、Cloud Run を使わないので、これらとは別にしています。
そのあたりはのちほど説明します。
gcloud コマンド + Knative マニフェスト を使ってデプロイする
ここまでで、コンテナイメージのビルド -> そのイメージを GCR に置く <- それを使って Cloud Run へのデプロイの準備までが出来ました。
このあとは実際に Cloud Run へデプロイする形となります。 ここでは gcloud コマンドを使った例とします。
先ほど作成した knative-manifests/${GCP_PROJECT}-service.yaml
を使用します。
gcloud beta run services replace knative-manifests/${GCP_PROJECT}-service.yaml --platform=managed --region=asia-northeast1 --project=${GCP_PROJECT}
なお、Git リポジトリとのインテグレーション (リポジトリが更新されたら CI/CD に自動的に送ってデプロイ)も Cloud Run は標準で対応出来ます。
今回そこは割愛して次の項目で Makefile によるそれぞれの実行にまでとします。
一通りまとめた Makefile を作る。
Makefile の例 (コピペするときはタグ -> スペースにならないように注意)
.DEFAULT_GOAL := help
PROJECTNAME := $(shell basename "$(PWD)")
REGION := asia-northeast1
.PHONY: help
help:
@echo "\033[32m$(PROJECTNAME):\033[0m"
@awk 'BEGIN {FS = ":.*##"; printf "Usage: make \033[36m<target>\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-10s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)
.PHONY: go_module
go_module: ## Run go mod tidy
go mod tidy
.PHONY: build
build: ## Build with buildpacks
docker build -t my-run-image -f run.Dockerfile .
pack build $(PROJECTNAME) --builder gcr.io/buildpacks/builder:v1 --run-image my-run-image
GCP_PROJECT=$(shell gcloud config get-value project) && \
docker tag $(PROJECTNAME) asia.gcr.io/$${GCP_PROJECT}/$(PROJECTNAME)
.PHONY: push
push: ## Push container image to GCR (Google Cloud Registory)
GCP_PROJECT=$(shell gcloud config get-value project) && \
docker push asia.gcr.io/$${GCP_PROJECT}/$(PROJECTNAME)
.PHONY: deploy
deploy: ## Deploy to Google Cloud Run
GCP_PROJECT=$(shell gcloud config get-value project) && \
gcloud beta run services replace knative-manifests/$${GCP_PROJECT}-service.yaml --platform=managed --region=$(REGION) --project=$${GCP_PROJECT}
.PHONY: test
test: ## Run go test
go test ./...
.PHONY: errcheck
errcheck: ## Run errcheck
errcheck -blank -asserts ./...
ローカルの Docker による動作確認の例
Cloud Run を使う前に Google Cloud の Secret Manager
を含めたテストをする方法の説明も一応しておきます。
※この章が不要な方は読み飛ばしてください
Cloud Runでそのままテストするよりもまずはローカルで試したい場合もあると思います。
その時は、上記「サービスアカウントを作成して IAM Role を割り当てる」 の項目で作成されたサービスアカウントの json ファイルが必要です。
(GCEインスタンスを用意すればjson ファイルを作らずにも出来そうですが、サーバレスなのにサーバを用意したくないですよね)
というわけで、GCP の IAM から当該サービスアカウントの json ファイルを生成します。
そのファイルを以下のように docker run
コマンドで ボリュームをマウントして利用して、環境変数でクレデンシャルなどの機密情報を渡します。
今回の例だと TOKEN
となります。
ローカルにイメージをビルドする
make build
docker run
の例
docker run -d -it -v $(pwd)/service-account.json:/workspace/service-account.json --env GOOGLE_APPLICATION_CREDENTIALS=/workspace/service-account.json --env TOKEN=projects/GCP_PROJECT_ID/secrets/SECRET_NAME/versions/latest --name test-run -p 8080:8080 --rm asia.gcr.io/${Your_GCP_Project}/${Your_Container_image}:latest
以下等で確認します。
docker logs -f test-run
繰り返しますが、この json ファイルはあくまでローカルで実行テスト用 なので、Cloud Run の実行には不要です。ソースコードに含める必要は全くありません。
手元の動作確認テストが終わったら即削除して、その場の利用だけにすると安全です。
ビルドからデプロイまで
Makefile が出来たら、以下のコマンドでビルドからデプロイまでサクッと出来ます。
事前準備
gcloud config set project ${Your_GCP_Project}
ローカルに設定されているGCP Projectの確認
gcloud config get-value project
ビルド 〜 デプロイ
make build && \
make push && \
make deploy
(本記事の対象外なので割愛しますが、先程書いたとおり上記以外でもデプロイまでのインテグレーションはGCPで標準サポートしています。適宜状況に合わせて使い分ける形が良いと考えています)
CI/CD
もしもテストやビルドで Secret Manager のシークレット取り出すを必要がある場合は、Cloud Build を使う方が良いでしょう。
それは例えば GCPサービス以外の物を使うことで、鍵の json ファイルが必要になり、そのせいであちこちに鍵ファイルを置いてしまうとリスクを増やすことになるので、わざわざ Secret Manager を使う効果が弱まってしまうためです。
逆にシークレットを取り出す必要が無ければ Github Actions でも CircleCI でも使いたいものを使うのが良いと思います。
余談:
- 5/18編集: 初回投稿当時に (Workload Indentity federation に対応しているプロバイダーであれば鍵は不要になりますが、2021/5現在、 CI/CD プロバイダーでサポートしているものはありません)と補足で書いていた内容について
- それ自体は正しいですが、Workload Indentity federation が扱うのはGCP以外からGCPのリソースが使えるようにID連携を組むことなので、
s/シークレット/リソース/
です - 少しややこしいかも知れませんが、以下の違いになります。
-
Secret Manager
- シークレットをバイナリ blob またはテキスト文字列として保存、管理、アクセス出来るようにするもの
-
Workload Indentity federation
- ID連携をし、GCPリソースを外部のサービスから利用できるようにするもの
- AWS の STS における SAML, Web ID federation みたいなものです
-
Secret Manager
- この章ではシークレットの扱いについて書いているので、混同した書き方をしていたことをお詫びいたします。
- @apstndb さん slack でご指摘ありがとうございました!
- それ自体は正しいですが、Workload Indentity federation が扱うのはGCP以外からGCPのリソースが使えるようにID連携を組むことなので、
クライアント/トリガー
上記までで Cloud Run でも動くようになりました。(サンプルコードなので少しは適宜追加する必要はあります)
それでは次にクライアント/トリガーの用意です。
テストで利用する場合はテストを用意してその中でトリガーを作れば良いです。
直接実行や PubSub でも良いですし、その他であれば Cloud Scheduler などを用意します。
ここではCloud Scheduler を例とし、必要な認証など設定を説明します。
先ほど knative のマニフェストファイルでも説明したとおり、Secret Manager を使う鍵無しのサービスアカウントを割り当てて、それでIAMの認証を通してCloud Run 上でシークレットが使えるようにしています。
Terraform だと次のような内容になります。
resource "google_cloud_scheduler_job" "sample_trigger" {
project = GCP_PROJECT
region = "asia-northeast1"
name = "sample-trigger"
description = "Cloud Run trigger."
time_zone = "Asia/Tokyo"
schedule = "0 */3 * * *"
http_target {
http_method = "GET"
uri = "https://your-cloud-run-url.run.app/"
headers = {}
oidc_token {
service_account_email = "example-sa@GCP_PROJECT.iam.gserviceaccount.com"
}
}
}
GCP コンソール上だと以下のような設定です。
これでOIDC tokenが認可され、無事に Cloud Run から Secret Manager のシークレット情報が呼び出せるようになります。
さいごに
まとめです。
本記事で以下のことが得られました。
- Go によるヘッダレスブラウザの使い方
- GCP の安全なシークレットの使い方
- 各種ビルドおよびテスト方法や使い分け
- Cloud Run の使い所と使い方
いかがでしたでしょうか?
読者層を広くするためにあえて冗長的に書いているところもあるので、もしかしたら大変そうに見えるところもあるかもしれません。
しかし実際には意外と楽に出来ますので、CSR に対応する機会が出てきたり、Cloud Run を使ってみたいことなどがありましたら是非参考にしてみてください。
Discussion