認証付きCloud FunctionsをCloud Schedulerで定期実行する

# はじめに

**やりたいことは？**
* 同一プロジェクトのリソース操作を定期的に実行したい（例：SecretManager上のシークレット取得）
* その処理は部外者が勝手に実行しては困る

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334782/3475931e-6f8d-3e7e-3ab8-9ec7fa98cd9a.png)


ということで、↑こんな感じの
**Cloud Functionsでサービスアカウントのみ実行可能なAPIを作成して
Cloud Scheduerで定期的にそのAPIを実行する**
というのをやります。

# 追記/修正

* 初版ではCloud Loggingへの書き込みロール追加で記載していましたが、ロール追加なしでログ追加できていたため、シークレットへのアクセスに修正しました。
* GUI説明を削除しました。


## IAM認証付きCloudFunctions

シークレット取得処理を実行するために、CloudFunctionsを利用する。

Cloud Functionsで関数を作成するとき、その関数の呼び出しに対して認証の有無が選択できる。
認証付きにすると、その関数の起動元ロールが付与されたプリンシパルからのみ、その関数を呼び出すことができる。

今回は第２世代の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を利用する場合

コンソールUIからCloudShellを立ち上げる
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/334782/5d6d3970-2752-37b9-e8bb-cf400aaa61c2.png)


### Linuxにgcloudをインストールする場合

gcloud のインストール

```:手元のubuntu22.04だとこれで動作した
# 要件
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=-
```

```:シークレットバージョンを取得
# １行目だけのコマンドでも取得は可能
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
```

```:デプロイした関数のURIを取得
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を作成する。

```:自己署名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トークンが取得できる。

```:自己署名のJWTで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になってない＆イメージ管理が面倒」なので、考えませんでした。
もしかしたら、「部外者から起動させない」は、起動元ロールでの制限より、内部トラフィックのみ許可するような構成のほうが考え方としては正しいのかも。。。


# 参考

https://cloud.google.com/sdk/docs/install?hl=ja#deb

https://cloud.google.com/docs/authentication/getting-started?hl=ja

https://cloud.google.com/docs/authentication/production?hl=ja

https://cloud.google.com/functions/docs/securing/authenticating?hl=ja

https://cloud.google.com/functions/docs/securing/authenticating?hl=ja#exchanging_a_self-signed_jwt_for_a_google-signed_id_token

https://cloud.google.com/scheduler/docs/http-target-auth?hl=ja

https://cloud.google.com/logging/docs/setup/python?hl=ja

https://cloud.google.com/iam/docs/creating-managing-service-account-keys?hl=ja#iam-service-account-keys-create-gcloud

https://cloud.google.com/secret-manager/docs/samples/secretmanager-get-secret-version?hl=ja