はじめに
こんにちは。開発1部の小松山です。現在はバックエンドを主軸にリユースプラットフォームCosmosの開発に携わっています。
Cosmosでは全面的にCloud Runを採用しており、同様に私のプロジェクトでもAPIサーバにCloud Runを採用しています。本記事ではそんなCloud Runの運用中に遭遇した課題を、ちょっとした工夫で解決した事例を紹介します。
背景/前提
プロジェクトの構成
記事執筆時点でのプロジェクトの大まかな構成は以下の通りです。
Cosmosはマイクロサービス構成になっていて、フロントエンドは各サービスのAPIにアクセスする際は全サービス共通のAPI Gatewayにリクエストをします。その後段には、私が開発に携わるプロジェクトを含む、各サービスのAPIサーバがあり、API Gateway・APIサーバはそれぞれCloud Runでホスティングされています。私のプロジェクトではAPIサーバに対してのアクセス制限を設けるため、IAM(Identity and Access Management)による認証が有効になっています。この認証を突破するため、API GatewayにAPIサーバの呼び出し権限を付与しています。
IAM認証付きのCloud Runサービスにリクエストを通す
前提知識として、IAM認証が有効化されたCloud Runサービスに対してリクエストを通す方法について説明します。
認証付きのCloud Runサービスへリクエストを送信する側のアカウントはCloud Run起動元(roles/run.invoker
)ロールを持っている必要があります。くわえて、リクエストを送信する際には、該当のロールを持ったアカウントの認証情報(IDトークン)をリクエストヘッダ(Authorization
またはX-Serverless-Authorization
)に付与する必要があります。
IDトークンは以下のいずれかの方法で取得できます。
gcloud CLIを使う場合
# 人間のアカウントまたはサービスアカウントとして認証を済ませている前提です
$ gcloud auth print-identity-token
eyJhbGci...
認証ライブラリを使う場合(Goの場合)
import ( "context" "google.golang.org/api/idtoken" ) func printIDToken(ctx context.Context, cloudRunEndpoint string) { // サービスアカウントとして認証を済ませていないとここで失敗する tokenSource, _ := idtoken.NewTokenSource(ctx, cloudRunEndpoint) token, _ := tokenSource.Token() println(token.AccessToken) // → eyJhbGci... }
詳細は下記リンクを参照してください。
https://cloud.google.com/run/docs/authenticating/service-to-service?hl=jacloud.google.com
課題
サードパーティのデプロイツールでIAM認証が突破できない
上記で紹介した構成を組むうえで困ったポイントがあります。それは、サードパーティ製のCLIを使ってHTTPリクエストを送るとき、Cloud RunのIAM認証が突破できないことです。
私のプロジェクトではAPIサーバにHasuraというオープンソースのGraphQLサーバを利用しています。HasuraはPostgreSQLなどのデータベースと接続すると、対応するCRUD APIを自動生成してくれるツールです。Hasura公式からDockerイメージが配布されているので、それをCloud Runにデプロイして使っています。
Hasuraのデプロイについて
Hasuraはデータベースのマイグレーションや自身のサーバの設定値(metadata)を管理する機能があります。これらの設定を設定ファイル化し、プロジェクトのリポジトリでコード管理しています。この設定をCloud RunにホスティングされたHasuraに対して適用することを便宜上「Hasuraのデプロイ」と呼びます。詳細は以下のリンクを参照してください。
デプロイの際はHasura CLIというものを使います。Hasura CLIはHasuraサーバのMetadata API、Schema APIにHTTPリクエストを送信することでマイグレーションや設定値の更新を実現しています。
従来の方法の問題点
ここまでの説明から察した方がいるかもしれませんが、Hasura CLIが行うMetadata API、Schema APIのリクエストのヘッダにはCloud Runの要求するIDトークンが含まれません。そのため、HasuraサーバのIAM認証が有効化されていた場合、Hasura CLIからのリクエストは拒否されてしまいます。
Hasura CLIに追加のヘッダを付与できる機能があるとよいのですが、現状そのような機能は提供されていません。
# こういうことができると良かったが、Hasura CLIにそんなオプションはない $ hasura deploy \ --endpoint https://hasura-xxx.a.run.app \ --header "X-Serverless-Authorization: Bearer $(gcloud auth print-identity-token)"
Cloud Runの認証を突破するため、どうにかしてHasura CLIが行うリクエストに、サービスアカウントのIDトークンを付与する必要がありました。
リバースプロキシを挟んでサービスアカウントの認証情報を付与する
実装方針
上記問題の解決のため、Hasura CLI → Cloud Runの通信の間にリバースプロキシを挟むことを考えました。実装言語はチームメンバーに馴染みがあり、かつ自分の中にも実装イメージを持てたGoを選択しました。大まかな作りは以下の通りです。
デプロイスクリプトの処理フローは以下のとおりです。
- デプロイ用サービスアカウントのIDトークンを取得
- リバースプロキシを起動
- ハンドラ内でIDトークンをリクエストヘッダに付与
- ホストをHasuraのCloud Runに差し替えてリクエストを転送
- Hasura CLIでリバースプロキシに向けてデプロイコマンドを実行
実はgcloud CLIには認証情報を挟んでくれるプロキシを立てる機能があるようです。しかし、こちらは記事執筆時点ではGA前であったり、テスト用ということがドキュメントに明示されているため、今回こちらは使用せず、自作のプロキシを作ることにしています。
net/http/httputilを使用したリバースプロキシの実装
Goでは標準パッケージであるnet/http/httputil
を使えばリバースプロキシを実装できます。
リバースプロキシ関連のコードは以下の通りです (簡略化のため、エラーハンドリング・ロギングなどを省略しています)。
package main import ( "context" "net/http" "net/http/httputil" "net/url" "os" "google.golang.org/api/idtoken" ) func main() { ctx := context.Background() endpoint := os.Getenv("CLOUD_RUN_ENDPOINT") port := os.Getenv("REVERSE_PROXY_PORT") secret := os.Getenv("HASURA_GRAPHQL_ADMIN_SECRET") tokenSource, _ := idtoken.NewTokenSource(ctx, endpoint) token, _ := tokenSource.Token() // SAのIDトークンの取得 remoteURL, _ := url.Parse(endpoint) server := &http.Server{ Addr: ":"+port, Handler: &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(remoteURL) pr.Out.URL.Path = pr.In.URL.Path pr.Out.URL.RawQuery = pr.In.URL.RawQuery pr.Out.Host = remoteURL.Host // 転送するリクエストのヘッダにサービスアカウントのIDトークンを付与 pr.Out.Header.Add("X-Serverless-Authorization", "Bearer "+token.AccessToken) }, }, } // 同スクリプト内でHasura CLIを実行したいので、go func()内でサーバを起動しておく go func() { if err := server.ListenAndServe(); err != nil { panic(err) } }() }
httputil.ReverseProxy
型を初期化する際にRewrite
という関数を渡すことができ、ここにリクエストの転送処理を書くことができます。この関数は引数に*httputil.ProxyRequest
型の変数をとります。ProxyRequest.In
にはプロキシが受信したリクエスト、ProxyRequest.Out
には転送先のリクエストが格納されています。ProxyRequest.Out
に転送先の情報を詰め込んでいくことで、認証情報を付与した上でのCloud Runへのリクエストを実現しています。
同スクリプト内でHasura CLIのデプロイコマンドも実行したいので、プロキシサーバの起動は新しくgoroutineを立ち上げて行っています。
os/execを使用した外部コマンドの実行
Hasura CLIでのデプロイの実行はos/exec
パッケージを使いました。
先ほど紹介したコードにHasura CLIのデプロイコマンドの実行処理を追加したものが以下です。
package main import ( "context" "fmt" "log/slog" "net/http" "net/http/httputil" "net/url" "os" "os/exec" "google.golang.org/api/idtoken" ) func main() { ctx := context.Background() endpoint := os.Getenv("CLOUD_RUN_ENDPOINT") port := os.Getenv("REVERSE_PROXY_PORT") secret := os.Getenv("HASURA_GRAPHQL_ADMIN_SECRET") tokenSource, _ := idtoken.NewTokenSource(ctx, endpoint) token, _ := tokenSource.Token() // SAのIDトークンの取得 remoteURL, _ := url.Parse(endpoint) server := &http.Server{ Addr: ":" + port, Handler: &httputil.ReverseProxy{ Rewrite: func(pr *httputil.ProxyRequest) { pr.SetURL(remoteURL) pr.Out.URL.Path = pr.In.URL.Path pr.Out.URL.RawQuery = pr.In.URL.RawQuery pr.Out.Host = remoteURL.Host pr.Out.Header.Add("X-Serverless-Authorization", "Bearer "+token.AccessToken) }, }, } // 同スクリプト内でHasura CLIを実行したいので、go func()内でサーバを起動しておく go func() { if err := server.ListenAndServe(); err != nil { panic(err) } }() // migration/metadataの適用 deployOutput, _ := exec.Command( "sh", "-c", fmt.Sprintf( "hasura deploy --endpoint http://localhost:%s --admin-secret '%s' --skip-update-check", port, secret, ), ).CombinedOutput() slog.Info(string(deployOutput)) // seed管理しているマスタデータの投入 seedOutput, _ := exec.Command( "sh", "-c", fmt.Sprintf( "hasura seed apply --database-name default --endpoint http://localhost:%s --admin-secret '%s' --skip-update-check", port, secret, ), ).CombinedOutput() slog.Info(string(seedOutput)) }
os/exec
パッケージには外部コマンド実行用のメソッドがいくつか用意されています。それぞれコマンドの実行結果の出力、同期実行か・非同期実行かなどによって使い分けられます。今回のケースでは、コマンドの実行終了まで次の処理を待ち、実行結果を標準出力・標準エラーから取得したかったためCombinedOutput()
メソッドを使いました。
実行コマンドを組み立てる際にfmt.Sprintf()
で変数をそのまま展開してしまっていますが、これはユーザからの入力を含まないため問題なしと判断しています。OSコマンドインジェクションを避けるため、ユーザからの入力を含む場合は入力値の検証やエスケープ処理を施すことが望ましいです。
また、exec.Command()
によるコマンド実行によって新しくプロセスが生成されるため、メモリの使用量が増えてしまいます。このスクリプトはCloud Runジョブで実行していますが、デフォルトのメモリ上限(512MiB)では足りず、途中でジョブが止まってしまうことがありました。これはメモリ上限を1GiBに増やすことで回避しました。
まとめ
IAM認証を有効化したCloud Runサービスに対して、サードパーティ製のツールからでもリクエストを通す方法を紹介しました。今回紹介した構成を組む以前は、諸般の事情によりCloud Runジョブでデプロイスクリプトを実行していたため、その方法を踏襲してしまいました(「諸般の事情」についてはこちらをご参照ください)。この対応によって、サービスアカウントとして認証ができればどこからでもHasuraのデプロイができるようになりました。GitHub Actions上などで直接このスクリプトを実行することで無駄を省くことができるかもしれないので、今後の改善点として検討していきたいと思います。
バイセルテクノロジーズではエンジニアを募集しています。