0
0

CUEを使用したKubernetesの制御

Posted at

このチュートリアルは、CUEプロジェクトが提供する原文を、AIアシスタントが日本語に翻訳したものです。技術的な正確性を保ちつつ、日本語として自然な表現になるよう努めました。ただし、翻訳の過程で誤りや不明確な点が生じている可能性があります。もし疑問点や改善点がございましたら、原文を参照いただくか、CUEプロジェクトの公式ドキュメントをご確認ください。

この翻訳が、日本語を母語とする開発者の方々にとって、CUEとKubernetesの理解を深める一助となれば幸いです。

翻訳日:2023年7月22日

はじめに

このチュートリアルでは、マイクロサービスのコレクションに対するKubernetes設定ファイルの変換方法を示します。

設定ファイルは、実際の設定ファイルから機密情報を削除し、名前を変更したものです。
ファイルは、関連するサービスをサブディレクトリにグループ化したディレクトリ階層で整理されています。
これは一般的なパターンです。
cueツールはこのユースケースに最適化されています。

このチュートリアルでは、以下のトピックを扱います:

  1. 与えられたYAMLファイルをCUEに変換する
  2. 共通のパターンを親ディレクトリに移動する
  3. ツールを使用してCUEファイルを書き換え、不要なフィールドを削除する
  4. 異なるサブディレクトリに対してステップ2を繰り返す
  5. 設定を操作するためのワークフローコマンドを定義する
  6. Kubernetes GoソースからCUEテンプレートを直接抽出する
  7. 設定を手動で調整する
  8. Kubernetes設定をdocker-composeにマッピングする(TODO)

このチュートリアルのコンテキスト

このチュートリアルのディレクトリに含まれるデータセットは、実際のケースに基づいており、サービスに異なる名前を使用しています。
実際のセットアップのすべての不整合が、実践的にCUEへの変換がどのように動作するかの現実的な印象を得るために、ファイルに複製されています。

:arrow_right: コンテキストに慣れる

与えられたYAMLファイルは様々なディレクトリにわたって整理されています。次のようにして、どのようなファイルが存在するかを確認できます:

:computer: terminal

find ./original -type f

これにより、以下のような出力が表示されます(ここでは省略しています):

./original/services/frontend/bartender/kube.yaml
./original/services/frontend/breaddispatcher/kube.yaml
./original/services/frontend/host/kube.yaml
./original/services/frontend/maitred/kube.yaml
./original/services/frontend/valeter/kube.yaml
./original/services/frontend/waiter/kube.yaml
./original/services/frontend/waterdispatcher/kube.yaml
./original/services/infra/download/kube.yaml
./original/services/infra/etcd/kube.yaml
./original/services/infra/events/kube.yaml
[ ... 省略 ... ]

各サブディレクトリには、しばしば類似した特性と設定を共有する関連するマイクロサービスが含まれています。
設定には、サービス、デプロイメント、コンフィグマップ、デーモンセット、ステートフルセット、クーロンジョブなど、幅広いKubernetesオブジェクトが含まれています。

最初のチュートリアルの結果は、「quick」(クイック&ダーティ)ディレクトリにあります。
手動で最適化された設定は「manual」ディレクトリにあります。

既存の設定のインポート

:arrow_right: データディレクトリのコピーを作成する

構造の変更を開始する前に、データディレクトリのコピーを作成しましょう:

:computer: terminal

cp -a original tmp
cd tmp

:arrow_right: CUEモジュールを初期化する

:computer: terminal

cue mod init

CUEモジュールを初期化することで、サブディレクトリ内のすべての設定ファイルを1つのパッケージの一部として扱うことができます。
後で、すべてに同じパッケージ名を付けることでこれを実現します。

モジュールを作成することで、パッケージが外部パッケージをインポートできるようにもなります。

:arrow_right: Goモジュールを初期化する

:computer: terminal

go mod init mod.test

Goモジュールを初期化することで、後でk8s.io/api/apps/v1のGoパッケージ依存関係を解決できるようになります。

:arrow_right: 試行1:YAMLファイルを単一のCUEパッケージにインポートする

cue importコマンドを使用して、与えられたYAMLファイルをCUEに変換し、kubeパッケージに含めてみましょう:

:computer: terminal

cd services
cue import ./... -p kube

複数のパッケージとファイルがあるため、それらが属すべきパッケージ(-p kube)を指定する必要がありました。

これにより、以下のようなエラーメッセージが出力されます:

path, list, or files flag needed to handle multiple objects in file ./services/frontend/bartender/kube.yaml

このエラーメッセージは、多くのファイルに複数のKubernetesオブジェクトが含まれているために表示されています。
さらに、すべてのファイルからすべてのオブジェクトを含む単一の設定を作成しようとしています。

:arrow_right: 試行2:動的オブジェクトアドレスを使用してYAMLファイルをインポートする

単一の設定内で各Kubernetesオブジェクトを個別に識別できるように整理する必要があります。
これを行うには、各タイプに対して異なる構造体を定義し、各オブジェクトをそれぞれの構造体に名前でキー付けします。
これにより、Kubernetesで許可されているように、異なるタイプのオブジェクトが同じ名前を共有できます。
これを実現するために、cueに各オブジェクトを設定ツリーの"kind"を最初の要素、"name"を2番目の要素とするパスに配置するよう指示します。

:computer: terminal

cue import ./... -p kube -l 'strings.ToCamel(kind)' -l metadata.name -f

追加された-lフラグは、各オブジェクトの値に基づいて各オブジェクトのラベルを定義し、フィールドラベルにCUEの通常の構文を使用します。
この場合、各オブジェクトのkindフィールドのキャメルケースバリアントと、metadataセクションのnameフィールドを各オブジェクトの名前として使用します。
また、以前に成功した少数のファイルを上書きするために-fフラグを追加しました。

何が起こったか見てみましょう。以下のコマンドを実行します:

:computer: terminal

find . -type f

これにより、以下のような出力が表示されます:

./frontend/bartender/kube.yaml
./frontend/bartender/kube.cue
./frontend/breaddispatcher/kube.yaml
./frontend/breaddispatcher/kube.cue
./frontend/host/kube.yaml
./frontend/host/kube.cue
./frontend/maitred/kube.yaml
./frontend/maitred/kube.cue
./frontend/valeter/kube.yaml
./frontend/valeter/kube.cue
[ ... 省略 ... ]

各YAMLファイルが対応するCUEファイルに変換されています。
YAMLファイルのコメントは保持されています。

しかし、結果は完全に満足のいくものではありません。
mon/prometheus/configmap.cueを見てみましょう。

:floppy_disk: mon/prometheus/configmap.cue

package kube

configMap: prometheus: {
	apiVersion: "v1"
	kind:       "ConfigMap"
	metadata: name: "prometheus"
	data: {
		"alert.rules": """
			groups:
			- name: rules.yaml
[ ... 省略 ... ]

設定ファイルには、フィールドの1つの文字列値に埋め込まれたYAMLがまだ含まれています。
元のYAMLファイルはすべて構造化されたデータのように見えたかもしれませんが、その大部分は(うまくいけば)有効なYAMLを含む文字列でした。

:arrow_right: 試行3:埋め込み文字列処理を使用してYAMLファイルをインポートする

cue import-Rオプションを使用して、設定ファイルに埋め込まれた構造化YAMLまたはJSON文字列を検出し、再帰的に変換しようとします:

:computer: terminal

cue import ./... -p kube -l 'strings.ToCamel(kind)' -l metadata.name -f -R

インポートされたCUEファイルを再確認してみましょう:

:floppy_disk: mon/prometheus/configmap.cue

package kube

import yaml656e63 "encoding/yaml"

configMap: prometheus: {
	apiVersion: "v1"
	kind:       "ConfigMap"
	metadata: name: "prometheus"
	data: {
		"alert.rules": yaml656e63.Marshal(_cue_alert_rules)
[ ... 省略 ... ]

これでよくなりました!
結果の設定ファイルは、元の埋め込み文字列をyaml.Marshalの呼び出しに置き換え、構造化されたCUEソースを同等のYAMLファイルを含む文字列に変換しています。

:arrow_right: CUE出力を検証する

アンダースコア(_)で始まるフィールドは、設定ファイルを出力する際に含まれません(フィールド名が二重引用符で囲まれている場合を除く)。

cue import-Rオプションを使用する際に追加された_cue_alert_rulesフィールドがcue出力に存在しないことを確認しましょう:

:computer: terminal

cue eval ./mon/prometheus -e configMap.prometheus

これにより、以下のような出力が表示されます:

apiVersion: "v1"
kind:       "ConfigMap"
metadata: {
    name: "prometheus"
}
data: {
    "alert.rules": """
        groups:
          - name: rules.yaml
            rules:
[ ... 省略 ... ]

やった!

クイック&ダーティな変換

このチュートリアルでは、一連の設定からボイラープレートを迅速に排除する方法を示します。
手動での調整は通常より良い結果をもたらしますが、かなりの思考を必要とします。一方で、クイック&ダーティなアプローチを取ると、ほとんどの目標を達成できます。
このような迅速な変換の結果は、より思慮深い手動最適化の良い基礎にもなります。

トップレベルテンプレートの作成

上記のYAMLファイルをインポートした後、簡素化プロセスを開始できます。

:arrow_right: 参照コピーを保存する

構造の変更を開始する前に、完全な評価を保存しましょう(これにより、簡素化が同じ結果をもたらすことを確認できます):

:computer: terminal

cue eval -c ./... >snapshot

-cオプションは、具体的な値(つまり有効なJSON)のみが許可されることをcueに指示します。

:arrow_right: テンプレートの作成を開始する

様々なkube.cueファイルで定義されたオブジェクトに焦点を当てます。
簡単な調査により、多くのDeploymentとServiceが共通の構造を共有していることがわかります。

両方を含むファイルの1つをコピーして、テンプレートの作成の基礎とし、ディレクトリツリーのルートに配置します。

:computer: terminal

cp frontend/breaddispatcher/kube.cue .

このファイルを以下のように修正します。

:floppy_disk: ./kube.cue

package kube

service: [ID=_]: {
	apiVersion: "v1"
	kind:       "Service"
	metadata: {
		name: ID
		labels: {
			app:       ID     // 慣例による
			domain:    "prod" // 与えられたファイルでは常に同じ
			component: string // ディレクトリごとに異なる
		}
	}
	spec: {
		// どのポートも以下のプロパティを持つ
		ports: [...{
			port:     int
			protocol: *"TCP" | "UDP" // Kubernetesの定義から
			name:     string | *"client"
		}]
		selector: metadata.labels // これらは同じであることを望む
	}
}

deployment: [ID=_]: {
	apiVersion: "apps/v1"
	kind:       "Deployment"
	metadata: name: ID
	spec: {
		// 1がデフォルトだが、任意の数を許可する
		replicas: *1 | int
		template: {
			metadata: labels: {
				app:       ID
				domain:    "prod"
				component: string
			}
			// 常に1つの同名コンテナを持つ
			spec: containers: [{name: ID}]
		}
	}
}

serviceとdeploymentの名前を[ID=_]に置き換えることで、定義を任意のフィールドにマッチするテンプレートに変更しました。
その結果、CUEはフィールド名をIDに束縛します。
インポート時にmetadata.nameをオブジェクト名のキーとして使用したので、このフィールドをIDに設定できます。

テンプレートは定義されている構造体のすべてのエントリに適用される(統合される)ので、breaddispatcher定義に特有のフィールドを削除するか、一般化するか、または削除する必要があります。

Kubernetesのメタデータで定義されているラベルの1つは、常に親ディレクトリ名に設定されているようです。
component: stringを定義することでこれを強制し、つまりcomponentという名前のフィールドが何らかの文字列値に設定されなければならないことを意味し、後でこれを定義します。
不完全に指定されたフィールドは、たとえばJSONに変換する際にエラーを引き起こします。
したがって、deploymentまたはserviceは、このラベルが定義されている場合にのみ有効になります。

:arrow_right: テンプレートをテストする

新しいテンプレートをマージした結果を元のスナップショットと比較してみましょう:

:computer: terminal

cue eval -c ./... >snapshot2

このコマンドは失敗し、以下のような(省略された)エラーメッセージが表示されます:

// :kube
deployment.alertmanager.spec.template.metadata.labels.component: incomplete value string:
    ./kube.cue:36:16
service.alertmanager.metadata.labels.component: incomplete value string:
    ./kube.cue:11:15
service.alertmanager.spec.selector.component: incomplete value string:
    ./kube.cue:11:15
// :kube
service."node-exporter".metadata.labels.component: incomplete value string:
    ./kube.cue:11:15
[ ... 省略 ... ]

おっと。
アラートマネージャーがcomponentラベルを指定していません。
これは、制約を使用して設定の不整合を捕捉する方法を示しています。

:arrow_right: テンプレートを修正する

このラベルを指定していないオブジェクトはごくわずかなので、すべての場所でこのラベルを含むように設定を変更します。
これを行うには、新しく定義したトップレベルフィールドを各ディレクトリのディレクトリ名に設定し、マスターテンプレートファイルを変更してそれを使用するようにします。

以下のスクリプトを実行して、ファイルをインラインで編集し、各ディレクトリにいくつかのCUEファイルを追加します:

:computer: terminal

# componentラベルを新しいトップレベルフィールドに設定する
sed -i.bak 's/component:.*string/component: #Component/' kube.cue
rm kube.cue.bak

# 新しいトップレベルフィールドを以前のテンプレート定義に追加する
cat <<EOF >> kube.cue

#Component: string
EOF

# componentラベルを含むファイルを各ディレクトリに追加する
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

:arrow_right: テンプレートを再度テストする

テンプレートが修正されたか確認しましょう:

:computer: terminal

cue eval -c ./... >snapshot2
diff -w --side-by-side snapshot snapshot2

これにより、snapshot2のラベルがより一貫性を持ち、一部の順序変更があることを除いて、snapshotsnapshot2の間に実質的な変更がないことがわかります。

:arrow_right: 結果を新しいベースラインとして保存する

:computer: terminal

cp snapshot2 snapshot

:arrow_right: ボイラープレートを削除する

テンプレートの内容によって暗示されるようになった多くのボイラープレート設定を、cue trimを使用して削除できます。

まず、ボイラープレート削除前の全kube.cueファイルの総行数を確認します:

:computer: terminal

find . | grep kube.cue | xargs wc -l | tail -1

これにより、以下のような結果が表示されます:

 1887 total

次に、cue trimを使用してボイラープレートを削除し、残っている行数を再確認します:

:computer: terminal

cue trim ./...
find . | grep kube.cue | xargs wc -l | tail -1

大幅な削減が見られるはずです:

 1312 total

cue trimは、テンプレートや包括表現によってすでに生成されている設定をファイルから削除します。
これにより、500行以上の設定、つまり30%以上が削除されました!

:arrow_right: ボイラープレート削除を確認する

意味的に何も変更されていないことを確認します:

:computer: terminal

cue eval -c ./... >snapshot2
diff -wu snapshot snapshot2 | wc -l

報告される行数は0になるはずです。

:arrow_right: テンプレートを改善する

さらに改善できます。

最初に注目すべき点は、DaemonSetとStatefulSetがDeploymentと類似した構造を共有していることです。
_specの必要性を排除するために、トップレベルテンプレートを以下のように一般化します。kube.cueに追加します:

:computer: terminal

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の古い定義をまだ削除していないことに注意してください。
これは問題ありません。
新しい定義と同等なので、それらを統合しても効果はありません。
その削除は読者の演習として残しておきます。

:arrow_right: テンプレートをさらに改善する

次に、すべてのdeployment、stateful set、およびdaemon setには、多くの同じフィールドを共有する付随するserviceがあることに注目します。
再びkube.cueのテンプレートに追加します:

:computer: terminal

cat <<EOF >> kube.cue

// _exportオプションを定義し、すべてのコンテナで定義されたすべてのポートのデフォルトをtrueに設定する
_spec: spec: template: spec: containers: [...{
	ports: [...{
		_export: *true | false // ポートをサービスに含める
	}]
}]

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はエイリアス
				port:       *Port | int
				targetPort: *Port | int
			},
		]
	}
}
EOF
cue fmt

この例では、いくつかの新しい概念を導入しています。
開放型リストは省略記号(...)で示されます。
省略記号の後の値は、後続の要素と統合され、追加のリスト要素の"タイプ"またはテンプレートを定義します。

Port宣言はエイリアスです。
エイリアスはその字句スコープでのみ可視であり、モデルの一部ではありません。
エイリアスは、ネストされたスコープ内で隠れたフィールドを可視にするため、または、この場合、新しいフィールドを導入せずにボイラープレートを減らすために使用できます。

最後に、この例ではリストとフィールドの包括表現を導入しています。
リストの包括表現は、他の言語で見られるリスト内包表記に類似しています。
フィールドの包括表現により、構造体にフィールドを挿入できます。
この場合、フィールドの包括表現は、deployment、daemonSet、およびstatefulSetごとに同名のサービスを追加します。
フィールドの包括表現は、条件付きでフィールドを追加するためにも使用できます。

targetPortを指定する必要はありませんが、多くのファイルでそれが定義されているため、ここで定義することでcue trimを使用してそれらの定義を削除できるようになります。
コンテナで定義されたポートに対して_exportオプションを追加し、ポートをサービスに含めるかどうかを指定します。そして、infra/eventsinfra/tasks、およびinfra/watcherの各ポートに対してこれを明示的にfalseに設定します。

このチュートリアルの目的のために、以下のクイックパッチを適用します:

:computer: terminal

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

実際には、元のポート宣言にこのフィールドを追加するのがより適切な方法です。

:arrow_right: テンプレートの改善を確認する

すべての変更が受け入れ可能であることを確認し、別のスナップショットを保存します。
その後、trimを実行してさらに設定を削減します:

:computer: terminal

cue trim ./...
find . | grep kube.cue | xargs wc -l | tail -1

書き直され、今や冗長になったdeployment定義を削除した後、テンプレートを追加したにもかかわらず、さらに約100行を削減しました。
ほとんどのファイルでservice定義が消えていることを確認できます。
残っているのは、追加の設定か、おそらくクリーンアップされるべき不整合です。

:arrow_right: 単一要素の構造体を折りたたむ

もう一つのテクニックがあります。

-sまたは--simplifyオプションを使用すると、trimまたはfmtに単一の要素を持つ構造体を1行に折りたたむよう指示できます。

例えば、frontend/breaddispatcher/kube.cueの先頭を確認してください:

:computer: terminal

head frontend/breaddispatcher/kube.cue

これは以下のように表示されるはずです:

package kube

deployment: breaddispatcher: {
	spec: {
		template: {
			metadata: {
				annotations: {
					"prometheus.io.scrape": "true"
					"prometheus.io.port":   "7080"
				}

次に、cue trim-sフラグを使用します:

:computer: terminal

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"
	}

設定全体での行数の削減を確認します:

:computer: terminal

find . | grep kube.cue | xargs wc -l | tail -1

さらに150行ほど削減されました!
このように行を折りたたむことで、かなりの量の句読点を削除し、設定の読みやすさを向上させることができます。

:arrow_right: 結果を新しいベースラインとして保存する

:computer: terminal

cue eval -c ./... >snapshot2
cp snapshot2 snapshot

複数のサブディレクトリに対して繰り返す

前のセクションでは、ディレクトリ構造のルートにserviceとdeploymentのテンプレートを定義して、すべてのserviceとdeploymentの共通の特性を捉えました。
さらに、ディレクトリ固有のラベルを定義しました。
このセクションでは、ディレクトリごとのオブジェクトの一般化について検討します。

:arrow_right: frontendディレクトリをテンプレート化する

frontendのサブディレクトリにあるすべてのdeploymentには、1つのポートを持つ単一のコンテナがあり、通常は7080ですが、時々8080であることに気づきます。
また、ほとんどが2つのprometheus関連のアノテーションを持っていますが、一部は1つだけです。
ポートの不整合は残しますが、両方のアノテーションを無条件に追加します。

:computer: terminal

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がデフォルト
	}]
}
EOF
cue fmt ./frontend

# 差分を確認する
cue eval -c ./... >snapshot2
diff -wu snapshot snapshot2

これにより、以下のような結果が表示されるはずです...

--- snapshot	2022-02-21 06:04:10.919832150 +0000
+++ snapshot2	2022-02-21 06:04:11.907780310 +0000
@@ -188,6 +188,7 @@
                 metadata: {
                     annotations: {
                         "prometheus.io.scrape": "true"
+                        "prometheus.io.port":   "7080"
                     }
                     labels: {
                         app:       "host"
@@ -327,6 +328,7 @@
                 metadata: {
                     annotations: {
                         "prometheus.io.scrape": "true"
+                        "prometheus.io.port":   "8080"
                     }
                     labels: {
                         app:       "valeter"

唯一の違いは、2行にアノテーションが追加され、一貫性が向上したことです。

trim操作を行い、設定全体での行数の削減を確認します:

:computer: terminal

cue trim ./frontend/... -s
find . | grep kube.cue | xargs wc -l | tail -1

さらに40行ほど削減されました。
これまでのような大幅な削減に慣れてしまったかもしれませんが、この時点では削除するものがほとんど残っていません:frontendファイルの一部では、残っている設定はわずか4行だけです。

:arrow_right: 結果を新しいベースラインとして保存する

:computer: terminal

cue eval -c ./... >snapshot2
cp snapshot2 snapshot

:arrow_right: kitchenディレクトリをテンプレート化する

このディレクトリでは、すべてのdeploymentが例外なく1つのコンテナとポート8080を持ち、すべて同じliveness probeを持ち、1行のprometheusアノテーションを持ち、ほとんどが類似したパターンで2つまたは3つのディスクを持っていることに気づきます。

ディスク以外のすべてを追加しましょう:

:computer: terminal

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

差分を取ると、1つのprometheusアノテーションがサービスに追加されたことがわかります。
これは偶然の脱落だと仮定し、差分を受け入れます。

ディスクは、テンプレートのspec部分とそれを使用するコンテナの両方で定義する必要があります。
これら2つの定義を一緒に保持したいと思います。
expiditer(2つのディスクを持つそのディレクトリの最初の設定)からvolumes定義を取り、一般化します:

:computer: terminal

cat <<EOF >> kitchen/kube.cue

deployment: [ID=_]: spec: template: spec: {
	_hasDisks: *true | bool

	// "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オブジェクトを整理したのと同様に、volumesのマップを定義し、そこからこれら2つのセクションを生成することです。
ただし、これにはある程度の設計が必要であり、"クイック&ダーティ"なチュートリアルには適していません。
このドキュメントの後半で、手動で最適化された設定を紹介します。

デフォルトで2つのディスクを追加し、オプトアウトするための_hasDisksオプションを定義します。
souschef設定は、ディスクを定義しない唯一のものです。

:arrow_right: トリムして差分を確認する

:computer: terminal

cue trim -s ./kitchen/...

# 差分を確認する
cue eval -c ./... >snapshot2
diff -wu snapshot snapshot2
cp snapshot2 snapshot
find . | grep kube.cue | xargs wc -l | tail -1

差分を見ると、_hasDisksオプションが追加されましたが、それ以外の違いは見られません。
また、再び大幅に設定が削減されました。

ただし、残りのファイルをよく見ると、一貫性のない命名の結果として、ディスク指定に多くのフィールドが残っていることがわかります。
このような方法で設定を削減すると、不整合が露呈します。
不整合は、特定の設定でオーバーライドを単に削除することで取り除くことができます。
そのままにしておくと、設定が一貫していないことを明確に示すシグナルになります。

クイック&ダーティなチュートリアル:結論

他のディレクトリでもまだ改善の余地があります。
1000行近く、つまり55%の削減を達成しましたが、残りは読者の演習として残しておきます。

CUEを使用してボイラープレートを削減し、一貫性を強制し、不整合を検出する方法を示しました。
一貫性と不整合の両方に対処できることは、制約ベースのモデルの結果であり、継承ベースの言語では実現が難しいものです。

間接的に、CUEが機械的な操作に適していることも示しました。
これは、構文と、その意味論から導かれる順序独立性の産物です。
trimコマンドは、この特性によって可能になる多くの自動化されたリファクタリングツールの一つです。
これも、継承ベースの設定言語では実現が難しいでしょう。

ワークフローコマンドの定義

cue exportコマンドを使用して、作成した設定をJSONに変換することができます。
この場合、マップされたKubernetesオブジェクトをリストに戻すために、トップレベルの「emit value」が必要です。
通常、この出力はkubectletcdctlなどのツールにパイプで渡されます。

実際には、これは同じコマンドを何度も繰り返し入力することを意味します。
次のステップは、しばしばラッパーツールを作成することです。
しかし、一般的に一つのサイズですべてに対応する解決策がないため、これは限定的に有用なツールの増殖につながります。
cueツールは、頻繁に使用されるコマンドをCUE自体で宣言できるようにすることで、代替手段を提供します。
利点:

  • CUEが改善された分析に使用できる追加のドメイン知識
  • 学ぶべき言語は1つだけ
  • ワークフローコマンドの簡単な発見
  • それ以上の設定は不要
  • ワークフローコマンド全体で統一されたCLI標準の強制
  • 組織全体で標準化されたワークフローコマンド

ワークフローコマンドは、ワークフローコマンドが操作すべき設定ファイルと同じパッケージにある_tool.cueで終わるファイルで定義されます。
設定のトップレベル値は、ツールファイルのトップレベルフィールドによって隠されない限り、ツールファイルから見えます。
ツールファイルのトップレベルフィールドは設定ファイルからは見えず、モデルの一部でもありません。

ツール定義には、追加の組み込みパッケージへのアクセスもあります。
CUE設定は完全に独立しており、外部からの影響を一切許可しません。
この特性により、trimコマンドのような自動化された分析と操作が可能になります。
しかし、ツール定義は、コマンドラインフラグや環境変数、乱数生成器、ファイルリストなどにアクセスできます。

この例では、以下のツールを定義します:

  • ls: 設定で定義されたKubernetesオブジェクトをリストする
  • dump: 選択されたすべてのオブジェクトをYAMLストリームとしてダンプする
  • create: 選択されたすべてのオブジェクトをkubectlに送信して作成する

準備

Kubernetesを扱うには、Kubernetesオブジェクトのマップを単純なリストに戻す必要があります。

:arrow_right: kube_tool.cueファイルを作成する

:floppy_disk: kube_tool.cue

package kube

objects: [ for v in objectSets for x in v {x}]

objectSets: [
	service,
	deployment,
	statefulSet,
	daemonSet,
	configMap,
]

オブジェクトのリスト

ワークフローコマンドは、ツールファイルのトップレベルのcommandセクションで定義されます。
cueワークフローコマンドは、コマンドラインフラグ、環境変数、および一連のタスクを定義します。
タスクの例としては、ファイルの読み込みや書き込み、コンソールへのダンプ、ウェブページのダウンロード、コマンドの実行などがあります。

:arrow_right: lsワークフローコマンドを定義する

lsワークフローコマンドは、すべてのオブジェクトをダンプします。これをls_tool.cueファイルに配置します:

:floppy_disk: ls_tool.cue

package kube

import (
	"text/tabwriter"
	"tool/cli"
	"tool/file"
)

command: ls: {
	task: print: cli.Print & {
		text: tabwriter.Write([
			for x in objects {
				"\(x.kind)  \t\(x.metadata.labels.component)  \t\(x.metadata.name)"
			},
		])
	}

	task: write: file.Create & {
		filename: "foo.txt"
		contents: task.print.text
	}
}

注意:タスク定義のAPIは変更される予定です。
必要に応じて、この形式のサポートを維持する可能性はあります。

:arrow_right: lsワークフローコマンドをテストする

lsワークフローコマンドがcueツールで使用可能になったことを確認します:

:computer: terminal

cue cmd ls ./frontend/maitred

これにより、以下の出力が表示されます:

Service      frontend   maitred
Deployment   frontend   maitred

複数のインスタンスが選択された場合、cueツールはそれらを1つずつ操作するか、マージすることができます。
デフォルトはマージです。
パッケージの異なるインスタンスは通常互換性がありません:
異なるサブディレクトリには異なる特殊化がある可能性があります。
マージは、各インスタンスのテンプレートを事前に展開し、その後でルート値をマージします。
結果には、トップレベルの#Componentフィールドのような競合が含まれる可能性がありますが、タイプごとのKubernetesオブジェクトのマップは競合がないはずです(競合がある場合、Kubernetesに問題があります)。
したがって、マージによってすべてのオブジェクトの統一されたビューが得られます。

:computer: terminal

cue cmd ls ./...

これにより、以下のような出力が表示されるはずです...

Service       frontend   bartender
Service       frontend   breaddispatcher
Service       frontend   host
Service       frontend   maitred
Service       frontend   valeter
Service       frontend   waiter
Service       frontend   waterdispatcher
Service       infra      download
Service       infra      etcd
Service       infra      events
[ ... 省略 ... ]

YAMLストリームのダンプ

:arrow_right: dumpワークフローコマンドを定義する

以下は、選択されたオブジェクトをYAMLストリームとしてダンプするワークフローコマンドを追加します。

:floppy_disk: dump_tool.cue

package kube

import (
	"encoding/yaml"
	"tool/cli"
)

command: dump: {
	task: print: cli.Print & {
		text: yaml.MarshalStream(objects)
	}
}

MarshalStream関数は、オブジェクトのリストを---で区切られたYAML値のストリームに変換します。

オブジェクトの作成

:arrow_right: createワークフローコマンドを定義する

createワークフローコマンドは、オブジェクトのリストをkubectl createに送信します。

:floppy_disk: create_tool.cue

package kube

import (
	"encoding/yaml"
	"tool/exec"
	"tool/cli"
)

command: create: {
	task: kube: exec.Run & {
		cmd:    "kubectl create --dry-run=client -f -"
		stdin:  yaml.MarshalStream(objects)
		stdout: string
	}

	task: display: cli.Print & {
		text: task.kube.stdout
	}
}

このワークフローコマンドには、kubedisplayという2つのタスクがあります。
displayタスクはkubeタスクの出力に依存します。
cueツールは依存関係の静的解析を行い、依存関係が満たされたすべてのタスクを並行して実行し、入力が欠けているタスクをブロックします。

:arrow_right: createワークフローコマンドをテストする

以下を実行します:

:computer: terminal

cue cmd create ./frontend/...

... これにより、以下のような出力が表示されます:

service/bartender created (dry run)
service/breaddispatcher created (dry run)
service/host created (dry run)
service/maitred created (dry run)
service/valeter created (dry run)
service/waiter created (dry run)
service/waterdispatcher created (dry run)
deployment.apps/bartender created (dry run)
deployment.apps/breaddispatcher created (dry run)
deployment.apps/host created (dry run)
[ ... 省略 ... ]

もちろん、実際の本番環境版では--dry-run=clientフラグを省略するべきです。

Kubernetes GoソースからCUEテンプレートを直接抽出する

:arrow_right: Kubernetes CUEスキーマを生成する

cue get goがGoソースからCUEテンプレートを生成するためには、事前にソースをローカルに用意する必要があります。

:computer: terminal

go get k8s.io/api/apps/v1@v0.23.4
cue get go k8s.io/api/apps/v1

:arrow_right: Kubernetes CUEスキーマを使用する

これでKubernetesの定義がモジュールに含まれたので、それらをインポートして使用できます。k8s_defs.cueファイルを作成します:

:floppy_disk: 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

最後に、CUEファイルをフォーマットします:

:computer: terminal

cue fmt

手動で調整された設定

「クイック&ダーティ」セクションでは、CUEをすぐに使い始める方法を示しました。
もう少し慎重に検討することで、設定をさらに削減することができます。
また、より汎用的で、Kubernetesに特化しない設定を定義したいと思います。

CUEの順序独立性を大いに活用します。これにより、同じオブジェクトの2つの設定を明確に定義された方法で簡単に組み合わせることができます。
例えば、頻繁に使用されるフィールドを1つのファイルに、より特殊なフィールドを別のファイルに配置し、一方が他方を上書きする心配なくそれらを組み合わせることが容易になります。
このセクションではこのアプローチを採用します。

このチュートリアルの最終結果は、トップレベルのmanualディレクトリにあります。
次のセクションでは、そこに至る方法を示します。

概要

設定の基本的な前提は、シンプルで抽象的な設定と、Kubernetesと互換性のある設定の2つを維持することです。
Kubernetes版は、シンプルな設定から自動的に生成されます。
各簡略化されたオブジェクトには、変換時にKubernetesオブジェクトにマージされるkubernetesセクションがあります。

汎用的な定義を含む1つのトップレベルファイルを定義します。

package cloud

service: [Name=_]: {
    name: *Name | string // サービスの名前

    ...

    // 変換時にKubernetesにマージされるKubernetes固有のオプション
    kubernetes: {
    }
}

deployment: [Name=_]: {
    name: *Name | string
   ...
}

次に、Kubernetes固有のファイルには、汎用オブジェクトをKubernetesに変換するための定義が含まれます。

全体として、サービスをモデル化するコードとKubernetesコードを生成するコードは分離されていますが、一般的なモデルにKubernetes固有のデータを注入することも可能です。
同時に、Kubernetesを壊すことなく、モデルに追加情報を加えることができます。

抽象的な設定と具体的な設定を分離することで、同じ設定に対して異なるアダプターを作成することもできます。

Deployment定義

設計上、すべてのKubernetes Podの派生物が1つのコンテナのみを定義すると仮定します。
これは一般的には明らかに当てはまりませんが、多くの場合そうであり、良い実践です。
都合よく、これによって我々のモデルも簡略化されます。

モデルは、「クイック&ダーティ」セクションで導出したマスターテンプレートを大まかに基にしています。
最初のステップは、statefulSetdaemonSetを排除し、代わりに異なる種類を許可するdeploymentだけを持つことです。

deployment: [Name=_]: _base & {
    name:     *Name | string
    ...

kindは、deploymentがstateful setまたはdaemonsetである場合にのみ指定する必要があります。
これにより、_specの必要性もなくなります。

次のステップは、imageなどの共通フィールドをトップレベルに引き上げることです。

引数はマップとして指定できます。

    arg: [string]: string
    args: [ for k, v in arg { "-\(k)=\(v)" } ] | [...string]

順序が重要な場合、ユーザーは明示的にリストを指定することもできます。

ポートについては、名前からポート番号へのシンプルなマップを2つ定義します:

    // expose portは、サービスで公開される名前付きポートを定義します
    expose: port: [string]: int

    // portは、サービスで公開されない名前付きポートを定義します
    port: [string]: int

両方のマップがコンテナ定義で定義されますが、portのみがサービス定義に含まれます。
これが最良のモデルではなく、すべての機能をサポートしているわけではありませんが、異なる表現を選択する方法を示しています。

環境変数についても同様です。
ほとんどの場合、文字列から文字列へのマッピングで十分です。
ただし、テストデータは他のオプションも使用しています。
シンプルなenvマップと、より詳細なケース用のenvSpecを定義します:

    env: [string]: string

    envSpec: [string]: {}
    envSpec: {
        for k, v in env {
            "\(k)" value: v
        }
    }

シンプルなマップは自動的により詳細なマップにマッピングされ、全体像を示します。

最後に、deploymentごとに1つのコンテナがあるという仮定により、volume specとvolume mountの情報を組み合わせた単一のボリューム定義を作成できます。

    volume: [Name=_]: {
        name:       *Name | string
        mountPath:  string
        subPath:    null | string
        readOnly:   bool
        kubernetes: {}
    }

定義したい他のすべてのフィールドは、生成された他のすべてのKubernetesデータとマージされる汎用的なkubernetes構造体に入れることができます。
これにより、コンテナに追加のフィールドを追加するなど、生成されたデータを拡張することさえ可能です。

Service定義

サービス定義は直接的です。
statefulとdaemon setを排除したので、サービスを自動的に導出するためのフィールド包括表現は少しシンプルになりました:

// deploymentによって暗示されるサービスを定義する
service: {
    for k, spec in deployment {
        "\(k)": {
            // コンテナから公開されたすべてのポートをコピーする
            for Name, Port in spec.expose.port {
                port: "\(Name)": {
                    port:       *Port | int
                    targetPort: *Port | int
                }
            }

            // ラベルをコピーする
            label: spec.label
        }
    }
}

完全なトップレベルのモデル定義は、
doc/tutorial/kubernetes/manual/services/cloud.cueにあります。

このプロジェクト固有の調整(ラベル)は
ここで定義されています。

Kubernetesへの変換

サービスの変換は比較的直接的です。

kubernetes: services: {
    for k, x in service {
        "\(k)": x.kubernetes & {
            apiVersion: "v1"
            kind:       "Service"

            metadata: name:   x.name
            metadata: labels: x.label
            spec: selector:   x.label

            spec: ports: [ for p in x.port { p } ]
        }
    }
}

Kubernetesのボイラープレートを追加し、トップレベルのフィールドをマッピングし、各サービスの生のkubernetesフィールドをマージします。

deploymentのマッピングはもう少し複雑ですが、類似しています。
Kubernetes変換の完全な定義は
doc/tutorial/kubernetes/manual/services/k8s.cueにあります。

トップレベルの定義を具体的なKubernetesコードに変換することは、この演習の中で最も難しい部分です。
とはいえ、ほとんどのCUEユーザーは設定を書くためにこのレベルのCUEを使用する必要はありません。
例えば、サブディレクトリ内のファイルには包括表現が含まれていません。これらのディレクトリ内のテンプレートファイル(kitchen/kube.cueなど)でさえそうです。
さらに、リーフディレクトリの設定ファイルには文字列補間が含まれていません。

メトリクス

完全に記述された手動設定はmanualディレクトリにあります。

manualディレクトリの総行数を確認します:

:computer: terminal

find manual | grep kube.cue | xargs wc -l | tail -1

報告される542行には変換テンプレートは含まれていません。
トップレベルのテンプレートが再利用可能であると仮定し、両方のアプローチでそれらをカウントしない場合、手動アプローチはさらに約150行を削減します。
テンプレートもカウントすると、2つのアプローチはほぼ同等です。

手動設定:結論

テンプレートファイルを手動で最適化することで、設定をさらにコンパクトにできることを示しました。
しかし、手動最適化は、クイック&ダーティな半自動削減と比較して、わずかな利点しかないことも示しました。
手動定義の利点は主に、得られる組織的な柔軟性にあります。

設定を手動で調整することで、論理的な定義とKubernetes固有の定義の間に抽象化レイヤーを作成できます。
同時に、CUEの順序独立性により、必要で適切な場所に低レベルのKubernetes設定を簡単に混ぜ込むことができます。

手動調整により、Kubernetesを壊すことなく独自の定義を追加することもできます。
これは、定義に関連するが、Kubernetesとは無関係な情報を、適切な場所に定義する上で重要です。

抽象的な設定と具体的な設定を分離することで、同じ設定に対して異なるアダプターを作成することもできます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0