はじめに
やりたいことは?
- 同一プロジェクトのリソース操作を定期的に実行したい(例:SecretManager上のシークレット取得)
- その処理は部外者が勝手に実行しては困る
ということで、↑こんな感じの
Cloud Functionsでサービスアカウントのみ実行可能なAPIを作成して
Cloud Scheduerで定期的にそのAPIを実行する
というのをやります。
追記/修正
- 初版ではCloud Loggingへの書き込みロール追加で記載していましたが、ロール追加なしでログ追加できていたため、シークレットへのアクセスに修正しました。
- GUI説明を削除しました。
IAM認証付きCloudFunctions
シークレット取得処理を実行するために、CloudFunctionsを利用する。
Cloud Functionsで関数を作成するとき、その関数の呼び出しに対して認証の有無が選択できる。
認証付きにすると、その関数の起動元ロールが付与されたプリンシパルからのみ、その関数を呼び出すことができる。
今回は第2世代のCloud Functionsを利用するが、これの実体はCloud Runになっている。
また、起動元のプリンシパルとしてサービスアカウントを利用する。
つまり、対象の関数(の実体であるCloud Run)の起動元ロールが付与されたプリンシパル(=サービスアカウント)からのみ、その関数を起動することができる。
CloudSchedulerで定期実行
定期的に実行するためにCloud Schedulerを利用する。
「認証付き関数の起動元ロールが付与されたサービスアカウント」を利用した「HTTPアクセス」を「定期的に実行する」ことで、今回のやりたいことが実現できる。
概要
作成するもの
リソース | 項目名 | 値 | 用途 |
---|---|---|---|
Cloud Functions | 関数 | func-timer | 関数の処理:Cloud Loggingへのログ書き込み、外部APIへのアクセス、シークレットへのアクセス |
Secret Manager | シークレット | p2-tutorial-secret | 複数行の適当な文字列 |
IAM | サービスアカウント | p2-tutorial-sa | シークレット取得ロールを付与 |
Cloud Scheduler | スケジュール | p2-tutorial-scheduler | 15分ごとにサービスアカウントでHTTPトリガ |
CloudShell/LinuxによるCLI作業
環境準備
CloudShellを利用する場合
Linuxにgcloudをインストールする場合
gcloud のインストール
# 要件
sudo apt-get install apt-transport-https ca-certificates gnupg
# gcloud CLI の配布 URI をパッケージ ソースとして追加します。
echo "deb [signed-by=/usr/share/keyrings/cloud.google.gpg] https://packages.cloud.google.com/apt cloud-sdk main" | sudo tee -a /etc/apt/sources.list.d/google-cloud-sdk.list
# Google Cloud の公開鍵をインポートします。
curl https://packages.cloud.google.com/apt/doc/apt-key.gpg | sudo apt-key --keyring /usr/share/keyrings/cloud.google.gpg add -
# gcloud CLI を更新してインストールします。
sudo apt-get update && sudo apt-get install google-cloud-cli
gcloud init
パラメータ指定
PJ_PREFIX=p2-tutorial
# 固定値
REGION=asia-northeast1
SCHEDULE="*/5 * * * *"
FUNCTION_TIMER=func-timer
# 接頭辞から自動設定
SECRET_NAME=${PJ_PREFIX}-secret
SA_NAME=${PJ_PREFIX}-sa
SCHEDULER_NAME=${PJ_PREFIX}-schduler
DIR_NAME=$HOME/tmp/${PJ_PREFIX}
SA_EMAIL=${SA_NAME}@$(gcloud config get-value project).iam.gserviceaccount.com
Secret Manager でシークレット作成
gcloud secrets create ${SECRET_NAME}
# 文字列を指定
SECRET="### ${PJ_PREFIX} secret body ###"'
シークレット文字列
secret string
'
# シークレットに新しいバージョンを追加
printf "${SECRET}" \
| gcloud secrets versions add ${SECRET_NAME} --data-file=-
# 1行目だけのコマンドでも取得は可能
gcloud secrets versions access latest --secret ${SECRET_NAME} \
--format='get(payload.data)' | tr '_-' '/+' | base64 -d
IAMでサービスアカウント作成
gcloud iam service-accounts create ${SA_NAME} \
--display-name=${SA_NAME}
関数実行時は、指定したサービスアカウントとして実行されるため、そのサービスアカウントに対してシークレット取得ロールを付与する。
gcloud projects add-iam-policy-binding $(gcloud config get-value project) \
--role="roles/secretmanager.secretAccessor" \
--member="serviceAccount:${SA_EMAIL}"
Cloud Functionsで関数作成
Pythonコードからシークレットを読み込むコードを作成する。
シークレットを環境変数やマウントポイントで指定方法もあるが、デプロイコマンド自体にその情報が必要であり面倒なため今回は使わない。
CICDなどでデプロイコマンドもコード化できる場合には、そちらの方法でも問題ない。
# 作業用ディレクトリを作成
mkdir -p ${DIR_NAME}/${FUNCTION_TIMER}
# ソースコードを作成
cat <<EOF > ${DIR_NAME}/${FUNCTION_TIMER}/main.py
import functions_framework
@functions_framework.http
def hello_http(request):
import requests
response = requests.get('https://yesno.wtf/api')
msg = "--- REQUEST API ---\n" + "body: {0}.\n".format(response.text)
from google.cloud import secretmanager
client = secretmanager.SecretManagerServiceClient()
name = f"projects/$(gcloud config get-value project)/secrets/${SECRET_NAME}/versions/latest"
response = client.access_secret_version(request={"name": name})
msg = msg + "\n--- GET SECRET ---\n" + response.payload.data.decode("UTF-8")
import google.cloud.logging
client = google.cloud.logging.Client()
client.setup_logging()
import logging
logging.warning("### Warn log from HTTP trigger function")
return msg
EOF
# 必要モジュールリストを作成
cat <<EOF > ${DIR_NAME}/${FUNCTION_TIMER}/requirements.txt
functions-framework==3.*
google-cloud-logging
google-cloud-secret-manager
EOF
ローカルでの動作テストと、デプロイ後のサービスアカウント経由での動作テスト用にサービスアカウントキーを発行する。
本番動作時は、Cloud Schedulerからの起動時に直接サービスアカウントを指定することができるので、動作テスト用のみに利用する。
このキーは、GCP外部からHTTP起動時に指定することで、サービスアカウントとしての動作ができてしまうため、テスト後は削除する。
gcloud iam service-accounts keys create ${DIR_NAME}/${SA_EMAIL}.key \
--iam-account=${SA_EMAIL}
ローカルテスト時にサービスアカウントとして動作させるようにクレデンシャルを指定する。
export GOOGLE_APPLICATION_CREDENTIALS="${DIR_NAME}/${SA_EMAIL}.key"
pythonのvirtualenv環境で functions-frameworkを利用してローカル動作確認を実施する。
# 関数アプリディレクトリに移動
cd ${DIR_NAME}/${FUNCTION_TIMER}
# ソースコードのディレクトリでvirutualenv環境を作成する
python3 -m venv .venv
source .venv/bin/activate
pip3 install -r requirements.txt
# これでローカルの8080ポートでhttp待ち受けが開始される
functions-framework --debug --target=hello_http
curl -X GET http://localhost:8080
Cloud Functions へのデプロイと動作テスト
ローカル動作テストに問題がなければ、Cloud Functionsに関数としてデプロイする。
gcloud functions deploy ${FUNCTION_TIMER} \
--gen2 \
--runtime=python311 \
--region=${REGION} \
--source=${DIR_NAME}/${FUNCTION_TIMER} \
--entry-point=hello_http \
--trigger-http \
--run-service-account ${SA_EMAIL} \
--quiet
FUNCTION_URI=$( \
gcloud functions \
describe ${FUNCTION_TIMER} \
--gen2 \
--region=${REGION} \
--format="value(serviceConfig.uri)" \
)
# 以下も利用できるようだが用途がわからない。。。
# FUNCTION_URI=https://${REGION}-$(gcloud config get-value project).cloudfunctions.net/$FUNCTION_TIMER
デプロイした関数の動作を確認する。
curl -X GET $FUNCTION_URI \
-H "Authorization: bearer $(gcloud auth print-identity-token)"
成功したログを確認する。
関数内でのログ書き込みは logging.warning("~")
としているため severity=WARNING
で検索する。
$ gcloud logging read "severity=WARNING" --freshness=5m --format="value(receiveTimestamp,textPayload)"
---
2023-04-02T09:22:12.105737379Z ### Warn log from HTTP trigger function
ここで想定通りに動作しない場合、関数動作時のサービスアカウントが処理内のリソースにアクセスできないなど、関数処理上の権限付与を疑う。
サービスアカウントからの起動のための設定と動作テスト
前項のテストでは、関数呼び出し時にコンソールを操作している自身のクレデンシャル(gcloud auth print-identity-token
)を利用した。
自身にはCloud Runのオーナー権限もついているため、関数の起動が可能であり動作テストが可能だった。
ここでは、Cloud Rnd(=Functionsの実体)の起動元ロールをサービスアカウントに付与して、サービスアカウントからの起動テストを実施する。
gcloud run services add-iam-policy-binding ${FUNCTION_TIMER} \
--region=${REGION} \
--role="roles/run.invoker" \
--member="serviceAccount:${SA_EMAIL}"
サービスアカウントとして外部からアクセスするために、サービスアカウントのIDトークンを生成する必要がある。
サービスアカウントによって署名したJWTを利用して、Googleが署名したIDトークンを入手する。
まず、JWT作成時の署名に使うためのサービスアカウント秘密鍵をファイルに書き落としておく。
# サービスアカウントキーから秘密鍵だけ抽出する
cat ${DIR_NAME}/${SA_EMAIL}.key | jq -r .private_key > ${DIR_NAME}/${SA_EMAIL}.key.pem
次に、サービスアカウントの秘密鍵で署名したJWTを作成する。
# ヘッダは指定のとおり
HEADER=$(echo -n '{"alg":"RS256","typ":"JWT"}' | base64 -w 0 | tr -d '\n=' | tr -- '+/' '-_' )
# 動作確認だけで利用するので有効期限は10分としておく
PAYLOAD=$(cat <<EOS | tr -d '\n' | tr -d ' ' | base64 -w 0 | tr -d '\n=' | tr -- '+/' '-_'
{
"target_audience": "$FUNCTION_URI",
"iss": "${SA_EMAIL}",
"sub": "${SA_EMAIL}",
"aud": "https://www.googleapis.com/oauth2/v4/token",
"exp": $(date --date '10min' +%s),
"iat": $(date +%s)
}
EOS
)
# 署名を作成
SIGNATURE=$(echo -n "${HEADER}.${PAYLOAD}" | openssl dgst -sha256 -sign ${DIR_NAME}/${SA_EMAIL}.key.pem | base64 -w 0 | tr -d '\n=' | tr -- '+/' '-_' )
# ヘッダ、ペイロード、署名をピリオドでくっつけてJWT完成!
JWT=${HEADER}.${PAYLOAD}.${SIGNATURE}
これを以下のようにgoogleに渡すとIDトークンが取得できる。
ID_TOKEN_JWT=$(curl -s -X POST https://www.googleapis.com/oauth2/v4/token \
-H "Authorization: Bearer $JWT" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=$JWT" \
| jq -r .id_token)
取得したトークンを利用してURLにアクセスする。
curl -X GET $FUNCTION_URI \
-H "Authorization: bearer $ID_TOKEN_JWT"
成功したログを確認する
関数内でのログ書き込みは logging.warning("~")
としているため severity=WARNING
で検索する
$ gcloud logging read "severity=WARNING" --freshness=5m --format="value(receiveTimestamp,textPayload)"
---
2023-04-02T09:23:50.761032106Z ### Warn log from HTTP trigger function
2023-04-02T09:22:12.105737379Z ### Warn log from HTTP trigger function
ここで想定通りに動作しない場合、関数呼び出しがうまくいっていないので、CloudRunの起動元ロール設定など、起動時の権限設定を疑う。
動作チェック以降は、外部からサービスアカウントとして起動させることはないので、キーを削除する。
# キーIDを取得する(有効期限が9999のやつ)
KEY_ID=$(
gcloud iam service-accounts keys list \
--iam-account=${SA_EMAIL} | grep 9999 | cut -d\ -f1
)
# キーを削除する
gcloud iam service-accounts keys delete $KEY_ID \
--iam-account=${SA_EMAIL} \
--quiet
CloudSchedulerでジョブ作成
gcloud scheduler jobs create http ${SCHEDULER_NAME} --schedule "${SCHEDULE}" \
--location=${REGION} \
--time-zone=Asia/Tokyo \
--http-method=GET \
--uri=${FUNCTION_URI} \
--oidc-service-account-email=${SA_EMAIL} \
--oidc-token-audience=${FUNCTION_URI}
動作したか確認する。
# 最後に起動した情報を取得
gcloud scheduler jobs describe ${SCHEDULER_NAME} \
--location=${REGION} \
--format='get(lastAttemptTime,status)'
コマンド出力が空白なら、まだ実行されていないので、実行してから状況を確認する。
# スケジューラを強制起動させる
gcloud scheduler jobs run ${SCHEDULER_NAME} \
--location=${REGION}
# 最後に起動した情報を取得
gcloud scheduler jobs describe ${SCHEDULER_NAME} \
--location=${REGION} \
--format='get(lastAttemptTime,status)'
正常であれば、前回の実行時間のみが表示されるが、実行時にエラーになっている場合は、 code=xx
というような表示になる。
実行時にエラーになっている場合は、以下のような状況になる。
2023-04-02T09:26:21.735503Z
2023-04-02T09:26:21.735503Z code=13
作成したリソースの削除
# ジョブを削除
gcloud scheduler jobs delete ${SCHEDULER_NAME} \
--location=${REGION} \
--quiet
# 関数を削除
gcloud functions delete ${FUNCTION_TIMER} \
--gen2 \
--region=${REGION} \
--quiet
# ロール付与情報だけが残らないように、サービスアカウントの削除前にロールを剥奪
gcloud projects remove-iam-policy-binding $(gcloud config get-value project) \
--role="roles/secretmanager.secretAccessor" \
--member="serviceAccount:${SA_EMAIL}"
# サービスアカウントを削除
gcloud iam service-accounts delete $SA_EMAIL \
--quiet
# シークレットを削除
gcloud secrets delete ${SECRET_NAME} \
--quiet
さいごに
Google Cloud Platform 初めて使いました。
いままでAzureしか使ったことなくて、勉強しながらのGCP初挑戦でした。
「関数のデプロイめちゃ簡単じゃん!」
とか
「あれっ、PythonもUIから編集できる!?」
とか
「AzureFunctionsの複雑怪奇なストレージアカウントとかサービスプランとかに対応するのってどれ?」「え?ないの?」
とか、とか、とか。
いろいろ楽だし、無料枠だけでも十分遊べるし、もっと使っていこうかな、と思いました。
Cloud Run のジョブ、ってのもあるみたいなんですが「GAになってない&イメージ管理が面倒」なので、考えませんでした。
もしかしたら、「部外者から起動させない」は、起動元ロールでの制限より、内部トラフィックのみ許可するような構成のほうが考え方としては正しいのかも。。。
参考