バイセル Tech Blog

バイセル Tech Blogは株式会社BuySell Technologiesのエンジニア達が知見・発見を共有する技術ブログです。

バイセル Tech Blog

リバースプロキシを使ってサードパーティ製のツールでもCloud RunのIAM認証を突破できるようにする

はじめに

こんにちは。開発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

cloud.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.io

デプロイの際はHasura CLIというものを使います。Hasura CLIはHasuraサーバのMetadata APISchema 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前であったり、テスト用ということがドキュメントに明示されているため、今回こちらは使用せず、自作のプロキシを作ることにしています。

cloud.google.com

net/http/httputilを使用したリバースプロキシの実装

Goでは標準パッケージであるnet/http/httputilを使えばリバースプロキシを実装できます。

pkg.go.dev

リバースプロキシ関連のコードは以下の通りです (簡略化のため、エラーハンドリング・ロギングなどを省略しています)。

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パッケージを使いました。

pkg.go.dev

先ほど紹介したコードに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上などで直接このスクリプトを実行することで無駄を省くことができるかもしれないので、今後の改善点として検討していきたいと思います。

バイセルテクノロジーズではエンジニアを募集しています。

herp.careers