tech.guitarrapc.cóm

Technical updates

RegoとConftest ことはじめ

Kubernetes の面倒なことといえばYAML、というのはよく聞くし私もそう思います。 YAMLが面倒なことはいくつもありますが、その1つに「YAMLの定義がポリシーとして正しいかの検証」があります。

コードを書いているときのように、YAML もポリシーにあっているのかユニットテストができれば、いざデプロイするときまでわからないという事態は避けられそうですね?

ということで、今回はKubernetes でポリシーチェックをする方法として良くあげられる Open Policy Agent のRego言語とContest を使っていくメモです。

なお、ポリシーチェックというとConftest や Gatekeeper がありますが、個人的にはローカルで気軽に始められるConftestから慣れていくのがいいと思います。試行錯誤しやすいところから入るのが大事。

tl;dr;

  • Rego のポリシーは、それぞれに名前つけたほうがテスト書いていて指定したポリシーと明示できるのがいい。デバッグのためにも判別できる工夫がかかせない
  • Rego のポリシー自体が正しいか試行錯誤することになるので、先にテスト書いたほうがいい。テストを書いてから、実際のYAMLに流すの流れが効率的
  • Rego のテストで引数使うの避けている。テストが通らなくなってしんどい。ダメな例: test_foo[msg] { }。いい例: test_foo { }
  • Rego のポリシーと結果をサクッと試すにはPlayground が便利
  • 公式サイトが充実しており、何度も公式サイトをリファレンスとして使うことになるので積極的に使っていきましょう

www.openpolicyagent.org

Rego基本情報

基本的なコンセプト、構文、関数はここを参照

Open Policy Agent | Policy Language

拡張子は .rego。 DSLでの記述と宣言的な実行に割り切っている。

全般的に package mainsprintf("%s", string) を含めて Golang っぽさがそこかしこにある。

2つ特徴がある。

  • 代入を除くと、式は同じポリシー内でも順不同に配置しても問題なく評価される
  • itelerator を使うため foo = input.keys[_] のように書くと、foo にはarray/set/mapの要素1が入ってくる

エディタ環境

VS Code + Remote Container で書くのがおすすめ。

VS Code に Open Policy Agent 拡張を入れると、フォーマッタが効くようになり、構文解析まで行ってくれる。おまけで、コマンドパレットで validate とかも出て VSCodeでプレイグラウンド的に使えるがそこまではいらない。

ただし、Open Policy Agent 拡張を動かすには、Open Policy Agent実行ファイル opa をインストールする必要があるので、Remote Container環境と相性がいい。

インテリセンスもないなど、IDEとして機能十分で使いやすいかというと微妙だが、正しい構文とフォーマットが維持できるのでないより圧倒的にいい。

実行方法

conftest + Open Policy Agent で実行するのが隙がない。

  • CI やローカルで実行するときは、conftest を用いることでサクッと実行できる、便利、よい
  • Kubernetes Cluster 内部でデプロイされる前にチェックする(GitOpsは典型) 場合は、Open Policy Agent | Kubernetes Admission Control を用いる

まずは conftest で実行できるようにすると、動作が把握できてカジュアルに試せるのでいい。 もっとカジュアルに試すなら Playground を活用するといい。

https://play.openpolicyagent.org/

ポリシーの基本形式

ポリシーの基本は次の通り。

  • ポリシーは複数のルール(=式) を持つ
  • ルールは bool を返し、AND評価(すべてのルールが true になるとポリシーに該当しているとみなされる)を行う
  • ポリシーの必須要素は、package宣言、violation などのポリシーの評価方法、msg
  • pacakge はmain以外は実行時に評価されない。このため、別名フォルダにおいて隔離しておいてimportで取り込むことで、 data.パッケージ名 として必要な関数を呼び出すことができる
package main

# 評価結果は、violation / deny / warn / allow で選べる。名前を付ける場合、先頭の文字がいずれかに該当していれば ok
# violation と deny は ポリシーを満たすとダメ扱い。warn は警告扱い。BlackListでのポリシー評価が楽なので、allow は使わない
violation[msg] {
    # boolを返すルールを複数記述できる。入力は、外部ライブラリを使わない限り input に入ってくる。
    msg := "ポリシー該当時のメッセージを記述する"
}

violation_何か名前をつけたり[msg] {}
violation_引数をマップにしたり[{"msg": msg}] {

式や組み込み関数の基本的な記述は公式をみるといい。

Open Policy Agent | Policy Language Open Policy Agent | Policy Reference | Built-in Functions

比較

比較だけややこしいので特記しておく。

同値の評価に :==== 演算子があるが、基本的に代入に :=、値比較に == を使うように公式にも記述があるので従っておくのがいい。

Equality  Applicable    Compiler Errors            Use Case
--------  -----------   -------------------------  ----------------------
:=        Inside rule   Var already assigned       Assign local variable
==        Inside rule   Var not assigned           Compare values
=         Everywhere    Values cannot be computed  Express query

例外的に、式の結果を受けるときに = を使って評価と代入を同時に行うケースがある。

[image_name, "latest"] = split_image(container.image)

split_image(image) = [image, "latest"] {
    not contains(image, ":")
}

split_image(image) = [image_name, tag] {
    [image_name, tag] = split(image, ":")
}

なお、数値の比較演算子には次のものがある。そろってるので困ったことはない。

a  ==  b  #  `a` is equal to `b`.
a  !=  b  #  `a` is not equal to `b`.
a  <   b  #  `a` is less than `b`.
a  <=  b  #  `a` is less than or equal to `b`.
a  >   b  #  `a` is greater than `b`.
a  >=  b  #  `a` is greater than or equal to `b`.

関数

関数は、戻り値を指定しない限りは bool が返る前提になっている。

is_foo {
    input.name == "foo"
}

パラメーターを与える場合は、 関数名(引数名){} と書く。

is_foo(value) {
    value == "foo"
}

bool ではなく、関数から特定の返り値をも耐える場合は 関数名() = 返り値{} と書く。複数の返り値があるなら、関数名() = [返り値1, 返り値2]{} となる。

get_name_age() = [name, age] {
    name := input.name
    age := input.age
}

例えば次のような input とポリシーが書ける。PlayGround で試しておみるといい。

Input

{
    "name": "John Doe",
    "age": 100
}

ポリシー

package play

deny[msg] {
    [name, age] = get_name_age
    msg := sprintf("name: %s, age: %v", [name, age])
}
get_name_age() = [name, age] {
    name := input.name
    age := input.age
}

Output

{
    "deny": [
        "name: John Doe, age: 100"
    ],
    "get_name_age": [
        "John Doe",
        100
    ]
}

OR評価

ポリシーは基本的に AND評価。 OR評価を行いたい場合は2つ方法がある。

  1. 同名の関数を用意する
  2. 配列に対して評価する

関数の場合、同名の関数を用意するとそれぞれの関数が個別に評価され、true が返った関数が利用される。

例えば、次のような is_byte_format 関数を用意すると、Gi でも Mi でもフォーマットを評価できる。

is_byte_format(size) {
    endswith(size, "Gi")
}

is_byte_format(size) {
    endswith(size, "Mi")
}

配列を使う場合、配列に許可する要素を記述しておいて、itelator を使って順次結果が評価したい値と一致するかを判定する。 イテレーター自体は普通なのだが、式を見ても直感的じゃないのであまり好きじゃない。

workload_resources := ["Deployment", "StatefulSet"]
input.kind == workload_resources[_] # kind が Deployment / StatefulSet の時だけ true になる。

いずれの方法でも、デバッグログが汚くなるので利用したらデバッグログは読みにくくなることを覚悟するしかない。

Kubernetes での利用

Kubernetes で使うにあたってポイント。

評価対象リソースの限定

Kubernetes で利用する場合、様々なリソースYAML に同じルールが適用されることを前提に書くことになる。 例えば、pod や container に関する記述は、 Pod / Deployment / StatefulSet / DaemonSet / Job / Cronjob に適用してほしいだろうが、Service や Ingress には適用してほしくない。

そのため、ポリシーの AND評価を利用して先に kindapiVersion で絞りこむことになる。

violate[msg] {
    input.kind == "Deployment"
}

だがこのような記述にすると、StatefulSet と Deployment にというのが書きにくい。そのため、先に配列で許可リストを用意するといい。

workload_resources := ["Deployment", "StatefulSet"]
is_deployment_or_statefulset {
    input.kind == workload_resources[_]
}

ポリシーでis_deployment_or_statefulset関数を呼び出すことで、そのポリシーは DeploymentかStatefulSetでのみ評価されることが保障できる。

violation[msg] {
    kubernetes.is_deployment_or_statefulset
    # 何かルール

    msg = "ポリシー違反です"
}

キーがないことの評価

「特定のラベルがないときにエラーにする」といったキーがないことを評価するときは not を使う。

violation[msg] {
    kubernetes.is_deployment_or_statefulset
    not input.metadata.labels["not-found"] # ココ
    msg = "ポリシー違反です"
}

複数のラベルに対して、どれか1つでもない場合にエラーにすることを評価するときは、配列 + not を使う。これで、どれか一つでもラベルにないとポリシーがひっかかる。

recommended_labels {
    input.metadata.labels["app.kubernetes.io/name"]
    input.metadata.labels["app.kubernetes.io/instance"]
    input.metadata.labels["app.kubernetes.io/version"]
    input.metadata.labels["app.kubernetes.io/component"]
    input.metadata.labels["app.kubernetes.io/part-of"]
    input.metadata.labels["app.kubernetes.io/managed-by"]
}
violation[msg] {
    kubernetes.is_deployment_or_statefulset
    not recommended_labels # ここ
    msg = "ポリシー違反です"
}

よく使う一式

これを kubernetes.rego として保存しておいて、使いたいポリシーで、import data.kubernetes してから kubernetes.is_service などのようにして使っている。必要に応じてヘルパーが増えていったりする。

package kubernetes

# properties
name := input.metadata.name

kind := input.kind

is_service {
    kind == "Service"
}

workload_resources := ["Deployment", "StatefulSet"]

environment_labels := ["development", "staging", "production"]

is_deployment_or_statefulset {
    input.kind == workload_resources[_]
}

is_pod {
    kind == "Pod"
}

is_service {
    kind == "Service"
}

is_not_local {
    input.metadata.labels.environment == environment_labels[_]
}

is_ingress {
    kind == "Ingress"
}

pod_containers(pod) = all_containers {
    keys := {"containers", "initContainers"}
    all_containers = [c | keys[k]; c = pod.spec[k][_]]
}

containers[container] {
    pods[pod]
    all_containers := pod_containers(pod)
    container := all_containers[_]
}

containers[container] {
    all_containers := pod_containers(input)
    container := all_containers[_]
}

pods[pod] {
    is_deployment_or_statefulset
    pod := input.spec.template
}

pods[pod] {
    is_pod
    pod := input
}

volumes[volume] {
    pods[pod]
    volume := pod.spec.volumes[_]
}

# image functions
split_image(image) = [image, "latest"] {
    not contains(image, ":")
}

split_image(image) = [image_name, tag] {
    [image_name, tag] = split(image, ":")
}

# security functions
dropped_capability(container, cap) {
    container.securityContext.capabilities.drop[_] == cap
}

added_capability(container, cap) {
    container.securityContext.capabilities.add[_] == cap
}

テスト

ポリシーは、それ正常に評価されているのか、使ってはだめなINPUT例と使っていいINPUT例 で繰り返し試しながら書くことになる。 実際の YAML に対して変更かけつつ試すと、普段のYAMLに対してどう追加すればいいのかを試す / ポリシー評価が意図通りか試す、試したものを戻す、といった余計は作業が多く発生する。

このため、ポリシーは「想定するINPUTを用意」して、「INPUTに対してポリシーを記述」して、「ポリシーをテストで通るか確認」して、「実際のYAMLに対して実行」するという流れがうまく当てはまる。

テストでは、次の3つを行う。

  • 想定するINPUTを用意
  • INPUTに対してポリシーを記述
  • ポリシーをテストで通るか確認

conftest を用いる場合、テストは次の形式で実行できる。

# テスト結果概要だけ表示
$ conftest verify --policy ./path/to/policy

# テスト一つずつの評価経過をtrace表示
$ conftest verify --policy ./path/to/policy --trace

# テスト結果概要 + 失敗したテストのみ評価経過を表示
$ conftest verify --policy ./path/to/policy --report failed

# テスト結果を一つずつPASS/FAILED表示 + 失敗したテストは評価経過を表示
$ conftest verify --policy ./path/to/policy --report full

テストのテンプレ

テストは、「ポリシーファイル_test.rego」が定番なので従っておく。(_test は必須じゃないが、ポリシーと同階層におくので自然とそうなる) テストには注意が3つある。

  • テストは test_ でポリシー命名が始まる
  • テストは、ポリシーに引数がない
  • テストの msg は、評価するポリシーと同じ出力になるようにしないといけない

引数がないのは忘れがち。万が一引数があるとポリシーが正常に評価されてもテストは通らない。

# ダメな例
test_violation_labels_recommended_missing[msg] {}

# いい例
test_violation_labels_recommended_missing {}

テストの msg はポリシーで固定文字列なら同じものを入れればいい。 だが、どのリソースでポリシーが違反したか判別のために input.kindinput.metadata.name を使っている場合は、テストでも同じ結果になるように注意が必要だ。

# 固定のメッセージならポリシーとテストで同じものを指定でok
msg = "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels)"
# 動的なmsgなポリシー
violation_some_policy[msg] {
    input.kind == "Deployment"
    input.metadata.name == "test-data"
    msg = sprintf("推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=%s,Name=%s]", [input.kind, input.metadata.name])
}

# ポリシーに該当するテスト
test_violation_some_policy {
    # テストのmsgはinput に合わせる必要がある。
    msg = "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=Deployment,Name=test-data]"
    violation_some_policy[msg] with input as {
        "kind": "Deployment",
        "metadata": {
            "name": "test-data",
        },
    }
}

テストのサンプル

例えばKubernetes推奨ラベルがあることを保証するポリシーがあるとしよう。

package main

recommended_labels {
    input.metadata.labels["app.kubernetes.io/name"]
    input.metadata.labels["app.kubernetes.io/instance"]
    input.metadata.labels["app.kubernetes.io/version"]
    input.metadata.labels["app.kubernetes.io/component"]
    input.metadata.labels["app.kubernetes.io/part-of"]
    input.metadata.labels["app.kubernetes.io/managed-by"]
}
workload_resources := ["Deployment", "StatefulSet"]

is_deployment_or_statefulset {
    input.kind == workload_resources[_]
}

# recommented labels must exists
violation_labels_recommended_exists[{"msg": msg}] {
    is_deployment_or_statefulset
    not recommended_labels

    msg = sprintf("推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=%s,Name=%s]", [input.kind, input.metadata.name])
}

これに対して、labelsがないテストとあるテストを用意すれば、実際のYAMLで確認することなくポリシーが妥当かユニットテストできる。 ポリシー違反しない場合は、not を付けることで正常にとおったことをテストできる。

test_violation_labels_recommended_missing {
    msg := "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=Deployment,Name=test-data]"
    input := {
        "kind": "Deployment",
        "metadata": {"name": "test-data"},
    }

    violation_labels_recommended_exists[{"msg": msg}] with input as input
}

test_violation_labels_recommended_exists {
    msg := "推奨ラベルを付与してください。(https://kubernetes.io/docs/concepts/overview/working-with-objects/common-labels/#labels): [Kind=Deployment,Name=test-data]"
    input := {
        "kind": "Deployment",
        "metadata": {
            "name": "test-data",
            "labels": {
                "app.kubernetes.io/name": "",
                "app.kubernetes.io/instance": "",
                "app.kubernetes.io/version": "",
                "app.kubernetes.io/component": "",
                "app.kubernetes.io/part-of": "",
                "app.kubernetes.io/managed-by": "",
            },
        },
    }

    not violation_labels_recommended_exists[{"msg": msg}] with input as input # 正常評価なら not を付ける
}

テストが通らないときのデバッグ

デバッガで止まるわけじゃないので、2つ使って評価を試す。

  1. Playground でポリシーとinput を用意して結果を見る
  2. conftest verify --report failedconftest verify --trace で評価経過を追いかける

ポリシーがそもそもおかしいかもしれない場合は、Playground で仮Input に対してポリシーがちゃんとかかっているか試行錯誤するのが手っ取り早い。なので、ポリシー取り合えず書いてみて、うまく当たるか見てみたい、という場合は Playground でサクッとやってみるのは結構オススメ。

プレイグラウンドでポリシーは当たったけど、どう評価されているか確認しながらやりたいだろう。そういうときは --trace したり、--report で見る。--trace は、全部のテストの trace が出るので注意。(最後のテストになるようにするといい) trace は正直読みにくいが、一行一行追っていけば、なるほど確かにという感じで評価されているのがわかるので、困ったら読む価値は十分ある。

テストの trace を読みやすくする

traceを使う注意点が2つある。

  • OR評価をすると trace ログが入れ子になってハイパー読みにくくなる
  • data に関数を逃がすとそれだけで入れ子になって読みにくい

このため、まず試す、というときは決め打ちでOR評価せず、インラインでポリシーに直接パスを指定して書くほうが trace は圧倒的に追いやすい。

「なんでテストが通らないかわからないけど、traceが読みにくくて追いきれない」そんなときは、dataもOR評価もせず、決め打ちのinputに対するミニマムポリシーを用意してルールを直書きしてみるといい。

traceを見てもポリシーはあってるがテストが通らない

テストに引数がついていないだろうか。 私はこれで数時間溶かした。

# ダメな例
test_violation_labels_recommended_missing[msg] {}

# いい例
test_violation_labels_recommended_missing {}

Reference

考え方

Conftest で CI 時に Rego で記述したテストを行う - @amsy810's Blog

ポリシーサンプル

conftest/examples/kustomize/policy at master · open-policy-agent/conftest

GitHub - redhat-cop/rego-policies: Rego policies collection

GitHub - swade1987/deprek8ion: Rego policies to monitor Kubernetes APIs deprecations.

Collecting together Kubernetes rego examples, including porting the https://kubesec.io rules to rego


  1. 正直気持ちが悪いしデバッグがしにくい原因の1つ。

Infrastructure as Code に最低限求めること

Infrastructure as Code (以降 IaC) で組むときに IaC ツールを選ぶ基準はいろいろあります。 IaCの言語がチームとって扱いやすいか、クラウドやサービスへのIaCの対応状況は早い/十分か、コミュニティの大きさは十分でググれば情報を入手できるか、実装がオープンソースで公開されているか、利用事例が欲しいか、など様々な理由で選ぶことでしょう。

しかし、こういった基準よりまず 「IaC というからには最低限出来ていないと困ること」というのが個人的にあります。

最近、Bicep で組んでいた Azure の環境を Pulumi に移行したのですが、その理由は Bicep では IaC が維持できないと判断したためでした。 IaC ツールを使っていて IaC が維持できないと判断したのは初めてだったので、いい機会ですし IaC に最低限求めることを言語化しようと思います。

tl;dr;

私はIaC に、コードとリソース1 状態の一致保障、実行プランが出力できること、ステート2 を持てることを求めます。 これらが不完全なIaCでは、コードでのリソース管理は不完全なものになり、今回のように維持できない原因になるため避けます。

  • コードとリソース状態の一致を保障できることを求めます。一致が保障できることで、コードからリソースの状態を完全に制御できます。(コードファースト)

コードとリソースの状態が一致

  • 実行前に実行プランを出力できることを求めます。実行プランによりコードの変更適用前にリソースへの影響がわかるため、PRベースでレビューをうけつつ開発ができます。また、正確な実行プランには、ステートかそれに準じたものと継続した開発体制が必要になるでしょう

実行プランによるコード変更によるリソース差分の確認

  • ステートをIaCサービスやオブジェクトストレージに配置し、ステート実行時にロックできることを求めます。ステートをローカル以外に持ち、実行ごとにロックがかかることで、チーム開発でブランチを同時に複数稼働させることができます

ステートファイルは実行ごとにロック

IaC でやりたいこと

IaC でやりたいことは「コードでクラウドリソースを管理できる」 これだけです。しかし、コードでクラウドリソースを管理できるとはどういうことでしょうか?

私は、コードで定義を書いたらリソースが追加されてほしいし、コードの定義を変更したらリソースが変更されてほしいし、コードの定義を消したらリソースが削除されてほしいです。 コードの定義 = リソースの状態と一致するとコードでクラウドリソースが管理でき、クラウドコンソールで操作する機会を大きく減らすことができます。3

コードでリソースを管理するためにはチームでスムーズに開発を行いたいです。 チーム各自でブランチを切って並行してコードを修正、修正ごとに実行プランで差分を確認し、レビューを受け、問題ないならマージしたいでしょう。 ステートをリモートに置いたり、実行ごとにロックされるとチーム開発で扱いやすくなります。

レビューの漏れに対応するためには、コードで誤って消されてもリソースの削除をブロックする機能も必要になるでしょう。 コードをリソースに適用した後に、クラウド側でリソースに変更がかかったりすることがあるのを考慮すると、リソースの変更差分を無視する機能も必要になるでしょう。

IaC に最低限求めること

IaC でやりたいことから、IaC に最低限求めることがわかります。

  • コード = リソース状態になることを期待します
    • コードを追加したときに、リソースでも追加されることを期待します
    • コードを変更したときに、リソースでも変更されることを期待します
    • コードを削除したときに、リソースは削除されることを期待します
    • コードで誤って削除がかかる変更を記述したときに、リソースの削除をブロックする記述ができることを期待します。(protection)
    • コードの適用時に、リソースで起こった変更を無視したりコードの変更を無視する記述ができることを期待します。(ignore)
  • コードの適用前に、実行プランを表示して実リソースとの差分を正確に示せることを期待します
    • 実行プランは、「現在のリソース状態とコードの差分」を示せることを期待します。(ステートとの差分では実リソースとずれることが多くある)
  • ステートは、オブジェクトストレージやIaCのサービスで保持できることを期待します
    • 実行ごとに透過的にロックがかかるとよいでしょう

IaC でコードとリソースの状態一致が保証できないとどうなるのか

やりたいことや求めることは分かりましたが、それは本当に必要なのでしょうか? IaC でコードとリソースの一致を保証できないと何が起こるのかを考えてみましょう。

題材は、冒頭にあげたAzure の Bicep です。

ARM Template と Bicep について軽く説明しましょう。ARM TemplateBicep は、Azureにおいて IaC の位置づけにあります。Bicep は ARM Template のための DSL という位置づけでビルドすると ARM Template (JSON定義) を出力します。

Bicep でコードを記述し、Azure にデプロイしたときに次の挙動をします。

  1. Bicepコードをビルドして ARM Template を出力
  2. Azure に ARM Template をデプロイ
  3. Azure は ARM Template に基づいてリソースの作成、既存のリソースがある場合は変更を実施
  4. Azure へデプロイを Complete モードで実施した場合は、Azure のリソースが削除対象か見て削除対象ならリソースを削除、削除対象じゃない場合は ARM Template からは消すがリソースは維持

一連の流れの中で特徴的なのが、Step3 と Step4 です。

ステートを持たない

Step3 は、Bicep/ARM Template がステートを持たない IaC であり、展開されたARM Template に基づいてリソースを更新することを示します。 このためか、Bicep でコードとリソースの実行プランには6つのステータスがあります。4 ステータスで注目すべきは デプロイ という適用まで挙動が不定なケースが含まれていることです。

  • デプロイ: リソースは存在し、Bicep ファイル内で定義されています。 リソースは再デプロイされます。 リソースのプロパティは、変更される場合と変更されない場合があります。 いずれかのプロパティが変更されるかどうか判断するのに十分な情報がない場合、操作ではこの変更の種類が返されます。 この状況は、ResultFormat が ResourceIdOnly に設定されている場合にのみ表示されます

ARM Template は、ステートではなく変更する予定の定義になります。定義と実リソースでも差分が取得できるか不明というのは、実行プランが信用できないということであり残念な体験に思います。

コードとリソースは一致が保証されていない

Step4 は、Bicep/ARM Template は、その定義を消してもリソースが削除される保証はないことを示します。 これは 削除のステータス説明にも明記されています。

  • 削除: この変更の種類は、JSON テンプレートのデプロイに完全モードを使用する場合にのみ適用されます。 リソースは存在しますが、Bicep ファイル内では定義されていません。 完全モードでは、リソースが削除されます。 この変更の種類には、完全モードの削除をサポートしているリソースのみが含まれます

BicepのデプロイにはIncrement と Complete の2モードがあります。 Increment は増分デプロイで、Bicep コードから定義を消してもリソースは残ります。 Complete は 完全デプロイで、ARM Template とリソースの同期のため変更や削除をします。コードとリソースの一致を考えると Complete モード一択ですが、コードを削除してもリソースが消えるかはリソース次第です。

Bicep でIaC を行おうとしてもコードとリソースの一致保障がなく、コードとリソースの乖離が進みます。 コードを見てもリソースの状態は保証できないため、今の状態、適用した後の状態を正確に把握するには Azure ポータルやCLIで状態を取得するしかありません。IaC とは?

残念ながら、Bicep や ARM Template は IaC に最低限求めることができず、継続的に IaC をすることはできないと判断しPulumi に移行したのでした。 Pulumi に移行したのは、Azure のネイティブ実装がありAzure の新規API公開から最速1日で対応するなどの高速な対応と Terraform に比べた時のAzure API の網羅性からです。

まとめ

リソースを作る、変更する、削除するをすべてコードで行うには、TerraformPulumiCDK などを採用します。いずれも、現在のリソースの状態とコードの差分を常に管理でき、正確な実行プランを持ち、ステートを安全に維持できます。

リソースを作ることに注力し、変更や削除はコンソールでいいという場合、ARM TemplateBicep が候補に入ります。しかし、作りっぱなしでメンテナンスされなくなるコードは、限定的なシーンでしか活用できない上に負債返却の機会を失うため、採用には慎重です。(再現環境を作ったりするのにはいいんですけどね!)

IaC を選ぶ前に、それが自分の期待する IaC かは考えたいと思う日々でした。

おまけ

書こう書こうと思って、ずっと書いていなかったので、IaC を使うにあたっての注意をいくつかおまけにどうぞ。

IaC やクラウドによる挙動の違い

IaC実行時の挙動は、IaCの実装やクラウド側のAPIにより異なります。同じIaC でもクラウドによって挙動が変わることがあるのは、マルチクラウドをしたことがある人なら経験があるのではないでしょうか。

  • リソースの設定がコード上でリソースのインラインで設定するのではなく別リソース定義を作る場合、コード削除時のリソース状態は IaC によって変わります。コード未設定時の値戻らず、設定されたときの状態が残るケースが散見されます。この場合、コードとリソースが不一致になるため使いにくいと感じるでしょう
  • 連続でIaC を実行すると、前回の実行が残っていて後から実行してね、と言われれる IaC とクラウドの組み合わせがあったりします

IaC とコンソールやCLIの違い

クラウドコンソールや CLI でリソースを作ると、クラウド側で設定していないことまで設定してくれます。

一方で、IaC はコードで設定したことしか設定しない (デフォルト値はある) ということ多いです。

これにより、IaC で作ったときはコンソールやCLI より手間がかかるという面もありますが、コードとリソースの状態が一致する要素になっていると感じます。

IaC 実行はローカルかリモートか

IaC を実行したときに、ローカル実行されるか、IaCのサービスでリモート実行されるかは、 IaC や連携方法によって変わります。 例えば Terraform は Terraform Cloud を使うとリモート実行することもできますし、Terraform Cloud には ステートだけ保持してローカル実行をすることもできます。


  1. リソース = クラウド環境における設定対象とします。
  2. ステート = 前回IaCを適用した状態とします。
  3. クラウドによる面があるのは同意するところです。AWS や GCP の場合ほぼコンソール操作がほぼなくなりますが、Azure では何かとポータルを使う機会があります。
  4. 6つもある理由は不明ですが、ステートがないことに由来すると考えています。多くの IaC のステータスは 4つ (create、update、delete、ignore) なのを考えると多いですね。

静音マウスと M650 SIGNATUREワイヤレスマウス

MxAnywhere3は素敵なマウスですが、最近出たM650 SIGNATUREマウス (以降m650) もいい静音マウスです。

2年前に静音マウスを試したときは「クリック感が鈍く、静音という割に音がする」と思ってやめていましたが、m650はMxAnywhere3に比べて違和感がすくないという風の噂を聞いて試してみました。

購入してから3か月使ってみて、いい感じだと思ったので記事に起こしておきます。

tl;dr;

左右のクリックは音が全くしないので静音最高です。が、ミドルクリックとサイドボタンは静かながら音はします。

いくつか使うにあたって気を付けたいと思ったことがあります。

  • MxAnywhere3を使っていたならm650はサイズMにしましょう。Lは大きすぎてつらい
  • 電池が入っている分、前が軽く中~後に重心があるので、軽く浮かした時のバランスは微妙です。(前だけ上がる感じ)
  • ラバーグリップはMxAnywhereよりも硬質でざらざらします
  • UnifyingではなくLogi Boltなので、接続の仕方が微妙に違います。時にUSB切り替えしている場合、PCごとにペアリングが必要です。(Unifyingのようなレシーバーとのペアリングではない)

この静かさになれたら、MxAnywhere3には戻れなくなりました。 そして、静音Realforce R3は圧倒的にマウスに比べてうるさいので、もっと静音してほしい。

tech.guitarrapc.com

MxAnywhere3 に残された課題

MxAnywhere3を買ってからずっと使っています。

tech.guitarrapc.com

個人的には今までで最高のマウスですが、最後の課題が音です。

MxAnywhere3は静音マウスではないので、静かな室内でクリック音がカチカチ鳴りうるさいと感じることがあります。これが静音化されればいいのに。 では実際Logicoolの静音マウスでどれぐらい期待できるのかを確認するためm650を試しています。

m650 と MxAnywhere3の比較

m650は2022年2月3日発売のマウスです。

www.logicool.co.jp

価格レンジが違うものの、m650は発売されてすぐで4290円、MxAnywhere 3は10000円前後とお手頃になっています。

マウスの比較

引用: https://www.logicool.co.jp/ja-jp/products/mice/m650-signature-wireless-mouse.html

サイズ

サイズが2通り(LとM) があります。MxAnywhere3を使っているならM一択です。

気づかずLを買ったのですが、大きすぎて失敗しました。

グリップ

m650はラバーグリップです。 MxAnywhere 3のようなシリコンじゃないのかと気になりますが、触った感じはべたべたしなさそうなのでスルーしています。

価格がお求めやすいのもあり、ダメになったら買い替えてもいいでしょう。

電池

充電式ではなく、単三電池1つで24か月持ちます。 電池はマウスが重くなる、予備電池が必要なので好みではないのですが長寿命なので気にしないことにしています。

MxAnyehreは数か月に一度気が付いたときに充電していたので、頻度的にはm650のほうが少なくなります。

静音

SilentTouch を採用しているとのことで、M170と比べるとM650のクリック音は90%軽減と聞きますが買うまでは眉唾でした。

実際に購入してみると、クリック音がほぼ聞こえないので確かに静音です。 1m離れると聞こえないので、ほかの人に聞こえることはあまりないでしょう。

クリック感は普通のマウス同様にあるので違和感もありません。

MacBookのトラックパッドかな? とはじめ思ったぐらいには静かですごい。

ホイール

SmartWheelになっているので、MxAnywhere 3の高速スクロール時のフリースピンはありません。 ページを一気に移動するときに若干不便ですが、勢いよくスクロールするとある程度一気に移動してくれるので妥協できる良さはあります。

水平スクロールはMxAnywhere 3と同じサイドボタンを押しながらスクロールなので便利。

接続

MxAnywhere 3はUnifyingでしたが、m650はLogi Boltに変わりました。 Logi Boltは各PCでペアリングが必要なのは面倒ですが、安定して接続できており今のところ一切不便はありません。

Bluetoothも可能ですが、マウスのBT接続はPCが高負荷時に困るのはあるあるなのでLogi Boltで利用しています。

まとめ

静音マウスすごく気に入ったのでm650大好きです。 コスパ考えても、このマウスは他の人がちょっと試すにはいいと思います。

MxAnywhere 3を使っている人はサイズはMにしましょう、忘れずに。 MxMaster 3の人はLでもいいですね。

GitHub Actions で README にリポジトリのコードを埋め込みたい

README に、リポジトリにおいているコードを埋め込みたい時があります。 そんな時に便利なのが、embedme です。

github.com

今回は、GitHub Actions を使って README にリポジトリのコードを埋め込むことをしたので紹介します。

tl;dr;

  • GitHub Actions で embedme を実行して、README.md にリポジトリのコードを埋め込み自動化ができる
  • リポジトリと README.md で同じコードを示す時の二重管理が解消するのでうれしい
  • 軽量シンプルなので、pull_request、push、workflow_dispatch など任意のトリガーで組めて便利

embedme の基本的な利用例

例として、README.md に src/example.ts の中身を埋め込むことを考えてみましょう。

まずはembedme をインストールします。

npm install -g embedme

README.md に次のようにコードブロックと言語を示しておいてリポジトリのファイルパスを示します。

gist.github.com

TIPS: OS問わずファイルパスは / 区切りがいいです。

あとは、インストールした embedme をREADME.md に実行します。

embedme README.md

これでexample.ts のファイルがコードブロックに差し込まれます。便利。

gist.github.com

行指定も直感的にできます。

// src/embedme.lib.ts#L44-L82

embedme のいいところ

個人的に気に入ってるところは3つあります。

埋め込むときに使ったコメントがコードブロックに残る

埋め込み後もコメントが残ることで、埋め込むソースコードを更新してembedmeを実行すると埋め込みが更新されるので、勝手にメンテが維持します。 どことリンクしていたかも一目瞭然で、README.md を見る人が探すこともできるので好きです。

実行が軽い

インストールも実行も早いのはいいこととです。 npm install -g embedme で入るので、GitHub Actions などの CI との相性もいいです。

実行も即座に終了するのでカジュアルに走らせられる安心感があります。

実行履歴

README.md の何行目のコードブロックを実行した、実行できなかったを理由を添えて表示してくれます。 例えばファイルパスが見つからず埋め込みができなかったら次のように教えてくれます。

   README.md#L381-L383 Found filename .github\workflows\concurrency_control_cancel_in_progress.yaml in comment in first line, but file does not exist at /home/runner/work/githubactions-lab/githubactions-lab/.github\workflows\concurrency_control_cancel_in_progress.yaml!

十分です。

embedme で困るであろうこと

コードブロックのコメント

コードブロック内のコメントを検知するので、埋め込む前提になってないのにコメントを適当にいれたコードブロックを作ると警告は出ます。 ファイルパスが示されていないければ問題ないですが注意です。

言語ごとのコメントが違う

言語ごとに対応しているので当然ですが注意しましょう。 yaml や bash なら # でコメント認識ですし、js や ts なら // でコメント認識です。

GitHub Actions で自動化する

embedme は GitHub Actions としては公開されていないので適当に組みます。 今回は、GitHub Actions をいろいろ試しているリポジトリで組んだ例を示します。

github.com

大量に yaml を埋め込んでいるので本当に助かっています。

https://github.com/guitarrapc/githubactions-lab での自動埋め込み例

GitHub Actions workflow を用意する

普段は pull_request で実行して、変更があれば PR にコミットを積むようにします。 必要に応じて workflow_dispatch で手動で更新もできるようにします。

gist.github.com

PRで リポジトリを checkout するときは、Merge コミットではなく PR のコミットを checkout します。 refを指定せず checkout すると、後段で git push したときにマージコミットが PR に入ってしまいます。

      - if: ${{ github.event_name == 'pull_request' }}
        uses: actions/checkout@v3
        with:
          ref: ${{ github.event.pull_request.head.sha }} # checkout PR HEAD commit instead of merge commit

19-22行目で embedded を実行します。

      - name: Embedding Code into README
        run: |
          npm install -g embedme
          embedme README.md

あとは変更があれば git push します。

      - name: git diff
        id: diff
        run: |
          git add -N .
          git diff --name-only --exit-code
        continue-on-error: true
      - if: steps.diff.outcome == 'failure'
        name: git-commit
        run: |
          git config user.name github-actions[bot]
          git config user.email 41898282+github-actions[bot]@users.noreply.github.com
          git add .
          git commit -m "[auto commit] Embed code"
      - if: steps.diff.outcome == 'failure'
        name: Push changes
        uses: ad-m/github-push-action@master
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          branch: ${{ github.head_ref }}

push で実行してもいいですが、私のリポジトリではREADMEの目次作成 (toc) を push でやっているので pull_request にしています。 コード生成は重なると直列実行 と checkout 時の ref 伝搬を気にしないといけないので注意です。

まとめ

embedme 便利です。 あんまり README にリポジトリのコードを埋め込むのを考えてこなかったのですが、いざやってみると便利でいろいろ使えそうです。(実際コピペではることありますし)

補足情報として、類似したものに tokusumi/markdown-embed-codeがありますが、これはそもそも実行できなかったのと docker のpullが走って重すぎるので好みじゃなかったです。

github.com

Pulumi を C# で書くときに気を付けていること

Pulumi は複数の言語で書くことができるのですが、そのうちの1つに .NET (C#) もあります。

Languages より https://www.pulumi.com/docs/intro/languages/

以前 Pulumi を C# で書く時の事始めシリーズを書いていましたが、あれから時間がたって書き方も変わりました。 今回は、実際にどのように書くことで Pulumi で書きやすい、IaC制御しやすいを目指しているのかを言語化します。

tl;dr;

Pulumi + C# の時に、Pulumi の考えているやりやすいにのっかりつつ、C# 的に書きやすく、IaC として管理しやすいかを検討します。 ポイントは4点です。

  • コンストラクタで処理を完結させる
  • Output`T型 で将来の値を表現する
  • リソースインスタンス作成はコンポーネントの責務を徹底する
  • 定数クラスと分岐で環境差分を表現する

DO/DO NOT/CONSIDER で気を付けていることを順にあげていきます。

コンストラクタで処理を完結させる

  • ✔️ DO: コンストラクタで処理を行う
  • ❌ DO NOT: Task メソッドに処理を分けて呼び出すことを避ける

AWS CDK も似た感じですが、リソース/コンポーネントの作成処理は コンストラクタで完結させます。 例えば次のようになります。

// DO
return await Deployment.RunAsync<MyStack>();

class MyStack : Stack
{
    public MyStack()
    {
        var foo = new FooResource("Foo",  new FooArgs
        {
            // 何か処理
        });
    }
}

Task ExecuteAsync() のように非同期メソッドを生やして呼び出す、ということは避けるといいでしょう。 非同期メソッドに処理を分けると、そのメソッド内部では async/await を使えますが、呼び出し元の最上流はコンストラクタです。 コンストラクタ呼び出し時点でリソースが作られないのは良しとしても、ExecuteAsync 呼び出し忘れや、Task<T>.GetAwaiter().GetResult() とか絶対避けたいでしょう。 あと、明らかに冗長です。

// DO NOT
return await Deployment.RunAsync<MyStack>();

class MyStack : Stack
{
    public MyStack()
    {
        var foo = new FooComponent();
        foo.ExecuteAsync().GetAwaiter().GetResult(); // したくない
    }
}

public class FooComponent : ComponentResource
{
    public FooComponent() : base()
    {
        // 初期化
    }

    public async Task ExecuteAsync()
    {
        var foo = new FooResource("Foo",  new FooArgs
        {
            // 何か処理
        });
    }
}

コンストラクタで async/await は使えないので非同期処理をどうするのか、という疑問がわきますが、それは次の Output<T> を使う話になります。

Output`T型 で将来の値を表現する

Pulumi で何かしらのリソースを作成するときに、リソースが作成されないと決定できない「将来の値 = (いわゆるPromise)」は Output<T> 型で表現されます。

www.pulumi.com

  • ✔️ DO: 将来の値は Output<T> を伝搬させる
  • ✔️ DO: Taskを扱う非同期処理は Output<T> でくるむ
  • ❌ DO NOT: await して生の値をとろうとしない
  • ✔️ DO: Output<T> の 生の値 Tが必要な場合は Output<T>.Apply() メソッド内部のラムダで利用する

Pulumi を C# で書くときは、Output<T> で将来の値を伝搬するのを徹底するのがよいでしょう。 例えば Output<T> を活用すると、次のように foo リソースが作成されていなくても bar リソースで fooリソースを使う/依存があることを示すことができます。

// DO
return await Deployment.RunAsync<MyStack>();

class MyStack : Stack
{
    public MyStack()
    {
        var foo = new FooResource("Foo",  new FooArgs
        {
            // 何か処理
        });

        var bar = new BarResource("Bar", new BarArgs
        {
            FooId = foo.Arn, // foo が生成されるまで値が未確定。fooへの依存も自動的に解析される。
        });
    }
}

Output<T> に対応する、リソースが受け入れるときの型が Input<T> ですが、自分で利用する機会はほぼなく Output<T> しか使わないでしょう。

非同期メソッドと Output`T

Pulumi には、現在認証している クラウド環境 やその環境のリソースを取得するメソッドが用意されています。 例えば、AWS 環境で既存の Vpc を取得するメソッドがあります。

// メソッドの返り値は、Task<Pulumi.Aws.Ec2.GetVocResult>
Pulumi.Aws.Ec2.GetVpc.InvokeAsync(new Pulumi.Aws.Ec2.GetVpcArgs { Id = "foo" });

C# 的には Task なんだから await することで待ちたくなりますが、それでは前出のasync/await を書こうとしたときのジレンマに陥ります。 Taskを扱う非同期処理は、await する代わりに Output<T> でくるんであげることで自然と扱えます。

先の例を Output<T> でくるむには、Output.Create<T>() メソッドを利用するといいでしょう。

// DO
var vpcId = Output.Create(Pulumi.Aws.Ec2.GetVpc.InvokeAsync(new Pulumi.Aws.Ec2.GetVpcArgs { Id = "vpc-0123456" }));

// DO NOT
var vpcId = await Pulumi.Aws.Ec2.GetVpc.InvokeAsync(new Pulumi.Aws.Ec2.GetVpcArgs { Id = "vpc-0123456" });

Output`T の T を取り出したい

また、リソースを生成した結果の Output<T> を 先ほどのInvokeAsync 内の処理で使いたいことがあります。

var foo = new FooResource("Foo",  new FooArgs
{
    // 何か処理
});

// プロパティは仮です、実際とは異なります。
Pulumi.Aws.Iam.GetPolicyDocument.InvokeAsync(new GetPolicyDocumentArgs
{
    Resources = new [] { /* ここでfooリソースのArnを使いたい*/ }
});

直接 foo.Arn を指定しようとすると foo.Arn は Output<T> なので文字列には変換されません。(ここが CDK との違いですね)

Output<T>T を直接使用した処理を書きたい場合は、Output<T>.Apply<a>()メソッドのラムダ内部で処理を書くといいでしょう。 Output<T>.Apply<a>() の返り値は Output<a> となるので、結果を他リソースに食わせる時も自然と与えることができます。

// DO
var policy = foo.Arn.Apply(arn => Pulumi.Aws.Iam.GetPolicyDocument.InvokeAsync(new GetPolicyDocumentArgs
{
    Resources = new [] { $"{arn}/*" } // T が入っている
}));

// DO NOT
var policy = Output.Create(Pulumi.Aws.Iam.GetPolicyDocument.InvokeAsync(new GetPolicyDocumentArgs
{
    Resources = new [] { $"{foo.Arn}/*" } // Output<T> のままなのでダメ
}));

Output<T> から別の Output<T> に変換したいときも同じように Output<T>.Apply()Output<T>.Format() が利用できます。

// 出力される Output<string> は arn:aws:s3:::foo に変換される。
Output.Create("foo").Apply(x => $"arn:aws:s3:::{x}");

リソースインスタンス作成はコンポーネントの責務を徹底する

  • ✔️ DO: 実装 (リソース) は コンポーネントに管理させる
  • ❌ DO NOT: Stack に直接実装 (リソース) を記述する
  • ✔️ CONSIDER: リソース処理の前に値の検証をする
  • ✔️ DO: 他のコンポーネントからリソースの結果を使用たいときはプロパティを公開する

Pulumi でリソースを作る = リソースインスタンスを作ることを指します。 また、リソースは1つ一つがユニークな urnで識別され、urn は リソースがコンポーネントに含まれるかどうかでも IDが変わります。 このため、リソースは当初からコンポーネントに分離することを念頭において、 Stack に直接書き出すのは避けるといいでしょう。

// DO
class MyStack : Stack
{
    public MyStack()
    {
        var opts = new ComponentResourceOptions { Parent = this };

        // コンポーネントを呼び出す
        var sg = new SecurityGroupComponent("my-sg", "securitygroup", opts, new SecurityGroupComponentArgs
        {
            VpcId = "vpc-0123456",
        });
    }
}

public class SecurityGroupComponent : ComponentResource
{
    public SecurityGroupComponent(string service, string component, ComponentResourceoptions opts, SecurityGroupComponentArgs args): base($"{service}:components:{component}", $"{service}-{component}", opts)
    {
        var opt = new CustomResourceOptions { Parent = this };

        // リソースインスタンス管理はコンポーネントのお仕事
        var egresss = new SecurityGroupEgressArgs
        {
            FromPort = 0,
            ToPort = 0,
            Protocol = "-1",
            CidrBlocks = new[] { "0.0.0.0/0" },
        };

        var aSg = new SecurityGroup("a-sg", new SecurityGroupArgs
        {
            Description = "a-SG"
            VpcId = args.VpcId
            Ingress = new[] {
                new SerurityGroupIngressArgs
                {
                    FromPort = 80,
                    ToPort = 80,
                    Protocol = "TCP",
                    Descrsiption = "HTTP access from XXXX",
                    CidrBlocks = "0.0.0.0/0",
                }
                new SerurityGroupIngressArgs
                {
                    FromPort = 443,
                    ToPort = 443,
                    Protocol = "TCP",
                    Descrsiption = "HTTPS access from XXXX",
                    CidrBlocks = "0.0.0.0/0",
                }
            },
            Egress = new[] { egress },
        }, opt);

        var bSg = new SecurityGroup("b-sg", new SecurityGroupArgs
        {
            Description = "b-SG"
            VpcId = args.VpcId
            Ingress = new[] {
                new SerurityGroupIngressArgs
                {
                    FromPort = 0,
                    ToPort = 0,
                    Protocol = "-1",
                    Descrsiption = aSg.Name,
                    SecurityGroups = aSg.Id,
                    Self = false,
                }
            },
            Egress = new[] { egress },
        }, opt);
    }
}

次のようにリソースを Stack に直接書き出したが最後、そのリソースの管理は誰がやるのか困り始めることでしょう。

// DO NOT
class MyStack : Stack
{
    public MyStack()
    {
        var opt = new CustomResourceOptions { Parent = this };

        // リソースを直接Stackに書き出す
        var egresss = new SecurityGroupEgressArgs
        {
            FromPort = 0,
            ToPort = 0,
            Protocol = "-1",
            CidrBlocks = new[] { "0.0.0.0/0" },
        };

        var aSg = new SecurityGroup("a-sg", new SecurityGroupArgs
        {
            Description = "a-SG"
            VpcId = args.VpcId
            Ingress = new[] {
                new SerurityGroupIngressArgs
                {
                    FromPort = 80,
                    ToPort = 80,
                    Protocol = "TCP",
                    Descrsiption = "HTTP access from XXXX",
                    CidrBlocks = "0.0.0.0/0",
                }
                new SerurityGroupIngressArgs
                {
                    FromPort = 443,
                    ToPort = 443,
                    Protocol = "TCP",
                    Descrsiption = "HTTPS access from XXXX",
                    CidrBlocks = "0.0.0.0/0",
                }
            },
            Egress = new[] { egress },
        }, opt);
    }
}

コンポーネントでリソースインスタンスを作成するので、リソース作成に必要な値をコンポーネントに渡す必要があります。 適当なrecord クラスで渡してあげると簡単でいいでしょう。

// DO
class MyStack : Stack
{
    public MyStack()
    {
        var opts = new ComponentResourceOptions { Parent = this };

        // コンポーネントを呼び出す
        var sg = new SecurityGroupComponent("my-sg", "securitygroup", opts, new SecurityGroupCompoonentArgs
        {
            VpcId = "vpc-0123456",
        });
    }
}

public class SecurityGroupComponent : ComponentResource
{
    public SecurityGroupComponent(string service, string component, ComponentResourceoptions opts, SecurityGroupComponentArgs args): base($"{service}:components:{component}", $"{service}-{component}", opts)
    {
        // 省略
    }
}

public record SecurityGroupComponentArgs
{
    public Output<string> VpcId { get; init; }
}

リソース処理の前に値の検証をする

各種ComponentArgs には、値の検証を担保させると Component内部での入力値の検査のほとんどを考慮しなくてよくなるのでオススメです。

// CONSIDER
public class SecurityGroupComponent : ComponentResource
{
    public SecurityGroupComponent(string service, string component, ComponentResourceoptions opts, SecurityGroupComponentArgs args): base($"{service}:components:{component}", $"{service}-{component}", opts)
    {
        // 初めに検証する
        args.Validate();

        // 省略
    }
}

public interface IValidate
{
    void Validate();
}

public record SecurityGroupComponentArgs : IValidate
{
    public Output<string>? VpcId { get; init; }

    [MemberNotNull(nameof(VpcId))]
    public void Validate()
    {
        if (VpcId is null) throw new ArgumentOutOfRangeException(nameof(VpcId));
    }
}

もちろんVpcId のように必須なものはコンストラクタでもいいでしょう。

// CONSIDER
public record SecurityGroupComponentArgs(Output<string> VpcId) : IValidate
{
    public IReadOnlyList<string>? Nanika { get; init; }

    [MemberNotNull(nameof(Nanika))]
    public void Validate()
    {
        if (Nanika is null) throw new ArgumentOutOfRangeException(nameof(Nanika));
    }
}

コンポーネント外部へのプロパティの公開

C# ではクラスの内部の情報を公開するときに プロパティを使いますが、Pulumi でもそれは変わりません。 公開したいリソースは get only プロパティを使うといいでしょう。

Pulumi コンソールでStack 出力に表示したい場合は、[Output] 属性を付けたプロパティで公開します。 プリミティブな型でないと表示できないので注意です。

// DO
class MyStack : Stack
{
    public MyStack()
    {
        var opts = new ComponentResourceOptions { Parent = this };

        var sg = new SecurityGroupComponent("my-sg", "securitygroup", opts, new SecurityGroupComponentArgs
        {
            VpcId = "vpc-0123456",
        });

        SecurityGroupAId = sg.A.Id
    }

    // Stack の Output に公開
    [Output]
    public Output<string> SecurityGroupAId { get; set; } // set 必須
}

public class SecurityGroupComponent : ComponentResource
{
    // リソースを公開する
    public SecurityGroup A { get; }

    public SecurityGroupComponent(string service, string component, ComponentResourceoptions opts, SecurityGroupComponentArgs args): base($"{service}:components:{component}", $"{service}-{component}", opts)
    {
        var aSg = new SecurityGroup("a-sg", new SecurityGroupArgs
        {
            // 省略
        }

        A = aSg;
    }
}

定数クラスと分岐で環境差分を表現する

  • ✔️ DO: 定数クラスに環境ごとの値を定義して Stack で参照させる
  • ❌ DO NOT: Stack に環境ごとの値を直接書く
  • ✔️ CONSIDER: 環境ごとのリソースの違いはコンポーネントの分岐などで表現する

環境差分と定数クラス

コンポーネントの処理は環境で同じ、コンポーネントに与える値だけ違うというケースが多いでしょう。 この場合、コンポーネント呼び出し時にStackへ直接値を書くのではなく、定数クラスを用意して参照させると環境ごとの差分が管理しやすいのでオススメです。

// DO
public static class Constants
{
    public const string Project = "MyProject";
    public const string Env = "dev";
    public const string Service = Project + "-" + Env;

    public static class Vpc
    {
        VpcId = "vpc-0123456"; // 環境ごとに違うであろう値
    }
}

class MyStack : Stack
{
    public MyStack()
    {
        var opts = new ComponentResourceOptions { Parent = this };

        var sg = new SecurityGroupComponent("my-sg", "securitygroup", opts, new SecurityGroupComponentArgs
        {
            VpcId = Constants.Vpc.VpcId,
        });
    }
}

// DO NOT
class MyStack : Stack
{
    public MyStack()
    {
        var opts = new ComponentResourceOptions { Parent = this };

        var sg = new SecurityGroupComponent("my-sg", "securitygroup", opts, new SecurityGroupComponentArgs
        {
            VpcId = "vpc-0123456", // 直接値を指定するのは避けたい
        });
    }
}

環境ごとのリソースの違いはコンポーネントで頑張る

コンポーネントの処理は環境で同じ、コンポーネントに与える値だけ違うというケースが多いでしょう。

と書きましたが、そんなの絵空事です。 実際には、開発にはあるけど、本番にはないリソースというのはよくある話でしょう。

こういった環境差分は、コンポーネントの中で分岐などで適当に頑張るといいでしょう。 幸いにして Terraform と違ってこういった処理は圧倒的にやりやすいのでいい感じの方法をとればいいと思います。

// CONSIDER
public class SecurityGroupComponent : ComponentResource
{
    public SecurityGroupComponent(string service, string component, ComponentResourceoptions opts, SecurityGroupComponentArgs args): base($"{service}:components:{component}", $"{service}-{component}", opts)
    {
        args.Validate();

        if (args.EnableA)
        {
            // A の セキュリティグループを作る
        }
    }
}

public record SecurityGroupComponentArgs : IValidate
{
    public Output<string>? VpcId { get; init; }
    public bool EnableA { get; init; }

    [MemberNotNull(nameof(VpcId))]
    public void Validate()
    {
        if (VpcId is null) throw new ArgumentOutOfRangeException(nameof(VpcId));
    }
}

まとめ

つらつらと普段気を付けていることを挙げてみました。 C# に限らず、Pulumiは各種言語のやりやすいように書けばインフラが管理できるのはとても便利です。 Pulumi と Terraform を行き来していると、Terraform の言語機能の貧弱さに驚くとともに、今ある値かを意識することなくかけるのはすごいと感じます。

利用者の多さ、言語機能の小ささから 今後もTerraform は広く使われるでしょうが、アプリケーションエンジニアの立場から見ると Pulumi は非常に扱いやすく設計も応用できるので好ましいと感じます。

Terrafom も Pulumi もよい、チームとしてより書きやすい、手になじむものを採用していけるといいですね。