[DevOpsプラットフォームの取り組み #4] CUE言語の紹介

はじめに

DevOpsプラットフォームの取り組みを紹介する4回目の記事です。

Qmonus Value Streamの開発チームの會澤です。

連載4回目では、Qmonus Value Streamの重要な構成要素であるCUE言語についてご紹介します。

前回の記事では、Infrastructuer as Code (以下IaC)の課題と、Cloud Native AdapterというQmonus Value Streamチームの独自技術について解説しました。 Cloud Native Adapterは、「インフラストラクチャの構成」と「ワークフロー」をCUE言語を使って宣言します。

CUE言語は、複雑なシステム構成をスケーラブルに管理できることから、近年注目を集めているデータ記述言語です。 前回の記事でご紹介したとおり、KubeVelaDaggerなど最近リリースされたIaCソリューションで採用されていることが多く、メルカリがKubernetes基盤を管理するために活用していることでも有名です1。 今回は、CUE言語の言語としての特徴と、Cloud Native Adapterの開発においてCUE言語をどのように活用しているかについてご紹介します。

Kubernetesにおけるアプリケーションのデプロイ

GCPやAWS、Azureといったパブリッククラウド環境を利用してサービスを公開する際、Kubernetes上に「マニフェスト」を適用してアプリケーションをデプロイする機会は多いと思います。 以下のYAMLで記述されたマニフェストは、Kubernetesの公式サイトから例として引用したものになります。 このようなマニフェストをKubernetesクラスタに適用することで、予め指定したパラメータを利用して、アプリケーションをクラウド上にデプロイできます。

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.14.2
        ports:
        - containerPort: 80

こういったマニフェストを利用することで、インフラストラクチャのリソース構成や設定をコードで記述・適用し、同じ設定を再利用できます。

その一方で、YAMLを使ってマニフェストを管理することで、以下のような難しさを感じることもあるのではないでしょうか。

  • データのKeyとValueやリストの構造をインデントによって構成しているため、ネストのズレにより意図しない構造でデータを記述することがある
  • データの定義に誤りがある場合でも、実際に適用してみるまで分からない
  • マニフェストで定義するデータを使い回すことができず、再利用性が低い
  • 複数のマニフェストで定義されたパラメータを一括で変更できない

CUE言語は、これらの課題をどのように解決するのでしょうか。

Better YAMLとしてのCUE言語

CUE言語では、YAMLやJSONといった一般的なデータ記述言語と同じように、数値や文字列、リスト、辞書といった型を組み合わせて任意のデータを記述できます。 上記のKubernetesマニフェストをCUE言語で書く場合は、以下のようになります。

apiVersion: "apps/v1"
kind:       "Deployment"
metadata: {
    name: "nginx-deployment"
    labels: app: "nginx"
}
spec: {
    replicas: 3
    selector: matchLabels: app: "nginx"
    template: {
        metadata: labels: app: "nginx"
        spec: containers: [{
            name:  "nginx"
            image: "nginx:1.14.2"
            ports: [{
                containerPort: 80
            }]
        }]
    }
}

カッコを使ってデータ構造を表現しておりJSONに似た文法ではありますが、単一の値を定義するときは apiVersion: "apps/v1" のように { } を省略できます。 これにより、selector: matchLabels: app: "nginx" といった形で、階層構造の深いデータを一行にまとめて表現することもできます。 YAMLと違ってカッコで辞書やリスト構造を表現しているため、インデントが無くてもデータの階層構造を正しく解釈できます。

また、利用者が記述するデータに対して型を指定することもできます。 以下のコードは上記のKubernetesマニフェストからPodのレプリカ数に関する定義を抜き出したものに、型による制約を追加したものになります。

spec: {
    replicas: int
}
spec: {
    replicas: 3
}

上記の例は、レプリカ数が整数であるという、型による制約を追加したものになります。 CUE言語によるデータの定義では、文字列や数値といった具体的な値とそのデータの型を同等のものとして記述でき、それによって型による制約が適用されます。 また、以下のように型による制約と並べて、具体的な値による制約を記述することもできます。

replicas: int
replicas: >2
replicas: 3

CUE言語では cue というCLIが提供されており、cue eval コマンドを用いてデータを評価することでその定義に矛盾がないか検証されます。 上記の例であれば、cue eval コマンドを実行することで、いずれの制約も満たす具体的な値が出力されます。

replicas: 3

もし、以下のような矛盾した制約を追加した場合、cue eval コマンドを実行することでエラーとして設定の誤りを検証できます。

replicas: int
replicas: >5
replicas: 3
replicas: invalid value 3 (out of bound >5):
    ./intro.cue:2:11
    ./intro.cue:3:11

CUE言語では、JSONやYAMLとの相互変換もサポートされています。 前述のCUE言語によるKubernetes Deploymentのマニフェストは cue import nginx-deployment.yaml というコマンドを利用し、YAMLで定義されたマニフェストをインポートして作成しています。 また、CUE言語で作成されたマニフェストも cue export nginx-deployment.cue --out yaml というコマンドでYAMLにエクスポートできます。

CUE言語による柔軟なデータ表現

この章では、CUE言語におけるいくつかの特徴的な機能に着目して、実際にKubernetesマニフェストを生成します。 以下の画像は、CUE言語を利用してプラットフォーム管理者とアプリケーション開発者が協力しながらKubernetesマニフェストを作成するシチュエーションをイメージしたフローチャートになります。

このフローチャートの流れに則り、Templating、Composite、API Schema Validationといった機能を確認しながらCUE言語のエッセンスに触れていきます。

Templating

CUE言語では、テンプレートとなる内容を予め作成しておき、そのテンプレートに従って新たなデータを定義できます。 ここでは、以下のようにDeploymentのマニフェストを作成するためのテンプレートを定義してみます。

deployments: [_name=string]: {
    apiVersion: "apps/v1"
    kind:       "Deployment"
    metadata: {
        name: _name + "-deployment"
        labels: app: _name
    }
    spec: {
        replicas: 3
        selector: matchLabels: app: _name
        template: {
            metadata: labels: app: _name
            spec: containers: [{
                name:  _name
                image: string
                ports: [{
                    containerPort: int
                }]
            }]
        }
    }
}

このテンプレートの1行目の [_name=string] と記述している箇所で、テンプレート利用者が宣言したマニフェスト名を受け取って _name という変数にエイリアスしています。 この _name 変数は、テンプレート内で参照できます。 また、metadata: name_name 変数で指定された文字列と -deployment を結合した文字列を設定しています。

このテンプレートを利用してマニフェストを作成します。 以下の例では、1行目の deployments: nginx: という記述によりテンプレートの _name 変数に nginx を代入しています。

deployments: nginx: {
    spec: template: spec: containers: [{
        image: "nginx:1.14.2"
        ports: [{
            containerPort: 80
        }]
    }]
}

このように定義されたデータは、以下のように -e オプションで出力する要素を指定して export コマンドを実行することで、YAMLとしてマニフェストを出力できます。

cue export templating.cue -e deployments.nginx --out yaml
spec:
  ...
      containers:
        - name: nginx
          image: nginx:1.14.2
          ports:
            - containerPort: 80

さらに一歩踏み込んで、テンプレートの中で変数を利用する例を見ていきます。 以下の例では、テンプレートの内容はほとんど同じですが、コンテナのイメージ名、ポート番号に変数を利用するよう変更を加えています。

deployments: [_name=string]: {
    _image: string
    _port:  int

    apiVersion: "apps/v1"
    kind:       "Deployment"
    metadata: {
        name: _name + "-deployment"
        labels: app: _name
    }
    spec: {
        replicas: 3
        selector: matchLabels: app: _name
        template: {
            metadata: labels: app: _name
            spec: containers: [{
                name:  _name
                image: _image
                ports: [{
                    containerPort: _port
                }]
            }]
        }
    }
}

今回は、テンプレートの定義の冒頭に _image_port という変数を追加しています。 変数の先頭には _ を付けることで、cue export コマンドを使用してマニフェストを出力する際に、これらの定義が出力されないようにしています。

この新しいテンプレートを使い、以下のようなアプリケーション用のマニフェストを記述することで、最小限の変数の指定からマニフェストを定義できます。

deployments: nginx: {
    _image: "nginx:1.14.2"
    _port:  80
}

変数で指定する値を変えるだけで、利用するイメージとポート番号が異なる別のアプリケーション用のマニフェストを定義することもできます。

deployments: postgres: {
    _image: "postgres:12.11"
    _port:  5432
}

ここで仮に、アプリケーションをデプロイするプラットフォーム側の要望として Security Context の設定をマニフェストに追加したくなった場合を考えてみます。 以下のようにテンプレートの内容を一部変更してみます。 (前述のマニフェストと重複する箇所は省略しています。)

deployments: [_name=string]: {
    _image: string
    _port:  int
    ...
    spec: {
        ...
        template: {
            metadata: labels: app: _name
            spec: {
                securityContext: {
                    runAsUser:  1000
                    runAsGroup: 3000
                    fsGroup:    2000
                }
                containers: [{
                    name:  _name
                    image: _image
                    ports: [{
                        containerPort: _port
                    }]
                }]
            }
        }
    }
}

ここでは、マニフェストの spec の中に securityContext の設定を追加しました。 これにより、このテンプレートを利用しているアプリケーション側のマニフェストを変更することなく、最終的に出力されるDeploymentのマニフェストにも、以下のように securityContext の設定が反映されます。

spec:
  ...
    spec:
      securityContext:
        runAsUser: 1000
        runAsGroup: 3000
        fsGroup: 2000
      containers:
        - name: nginx
          image: nginx:1.14.2
          ports:
            - containerPort: 80

テンプレート機能を利用してマニフェストを記述することで、重複した構成を省略し、再利用が容易な形でデータを定義できます。

Composite

CUE言語におけるTemplating機能を利用することで、自由に設定できる変数を事前に決めておき、それに従って値を埋めてマニフェストを作成できました。 ですが、実際にアプリケーションを開発していく中では、より自由に設定を追加したいことも多々あります。 そこで、CUE言語のComposite機能により値を設定する例を確認してみます。

以下の例は、テンプレート側の定義を変更せず、アプリケーション側の設定にコンテナで利用する環境変数を追加したデータ定義になります。

// create configuration with the template
deployments: nginx: {
    _image: "nginx:1.14.2"
    _port:  80
}

// composite extra configuration
deployments: nginx: spec: template: spec: containers: [{
    env: [{
        name:  "NGINX_HOST"
        value: "example.com"
    }]
}]

今回はアプリケーション側の設定を変更することで、コンテナに環境変数の定義を追加しました。 このデータを利用することで、以下のように環境変数の設定を追加したマニフェストを出力できます。

spec:
  ...
      containers:
        - env:
            - name: NGINX_HOST
              value: example.com
          name: nginx
          image: nginx:1.14.2
          ports:
            - containerPort: 80

上記の例のように、CUE言語ではテンプレート側で全ての設定を明示していない場合であっても、別のデータ定義と結合して新たな設定を追加できます。 テンプレートで宣言したデータと追加で宣言したデータは、任意の階層で結合されます。結合の順序に関係なく一貫した結果が生成されるため、テンプレートを使う定義と追加定義の順番を入れ替えても同じ結果が出力されます。 一方で、CUE言語の仕様として、一度定義された値を後から変更・削除できません。 例えば、テンプレートで定義された securicyContext を上書きして、runAsUser を変えるようなマニフェストは宣言できません。 これにより、テンプレートで予め定義された設定内容を遵守しつつも、必要に応じて設定を追加するといった、堅牢さと柔軟さを兼ね備えたデータ定義が可能となります。

API Schema Validation

これまでも設定の中で使ってきたように、CUE言語では intstring などの型を利用して、定義された値を型検査できます。 これにより、YAMLとしてマニフェストを生成するタイミングで無効な値が設定されていないか検証でき、マニフェストをKubernetesクラスタに適用する際の手戻りを減らすことができます。 その一方で、マニフェストで設定する全ての値の型情報を予め記述するのは非常に困難です。 このような課題に対し、CUE言語は、Go実装の構造体定義からCUE言語として評価可能な型定義を生成する機能を提供しています。 ここでは、マニフェストを作成するにあたり、事前にKubernetes APIの型定義を生成して、型検査に適用する方法を紹介します。

Kubernetes APIの型定義を取得するには、Go言語で利用される gocue のCLIを利用して以下のコマンドを実行します。

go mod init example.com
go get k8s.io/api/apps/v1
cue get go k8s.io/api/apps/v1

このコマンドを実行することで、カレントディレクトリに cue.mod ディレクトリが作成され、その中に型情報を定義したCUEファイルが保存されます。 ここで保存されるファイルは、Kubernetes APIを実装しているGo言語のソースコードから自動生成されたものになります。

KubernetesのDeploymentに関する型定義は k8s.io/api/apps/v1 というパッケージに #Deployment という名前で記述されているため、以下のようにマニフェストを修正して定義を参照できます。

import (
    "k8s.io/api/apps/v1"
)

deployments: [_name=string]: v1.#Deployment & {
    _image: string
    _port:  int
    ...
}

上記のマニフェストでは、5行目のテンプレートのデータ構造を定義する箇所で、v1.#Deployment & という記述を追加しています。 この記述により、定義されたデータ構造が #Deployment として定義された型を利用して自動的に型検査されます。 例えば、コンテナの name を宣言し忘れたDeploymentマニフェストを生成しようとした際、#Deloyment で定義された型に違反したことでCUEの評価が失敗します。 このように、実際のKubernetes APIを呼び出す前に、API schemaを使ってデータを検査できます。

ここで利用した型定義の出力機能は、Go言語のソースコードに対してだけでなく、Protocol BuffersOpenAPI でも利用できます。 この機能を活用することで、様々なAPIに対してCUE言語による型によって安全性が保証されたデータの定義を実現できます。

CUE言語とCloud Native Adapter

ここまでの説明で、Templating、Composite、API Schema Validation といったCUE言語の特徴的な機能を確認してきました。 Qmonus Value Streamでは、CUE言語が提供されているGo APIを活用して言語機能を拡張し、Cloud Native Adapterという独自のIaCを実装しています。 Qmonus Value Streamのユーザは、Cloud Native AdapterをCUE言語を用いて記述することで、自身のユースケースに合わせた「インフラストラクチャの構成」と「ワークフロー」を構築できます。 加えて、Qmonus Value Stream独自のComposite機能により、汎化されたCloud Native Adapterを自由に選択・結合することが可能となり、ユーザ間でのモジュールの再利用・プラクティスの共有を促します。 Cloud Native Adapterについては、前回の記事で解説していますので、興味があればそちらもご覧ください。

また、CUE言語のより詳細な情報については、以下のwebサイトも合わせてご覧ください。

おわりに

DevOpsプラットフォームの取り組み連載の4回目の記事として、Qmonus Value Streamチームが利用しているCUE言語について紹介しました。

Qmonus Value Streamチームは、メンバ一人一人が、利用者であるアプリケーション開発者のために信念を持って活動を続けています。 プラットフォームを内製開発するポジション、プラットフォームを使ってNTTグループのクラウド基盤やCI/CDを構築支援するポジションそれぞれで 、一緒に働く仲間を募集していますので、興味を持たれた方は応募いただけると嬉しいです。

次回は、Qmonus Value StreamチームがCI/CDパイプラインを構築するために利用しているTektonについて紹介します。お楽しみに!

© NTT Communications Corporation All Rights Reserved.