tech.guitarrapc.cóm

Technical updates

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

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

f:id:guitarrapc_tech:20220406161700p:plain
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 でリソースを作る = リソースインスタンスを作ることを指します。 また、リソースは一つ一つがユニークな 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 もよい、チームとしてより書きやすい、手になじむものを採用していけるといいですね。