chroju.dev/blog

the world as code

CUEでTerraformを書いてみる

最近 CUE の話題を少しずつだがよく見かけるようになってきた。

CUE とは何か、レポジトリの README から引用すると以下のように書かれている。

CUE is an open source data constraint language which aims to simplify tasks involving defining and using data. It is a superset of JSON, allowing users familiar with JSON to get started quickly.

ポイントとしては JSON のスーパーセットであることと、データの定義や利用をシンプルにすることを目的としているという点だと理解している。昨今、Kubernetes の隆盛などにより、JSON やそれと互換性を持つ YAML を用いる機会が多くなったが、これら言語の機能だけではスキーマを定義したり、制約を書いたりすることは難しい。CUE はこれを解消する機能を持っているようである。

CUE が JSON のスーパーセットであるということは、JSON と互換性を持つ HCL のスーパーセットとしても使えるはずである。筆者はそもそも CUE に触ったこと自体がなかったので、今回は CUE で Terraform の定義をどのように書けばよいのか探りながら CUE に入門してみた。

HCL と JSON の互換性

JSON Configuration Syntax - Configuration Language | Terraform | HashiCorp Developer
Learn about the JSON-compatible language syntax, including file structure, expression mapping, block mapping, and block-type-specific exceptions.
JSON Configuration Syntax - Configuration Language | Terraform | HashiCorp Developer favicon https://developer.hashicorp.com/terraform/language/syntax/json
JSON Configuration Syntax - Configuration Language | Terraform | HashiCorp Developer

HCL と JSON には互換性があり、Terraform の定義も JSON で書くことが可能である。 terraform コマンドの対象として、カレントディレクトリの *.tf に加えて、 *.tf.json という postfix の JSON ファイルも読み取られる仕様となっている。

変換の手順としてはシンプルに key value をそのまま JSON として起こしていくだけだが、 resource "aws_instance" "this" {} のように複数の label が連続する箇所については、JSON Object がネストする形になる。例えば以下の HCL があるとする。

resource "aws_ebs_volume" "this" {
    availability_zone = "ap-northeast-1a"
    size              = 50
}

resource "aws_s3_bucket" "this" {
    bucket = "example"
}

これは以下の JSON に等しい。

"resource" : {
    "aws_ebs_volume" : {
        "this" : {
            "availability_zone" : "ap-northeast-1a",
            "size" : 50
        }
    },
    "aws_s3_bucket" : {
        "this" : {
            "bucket" : "example"
        }
    }
}

CUE で Terraform の定義を書く

早速ではあるが、上記の JSON を CUE に起こすと以下のようになる。

resource: {
    aws_ebs_volume: {
        this: {
            availability_zone: "ap-northeast-1a"
            size: 50
        }
    }
    aws_s3_bucket: {
        this: {
            bucket: "example"
        }
    }
}

あるいは、 フィールドが 1 つだけの場合はブラケットを省略できる ので、以下も同義になる。

resource: {
    aws_ebs_volume: this: {
        availability_zone: "ap-northeast-1a"
        size: 50
    }
    aws_s3_bucket: this: {
        bucket: "example"
    }
}

JSON への変換は cue コマンドを使って行う。コマンドのインストール方法は ドキュメント では brew install cue-lang/tap/cue となっているが、12 月の現時点では homebrew-core にも入っているようである。 cue eval sample.cue でバリデーション、 cue export sample.cue で JSON への変換が行える。

これだけだと key はクォーティングしなくて良くてちょっと楽ですねとか、それぐらいなものではあり、特に魅力を感じられる気はしてこない。

スキーマ定義とバリデーション

ここからが本領、CUE でスキーマを定義したり、値のバリデーションをかけたりするフェーズに入っていく。

CUE では「型は値である」と言われる。これはあるフィールドを定義するものとして、型や制約と、具体的な値とが同じように評価されるということを意味する。数学的には Lattice、日本語で「束(そく)」の概念を取り入れている。

resource: aws_ebs_volume: this: {
    availability_zone: string
    size: <100
}

resource: aws_ebs_volume: this: {
    availability_zone: "ap-northeast-1a"
    size: 50
    encrypted: true
}

上記の CUE は成り立つ。まず、CUE では同じフィールドに対する定義を複数回書けるので、 resource: aws_ebs_volume: this: が複数存在していてもエラーにはならない。複数回記述されたフィールドは、その論理積が取られるような形になる。 availability_zonestring かつ "ap-northeast-1a" であり、 size<100 かつ 50 、という具合である。ここにさらに size: >100availability_zone: "us-east-1" といった記述を追加すると論理積は空集合となってしまい、CUE の評価は失敗する。また 2 つの定義の双方で、フィールドの過不足があっても問題ない。上記の例の場合、最終的には encrypted: true が存在する形になる。

バリデーションは多様な書き方ができる。string に対しては正規表現も書けるし、論理和 | のオペランドが備わっていて、 enums のように availability_zone: "ap-northeast-1a" | "ap-northeast-1c" といった書き方もできる。また同様に論理和を使った書き方として、アスタリスクを付けるとデフォルト値を指定でき、 size: <100 | *50 とすれば、具体的に size の指定がなければ 50 として評価される。

Definition

CUE で記述されたデータを JSON に変換するには、当然ではあるが最終的に具体的な値を持つ必要がある。様々な AWS リソースのスキーマだけを書いたファイルを取りあえず用意して、このうち一部のリソースしか実際には定義しない、ということもあるだろうが、スキーマだけの状態だと値が確定できず、 cue export は失敗する。

$ cat <<EOF > sample.cue
resource: aws_ebs_volume: this: {
    availability_zone: string
    size: <100
}
EOF

$ cue export sample.cue
resource.aws_ebs_volume.this.availability_zone: incomplete value string:
    ./sample.cue:2:24
resource.aws_ebs_volume.this.size: incomplete value <100:
    ./sample.cue:3:11

このような場合は Definition を使う。頭に # を付けたフィールドは Definition として扱われ、データとしては評価されない。

$ cat <<EOF > sample.cue
#resource: aws_ebs_volume: this: {
    availability_zone: string
    size: <100
}
EOF

$ cue export sample.cue
{}

Definition を利用するときには & を使って呼び出す。上記は簡易な例として resource を Definition にしてしまったが、実際に Definition として作りたいのは aws_ebs_volume の方なので、より実践的な書き方としては以下のようになる。

$ cat <<EOF > sample.cue
#aws_ebs_volume: this: {
    availability_zone: string
    size: <100
}

resource: aws_ebs_volume: #aws_ebs_volume & {
    this: {
        availability_zone: "ap-northeast-1a"
        size: 10
    }
}
EOF

$ cue export sample.cue
{
    "resource": {
        "aws_ebs_volume": {
            "this": {
                "availability_zone": "ap-northeast-1a",
                "size": 10
            }
        }
    }
}

Terraform resource の定義は JSON だと深いネストを必要とするので、これでも少し冗長に感じる。呼び出し側の記述をもう少しシンプルにしたければ、 Definition をフル活用して以下のようにも書ける。 #resource_name は Terraform らしく、半角英字とアンダースコアしか使えない制約も追加した。

#aws_ebs_volume: {
    #resource_name: =~ "^[a-z_]+$"
    #availability_zone: string
    #size: <100
    resource: aws_ebs_volume: "\(#resource_name)": {
        availability_zone: #availability_zone
        size: #size
    }
}

#aws_ebs_volume & {
    #resource_name: "prod_instance_ebs"
    #availability_zone: "ap-northeast-1a"
    #size: 10
}

これだと非常にシンプルにはなったが、呼び出し側で aws_ebs_volume に新たなパラメータを追加できなくなっていることには注意が必要だ。頑張れば何か手段があるかもしれないが、今の時点では思い浮かばなかった。

Package / Modules の分離

Definition による型や制約は、何らかの形で切り出して、複数のプロダクトなどで使い回したくなってくる。コードの再利用を進める仕組みとして、 CUE にも Package / Modules の概念が存在している。

CUE で Modules を使うには、 module: "example.com/pkg" と記したファイルを ./cue.mod/module.cue に配置する必要がある。手動で作ってもよいが、 cue mod init example.com/pkg コマンドで自動生成もしてくれる。このあたりは Go の設計に影響を受けており、モジュール名も github.com/user/repo 形式が 原則とされているcue mod init により、Go での外部パッケージ管理を彷彿とさせる cue.mod/{pkg,usr} といったディレクトリも作成されるのだが、現時点では CUE 自体の Package Management の仕組みがなく、あまり活用する機会がないように思えている。

Proposal: package management · Issue #851 · cue-lang/cue
Originally opened by @myitcv in cuelang/cue#851 With extensive inputs from @mpvl. Proposal summary We propose adding package management to CUE, using an approach analogous to Go using Minimum Versi...
Proposal: package management · Issue #851 · cue-lang/cue favicon https://github.com/cue-lang/cue/issues/851
Proposal: package management · Issue #851 · cue-lang/cue

Package を宣言するのも Go と同様で、1 行目に package main のように記述する。なお Package と Module は包含関係にあり、 Module 内に複数 Package が存在できる。 Module はディレクトリで切られるが、その中に異なる Package のファイルが複数配置可能だ。

実践してみる。先の例から、スキーマについては別の Package に切り出すとして、 ./schema/terraform に以下のようなファイルを作る。

package ebs

#aws_ebs_volume: {
    #resource_name: =~ "^[a-z_]+$"
    #availability_zone: string
    #size: <100
    resource: aws_ebs_volume: "\(#resource_name)": {
        availability_zone: #availability_zone
        size: #size
    }
}

これを ./main.cue で呼び出すときも、やはり Go のような書き方になる。ここでは仮に cue mod init github.com/chroju/cue-sandbox を実行済みとする。

package main

import (
    "github.com/chroju/cue-sandbox/schema/terraform:ebs`
)

ebs.#aws_ebs_volume & {
    #resource_name: "prod_instance_ebs"
    #availability_zone: "ap-northeast-1a"
    #size: 10
}

実際の現場で使う場合は、さらに複雑なモジュール構成になっていくのかもしれない。例えば社内標準の #aws_ebs_volume を定義した Package を設け、それを package prod で本番環境相当により厳しくした #aws_ebs_volume と掛け合わせる、といった使い方が想定できる。

Script で Terraform を実行する

今更ながら CUE という単語の意味を紐解くと、 Configure Unify Execute の略だとされている。最後の Execute についてだが、CUE には Scripting というコマンド実行の機能が備わっている。組み込みの tools/exectools/file といったパッケージを使って、シンプルなシェルコマンドの実行からファイルの作成削除など、様々な操作が可能だ。

CUE で Terraform を扱う場合、Terraform コマンドの実行前に CUE から JSON への変換などを行う必要があるので、これをスクリプトにまとめると便利になる。詳細は割愛するが、以下のような CUE を書いて、 cue cmd plan と実行することにより、変換から terraform plan の実行、変換後 JSON の削除までを一発で完了させることができた。

package main

import (
	"tool/cli"
	"tool/exec"
	"tool/file"
)

command: plan: {
  echo_start: cli.Print & {
    text: "Convert cue to JSON ..."
  }

  export: exec.Run & {
    cmd: ["cue", "export", "."]
    stdout: string
  }

  remove_old_file: file.RemoveAll & {
    path: "terraform.tf.json"
  }

  generate_file: file.Append & {
    filename: "terraform.tf.json"
    contents: export.stdout
  }

  echo_plan: cli.Print & {
    text: "Execute terraform plan ..."
  }

  tfplan: exec.Run & {
    cmd: ["terraform", "plan", "-out=tfplan"]
  }

  remove_file: file.RemoveAll & {
    $dep: tfplan
    path: "terraform.tf.json"
  }
}

Impression

ざっくりと型定義、バリデーションなども行いつつ、CUE を使って Terraform を実行するまでを眺めてみた。率直な感想としてはわりと使えそうかもしれないと感じた。どちらかの方向への依存関係が発生するわけではなく、複数の定義を掛け合わせて最終的なデータを作って行くという形式は柔軟性が高い。Terraform 標準の機能では module の中で module を使ったりするとかなり複雑化してくるが、CUE であれば後からパラメータを追加するといったことも簡単にできる。かなり高機能なバリデーションを、 Sentinel や OPA/Rego といった別のツールを使わず、 CUE の中で完結できるのも良い。

一方、注意点も少なくない。当然ながら HCL の機能は使えないため、 bucket = aws_s3_bucket.this.id のようなリテラルは利用できず、懐かしい "${aws_s3_bucket.this.id}" 形式を使う必要がある。また先述したとおり、Package management の仕組みがないのは少々想定外だった。個人的にはスキーマだけを切り出した Module を 1 つのレポジトリで共有して、各プロダクトでそれを import するような使い方を想定していたからだ。インポートの機能としては cue get go コマンドによって Go Module の struct を CUE の struct として読めるという機能はあるので、そう遠くないうちに CUE Module もインポートできるようになるだろうとは思う(Kubernetes のリソースは Go の struct で定義されているため、これが結構便利らしい。残念ながら Terraform resource のスキーマ自体は struct ではない)。VSCode Extension などの入力支援もまだ乏しく、良くも悪くもまだまだ発展途上にあると感じてもいる。

あとは CUE の学習コストをかけてまで移行するメリットを見出せるか次第かなと思う。Lattice の概念に基づく記述は他に経験がなく、慣れるまでに多少の時間はかかったし、ここに書いた記述法でもまだ触り程度であり、CUE の表現力は極めて高く、それなりに学習コストを要する言語ではある。ただ、一度覚えれば Kubernetes にも Terraform にも、その他 JSON や YAML を使った様々な言語も内包し得るというのは魅力に映る。冒頭に貼った NTT コミュニケーションズやメルカリが目指しているのは、おそらくそういう地平なんじゃないだろうか。

Reference

今回参考にしたサイトをいくつか貼っておく。公式の Docs を見るのであれば Tutorials と Language Specification は特におすすめである。ただ、これだけではなかなかイメージが掴めず、Cutorials に非常に助けられた。