🎢

Cloud Workflows を触ってみた(Advanced)

2021/08/16に公開

はじめに

前回の記事では Cloud Workflows の基本的な書き方や文法を中心に紹介しましたが、今回は Workflow 内部で他の Google Cloud サービスを連携するパターンをいくつか構築してみたいと思います。

連携をサポートする Cloud Workflow Connector っていう機能がありまして、現在 15個以上の Google Cloud サービスとの連携が可能になっています。でも、ドキュメントが若干読みにくいし、そもそも Google Cloud サービスの仕様が良く分からない状態の場合、理解するのに一定量の時間がかかることを覚悟した方が精神的に楽かなと個人的に思われます。

触ってみる

Cloud Build + Cloud Source Repository パターン

https://cloud.google.com/workflows/docs/reference/stdlib/googleapis/cloudbuild/Overview

まず、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 パターン

https://cloud.google.com/workflows/docs/reference/googleapis/firestore/Overview

実装

基本的な 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 パターン

https://cloud.google.com/workflows/docs/reference/stdlib/http/get
https://cloud.google.com/workflows/docs/reference/stdlib/http/post

実装

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 でそれぞれの処理を関数化するのもありかなと思います。

こちらの記事に載せられているコードは下記のリポジトリにまとめられているのでご参考お願いします。
https://github.com/sano307/cloud-workflows-demo

参考

Discussion