tech.guitarrapc.cóm

Technical updates

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

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

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

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

概要

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

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

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

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

  • DO: コンストラクタで処理する
  • AVOID: 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()とか絶対避けたいでしょう。 あと、明らかに冗長です。

// AVOID
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>型で表現されます。

https://www.pulumi.com/docs/intro/concepts/inputs-outputs/

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

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

// AVOID
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 が入っている
}));

// AVOID
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: 他のコンポーネントからリソースの結果を使用たいときはプロパティを公開する
  • CONSIDER: リソース処理の前に値の検証をする
  • AVOID: Stackに直接実装 (リソース) を記述する

Pulumiでリソースを作る = リソースインスタンスを作ることを指します。 また、リソースは1つ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に直接書き出したが最後、そのリソースの管理は誰がやるのか困り始めることでしょう。

// AVOID
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で参照させる
  • CONSIDER: 環境ごとのリソースの違いはコンポーネントの分岐などで表現する
  • AVOID: Stackに環境ごとの値を直接書く

環境差分と定数クラス

コンポーネントの処理は環境で同じ、コンポーネントに与える値だけ違うというケースが多いでしょう。 この場合、コンポーネント呼び出し時に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,
        });
    }
}

// AVOID
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どちらもよい、チームとしてより書きやすい、手になじむものを採用していけるといいですね。