上記、CUE公式のKubernetesチュートリアルの和訳記事です。
説明が少し足りないと感じた箇所は適宜言葉を補っています。
また、手前味噌ではありますが、CUE言語(cuelang)についてあまりまだわかっていないという方は、以下の記事からお読みいただけると入りやすいのではないかなと思います。
こちらでは、CUE言語が何を成し遂げているのかについて議論しています。
Kubernetesチュートリアル
このチュートリアルでは、マイクロサービスのコレクションのためにKubernetesの設定ファイルをどのように変換するかについて示します。
設定ファイルは、実際の設定ファイルから不要なものを取り除き、名前を変更しただけのものです。
これらのファイルは、関連するサービスごとにサブディレクトリにまとめて構成されています。
Kubernetesのマニフェスト管理では、非常によくあるパターンです。そして、cueツールはこのユースケースを意識して作られています。
このチュートリアルでは、以下のトピックを取り上げます。
- YAMLからCUEへの変換
- 親ディレクトリに反映させる
- ツールを使用してCUEファイルを書き換え、不要なフィールドを削除
- 異なるサブディレクトリについて、手順2から繰り返す
- 構成を操作するコマンドを定義 (和訳できていません。)
- KubernetesのソースコードにあるGoの構造体からCUEテンプレートを抽出
- 手動で設定を調整する (和訳できていません。)
データセットについて
このデータセットは、実際のケースに基づいており、サービスに異なる名前を使用しています。CUEへの変換が実際にどのように行われるかについての現実的なイメージを得るために、実際のセットアップの不整合がすべてファイルに再現されています。
┌─(~/works/cue-lang/cue/doc/tutorial/kubernetes)
└─(15:22:11 on master)──> tree ./original | head ──(木,1209)─┘
./original
└── services
├── frontend
│ ├── bartender
│ │ └── kube.yaml
│ ├── breaddispatcher
│ │ └── kube.yaml
│ ├── host
│ │ └── kube.yaml
│ ├── maitred
...
各サブディレクトリには、よく似た特徴や構成を持つ関連するマイクロサービスが含まれています。構成には、サービス、デプロイメント、コンフィグマップ、デーモンセット、ステートフルセット、cronジョブなど、さまざまなKubernetesオブジェクトが含まれています。
最初のチュートリアルの結果は、"quick and dirty "を意味するquickディレクトリにあります。手動で最適化された設定は、manual
ディレクトリにあります。
既存の設定のインポート
まず、データディレクトリのコピーを作成します。
$ cp -a original tmp
$ cd tmp
次に、サブディレクトリ内のすべての設定ファイルを1つのパッケージとして扱うために、モジュールを初期化します。
すべてに同じパッケージ名をつけることで、そのように扱うことができます。
また、Goのモジュールも初期化しておきます。これは、のちにKubernetesのソースコードを参照するためです。
$ cue mod init
$ go mod init example.com
また,モジュールを作成することで,外部パッケージをインポートすることができます。試しに、cue import
コマンドを使って、与えられたYAMLファイルをCUEに変換してみましょう。
$ cd services
$ cue import ./...
path, list, or files flag needed to handle multiple objects in file ./services/frontend/bartender/kube.yaml
複数のパッケージがkube.yamlファイルがあるので、それらが属するべきパッケージを指定する必要があるというエラーメッセージが表示されました。
$ cue import ./... -p kube
path, list, or files flag needed to handle multiple objects in file "./frontend/bartender/kube.yaml"
多くのファイルは、複数のKubernetesオブジェクトを含んでいます。
さらに、すべてのファイルのすべてのオブジェクトを含む単一の設定を作ろうとしています。
すべてのKubernetesオブジェクトをまずは整理し、1つの設定の中でそれぞれが識別できるようにする必要があります。
そのためには、それぞれのタイプごとに異なる構造体を定義しオブジェクトの名前をキーにして格納することによって実現します。
これにより、Kubernetesで許可されているように、異なるタイプのオブジェクトが同じ名前を共有することができます。
そのために、cue
には、コンフィグレーションツリーの中で、「kind」を第一要素、「name」を第二要素とするパスに、各オブジェクトを置くように指示します。
$ cue import ./... -p kube -l 'strings.ToCamel(kind)' -l metadata.name -f
cue importコマンドのオプションが増えてきたので整理しましょう。
まずは、先ほどから使用している-p
フラグ(--package)は、CUEでないファイルに対してパッケージ名を付与するオプションです。
今行いたいことは、KubernetesのyamlをCUEファイルに変換することです。その際に、CUE側のpackage名が必要になるので、明示的に設定しています。
追加された -l
フラグ(--path)は、各オブジェクトからの値に基づいて、通常の CUE のフィールドラベルの構文を使用して、各オブジェクトのパスを定義します。
ここでは、各オブジェクトの kind
フィールドにキャメルケースのバリアントを使用し、metadata
セクションの name
フィールドを各オブジェクトの名前として使用しています。
なぜ、明示的にパスを指定する必要があるのか、疑問に思うかもしれません。
これは、cueのpackageをexportするときに一意に定まるパスが必要になるからです。今回の場合は、cue export exported_path -e (kind).(metaname) --out yaml
のようなコマンドで逆にCUEからyamlにexportできる。この時に(kind).(metaname)
を使ってシングルパスコンポーネントを利用している。
また、-f
フラグ(--force)は、ファイルに上書きを許可するためのオプションだ。
では、実際に何が起こったか見てみよう。
$ tree . | head
.
└── services
├── frontend
│ ├── bartender
│ │ ├── kube.cue
│ │ └── kube.yaml
│ ├── breaddispatcher
│ │ ├── kube.cue
│ │ └── kube.yaml
...
それぞれのYAMLファイルは、対応するCUEファイルに変換されています。YAMLファイルのコメントも保存されています。
しかし、完璧ではない部分もあります。
まず、mon/prometheus/configmap.cue
を見てみてください。
$ cat mon/prometheus/configmap.cue
package kube
apiVersion: "v1"
kind: "ConfigMap"
metadata: name: "prometheus"
data: {
"alert.rules": """
groups:
- name: rules.yaml
...
この設定ファイルのdata: 以下をよく見ると、これはcueの形式ではありません。yamlの形式です。文字列としてのyamlがそのまま残っているのは好ましくありません。
それを解決するためのオプションが-R
ラベルです。これによって、再起的に設定ファイルに埋め込まれたYAMLやJSONの文字列を検出し、CUEへ変換します。
$ cue import ./... -p kube -l 'strings.ToCamel(kind)' -l metadata.name -f -R
その結果が以下です。
$ cat mon/prometheus/configmap.cue
package kube
import "encoding/yaml"
configMap: prometheus: {
apiVersion: "v1"
kind: "ConfigMap"
metadata: name: "prometheus"
data: {
"alert.rules": yaml.Marshal(_cue_alert_rules)
_cue_alert_rules: {
groups: [{
...
素晴らしい。
結果として得られる設定ファイルは、構造化されたCUEソースをYAMLファイルと同等の文字列に変換するyaml.Marshal
の呼び出しで、元の埋め込み文字列を置き換えます。
文字列に変換し、同等のYAMLファイルを作成します。
アンダースコア(_
)で始まるフィールドは、コンフィギュレーションファイルを出力する際には含まれません(二重引用符で囲まれている場合は含まれます)。
$ cue eval ./mon/prometheus -e configMap.prometheus
apiVersion: "v1"
kind: "ConfigMap"
metadata: {
name: "prometheus"
}
data: {
"alert.rules": """
groups:
- name: rules.yaml
...
Yay!
クイック&ダーティ チュートリアル
このチュートリアルでは、一連の設定から定型文を素早く削除する方法を紹介します。
を削除する方法を紹介します。
手動で調整すると、通常はより良い結果が得られますが、かなり考えなければなりません。一方、クイック&ダーティなアプローチでは、ほとんどのことができます。
このような迅速な変換の結果は、より慎重な手動の最適化のための良い基礎となります。
トップレベルのテンプレートを作成する
これでYAMLファイルのインポートが完了したので、単純化のプロセスを開始できます。
再構築を始める前に、完全な評価を保存して、簡略化しても同じ結果になることを確認できます。
$ cue eval -c ./... > snapshot
-c
オプションは、具体的な値、つまり有効な JSON のみが許可されることを cue に伝えます。ここでは、様々な kube.cue
ファイルで定義されているオブジェクトに注目します。ざっと見たところ、デプロイメントとサービスの多くは共通の構造を持っています。
ここでは、テンプレート作成の基礎となる両方を含むファイルの1つを、ディレクトリ・ツリーのルートにコピーします。
cp frontend/breaddispatcher/kube.cue .
このファイルを少し修正して、以下のようにした。
$ cat <<EOF > kube.cue
package kube
service: [ID=_]: {
apiVersion: "v1"
kind: "Service"
metadata: {
name: ID
labels: {
app: ID // by convention
domain: "prod" // always the same in the given files
component: string // varies per directory
}
}
spec: {
// Any port has the following properties.
ports: [...{
port: int
protocol: *"TCP" | "UDP" // from the Kubernetes definition
name: string | *"client"
}]
selector: metadata.labels // we want those to be the same
}
}
deployment: [ID=_]: {
apiVersion: "apps/v1"
kind: "Deployment"
metadata: name: ID
spec: {
// 1 is the default, but we allow any number
replicas: *1 | int
template: {
metadata: labels: {
app: ID
domain: "prod"
component: string
}
// we always have one namesake container
spec: containers: [{ name: ID }]
}
}
}
EOF
サービス名とデプロイメント名を[ID=_]に置き換えることで、定義を任意のフィールドにマッチするテンプレートに変更しました。CUEはフィールド名を ID にバインドします。
インポートの際、オブジェクト名のキーとして metadata.name を使用したので、このフィールドを ID に設定することができます。
テンプレートは、定義されている構造体のすべてのエントリに適用されるので、breaddispatcherの定義に固有のフィールドを取り除くか、一般化するか、削除する必要があります。
Kubernetesのメタデータで定義されているラベルの1つは、常に親ディレクトリ名に設定されているようです。component: stringを定義することでこれを強制しています。つまり、componentという名前のフィールドには何らかの文字列値を設定しなければならず、これを後から定義します。指定されていないフィールドがあると、たとえばJSONに変換するときにエラーになります。つまり、このラベルが定義されている場合にのみ、デプロイメントやサービスが有効になります。
新しいテンプレートをマージした結果を、元のスナップショットと比較してみましょう。
$ cue eval ./... -c > snapshot2
--- ./mon/alertmanager
service.alertmanager.metadata.labels.component: incomplete value (string):
./kube.cue:11:24
service.alertmanager.spec.selector.component: incomplete value (string):
./kube.cue:11:24
deployment.alertmanager.spec.template.metadata.labels.component: incomplete value (string):
./kube.cue:36:28
service."node-exporter".metadata.labels.component: incomplete value (string):
./kube.cue:11:24
...
alertmanagerに component
ラベルが指定されていません。
構成の不整合がCUEによって発見されました。
component
ラベルを指定していないオブジェクトはほとんどないので、あらゆる場所にそれらを含めるように構成を変更します。
そのために、各ディレクトリに新しく定義したトップレベルのフィールドをディレクトリ名に設定し、マスターテンプレートファイルを修正して使用します。
# コンポーネントラベルを新しいトップレベルフィールドに設定します
$ sed -i.bak 's/component:.*string/component: #Component/' kube.cue && rm kube.cue.bak
# 以前のテンプレート定義に新しいトップレベルフィールドを追加します
$ cat <<EOF >> kube.cue
#Component: string
EOF
# コンポーネントラベルの付いたファイルを各ディレクトリに追加します
$ ls -d */ | sed 's/.$//' | xargs -I DIR sh -c 'cd DIR; echo "package kube
#Component: \"DIR\"
" > kube.cue; cd ..'
# ファイルのフォーマットを整えます
$ cue fmt kube.cue */kube.cue
$ cue eval -c ./... > snapshot2
$ diff snapshot snapshot2
...
ラベルなどの順序など以外は一致しています。いい結果だったので、これを新たなベースラインとして利用しましょう。
$ cp snapshot2 snapshot
これによって、対応するボイラープレートをcue trim
で削除できます。
$ find . | grep kube.cue | xargs wc | tail -1
1792 3616 34815 total
$ cue trim ./...
$ find . | grep kube.cue | xargs wc | tail -1
1223 2374 22903 total
cue trim
は,テンプレートや内包によってすでに生成されている設定をファイルから削除します。
上記のように、500行以上、つまり30%以上の設定が削除されました。
以下のように、内容は変わっていません。
$ cue eval -c ./... > snapshot2
$ diff snapshot snapshot2 | wc
0 0 0
しかし、もっと上手くできます。
まず、DaemonSetsとStatefulSetsはDeploymentsと同じような構造を持っていることに注意してください。トップレベルのテンプレートを以下のように一般化します。
$ cat <<EOF >> kube.cue
daemonSet: [ID=_]: _spec & {
apiVersion: "apps/v1"
kind: "DaemonSet"
_name: ID
}
statefulSet: [ID=_]: _spec & {
apiVersion: "apps/v1"
kind: "StatefulSet"
_name: ID
}
deployment: [ID=_]: _spec & {
apiVersion: "apps/v1"
kind: "Deployment"
_name: ID
spec: replicas: *1 | int
}
configMap: [ID=_]: {
metadata: name: ID
metadata: labels: component: #Component
}
_spec: {
_name: string
metadata: name: _name
metadata: labels: component: #Component
spec: selector: {}
spec: template: {
metadata: labels: {
app: _name
component: #Component
domain: "prod"
}
spec: containers: [{name: _name}]
}
}
EOF
$ cue fmt
共通の設定を _spec
に集約します。
また、オブジェクトの名前を指定したり、参照したりするために、_name
を導入し、トップレベルのエントリとして configMap
を追加します。
古いデプロイメントの定義はまだ削除していません。新しい定義と同等なので、削除したところで、意味は同じです。削除は読者の課題としましょう。
次に、すべてのDeployment、StatefulSet、DaemonSetには、同じフィールドの多くを共有する付属サービスがあることを確認します。追加します。
$ cat <<EOF >> kube.cue
// Define the _export option and set the default to true
// for all ports defined in all containers.
_spec: spec: template: spec: containers: [...{
ports: [...{
_export: *true | false // include the port in the service
}]
}]
for x in [deployment, daemonSet, statefulSet] for k, v in x {
service: "\(k)": {
spec: selector: v.spec.template.metadata.labels
spec: ports: [
for c in v.spec.template.spec.containers
for p in c.ports
if p._export {
let Port = p.containerPort // Port is an alias
port: *Port | int
targetPort: *Port | int
}
]
}
}
EOF
$ cue fmt
この例では、いくつかの新しい概念を紹介します。
オープンエンドのリストは、...
で表現されます。これを用いると、後続の要素と統合され、追加のリスト要素のタイプまたはテンプレートを定義します。
Port宣言はエイリアスです。エイリアスは、その辞書的なスコープでのみ表示され、モデルの一部ではありません。エイリアスは、シャドウイングされたフィールドをネストされたスコープ内で見えるようにしたり、この例では新しいフィールドを導入せずに定型文を減らすために使用されます。
最後に、この例ではリスト内包とフィールド内包を紹介します。リスト内包は、他の言語で見られるリスト内包と似ています。フィールド内包は、構造体にフィールドを挿入することができます。この例では、フィールド内包によって、任意のデプロイメント、daemonSet、および statefulSet の名前付けサービスが追加されます。フィールド内包は、条件付きでフィールドを追加するためにも使用できます。
targetPortの指定は必須ではありませんが、多くのファイルで定義されているので、ここで定義しておくと、キュー・トリムを使ってそれらの定義を削除することができます。コンテナで定義されたポートに対して、サービスに含めるかどうかを指定するオプション_exportを追加し、infra/events、infra/tasks、infra/watcherのそれぞれのポートに対して、明示的にこれをfalseに設定しています。
このチュートリアルの目的のために、いくつかのクイックパッチを紹介します。
$ cat <<EOF >> infra/events/kube.cue
deployment: events: spec: template: spec: containers: [{ ports: [{_export: false}, _] }]
EOF
$ cat <<EOF >> infra/tasks/kube.cue
deployment: tasks: spec: template: spec: containers: [{ ports: [{_export: false}, _] }]
EOF
$ cat <<EOF >> infra/watcher/kube.cue
deployment: watcher: spec: template: spec: containers: [{ ports: [{_export: false}, _] }]
EOF
実際には、オリジナルのportの宣言にこのフィールドを追加するのがより適切な形式でしょう。
すべての変更が受け入れられることを確認し、別のスナップショットを保存します。
その後、trimを実行して構成を圧縮します。
$ cue trim ./...
$ find . | grep kube.cue | xargs wc | tail -1
1129 2270 22073 total
テンプレートを追加した後にもかかわらず、さらに100行近くが削減されています。
ほとんどのファイルにおいて、サービス定義がなくなっているでしょう。
残っているのは、追加の設定か、クリーンアップすべき不整合のどちらかです。
しかし、私たちにはもう一つの秘策があります。
-s
または--simplify
オプションを使用すると、trim
またはfmt
に、単一の要素を持つ構造体を1つにまとめるように指示できます。
構造体を折り畳んで一行に収めることができます。
$ head frontend/breaddispatcher/kube.cue
package kube
deployment: breaddispatcher: {
spec: {
template: {
metadata: {
annotations: {
"prometheus.io.scrape": "true"
"prometheus.io.port": "7080"
}
$ cue trim ./... -s
$ head -7 frontend/breaddispatcher/kube.cue
package kube
deployment: breaddispatcher: spec: template: {
metadata: annotations: {
"prometheus.io.scrape": "true"
"prometheus.io.port": "7080"
}
$ find . | grep kube.cue | xargs wc | tail -1
975 2116 20264 total
さらに150行も減らすことができました。
このように行を圧縮することで、かなりの量の句読点がなくなり、設定の可読性が向上します。
サブディレクトリに対して繰り返し行う
前節では、すべてのサービスとデプロイメントに共通する特徴を把握するために、ディレクトリ構造のルートにサービスとデプロイメントのテンプレートを定義しました。さらに、ディレクトリごとのラベルも定義しました。このセクションでは、ディレクトリごとにオブジェクトを一般化することを検討します。
Directory frontend:
frontend のサブディレクトリにあるすべてのデプロイメントは、1つのポートを持つ1つのコンテナを持っていることがわかります。また、prometheus関連のアノテーションが2つあるものが多いですが、1つのものもあります。ポートの不一致はそのままにして、両方のアノテーションを無条件に追加します。
$ cat <<EOF >> frontend/kube.cue
deployment: [string]: spec: template: {
metadata: annotations: {
"prometheus.io.scrape": "true"
"prometheus.io.port": "\(spec.containers[0].ports[0].containerPort)"
}
spec: containers: [{
ports: [{containerPort: *7080 | int}] // 7080 is the default
}]
}
EOF
$ cue fmt ./frontend
# check differences
$ cue eval -c ./... > snapshot2
$ diff snapshot snapshot2
368a369
> prometheus.io.port: "7080"
577a579
> prometheus.io.port: "8080"
$ cp snapshot2 snapshot
注釈が追加された2行によって、一貫性が向上しています。
$ cue trim -s ./frontend/...
$ find . | grep kube.cue | xargs wc | tail -1
931 2052 19624 total
さらに40行を削減することに成功しました。
これ以上は削除すべきものがあまり残っていません。フロントエンドファイルの中には4行しか設定が残っていないものすらもあります。
Directory kitchen:
このディレクトリを見ると、すべてのDeploymentには例外なくPort8080のコンテナが1つあり、すべてのデプロイメントには同じliveness Prove、1行のprometheusアノテーションがあり、ほとんどのデプロイメントには同じようなパターンのディスクが2つまたは3つあることがわかります。
ここでは、ディスク以外のすべてを追加してみましょう。
$ cat <<EOF >> kitchen/kube.cue
deployment: [string]: spec: template: {
metadata: annotations: "prometheus.io.scrape": "true"
spec: containers: [{
ports: [{
containerPort: 8080
}]
livenessProbe: {
httpGet: {
path: "/debug/health"
port: 8080
}
initialDelaySeconds: 40
periodSeconds: 3
}
}]
}
EOF
$ cue fmt ./kitchen
diffを見ると、サービスにプロメテウスのアノテーションが1つ追加されています。これは偶然の差分でしょう。
ディスクは、テンプレートの仕様セクションと、使用されるコンテナの両方で定義する必要があります。この2つの定義は一緒にしておくことをお勧めします。expiditerのボリューム定義を取り、それを一般化します。
$ cat <<EOF >> kitchen/kube.cue
deployment: [ID=_]: spec: template: spec: {
_hasDisks: *true | bool
// field comprehension using just "if"
if _hasDisks {
volumes: [{
name: *"\(ID)-disk" | string
gcePersistentDisk: pdName: *"\(ID)-disk" | string
gcePersistentDisk: fsType: "ext4"
}, {
name: *"secret-\(ID)" | string
secret: secretName: *"\(ID)-secrets" | string
}, ...]
containers: [{
volumeMounts: [{
name: *"\(ID)-disk" | string
mountPath: *"/logs" | string
}, {
mountPath: *"/etc/certs" | string
name: *"secret-\(ID)" | string
readOnly: true
}, ...]
}]
}
}
EOF
$ cat <<EOF >> kitchen/souschef/kube.cue
deployment: souschef: spec: template: spec: {
_hasDisks: false
}
EOF
$ cue fmt ./kitchen/...
このテンプレートの定義は理想的ではありません。定義が順番によって定義されていることは好ましくありません。
ディスクを別の順番で定義した場合、再利用はおろか、衝突さえも起こらないのです。また、この制限に対処するために、ほとんどすべてのフィールド値は単なるデフォルト値であり、インスタンスによって上書きすることができてしまいます。
より良い方法は、Kubernetesのトップレベルのオブジェクトを整理したのと同様に、ボリュームのマップを定義し、このマップからこの2つのセクションを生成することです。
しかし、これにはいくつかの設計が必要であり、「クイック&ダーティ」なチュートリアルにはふさわしくありません。このドキュメントの後半では、手動で最適化した構成を紹介します。
ここでは、デフォルトで2つのディスクを追加し、_hasDisks
オプションを定義することで、それを無効にすることができます。souschef
の設定は、ディスクなしを定義している唯一の設定です。
$ cue trim -s ./kitchen/...
# check differences
$ cue eval ./... > snapshot2
$ diff snapshot snapshot2
...
$ cp snapshot2 snapshot
$ find . | grep kube.cue | xargs wc | tail -1
807 1862 17190 total
diffを見ると、_hadDisksオプションを追加したことがわかりますが、それ以外は特に違いはありません。さらに、構成を大幅に削減しました。
しかし、残ったファイルをよく見てみると、名前の一貫性がないために、ディスク仕様に多くのフィールドが残っています。今回のように構成を減らすと、不整合が露呈します。この不整合は、特定の構成のオーバーライドを削除するだけで解消できます。そのままにしておくと、構成が矛盾していることを明確に示すことができます。
クイック&ダーティ チュートリアルの結論
他のディレクトリでは、まだ若干の圧縮を行うことができます。
このチュートリアルでは、1,000行(55%)の削減に成功しましたが、残りの部分は読者の皆様の課題としましょう。
以上、CUEを使って、定型文の削減、整合性の確保、不整合の検出ができることを紹介しました。一貫性や不整合に対処できることは、制約ベースのモデルの結果であり、継承ベースの言語では難しいことです。
また、CUEが機械操作に適していることも間接的に示しました。これは、シンタックスと、そのセマンティクスからくる順序独立性の要因です。trimコマンドは、この性質を利用して可能になる数多くの自動リファクタリングツールの一つです。また、継承ベースの設定言語では、このようなことを行うのは難しいでしょう。
KubernetesのソースコードにあるGoのstructから、CUEテンプレートを抽出する
cue get go
がGoのソースからCUEテンプレートを生成するためには、まず、ソースをローカルに用意する必要があります。
$ go get k8s.io/api/apps/v1
$ cue get go k8s.io/api/apps/v1
そうすると、Kubernetesの定義が手元にある状態になるので、それをインポートして使うことができるようになります。
$ cat <<EOF > k8s_defs.cue
package kube
import (
"k8s.io/api/core/v1"
apps_v1 "k8s.io/api/apps/v1"
)
service: [string]: v1.#Service
deployment: [string]: apps_v1.#Deployment
daemonSet: [string]: apps_v1.#DaemonSet
statefulSet: [string]: apps_v1.#StatefulSet
EOF
最後にもう一度フォーマットを整形しましょう。
cue fmt