🐈

CUEでCloud RunのYAMLファイルを作ってみる

2023/12/25に公開

この記事はAll About Group(株式会社オールアバウト)Advent Calendar 2023の25日目の投稿です。

https://qiita.com/advent-calendar/2023/allabout

Cloud RunのYAMLファイルについて

Cloud Runをデプロイする方法はいくつかありますが、k8sと同じように設定をYAMLファイルに書いてデプロイすることができます。

具体的には以下のようなファイルになります。k8sのマニフェストファイルを書いたことがある方なら初見でも理解できる内容かと思います。

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: hello-01
  annotations:
    run.googleapis.com/ingress: all
spec:
  template:
    spec:
      containerConcurrency: 80
      timeoutSeconds: 300
      serviceAccountName: hello-world@sample-project.iam.gserviceaccount.com
      containers:
      - name: hello-1
        image: us-docker.pkg.dev/cloudrun/container/hello:latest
        ports:
        - name: http1
          containerPort: 8080
        resources:
          limits:
            cpu: 1000m
            memory: 512Mi
        startupProbe:
          timeoutSeconds: 240
          periodSeconds: 240
          failureThreshold: 1
          tcpSocket:
            port: 8080

このYAMLファイルを生成する方法はいくつかありますが(もちろん直接書くのも一つです)、CUE言語を使うことで、記述内容の省略とガードレール設定の両方を比較的容易に実現することができるので今回紹介していきます。

CUEの概要

CUEは設定ファイルの生成を簡易にすることを目的に作られた言語でJSONのスーパーセットで、データ内容に合わせた詳細なvalidationを設定したり、設定ファイルにありがちな定型的な記述を省略・簡略化したりすることができます。

詳しくはGithubのREADMEの記載をご確認ください。
https://github.com/cue-lang/cue

国内での利用例はあまり多くありませんが、メルカリやカウシェなどで既に使われており、今後の利用拡大が期待される技術の一つだと思っています。

https://engineering.mercari.com/blog/entry/20220127-kubernetes-configuration-management-with-cue/

https://medium.com/kauche/managing-envoy-configurations-by-cue-f55a597bfaf6

CUEでCloud RunのYAMLファイルを生成

それでは本題に入っていきます。

まずは全てのCloud Runで共通で利用するテンプレートファイルを作成していきます。

template.cue

package template

apiVersion: "serving.knative.dev/v1"
kind: "Service"

#Metadata: {
  name: string
  annotations?: {
    "run.googleapis.com/launch-stage"?: "BETA"
    "run.googleapis.com/ingress": "all" | "internal-only" | "internal-and-cloud-load-balancing" | *"internal-and-cloud-load-balancing"
  }
}

#Spec: {
  template: {
    metadata: {
      annotations: {
        "run.googleapis.com/execution-environment": "gen1" | "gen2" | *"gen1"
        "autoscaling.knative.dev/minScale": int & <=10 | *0
        "autoscaling.knative.dev/maxScale": int & <=50 | *5
        "run.googleapis.com/cpu-throttling": bool | *true
        "run.googleapis.com/vpc-access-connector"?: string & =~ "projects/.*/locations/.*/connectors/.*"
        "run.googleapis.com/vpc-access-egress"?: string & =~ "all|private-ranges-only"
        "run.googleapis.com/startup-cpu-boost"?: int & >=2 & <=10
      }
    }
    spec: {
      containerConcurrency: int & <=100 | *10
      serviceAccountName: string & =~".*iam.gserviceaccount.com$"
      timeoutSeconds: int & <=300 | *5
      containers: [
        ...{
          name: string
          image: string & =~"(asia-east1-docker.pkg.dev|asia-northeast1-docker.pkg.dev)/prj-.*/.*/.*"
          ports: [
            {
              name: "http1" | "h2c" | *"http1"
              containerPort: int & >=0 & <=65535 | *8080
            }
          ]
          resources: {
            limits: {
              cpu: "500m" | "1000m" | "1500m" | "2000m" | *"1000m"
              memory: "512Mi" | "1024Mi" | "2048Mi" | *"1024Mi"
            }
          }
          livenessProbe: {
            initialDelaySeconds: int & >=0 & <=300 | *3
            timeoutSeconds: int & >=1 & <=300 | *1
            periodSeconds: int & >=1 & <=300 | *10
            successThreshold: int & >=1 & <=10 | *1
            failureThreshold: int & >=1 & <=10 | *3
            httpGet: {
              path: string
              port: int & >=0 & <=65535 | *8080
              httpHeaders?: [
                ...{
                  name: string
                  value: string
                }
              ]
            }
          }
          startupProbe: {
            initialDelaySeconds: int & >=0 & <=300 | *3
            timeoutSeconds: int & >=1 & <=300 | *1
            periodSeconds: int & >=1 & <=300 | *10
            successThreshold: int & >=1 & <=10 | *1
            failureThreshold: int & >=1 & <=10 | *3
            httpGet: {
              path: string
              port: int & >=0 & <=65535 | *8080
              httpHeaders?: [
                ...{
                  name: string
                  value: string
                }
              ]
            }
          }
          args?: [...string]
          env?: [
            ...{
              name: string
              value?: string
              valueFrom?: {
                secretKeyRef: {
                  name: string
                  key: int | "in-use"
                }
              }
            }
          ]
          volumeMounts?: [
            ...{
              name: string
              mountPath: string
            }
          ]
        }
      ]
      volumes?: [
        ...{
          name: string
          secret: {
            secretName: string
            item: {
              key: int | "in-use"
              path: string
            }
          }
        }
      ]
    }
  }
}

かなり長くなりました。全て説明しているととんでもない量になるのでトピックに絞って解説していきます。

package service

apiVersion: "serving.knative.dev/v1"
kind: "Service"

CUEにはパッケージの概念があるので先頭でパッケージ名を宣言しています。こうすることで別ファイルからパッケージ名でimportできるようになります。その下は固定値で入る値をそのまま設定しています。

次にmedataです。

#Metadata: {
  name: string
  annotations?: {
    "run.googleapis.com/launch-stage"?: "BETA"
    "run.googleapis.com/ingress": "all" | "internal-only" | "internal-and-cloud-load-balancing" | *"internal-and-cloud-load-balancing"
  }
}

CUEではこのようにデータ型を定義したり、取り得る値を列挙したりすることができます。またデフォルト値を設定することもできます。

上記の例ですとmetadata.annotations."run.googleapis.com/ingress"は「all」、「internal-only」、「internal-and-cloud-load-balancing」のいずれかでデフォルトでは「internal-and-cloud-load-balancing」になります。またキー名に「?」が付いている項目は省略可能です。

#Spec: {
  template: {
    spec: {
      serviceAccountName: string & =~".*iam.gserviceaccount.com$"
      containers: [
        ...{
          image: string & =~"(asia-east1-docker.pkg.dev|asia-northeast1-docker.pkg.dev)/prj-.*/.*/.*"
          ports: [
            {
              name: "http1" | "h2c" | *"http1"
              containerPort: int & >=0 & <=65535 | *8080
            }
          ]
        }
      ]
    }
  }
}

CUEでは正規表現を使ったvalidationも設定できるのでサービスアカウントやコンテナイメージのように形式が決まっている文字列は上記のように定義することでタイポ等の思わぬエラーがデプロイ時に発生するのを防止できます。

また spec.template.spec.containers[0].ports[0].containerPort のように範囲を定義しつつデフォルト値も設定する、といった組み合わせも可能です。このようにCUEの定義方法はとても柔軟性があります。

service.yml

import (
  "github.com/sample-org/sample-repos/service"
)

apiVersion: template.apiVersion
kind: template.kind

metadata: template.#Metadata & {
  name: "sample-app"
}

spec: template.#Spec & {
  template:spec: {
    serviceAccountName: "sample-app@sample-project.iam.gserviceaccount.com"       containers: [
      {
        name: "sample-app",
        image: "asia-northeast1-docker.pkg.dev/sample-project/sample-repos/sample-app:latest",
        ports: [{
          containerPort: 8080
        }],
        livenessProbe: httpGet: {
          path: "/health",
          port: 8080
        },
        startupProbe: httpGet: {
          path: "/health",
          port: 8080
        },
      }
    ]
  }
}

template.cueをもとにサービス固有の値を設定していきます。基本的にJSONと同じ記法ですが、コメントを書くことができたり、以下のような省略記法があったりとJSONには存在しない記法もいくつか存在します。

template:spec: {

YAMLファイルを生成

以下コマンドを実行することでYAMLファイルを生成できます。

$ cue export service.cue --out yaml
apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: sample-app
spec:
  template:
    metadata:
      annotations:
        run.googleapis.com/execution-environment: gen1
        autoscaling.knative.dev/minScale: 0
        autoscaling.knative.dev/maxScale: 5
        run.googleapis.com/cpu-throttling: true
    spec:
      containerConcurrency: 10
      serviceAccountName: sample-app@sample-project.iam.gserviceaccount.com
      timeoutSeconds: 5
      containers:
        - name: sample-app
          image: "asia-northeast1-docker.pkg.dev/sample-project/sample-repos/sample-app:latest"
          ports:
            - name: http1
              containerPort: 8080
          resources:
            limits:
              cpu: 1000m
              memory: 1024Mi
          livenessProbe:
            initialDelaySeconds: 3
            timeoutSeconds: 1
            periodSeconds: 10
            successThreshold: 1
            failureThreshold: 3
            httpGet:
              path: /health
              port: 8080
          startupProbe: 
	      path: /health
              port: 8080

なお、CUEコマンドは以下からインストールできます。

https://github.com/cue-lang/cue?tab=readme-ov-file#download-and-install

外部パッケージを利用する場合の注意点

2023年12月現在CUEには外部リポジトリで作成したパッケージをダウンロードする機能がありません。そのためimportを利用してパッケージを参照する場合、事前に所定のディレクトリにCUEのコードを配置しておく必要があります。

例えば以下のようにimportする場合 cue.mod/pkg/github/sample-org/sample-repos ディレクトリにテンプレートとなるCUEファイルを設置してからcue export...を実行する必要があります。

import (
  "github.com/sample-org/sample-repos/template"
)

なお、以下discussionによるとパッケージのダウンロード機能に関しては開発中とのことなので近い将来機能として追加される見込みです。

https://github.com/cue-lang/cue/discussions/2705

まとめ

プロダクトチームが複数あるような中規模以上の組織では、プロダクトごとに乖離しがちな設定ファイルをどうガバナンスしていくかが検討事項の一つになるかと思います。CUEを使うことで各チームに過度な負担や学習コストを強いることなくガバナンスを効かせることができるので設定ファイルに関して悩みを持っている場合はぜひ一度検討してみては如何でしょうか?

今回の例はCloud Runでしたが、github actionsでCI/CDを作る時に活用しても面白いと思います。

以上「CUEでCloud RunのYAMLファイルを作ってみる」でした。

Discussion