🟠

GoogleCloudでカスタムオーディエンスを使用してCloudRunのマルチリージョン環境を作ってみた

2023/12/01に公開

はじめに

CloudRunでマルチリージョンの構成をとりたい場合、どのように作成するでしょうか。
CloudRunサービスを複数立ちあげて、サービスごとに別々のリージョンを指定することでマルチリージョンの構成をとるのが一般的かと思います。

CloudRunのSLAは本記事を投稿した2023年11月時点で99.95%となっており、実はそこまで高くありません。(年間6.18時間のダウンタイムが許容されていることになります。)
マルチリージョンの構成をとることで予期せぬダウンタイムへの対応が可能になります。

ではマルチリージョン構成に加えて、サービス間認証をしようとした場合どのような作りになるでしょうか。

例えばCloudRunにデプロイされたアプリへのアクセスを特定のアプリケーションからのみ受け付ける制限をしたい場合、一般的には呼び出し元のアプリ側でアクセストークンを発行し、トークンをヘッダーに付与したうえでリクエストを行います。

しかし呼び出し元でトークンを発行するには呼び出し先のCloudRunサービスのURLが必要となるため、マルチリージョン構成にしているとどのサービスのURLを指定したらよいか判断できません。

そのため私が携わっていたプロジェクトでは泣く泣く単一リージョンにしたのですが、この度カスタムオーディエンスという機能が正式にGAされ、マルチリージョン+サービス間認証の構成を簡単に構築できるようになったためサンプルを交えて設定方法を共有いたします。

サンプルの概要

サンプルではカスタムオーディエンスを指定することによってリクエスト元と同じリージョンのCloudRunサービスにリクエストされることを確認したいと思います。

2つのGoogleCloudプロジェクト(プロジェクトAとプロジェクトB)を用意し、各プロジェクトにリージョンを別にしてCloudRunサービスを2つ用意します。(サンプルでは東京と大阪を指定しました。)

プロジェクトAのtokyo-serviceとosaka-serviceはリージョンが異なるだけで処理はプロジェクトBにリクエストを送っているだけです。

プロジェクトBのサービスはリクエストを受け付けたら、サービス名を表示します。

以下はプロジェクトAのサービスにデプロイしたソース

main.go
package main

import (
	"context"
	"fmt"
	"io"
	"log"
	"net/http"
	"os"

	"golang.org/x/oauth2/google"
	"google.golang.org/api/idtoken"
	"google.golang.org/api/option"
)

func main() {
	audience := os.Getenv("AUDIENCE")
	if audience == "" {
		log.Fatal("AUDIENCE environment variable not set")
	}

	url := os.Getenv("URL")
	if url == "" {
		log.Fatal("URL environment variable not set")
	}

	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		handleRequest(w, r, audience, url)
	})
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleRequest(w http.ResponseWriter, r *http.Request, audience, url string) {

	//IDトークンを発行する
	token, err := generateIDToken(audience)
	if err != nil {
		http.Error(w, "Error generating ID token", http.StatusInternalServerError)
		log.Println(err)
		return
	}

	//リクエストを送信する
	resp, err := sendRequest(url, token)
	if err != nil {
		http.Error(w, "Error sending request", http.StatusInternalServerError)
		log.Println(err)
		return
	}
	defer resp.Body.Close()

	//レスポンスを取得する
	body, err := io.ReadAll(resp.Body)
	if err != nil {
		http.Error(w, "Error reading response body", http.StatusInternalServerError)
		log.Println(err)
		return
	}

	fmt.Fprintf(w, "Response from App B: %s", string(body))
}

func generateIDToken(audience string) (string, error) {
	ctx := context.Background()
	creds, err := google.FindDefaultCredentials(ctx, audience)
	if err != nil {
		return "", err
	}

	idTokenSource, err := idtoken.NewTokenSource(ctx, audience, option.WithCredentials(creds))
	if err != nil {
		return "", err
	}

	token, err := idTokenSource.Token()
	if err != nil {
		return "", err
	}

	return token.AccessToken, nil
}

func sendRequest(url, token string) (*http.Response, error) {
	client := &http.Client{}
	req, err := http.NewRequest("GET", url, nil)
	if err != nil {
		return nil, err
	}
	req.Header.Add("Authorization", "Bearer "+token)

	return client.Do(req)
}

以下はプロジェクトBのサービスにデプロイしたソース

main.go
package main

import (
	"fmt"
	"log"
	"net/http"
	"os"
)

func main() {
	http.HandleFunc("/", handler)
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func handler(w http.ResponseWriter, r *http.Request) {

	// どのサービスにルーティングされたかわかりやすくするために環境変数SERVICE_NAMEを取得
	serviceName := os.Getenv("SERVICE_NAME")

	if serviceName == "" {
		serviceName = "Unknown Service"
	}

	fmt.Fprintf(w, "Hello from %s!", serviceName)
}

CloudRunにデプロイする際はDockerイメージにする必要があるためDockerfileも作成する

Dockerfile
# 使用するGoのバージョンを指定
FROM golang:1.21 as builder

# 作業ディレクトリを設定
WORKDIR /app

# ソースコードをコンテナにコピー
COPY . .

# Goアプリケーションをビルド
RUN CGO_ENABLED=0 GOOS=linux go build -v -o app

# 実行ステージ
FROM alpine:latest  
RUN apk --no-cache add ca-certificates

WORKDIR /root/

# ビルドしたバイナリをコピー
COPY --from=builder /app/app .

# コンテナがリッスンするポートを指定
EXPOSE 8080

# 実行コマンド
CMD ["./app"]

ローカルでdocker buildを行いイメージを作成したら各GCPプロジェクトのArtifactRegistoryにpushしてください。
https://cloud.google.com/artifact-registry/docs/docker/pushing-and-pulling?hl=ja

プロジェクトBのCloudRunの設定

まずはプロジェクトBのCloudRunサービスを作成します。

項目 設定内容
コンテナイメージのURL 先ほどArtifactRegistryにpushしたイメージ
リージョン asia-northeast-1(東京)、asia-northeast-2(大阪)
環境変数 SERVICE_NAME(Valueにはレスポンスに表示したいサービス名を指定する)
認証 認証が必要
Ingressの制御 すべて

カスタムオーディエンスの設定

以下のドキュメントを参考にプロジェクトBのCloudRunサービスに対してカスタムオーディエンスを作成します。
https://cloud.google.com/run/docs/configuring/custom-audiences?hl=ja#command-line

gcloud run services update サービス名 --add-custom-audiences=オーディエンス名

上記コマンドをサービス分実行しますが、オーディエンス名は同じものを指定してください。
コマンド実行後はリージョンを選択するよう促されるのでサービスのリージョンを指定してください。

またロードバランサーを作成してプロジェクトBのCloudRunサービスをバックエンドサービスとして指定してください。

プロジェクトAのCloudRunの設定

次にプロジェクトAのCloudRunサービスを作成します。

項目 設定内容
コンテナイメージのURL 先ほどArtifactRegistryにpushしたイメージ
リージョン asia-northeast-1(東京)、asia-northeast-2(大阪)
環境変数 AUDIENCE(Valueにはカスタムオーディエンスのオーディエンス名を指定する)
環境変数 URL(ValueにはロードバランサーのグローバルIPを指定する)
認証 未認証の呼び出しを許可
Ingressの制御 すべて

権限の設定

プロジェクトBのCloudRunサービスにプリンシパルを追加して、プロジェクトAのCloudRunサービスのデフォルトアカウントに「CloudRun起動元」ロールを付与してください。

動作確認

プロジェクトAのサービスに対してリクエストを送信してみましょう。

プロジェクトBのサービスに対してリクエストを送信してみましょう。

これでカスタムオーディエンスを指定することで簡単にマルチリージョン+サービス間認証の構成をとることができました。

ちなみにサービスの詳細画面からカスタムオーディエンスを設定しているかを確認できます。

参考文献

Discussion