Cloud Workflows を触ってみた(Advanced)
はじめに
前回の記事では Cloud Workflows の基本的な書き方や文法を中心に紹介しましたが、今回は Workflow 内部で他の Google Cloud サービスを連携するパターンをいくつか構築してみたいと思います。
連携をサポートする Cloud Workflow Connector っていう機能がありまして、現在 15個以上の Google Cloud サービスとの連携が可能になっています。でも、ドキュメントが若干読みにくいし、そもそも Google Cloud サービスの仕様が良く分からない状態の場合、理解するのに一定量の時間がかかることを覚悟した方が精神的に楽かなと個人的に思われます。
触ってみる
Cloud Build + Cloud Source Repository パターン
まず、GitHub のリポジトリを Cloud Source Repository にミラーリングする必要があるので、こちらの記事を参考に連携しましょう。Terraform 単体では GitHub みたいな外部リポジトリとの連携ができないので GUI から作成したリソースを terraform import
する感じで管理します。
resource "google_sourcerepo_repository" "cloud_workflows_demo" {
name = "github_sano307_cloud-workflows-demo"
}
$ terraform import google_sourcerepo_repository.cloud_workflows_demo github_sano307_cloud-workflows-demo
実装
簡単に terraform plan
を実行した結果を Cloud Logging に出力する処理を書いてみました。
resource "google_sourcerepo_repository" "cloud_workflows_demo" {
name = "YOUR_SOURCE_REPOSITORY_NAME"
}
resource "google_service_account" "integration_cloud_source_repository" {
account_id = "icsr-workflow"
}
variable "workflow_roles" {
default = [
"roles/logging.logWriter",
"roles/cloudbuild.builds.builder"
]
}
resource "google_project_iam_member" "workflow" {
for_each = toset(var.workflow_roles)
role = each.value
member = "serviceAccount:${google_service_account.integration_cloud_source_repository.email}"
}
resource "google_workflows_workflow" "integration_cloud_source_repository" {
name = "integration-cloud-source-repository-workflow"
region = "asia-southeast1"
service_account = google_service_account.integration_cloud_source_repository.id
source_contents = templatefile("${path.module}/workflow.yaml", {
sourcerepo_name = split("/", google_sourcerepo_repository.cloud_workflows_demo.id)[3]
})
}
workflow.yaml
main:
params: [args]
steps:
- init:
assign:
- project_id: $${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
- tfplan_filename: $${args.filename}
- test_cloudbuild:
call: googleapis.cloudbuild.v1.projects.builds.create
args:
projectId: $${project_id}
body:
source:
repoSource:
repoName: ${sourcerepo_name}
branchName: main
substitutions:
_TFPLAN: $${tfplan_filename}
steps:
- name: "hashicorp/terraform:1.0.2"
entrypoint: "sh"
args:
- "-c"
- |
terraform init
terraform plan --out $${_TFPLAN}.binary \
&& terraform show -json $${_TFPLAN}.binary > $${_TFPLAN}.json
cat $${_TFPLAN}.json > /builder/outputs/output
result: resp
- test_output:
call: sys.log
args:
text: $${text.replace_all(text.decode(base64.decode(resp.metadata.build.results.buildStepOutputs[0])), "\n", "")}
severity: INFO
- finish:
return: "OK"
はまった箇所
Workflow を実行する時に設定するインプットデータを Cloud Build の内部で参照できるかどうかを試したくて何も考えずに options.env に設定したんですが、実際に TFPLAN
に代入されるバリューは ${tfplan_filename}
っていう生文字列でした。おそらく options.env
のタイプが文字列の配列なので勝手に全てを生文字列として認識する処理が裏で書かれているんじゃないかなと思います。こちらの問題は substitutions で解決しました。
options:
env:
- TFPLAN=$${tfplan_filename}
Cloud Build 内部のものを Workflow 側でハンドリングできるかどうかを試したくて調べてみたら $BUILDER_OUTPUT/output っていうステップの結果として次のステップで参照できるやつが用意されていました。
steps:
- name: "hashicorp/terraform:1.0.2"
entrypoint: "sh"
args:
- "-c"
- |
...
cat $${_TFPLAN}.json > /builder/outputs/output
参照のやり方は Workflow ステップの結果で設定した変数を利用する形でした。
resp.metadata.build.results.buildStepOutputs[Cloud Build 内部のステップ番号]
参照したバリューは Base64 でエンコーディングされていたので base64.decode()
と text.decode()
を使わないといけなかったし、さらに文字列の末尾に \n
が付けられていたので text.replace_all()
で対応しました。
text.replace_all(text.decode(base64.decode(resp.metadata.build.results.buildStepOutputs[0])), "\n", "")
Cloud Firestore パターン
実装
基本的な CRUD 操作を試してみました。
resource "google_service_account" "integration_cloud_firestore" {
account_id = "icf-workflow"
}
variable "workflow_roles" {
default = [
"roles/logging.logWriter",
"roles/firebase.developAdmin"
]
}
resource "google_project_iam_member" "workflow" {
for_each = toset(var.workflow_roles)
role = each.value
member = "serviceAccount:${google_service_account.integration_cloud_firestore.email}"
}
resource "google_workflows_workflow" "integration_cloud_firestore" {
name = "integration-cloud-firestore-workflow"
region = "asia-southeast1"
service_account = google_service_account.integration_cloud_firestore.id
source_contents = templatefile("${path.module}/workflow.yaml", {})
}
workflow.yaml
main:
params: [args]
steps:
- init:
assign:
- project_id: $${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
- test_firestore_create:
call: googleapis.firestore.v1.projects.databases.documents.createDocument
args:
parent: $${"projects/" + project_id + "/databases/(default)/documents"}
collectionId: users
documentId: sano307
body:
fields:
nickname:
stringValue: sano307
greetings:
stringValue: Hello World
result: resp
- logging_firestore_create:
call: sys.log
args:
text: $${resp}
severity: INFO
- test_firestore_read:
call: googleapis.firestore.v1.projects.databases.documents.get
args:
name: $${"projects/" + project_id + "/databases/(default)/documents/users/sano307"}
mask:
fieldPaths:
- greetings
result: resp
- logging_firestore_read:
call: sys.log
args:
text: $${resp}
severity: INFO
- test_firestore_update:
call: googleapis.firestore.v1.projects.databases.documents.patch
args:
name: $${"projects/" + project_id + "/databases/(default)/documents/users/sano307"}
updateMask:
fieldPaths:
- greetings
currentDocument:
exists: true
body:
fields:
greetings:
stringValue: Hello World!
result: resp
- logging_firestore_update:
call: sys.log
args:
text: $${resp}
severity: INFO
- test_firestore_confirm:
call: googleapis.firestore.v1.projects.databases.documents.runQuery
args:
parent: $${"projects/" + project_id + "/databases/(default)/documents"}
body:
structuredQuery:
select:
fields:
fieldPath: greetings
from:
collectionId: users
where:
fieldFilter:
field:
fieldPath: nickname
op: EQUAL
value:
stringValue: sano307
result: resp
- logging_firestore_confirm:
call: sys.log
args:
text: $${resp}
severity: INFO
- test_firestore_delete:
call: googleapis.firestore.v1.projects.databases.documents.delete
args:
name: $${"projects/" + project_id + "/databases/(default)/documents/users/sano307"}
currentDocument:
exists: true
result: resp
- logging_firestore_delete:
call: sys.log
args:
text: $${resp}
severity: INFO
- finish:
return: "OK"
ハマった箇所
久しぶりに YAML をがっつり書いたのでちょっとバタバタしましたが、ドキュメントを 2~3回読んでみたら雰囲気が分かってきたので特に問題なかったです。
Cloud API Gateway + Cloud Functions パターン
実装
Cloud Function を Cloud API Gateway と連携して Workflow 側から呼び出してみました。
resource "google_storage_bucket" "functions" {
name = "YOUR_GCS_BUCKET_NAME"
}
data "archive_file" "hello_get" {
type = "zip"
source_dir = "${path.module}/functions/hello"
output_path = "tmp/hello_get.zip"
}
resource "google_storage_bucket_object" "hello_get" {
name = "hello_get-${data.archive_file.hello_get.output_md5}"
bucket = google_storage_bucket.functions.name
source = data.archive_file.hello_get.output_path
}
resource "google_cloudfunctions_function" "hello_get" {
name = "HelloGet"
runtime = "go113"
available_memory_mb = 128
source_archive_bucket = google_storage_bucket.functions.name
source_archive_object = google_storage_bucket_object.hello_get.name
trigger_http = true
entry_point = "Get"
}
resource "google_cloudfunctions_function_iam_member" "hello_get_invoker" {
project = google_cloudfunctions_function.hello_get.project
region = google_cloudfunctions_function.hello_get.region
cloud_function = google_cloudfunctions_function.hello_get.name
role = "roles/cloudfunctions.invoker"
member = "allUsers"
}
data "archive_file" "hello_post" {
type = "zip"
source_dir = "${path.module}/functions/hello"
output_path = "tmp/hello_post.zip"
}
resource "google_storage_bucket_object" "hello_post" {
name = "hello_post-${data.archive_file.hello_post.output_md5}"
bucket = google_storage_bucket.functions.name
source = data.archive_file.hello_post.output_path
}
resource "google_cloudfunctions_function" "hello_post" {
name = "HelloPost"
runtime = "go113"
available_memory_mb = 128
source_archive_bucket = google_storage_bucket.functions.name
source_archive_object = google_storage_bucket_object.hello_post.name
trigger_http = true
entry_point = "Post"
}
resource "google_cloudfunctions_function_iam_member" "hello_post_invoker" {
project = google_cloudfunctions_function.hello_post.project
region = google_cloudfunctions_function.hello_post.region
cloud_function = google_cloudfunctions_function.hello_post.name
role = "roles/cloudfunctions.invoker"
member = "allUsers"
}
resource "google_api_gateway_api" "demo" {
provider = google-beta
api_id = "test-kim-apigw-demo"
}
resource "google_api_gateway_api_config" "demo" {
provider = google-beta
api = google_api_gateway_api.demo.api_id
api_config_id_prefix = "test-kim-apiconfig-demo-"
openapi_documents {
document {
path = "${path.module}/apigw.yaml"
contents = base64encode(
templatefile("${path.module}/apigw.yaml", {
hello_get_address = google_cloudfunctions_function.hello_get.https_trigger_url
hello_post_address = google_cloudfunctions_function.hello_post.https_trigger_url
})
)
}
}
lifecycle {
create_before_destroy = true
}
}
resource "google_api_gateway_gateway" "demo" {
provider = google-beta
api_config = google_api_gateway_api_config.demo.id
gateway_id = "test-kim-gateway-demo"
}
resource "google_service_account" "integration_cloud_api_gateway" {
account_id = "icag-workflow"
}
variable "workflow_roles" {
default = [
"roles/logging.logWriter"
]
}
resource "google_project_iam_member" "workflow" {
for_each = toset(var.workflow_roles)
role = each.value
member = "serviceAccount:${google_service_account.integration_cloud_api_gateway.email}"
}
resource "google_workflows_workflow" "integration_cloud_api_gateway" {
name = "integration-cloud-api-gateway-workflow"
region = "asia-southeast1"
service_account = google_service_account.integration_cloud_api_gateway.id
source_contents = templatefile("${path.module}/workflow.yaml", {
apigw_hostname = google_api_gateway_gateway.demo.default_hostname
})
}
apigw.yaml
swagger: "2.0"
info:
title: Cloud Workflows Demo
version: 1.0.0
schemes:
- https
produces:
- application/json
paths:
/hello:
get:
summary: Get Hello
operationId: getHello
parameters:
- in: query
required: true
type: string
name: nickname
x-google-backend:
address: ${hello_get_address}
protocol: h2
responses:
'200':
description: "Success"
schema:
type: object
properties:
message:
type: "string"
example: OK
'500':
description: "Failed"
schema:
type: object
properties:
message:
type: "string"
example: NG
post:
summary: Post Hello
operationId: postHello
parameters:
- in: body
name: body
required: true
schema:
type: object
required:
- nickname
properties:
nickname:
type: string
example:
nickname: sano307
x-google-backend:
address: ${hello_post_address}
protocol: h2
responses:
'200':
description: "Success"
schema:
type: object
properties:
message:
type: "string"
example: OK
workflow.yaml
main:
params: [args]
steps:
- init:
assign:
- project_id: $${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}
- test_cloud_apigateway_get:
call: http.get
args:
url: https://${apigw_hostname}/hello
timeout: 5
query:
nickname: sano307
result: resp
- logging_cloud_apigateway_get:
call: sys.log
args:
text: $${resp}
severity: INFO
- test_cloud_apigateway_post:
call: http.post
args:
url: https://${apigw_hostname}/hello
timeout: 5
body:
nickname: sano307
result: resp
- logging_cloud_apigateway_post:
call: sys.log
args:
text: $${resp}
severity: INFO
- finish:
return: "OK"
はまった箇所
Cloud API Gateway は OpenAPI v2.0 のみサポートしていたんですが、最初は OpenAPI v3.0 ドキュメントをずっと見ていたので結構時間が溶けました。
ref. https://cloud.google.com/api-gateway/docs/openapi-overview
swagger: "2.0"
OpenAPI v2.0 だと POST の body は in: body
で定義します。ちなみに、OpenAPI v3.0 だと Post の body は requestBody っていうやつで定義できます。
ref. https://swagger.io/docs/specification/2-0/describing-request-body/
post:
summary: Post Hello
operationId: postHello
parameters:
- in: body
name: body
Workflow については HTTP 関数の使い方がシンプルだったので特に問題なかったです。
終わりに
簡単な処理は問題ないと思いますが、どんどん処理が追加されると YAML なので一目で把握するのが結構しんどくなるんじゃないかなと思いました。さらに、例外処理やリトライ処理、next
分岐処理などが溜まってくるとスパゲッティコードになってしまう可能性が高いと思うので、Sub Workflow でそれぞれの処理を関数化するのもありかなと思います。
こちらの記事に載せられているコードは下記のリポジトリにまとめられているのでご参考お願いします。
Discussion