ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

Cloud BuildからCloud Functionsを呼びたい、それだけなんだ

Google Cloudのサーバレス主要サービスであるCloud BuildCloud Functionsを使っていて、Cloud BuildからCloud Functionsをなるべく安全っぽく呼びたかったのだが、適当な手順がなかなか見つからなかったのでここに残しておき、将来自分が忘れた時に検索可能にしたい。

Prelude: Compute EngineのVMからCloud Functionsを呼ぶ

Cloud FunctionsはCompute EngineのVMから呼ぶのは比較的簡単である。以下は、認証が必要なHTTPS呼び出し設定をしたFunctionsを前提とする。

最初に、Cloud Functions 起動元(roles/cloudfunctions.invoker)を付与したサービスアカウントを作り、それをVMに紐づけて起動する。

そのVMの中から以下のようにJWTを発行すれば関数を呼び出せる。

FUNCTION_NAME="https://${REGION}-${PROJECT_ID}.cloudfunctions.net/hogehoge-func"

# gcloud コマンドがある時
IDENTITY_TOKEN=$(gcloud auth print-identity-token --audiences=$FUNCTION_NAME)

# metadata から取る時
IDENTITY_TOKEN=$(curl -H "Metadata-Flavor: Google" "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/identity?audience=$FUNCTION_NAME")

# あとはAuthorizationヘッダにセットする
curl -H "Authorization: bearer $IDENTITY_TOKEN" -X POST $FUNCTION_NAME

Cloud Buildのタスクの中から呼ぶには

ところが、Cloud Buildのタスクの実行環境の中では、上記の方法が使えなくなっている。 gcloud auth print-identity-token を実行しようとすると怒られるし、metadataのidentityエントリポイントは404になっている。

一応、JSON credentialをSecretか何かで渡して、 gcloud auth login などでログインし直してあげれば gcloud auth print-identity-token は発行できる。のだが、当該サービスアカウントでタスクを実行しているはずなのになぜ再ログインが必要なのか... というそもそもの気持ちと、あとSecretが別に一つ増えてしまうのは管理とか場合によって入れ替えとか運用的な手間になりそうで気になる。

ということでもう少し安全っぽくCloud Functionsを呼ぶ。

projects.serviceAccounts.generateIdToken を個別に呼び出す

cloud.google.com

実は、Googleのcredentials APIにはこういうエントリポイントがあり、 print-identity-token コマンドで発行されるようなJWTを個別で発行可能になっている。

このエントリポイントを叩くにはサービスアカウントトークン作成者(roles/iam.serviceAccountTokenCreator)のロールが必要なので、

以下のように自分のトークンだけを発行できるようにロールバインディングを作る*1

gcloud iam service-accounts add-iam-policy-binding mybuilder@${PROJECT_ID}.iam.gserviceaccount.com \
  --member='serviceAccount:mybuilder@${PROJECT_ID}.iam.gserviceaccount.com' \
  --role='roles/iam.serviceAccountTokenCreator'

その上で以下のようにAPIcurlで直接叩けば無事トークンが発行されるはず。もちろん実行するSAには roles/cloudfunctions.invoker を付与しておくこと。

resp=$(curl -X POST -H "content-type: application/json" \
      -H "Authorization: Bearer $(gcloud auth print-access-token)" \
      -d '{"audience": "'$FUNCTION_NAME'"}' \
      "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${SERVICE_ACCOUNT}:generateIdToken")
IDENTITY_TOKEN=$(echo $resp | jq -r .token)

stepの例

steps:
- id: 'get-token'
  name: 'gcr.io/cloud-builders/gcloud:latest'
  entrypoint: 'bash'
  args:
  - -c
  - |
    set -e
    FUNCTION_URL="https://${_REGION_NAME}-${PROJECT_ID}.cloudfunctions.net/${_FUNCTION_NAME}"
    SERVICE_ACCOUNT="$(gcloud config list account --format 'value(core.account)')"

    resp=$(curl -s -f -X POST -H "content-type: application/json" \
      -H "Authorization: Bearer $(gcloud auth print-access-token)" \
      -d '{"audience": "'$$FUNCTION_URL'"}' \
      "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/$${SERVICE_ACCOUNT}:generateIdToken")
    # echo $$resp

    # IDENTITY_TOKEN="$(echo $$resp | jq -r .token)"
    # gcr.io/cloud-builders/gcloud に元々入ってるものだけでなんとかする場合
    IDENTITY_TOKEN=$(echo $$resp | python3 -m json.tool | grep token | awk -F'"' '{print $4}')
    curl -H "Content-Type: application/json" -H "Authorization: bearer $$IDENTITY_TOKEN" $$FUNCTION_URL

    echo "OK"

デバッグ

どうしても認証が通らない... みたいな場合、SAの権限はもちろんだがJWTが正しい権限で発行されているか確認すると良さそう。

echo $$IDENTITY_TOKEN | awk -F. '{print $2}' | base64 -d | python3 -m json.tool

こういう感じ*2で以下のようにペイロードが取れる。

{
    "aud": "https://***.cloudfunctions.net/...",
    "azp": "123456789...",
    "exp": 1673412169,
    "iat": 1673408569,
    "iss": "https://accounts.google.com",
    "sub": "123456789..."
}

audエスケープ間違い(Cloud Buildでよくあること)で想定していない文字列になっていないか、というのと、 sub はidentityを発行したサービスアカウントの内部ID(Webコンソールなどで確認できる)なのでそれが一致しているかも見る感じ。

参考

stackoverflow.com

medium.com

多分、今回の手順はIAPを通したい時や、Cloud Run呼びたい時などにも使える(という認識)。

*1:プロジェクト単位でつけてしまうと、そのプロジェクトのどのSAとしてもトークンを発行できるようになってしまうので、それはちょっとまずい

*2:正確にはurl safe base64か何かっぽいけど