🚀

Cloud Deploy で Cloud Run functions に継続的デリバリーする

2024/12/14に公開

Cloud Deploy は継続的デリバリーを行うための Google Cloud のフルマネージドサービスです。
標準では Google Kubernetes Engine と Cloud Run (service と job) へのデプロイをサポートしていますが、カスタムターゲットを定義することでそれ以外の対象にもデプロイすることができます。
今回はカスタムターゲットを利用して Cloud Run functions へのデプロイを自動化してみます。

本記事では Cloud Deploy の基本的な概念(ターゲット、リリース、デプロイパイプラインなど)については説明しません。
これらについては以下の記事が分かりやすかったのでご覧下さい。
https://zenn.dev/knowledgework/articles/cloud-deploy-for-cloud-run

本記事のコードは以下のリポジトリで公開しています。
https://github.com/YunosukeY/cloud-deploy-cloud-run-functions-sample

カスタムターゲットについて

実は Cloud Deploy にはカスタムターゲットというリソースがあるわけではありません。
実際にはカスタムターゲットは以下の 3 種類のリソースで構成されます。

  • カスタムアクション
  • カスタムターゲットタイプの定義
  • ターゲット定義

カスタムアクションではデプロイ対象についてのレンダリングアクションとデプロイアクションを定義します。
そして上記で定義したアクションを使ってカスタムターゲットタイプの定義を作成します。
最後に上記の定義を使ってターゲット定義を作成します。
実際のコードは後の章でお見せしますが、カスタムターゲットタイプの定義とターゲット定義はそれより前に作成したものを参照するだけでそんなに難しくありません。
カスタムアクションについては少しややこしいのでもう少し詳しく説明します。

レンダリングアクションの入力

レンダリングアクションでは環境変数として様々な値を受け取ります。
ここでは特に重要な以下の 3 つの変数について説明します。

  • CLOUD_DEPLOY_FEATURES
  • CLOUD_DEPLOY_PERCENTAGE_DEPLOY
  • CLOUD_DEPLOY_OUTPUT_GCS_PATH

CLOUD_DEPLOY_FEATURES ではカスタムアクションがサポートしなくてはいけない Cloud Deploy の機能が与えられます。
実際にはこの値は空文字列か CANARY のどちらかです(標準デプロイの場合は空文字列、カナリアデプロイの場合は CANARY)。
カスタムアクションがカナリアデプロイをサポートしておらず、CLOUD_DEPLOY_FEATURESCANARY だった場合、アクションは失敗することが推奨されています。

CLOUD_DEPLOY_PERCENTAGE_DEPLOY はデプロイの割合です。
標準デプロイとカナリアデプロイの stable フェーズの場合は 100 になります。

CLOUD_DEPLOY_OUTPUT_GCS_PATH はレンダリングアクションが結果を出力するべき Cloud Storage のパスです。
どのようなファイルを出力する必要があるかは次の節で説明します。

レンダリングアクションの出力

レンダリングアクションでは以下の 2 種類のファイルを CLOUD_DEPLOY_OUTPUT_GCS_PATH で指定されたパスに作成する必要があります。

  • レンダリングされた構成ファイル
  • results.json

構成ファイルとして何を出力するべきかは、そのカスタムアクションでどこにデプロイしようとしているかによって変わります。
例えばデプロイアクションで Terraform を利用するなら tf ファイルですし、K8s 関連なら YAML 形式の何かになるでしょう。

results.json に含めるべき情報はいくつかありますが、必須なのは以下の 2 つです。

  • resultStatus
  • manifestFile

resultStatus はそのレンダリングアクションの成功/失敗を表し、値は SUCCEEDEDFAILED のどちらかです。
manifestFile は構成ファイルのパスです。
result.json の例を以下に示します。

{
  "resultStatus": "SUCCEEDED",
  "manifestFile": "gs://bucket/my-pipeline/release-001/rollout-a/01234/custom-output/manifest.yaml"
}

デプロイアクションの入力

レンダリングアクションと同じく、デプロイアクションでも環境変数として様々な値を受け取ります。
デプロイアクションの場合は以下の 4 つが重要です。

  • CLOUD_DEPLOY_FEATURES
  • CLOUD_DEPLOY_PERCENTAGE_DEPLOY
  • CLOUD_DEPLOY_MANIFEST_GCS_PATH
  • CLOUD_DEPLOY_OUTPUT_GCS_PATH

このうち、CLOUD_DEPLOY_FEATURES, CLOUD_DEPLOY_PERCENTAGE_DEPLOY, CLOUD_DEPLOY_OUTPUT_GCS_PATH についてはレンダリングアクションと同じような意味を持ちます。

CLOUD_DEPLOY_MANIFEST_GCS_PATH はレンダリングアクションで出力した構成ファイルの Cloud Storage パスになります。
このファイルを取得し、デプロイ操作をしていくことになります。

デプロイアクションの出力

デプロイアクションでは results.jsonCLOUD_DEPLOY_OUTPUT_GCS_PATH で指定されたパスに作成する必要があります。
デプロイアクションでも results.json に含めるべき情報はいくつかありますが、必須なのは resultStatus のみです。
デプロイアクションの場合、resultStatus の値は SUCCEEDED, FAILED, SKIPPED のいずれかです。
SKIPPED については、対象のアクションがカナリアフェーズで実行されており(つまり CLOUD_DEPLOY_PERCENTAGE_DEPLOY100 でなく)、対象の環境にまだ一度もデプロイされていない場合に出力します。
これはこれまでに一度もデプロイされていない環境に対してはカナリアデプロイが意味を持たず、stable フェーズが実行されるべきなためです。

デプロイパラメータ

カスタムアクションでは上記で説明したような Cloud Deploy が提供する値以外にも動的な値が欲しくなります。
Cloud Deploy ではリリース作成時に動的な値をデプロイパラメータとして渡すことができます。
カスタムターゲットの場合は customTarget/ という prefix をデプロイパラメータにつけることで、レンダリングアクションとデプロイアクションの両方で環境変数として参照できるようになります。
例えば customTarget/vertexAIModel というデプロイパラメータを渡した場合、アクションでは CLOUD_DEPLOY_customTarget_vertexAIModel で参照できます。

標準デプロイ

この章では Cloud Run functions に対して標準デプロイ、すなわち常に 100% の割合でデプロイするパイプラインを実装します。

実装 (skaffold.yaml)

まずはカスタムアクションを作成します。
雛形は以下のようになります。

apiVersion: skaffold/v4beta7
kind: Config
customActions:
  - name: cloud-run-functions-renderer
    containers:
      - name: render
        image: gcr.io/google.com/cloudsdktool/google-cloud-cli:503.0.0-stable
        command: ["/bin/bash"]
        args:
          - "-c"
          - |-
            ...
  - name: cloud-run-functions-deployer
    containers:
      - name: deploy
        image: gcr.io/google.com/cloudsdktool/google-cloud-cli:503.0.0-stable
        command: ["/bin/bash"]
        args:
          - "-c"
          - |-
            ...

まずレンダリングアクションについてですが、レンダリングアクションは構成ファイルと result.json を Cloud Storage に作成する必要がありました。
Cloud Run functions では構成ファイルはありませんから、ここではそれっぽい内容を出力すれば良いです。
今回はデプロイパラメータで関数のコミットハッシュを与えることにして、それを出力しておくことにします。

echo {\"commit\": \"$CLOUD_DEPLOY_customTarget_commit\"} > manifest.json
gcloud storage cp manifest.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json

result.json についてですが、この章ではカナリアデプロイはサポートしないため、CLOUD_DEPLOY_FEATURESCANARY なら失敗、そうでなければ成功のステータスで作成します。

if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ]; then
  echo {\"resultStatus\": \"FAILED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
else
  echo {\"resultStatus\": \"SUCCEEDED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
fi
gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json

次にデプロイアクションについてです。
デプロイアクションでは result.json だけ出力すれば良いので、CLOUD_DEPLOY_FEATURESCANARY の場合の早期リターンしてしまいます。

if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ]; then
  echo {\"resultStatus\": \"FAILED\"} > results.json
  gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json
  exit 0
fi

実際のデプロイについてですが、デプロイパラメータで指定されたコミットハッシュでリポジトリをチェックアウトし、gcloud functions deploy コマンドでデプロイするようにします。

apt-get update
apt-get install -y git

git clone https://github.com/YunosukeY/cloud-deploy-cloud-run-functions-sample.git
cd cloud-deploy-cloud-run-functions-sample
git checkout $CLOUD_DEPLOY_customTarget_commit

gcloud functions deploy go-http-function \
  --gen2 \
  --runtime=go123 \
  --region=asia-northeast1 \
  --source=. \
  --entry-point=HelloGet \
  --trigger-http \
  --no-allow-unauthenticated

最後に成功の場合の result.json を出力します。

echo {\"resultStatus\": \"SUCCEEDED\"} > results.json
gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json

skaffold.yaml については以上で完成です。

skaffold.yaml の全体
apiVersion: skaffold/v4beta7
kind: Config
customActions:
  - name: cloud-run-functions-renderer
    containers:
      - name: render
        image: gcr.io/google.com/cloudsdktool/google-cloud-cli:503.0.0-stable
        command: ["/bin/bash"]
        args:
          - "-c"
          - |-
            echo {\"commit\": \"$CLOUD_DEPLOY_customTarget_commit\"} > manifest.json
            gcloud storage cp manifest.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json

            if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ]; then
              echo {\"resultStatus\": \"FAILED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
            else
              echo {\"resultStatus\": \"SUCCEEDED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
            fi
            gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json
  - name: cloud-run-functions-deployer
    containers:
      - name: deploy
        image: gcr.io/google.com/cloudsdktool/google-cloud-cli:503.0.0-stable
        command: ["/bin/bash"]
        args:
          - "-c"
          - |-
            if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ]; then
              echo {\"resultStatus\": \"FAILED\"} > results.json
              gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json
              exit 0
            fi

            apt-get update
            apt-get install -y git

            git clone https://github.com/YunosukeY/cloud-deploy-cloud-run-functions-sample.git
            cd cloud-deploy-cloud-run-functions-sample
            git checkout $CLOUD_DEPLOY_customTarget_commit

            gcloud functions deploy go-http-function \
              --gen2 \
              --runtime=go123 \
              --region=asia-northeast1 \
              --source=. \
              --entry-point=HelloGet \
              --trigger-http \
              --no-allow-unauthenticated

            echo {\"resultStatus\": \"SUCCEEDED\"} > results.json
            gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json

実装 (clouddeploy.yaml)

次にカスタムターゲットタイプ、ターゲット、デプロイパイプラインをそれぞれ定義します。
とは言ってもここはそれより前に参照していくだけです。

カスタムターゲットタイプ

apiVersion: deploy.cloud.google.com/v1
kind: CustomTargetType
metadata:
  name: cloud-run-functions
customActions:
  renderAction: cloud-run-functions-renderer
  deployAction: cloud-run-functions-deployer

ターゲット

apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: sample-env
customTarget:
  customTargetType: cloud-run-functions

デプロイパイプライン

apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: cloud-run-functions-pipeline
serialPipeline:
  stages:
    - targetId: sample-env

clouddeploy.yaml については以上で完成です。

clouddeploy.yaml の全体
apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: cloud-run-functions-pipeline
serialPipeline:
  stages:
    - targetId: sample-env
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: sample-env
customTarget:
  customTargetType: cloud-run-functions
---
apiVersion: deploy.cloud.google.com/v1
kind: CustomTargetType
metadata:
  name: cloud-run-functions
customActions:
  renderAction: cloud-run-functions-renderer
  deployAction: cloud-run-functions-deployer

動作確認

パイプライン作成

gcloud deploy apply \
  --file=clouddeploy.yaml \
  --region=asia-northeast1

リリース作成
デプロイパラメータで最初のコミットを指定します。

gcloud deploy releases create test-release-001 \
  --region=asia-northeast1 \
  --delivery-pipeline=cloud-run-functions-pipeline \
  --deploy-parameters="customTarget/commit=0cbb1d72abe7d5fac69d6c9e9fbb5816b587d612"

レンダリングが完了すると Cloud Storage にコミットハッシュの書かれた manifest.json が作成されます。

{
  "commit": "0cbb1d72abe7d5fac69d6c9e9fbb5816b587d612"
}

デプロイ完了後、ログに表示される関数の URL に対してリクエストを送ると Hello, World! が返ります。

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World!%

カナリアデプロイ

この章では、前の章で作成した skaffold.yamlclouddeploy.yaml を修正し、Cloud Run functions に対してカナリアデプロイするパイプラインを実装します。

修正 (skaffold.yaml)

まずレンダリングアクションについてですが、カナリアデプロイをサポートするため、分岐は削除し必ず成功するように修正します。

+echo {\"resultStatus\": \"SUCCEEDED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
-if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ]; then
-  echo {\"resultStatus\": \"FAILED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
-else
-  echo {\"resultStatus\": \"SUCCEEDED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
-fi

次にデプロイアクションについてです。
デプロイアクションでは、現在デプロイのリビジョンが分かると便利なので初めに取得します。

+LATEST_REVISION=$(gcloud run revisions list --region=asia-northeast1 --service=go-http-function --format="value(REVISION)" --limit=1)

次にレンダリングアクションと同じく、カナリアデプロイをサポートするため、分岐は削除します。
その代わりにカナリアフェーズでこれまで一度もデプロイされていない場合にスキップする分岐を追加します。

+if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ] \
+  && [ \"$CLOUD_DEPLOY_PERCENTAGE_DEPLOY\" -ne 100 ] \
+  && [ -z \"$LATEST_REVISION\" ]; then
+  echo {\"resultStatus\": \"SKIPPED\"} > results.json
-if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ]; then
-  echo {\"resultStatus\": \"FAILED\"} > results.json
  gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json
  exit 0
fi

実際のデプロイについてですが、gcloud beta run deploy コマンドを用いてトラフィックなしでデプロイし、その後 gcloud run services update-traffic コマンドを用いて割合を変更します。
注意としては、gcloud beta run deploy コマンドについては現在のコミットハッシュの関数がデプロイされていない場合のみ行う必要があります。
そこでリビジョンの suffix をコミットハッシュにするようにしています。

+if [ \"$LATEST_REVISION\" != \"go-http-function-$CLOUD_DEPLOY_customTarget_commit\" ]; then
  ...

+  gcloud beta run deploy go-http-function \
+    --base-image go122 \
+    --region asia-northeast1 \
+    --source . \
+    --function HelloGet \
+    --no-allow-unauthenticated \
+    --no-traffic \
+    --revision-suffix $CLOUD_DEPLOY_customTarget_commit
-gcloud functions deploy go-http-function \
-  --gen2 \
-  --runtime=go123 \
-  --region=asia-northeast1 \
-  --source=. \
-  --entry-point=HelloGet \
-  --trigger-http \
-  --no-allow-unauthenticated
+fi

+gcloud run services update-traffic go-http-function \
+  --region asia-northeast1 \
+  --to-revisions=LATEST=$CLOUD_DEPLOY_PERCENTAGE_DEPLOY

skaffold.yaml については以上で完了です。

skaffold.yaml の全体
apiVersion: skaffold/v4beta7
kind: Config
customActions:
  - name: cloud-run-functions-renderer
    containers:
      - name: render
        image: gcr.io/google.com/cloudsdktool/google-cloud-cli:503.0.0-stable
        command: ["/bin/bash"]
        args:
          - "-c"
          - |-
            echo {\"commit\": \"$CLOUD_DEPLOY_customTarget_commit\"} > manifest.json
            gcloud storage cp manifest.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json

            echo {\"resultStatus\": \"SUCCEEDED\", \"manifestFile\": \"$CLOUD_DEPLOY_OUTPUT_GCS_PATH/manifest.json\"} > results.json
            gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json
  - name: cloud-run-functions-deployer
    containers:
      - name: deploy
        image: gcr.io/google.com/cloudsdktool/google-cloud-cli:503.0.0-stable
        command: ["/bin/bash"]
        args:
          - "-c"
          - |-
            LATEST_REVISION=$(gcloud run revisions list --region=asia-northeast1 --service=go-http-function --format="value(REVISION)" --limit=1)

            if [ \"$CLOUD_DEPLOY_FEATURES\" = \"CANARY\" ] \
              && [ \"$CLOUD_DEPLOY_PERCENTAGE_DEPLOY\" -ne 100 ] \
              && [ -z \"$LATEST_REVISION\" ]; then
              echo {\"resultStatus\": \"SKIPPED\"} > results.json
              gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json
              exit 0
            fi

            if [ \"$LATEST_REVISION\" != \"go-http-function-$CLOUD_DEPLOY_customTarget_commit\" ]; then
              apt-get update
              apt-get install -y git

              git clone https://github.com/YunosukeY/cloud-deploy-cloud-run-functions-sample.git
              cd cloud-deploy-cloud-run-functions-sample
              git checkout $CLOUD_DEPLOY_customTarget_commit

              gcloud beta run deploy go-http-function \
                --base-image go122 \
                --region asia-northeast1 \
                --source . \
                --function HelloGet \
                --no-allow-unauthenticated \
                --no-traffic \
                --revision-suffix $CLOUD_DEPLOY_customTarget_commit
            fi

            gcloud run services update-traffic go-http-function \
              --region asia-northeast1 \
              --to-revisions=LATEST=$CLOUD_DEPLOY_PERCENTAGE_DEPLOY

            echo {\"resultStatus\": \"SUCCEEDED\"} > results.json
            gcloud storage cp results.json $CLOUD_DEPLOY_OUTPUT_GCS_PATH/results.json

修正 (clouddeploy.yaml)

clouddeploy.yaml についてですが、カスタムターゲットタイプとターゲットは修正不要です。
デプロイパイプラインについてはカナリアデプロイについての定義を追加します。
カスタムターゲットの場合、カナリアデプロイはカスタムカナリアとして定義します。

 serialPipeline:
   stages:
     - targetId: sample-env
+      strategy:
+        canary:
+          customCanaryDeployment:
+            phaseConfigs:
+              - phaseId: canary-25
+                percentage: 25
+              - phaseId: canary-50
+                percentage: 50
+              - phaseId: canary-75
+                percentage: 75
+              - phaseId: stable
+                percentage: 100

clouddeploy.yaml については以上で完了です。

clouddeploy.yaml の全体
apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: cloud-run-functions-pipeline
serialPipeline:
  stages:
    - targetId: sample-env
      strategy:
        canary:
          customCanaryDeployment:
            phaseConfigs:
              - phaseId: canary-25
                percentage: 25
              - phaseId: canary-50
                percentage: 50
              - phaseId: canary-75
                percentage: 75
              - phaseId: stable
                percentage: 100
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: sample-env
customTarget:
  customTargetType: cloud-run-functions
---
apiVersion: deploy.cloud.google.com/v1
kind: CustomTargetType
metadata:
  name: cloud-run-functions
customActions:
  renderAction: cloud-run-functions-renderer
  deployAction: cloud-run-functions-deployer

動作確認

パイプラインの更新

gcloud deploy apply \
  --file=clouddeploy.yaml \
  --region=asia-northeast1

リリース作成
デプロイパラメータで 2 つ目のコミットを指定します。

gcloud deploy releases create test-release-002 \
  --region=asia-northeast1 \
  --delivery-pipeline=cloud-run-functions-pipeline \
  --deploy-parameters="customTarget/commit=7252129b3f438a2fe0f61d49b54d0f9bb811636d"

デプロイが完了すると Cloud Run の画面でトラフィックが分割されていることを確認できます。

関数の URL に対してリクエストを送ると数回に 1 回 Hello, World2! が返るようになっています。

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World!%

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World2!%

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World!%

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World!%

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World!%

カナリアを進めます。

gcloud deploy rollouts advance test-release-002-to-sample-env-0001 \
  --release=test-release-002 \
  --delivery-pipeline=cloud-run-functions-pipeline \
  --region=asia-northeast1 \
  --quiet

デプロイが完了すると、トラフィックの割合が更新されています。

関数の URL に対してリクエストを送ると半分は Hello, World2! が返るようになっています。

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World2!%

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World!%

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World!%

curl {関数のURL} -H "Authorization: Bearer $(gcloud auth print-identity-token)"
Hello, World2!%

謝辞

本記事は、以下の記事で同僚の方が書いてくれた疑問が元ネタとなっています。

Cloud Functions などの Cloud Deploy に対応してないリソースがある場合はどうするんだろうと思った。

https://zenn.dev/hirohokke/articles/3da08d8c88d626

私自身 Cloud Deploy を触ったことがなかったため、とても良いきっかけになりました。
ありがとうございます。

参考

https://cloud.google.com/deploy/docs/custom-targets
https://cloud.google.com/deploy/docs/deploy-app-custom-target
https://cloud.google.com/deploy/docs/parameters
https://cloud.google.com/deploy/docs/deployment-strategies/canary

Discussion