ねののお庭。

かりかりもふもふ。

【C#】Aspire のプリミティブとカスタムリソース。

Aspire は大変便利です。 開発用の docker compose の yaml 等を書く必要がなくなり、全て C# で型安全に記述する事ができるようになりました。ハッピーだっピね!

開発をしていると頻出の PostgreSQL, MySQL, Redis, RabbitMQ といったコンポーネント向けの Aspire Integration は公式が用意してくれています。 どんなものが用意されているかは Aspire のレポジトリを眺めると分かります。 公式が用意していない場合でもコミュニティ実装でいろいろコンポーネントが用意されています。

しかしながら、自分が開発で使いたいコンポーネント向けの Aspire Integration が存在するとは限りません...! そんな時どうすれば良いかというと、Aspire では簡単にコンテナのイメージを任意のレジストリから引っ張ってきて Aspire のコンポーネントとして扱えるのでそれを用いる事になります。 が!単にコンテナのイメージを引っ張ってきて Aspire にコンテナの up/down をさせるだけでは、Aspire 的な快適な使い心地には至りません。 なのでなんやーかんやーでカスタムリソースを作ったほうが良かったりします。 という事で、この記事では Aspire のプリミティブな API がどのようになっているか、そしてどのようにカスタムリソースを作っていけばよいのかつらつら書いていこうと思います。

Aspire のプリミティブ

カスタムリソースを作る前に、Aspire の基本的な内部の作りについて触れておきます。 Aspire にとっての重要なプリミティブにあたる型は2つあります。 それは IResourceIResourceAnnotation です。

IResource interface はその名の通りリソースを表現します。 リソースというのは、要は .NET のプロジェクトであったり、コンテナであったり、Aspire に起動させるアプリケーションやミドルウェアの事です。 IResource の定義はいたって単純。リソースの名前と IResourceAnnotation のコレクションを持っているだけです。

public interface IResource
{
    string Name { get; }
    ResourceAnnotationCollection Annotations { get; }
}

この IResource で表現されたオブジェクトは純粋なデータオブジェクトです。 リソースのオーケストレーション、ライフサイクルの管理とかは別のところで管理されます。 別のところとは Microsoft Developer Control Plane (DCP) です。 DCP についてはこちらこちらをご覧ください。Kubernetes API が生えているので kubectl が叩けたりします (叩く必要性に駆られる事はほぼ無いと思いますが)。DCP については以下の画像が分かりやすいでしょう。

DCP の概要 (公式ドキュメントから引用)

現実的には IResource を直接実装する事はほぼなく、IResource を実装した Resource class や、さらにそれを継承した ProjectResource, ContainerResource, ExecutableResource などの Aspire に組み込まれている class 達を継承してカスタムリソースを作成していく事になります。 殆どの場合 ContainerResource, ExecutableResource を継承してカスタムリソースを作っていく事になるでしょう。

さて、IResource には Annotations という ResourceAnnotationCollection 型のプロパティが生えています。 ResourceAnnotationCollection はその名の通り、Aspire のプリミティブにあたる IResourceAnnotation のコレクションです。 ここで「アノテーションってなによ?」となりますが、これはリソースに対して付与するメタデータに相当するものです。 たとえばあるリソースに対して環境変数を設定したかったら WithEnvironment メソッドを用いるのですが、WithEnvironment 内部ではリソースに対して以下のように EnvironmentAnnotation アノテーションが追加されるようになっています。

public static IResourceBuilder<T> WithEnvironment<T>(this IResourceBuilder<T> builder, string name, string? value)
    where T : IResourceWithEnvironment
{
    return builder.WithAnnotation(new EnvironmentAnnotation(name, value ?? string.Empty));
}

上記に登場した ResourceAnnotationCollectionEnvironmentAnnotation は以下のような定義になっています。非常にシンプル。 IResourceAnnotation を実装した class は EnvironmentAnnotation 以外にも色々あるので、コードを覗いてみるとよいでしょう。

public interface IResourceAnnotation
{
}

public sealed class ResourceAnnotationCollection : Collection<IResourceAnnotation>
{
}

public class EnvironmentCallbackAnnotation : IResourceAnnotation
{
    // ...
}

internal sealed class EnvironmentAnnotation : EnvironmentCallbackAnnotation
{
    // ...
}

Aspire のプリミティブな型についてはこんなところです。 しかし、カスタムリソースで Aspire 的書き心地を得るためにはまだ少し足りません。 Aspire では以下のように WithReference を使う事で、アプリケーションやミドルウェアの依存関係を記述する事ができます。

var builder = DistributedApplication.CreateBuilder(args);

var postgres = builder.AddPostgres("postgres");

var webapi = builder.AddProject<Projects.MyWebApi>("webapi")
    .WithReference(postgres); // <- これにより webapi に postgres に接続するための接続文字列が環境変数として注入される

builder.Build().Run();

これにより、webapi に対して postgres に接続するための接続文字列を環境変数として注入する事ができます。 嬉しい。 ですが、以下のように単に AddContainer で image を引っ張ってきて WithReference で依存関係を記述したところで、接続文字列が環境変数として注入される事はありません。

var myContainer = builder.AddContainer("myContainer", "my/image", "tag");

var webapi = builder.AddProject<Projects.MyWebApi>("webapi")
    .WithReference(myContainer); // <- webapi に対して接続文字列は注入されない

さて、ではどのようにすれば良いかというと、ContainerResource を継承した class を定義し、その class に対して IResourceWithConnectionString interface を実装してあげればよいだけです。

public sealed class MyResource(string name)
    : ContainerResource(name), IResourceWithConnectionString
{
    public ReferenceExpression ConnectionStringExpression => ...;
}

そして以下のように使ってあげれば、接続文字列を注入したいアプリケーションに対して注入できるようになります。

var myContainer = builder.AddResource(new MyResource("myContainer"))
    .WithImage("my/image", "tag");

var webapi = builder.AddProject<Projects.MyWebApi>("webapi")
    .WithReference(myContainer); // <- webapi に対して接続文字列は注入される!

なお IResourceWithConnectionString 以外にもいろいろ interface が用意されているので、必要に応じて使いましょう。

  • IResourceWithConnectionString
  • IResourceWithServiceDiscovery
  • IResourceWithWithoutLifetime
  • IResourceWithEnvironment
  • IResourceWithEndpoints
  • IResourceWithArgs
  • IResourceWithWaitSupport

とはいえ、 ContainerResource には以下のようにある程度 interface が実装されているので、自前で実装する必要はなかったりします。

public class ContainerResource(string name, string? entrypoint = null) : Resource(name),
    IResourceWithEnvironment, IResourceWithArgs, IResourceWithEndpoints,
    IResourceWithWaitSupport, IComputeResource
{
    // ...
}

カスタムリソースを定義する

完成品がどうなっているかは GitHub をご覧ください。 NuGet パッケージとしてもで配布しています。

github.com

www.nuget.org

要求と理想の API

ライブラリを作る際にはまず要求と理想の API を考えておく必要があります。 とはいえ今回は独自のライブラリではなく、あくまで Aspire の拡張なので API は公式のお作法に合わせる事になります。 要求としても今回は概ね以下が満たせれば十分でしょう。

  • API は公式のお作法に合わせる
  • WithReference した際に依存関係のあるリソースに接続文字列を渡したい
  • WaitFor で Minio が Healthy になるまで他のリソースの起動を待機したい
  • volume マウントしたい
  • Minio の起動とともにバケットを作成しておきたい
  • minio/miniominio/mc のイメージのタグは別々に指定したい
    • タグが一致していないため。

なので、以下のような形で使えると good です。

var builder = DistributedApplication.CreateBuilder(args);

var minio = builder.AddMinio(
        name: "minio",
        userName: builder.AddParameter("MinioUser"),
        password: builder.AddParameter("MinioPassword")
    )
    .WithImageTag("RELEASE.2025-07-23T15-54-02Z")
    .WithDataVolume("my.minio.volume");

var bucketCreation = minio.AddBucket(["my-bucket-1", "my-bucket-2"])
    .WithImageTag("RELEASE.2025-07-21T05-28-08Z");

var webapi = builder.AddProject<Projects.MyWebApi>("webapi")
    .WithReference(minio)
    .WaitFor(minio)
    .WaitForCompletion(bucketCreation);

という事で実装してきましょう。

カスタムリソースの実装

まず今回は WithReference で接続文字列を渡したいので、単に AddContainer するのではなくカスタムリソースを作成してきましょう。まず以下のような class を定義します。

public sealed class MinioResource(string name, ParameterResource user, ParameterResource password)
    : ContainerResource(name), IResourceWithConnectionString
{
    public const string PrimaryEndpointName = "http";
    public const string ConsoleEndpointName = "console";

    internal const int PrimaryTargetPort = 9000;
    internal const int ConsoleTargetPort = 9001;

    internal ParameterResource User { get; } = user;
    internal ParameterResource Password { get; } = password;

    public ReferenceExpression ConnectionStringExpression
        => this.TryGetLastAnnotation<ConnectionStringRedirectAnnotation>(out var connectionStringAnnotation)
            ? connectionStringAnnotation.Resource.ConnectionStringExpression
            : ReferenceExpression.Create($"Endpoint={this.GetEndpoint(PrimaryEndpointName).Url};User={this.User};Password={this.Password}");
}

Minio はコンテナで動かすので ContainerResource を継承させます。 そして接続文字列を WithReference で渡せるようにしたいので IResourceWithConnectionString も実装します。

あとは上で定義した MinioResource を用いる拡張メソッドを以下のような形で定義しましょう。 最低限必要な設定と環境変数をせっせと設定していきます。

public static IResourceBuilder<MinioResource> AddMinio(
    this IDistributedApplicationBuilder builder,
    string name,
    IResourceBuilder<ParameterResource> userName,
    IResourceBuilder<ParameterResource> password,
    int? port = null,
    int? consolePort = null)
{
    var resource = new MinioResource(name, userName.Resource, password.Resource);

    return builder.AddResource(resource)
        .WithImage("minio/minio")
        .WithImageRegistry("docker.io")
        .WithHttpEndpoint(name: MinioResource.PrimaryEndpointName, port: port, targetPort: MinioResource.PrimaryTargetPort)
        .WithHttpEndpoint(name: MinioResource.ConsoleEndpointName, port: consolePort, targetPort: MinioResource.ConsoleTargetPort)
        .WithUrlForEndpoint(MinioResource.PrimaryEndpointName, annot =>
        {
            annot.DisplayText = "Primary";
        })
        .WithUrlForEndpoint(MinioResource.ConsoleEndpointName, annot =>
        {
            annot.DisplayText = "Console";
        })
        .WithEnvironment("STORAGE_TYPE", "minio")
        .WithEnvironment("MINIO_HOST", "localhost")
        .WithEnvironment("MINIO_PORT", MinioResource.PrimaryTargetPort.ToString())
        .WithEnvironment("MINIO_ROOT_USER", userName)
        .WithEnvironment("MINIO_ROOT_PASSWORD", password)
        .WithHttpHealthCheck("/minio/health/live", 200, MinioResource.PrimaryEndpointName)
        .WithArgs("server", "/data", "--console-address", $":{MinioResource.ConsoleTargetPort}");
}

WithHttpEndpoint でコンテナのポートとローカルのポートをバインドします。 Minio の場合、Minio client が叩くポートと Minio の管理コンソールを覗くためのポートがあります。 なので WithHttpEndpoint しただけだと以下の画像のように URL が表示された時、どっちがどっちやねんとなります。

そこで WithUrlForEndpoint を用いて、ダッシュボードに表示される文字列を設定してあげます。 これにより、ダッシュボード上で以下のような表示にする事ができます。

WithHttpHealthCheck でヘルスチェックも追加しておく事も重要です。 WaitFor で依存関係のあるリソースの起動を待つ際には、コンテナが Running かつ Healthy な状態になるまで待ってくれるのですが、WithHttpHealthCheck でヘルスチェックの設定をしていないと Running になった時点で Healthy とみなされて WaitFor で待機していたリソースが起動してしまいます。

次にボリュームマウントするための拡張メソッドを定義します。 Aspire が公式で出している Postgres 等のパッケージの API に合わせると以下のような具合になります。

public static IResourceBuilder<MinioResource> WithDataVolume(this IResourceBuilder<MinioResource> builder, string? name = null)
{
    return builder.WithVolume(name ?? VolumeNameGenerator.Generate(builder, "data"), "/data");
}

なお VolumeNameGenerator は Aspire が提供している API です。 個人的には name は設定したほうが分かりやすいので、明示的に設定する事をお勧めします。

次に起動した後にバケットが作成されている状態にしましょう。 バケットの作成には Minio が公式で提供している mc コマンドを使っていきます。 リソース的には以下のような流れになります。

  1. Minio 本体が立ち上がったら mc コマンドを叩けるコンテナを Minio 本体とは別に立ち上げ
  2. そのコンテナの中でバケット作成コマンドを叩き
  3. それらが一式終了したらコンテナが落ちる

という事で以下のように AddBucket という拡張メソッドを定義します。 なお上2つのオーバーロードは便利のためのものです。

public static IResourceBuilder<ContainerResource> AddBucket(this IResourceBuilder<MinioResource> builder, string bucketName)
{
    return builder.AddBucket(
        name: $"{builder.Resource.Name}-create-bucket-{bucketName}",
        bucketNames: [bucketName]
    );
}

public static IResourceBuilder<ContainerResource> AddBucket(this IResourceBuilder<MinioResource> builder, IReadOnlyList<string> bucketNames)
{
    return builder.AddBucket(
        name: $"{builder.Resource.Name}-create-buckets-{bucketNames[0]}",
        bucketNames: bucketNames
    );
}

public static IResourceBuilder<ContainerResource> AddBucket(
    this IResourceBuilder<MinioResource> builder,
    string name,
    IReadOnlyList<string> bucketNames)
{
    return builder.ApplicationBuilder
        .AddContainer(name, "minio/mc")
        .WithImageRegistry("docker.io")
        // When using WithParentRelationship, WaitFor doesn't work.
        // issue: https://github.com/dotnet/aspire/issues/9163
        //.WithParentRelationship(builder)
        .WithReference(builder)
        .WaitFor(builder)
        .WithEntrypoint("/bin/sh")
        .WithArgs(async ctx =>
        {
            var minio = builder.Resource;

            var user = await minio.User.GetValueAsync(ctx.CancellationToken);
            var password = await minio.Password.GetValueAsync(ctx.CancellationToken);

            var sb = new StringBuilder();

            sb.Append($"mc alias set minio {GetMinioPrimaryUri(minio)} {user} {password};");

            foreach (var bucket in bucketNames)
            {
                if (!string.IsNullOrWhiteSpace(bucket))
                {
                    sb.Append($"mc mb minio/{bucket} --ignore-existing;");
                }
            }

            ctx.Args.Add("-c");
            ctx.Args.Add(sb.ToString());
        });

    static string GetMinioPrimaryUri(MinioResource minio)
    {
        var endpoint = minio.GetEndpoint(MinioResource.PrimaryEndpointName);
        return $"{endpoint.Scheme}://{minio.Name}:{endpoint.TargetPort}";
    }
}

注意するべき事は1つだけ。 mc コマンドはコンテナ内で動かすので、当然 Minio を叩くためには localhost を用いる事はできません。 なのでどうにかする必要があるのですが、Aspire の場合リソース名で名前解決できるようになっています。 なので GetMinioPrimaryUri 内でホスト名が minio.Name で設定されているわけです。

なお、今回の場合は「コンテナからコンテナ」への通信なのでリソース名で名前解決されるのですが「コンテナからプロジェクト/実行ファイル」への通信を行う場合はリソース名で名前解決されません。 なので「コンテナからプロジェクト/実行ファイル」への通信をしたい場合は、ホスト名を以下のように EndpointReference.ContainerHost を用いて設定してあげるとよいでしょう。 EndpointReference.ContainerHost は Docker Desktop を用いている場合、host.docker.internal、Podman を用いている場合は host.containers.internal が返ってくるので、Docker Desktop / Podman のどちらを使っていても問題なく機能するでしょう。

static string GetMyResourcePrimaryUri(MyResource resource)
{
    var endpoint = resource.GetEndpoint(MyResource.PrimaryEndpointName);
    // Container to Container の場合はリソース名をホスト名として用いればよいが、
    // Container to Project/Executable の場合は ContainerHost を用いるとよい。
    // ContainerHost は用いているコンテナランタイムによって返す値が異なる。
    //     Docker Desktop の場合: host.docker.internal
    //     Podman         の場合: host.containers.internal
    return $"{endpoint.Scheme}://{endpoint.ContainerHost}:{endpoint.Port}";
}

なお前述の通り、AddBucket で追加されるリソースは mc コマンドを用いてバケットを作成し終わったら落ちます。 なので WaitFor ではなく WaitForCompletion を用いて待機する事で、コマンドが一式叩き終わりリソース(コンテナ)が落ちるまで待機する必要があります。。

というわけで、最終的には以下のようになります。 最初に理想とした API 通りです。

var builder = DistributedApplication.CreateBuilder(args);

var minio = builder.AddMinio(
        name: "minio",
        userName: builder.AddParameter("MinioUser"),
        password: builder.AddParameter("MinioPassword")
    )
    .WithImageTag("RELEASE.2025-07-23T15-54-02Z")
    .WithDataVolume("my.minio.volume");

var bucketCreation = minio.AddBucket(["MyBucket1", "MyBucket2"])
    .WithImageTag("RELEASE.2025-07-21T05-28-08Z");

var webapi = builder.AddProject<Projects.MyWebApi>("webapi")
    .WithReference(minio)
    .WaitFor(minio)
    .WaitForCompletion(bucketCreation);

Aspire v9.4.1 時点でのバグ

上のコード例にしれっと記述しているのですが、Aspire v9.4.1 時点で WithParentRelationship はバグっています。

Aspire には便利なダッシュボードが用意されていますが、そのダッシュボード上でリソースの親子系を示すための API として WithParentRelationship という拡張メソッドが用意されています。 この API は本来リソースのライフサイクルには影響を与えない API として提供されています。

しかしながら、v9.4.1 時点においてはバグがあり、WithParentRelationship を用いると WaitFor が正しく機能しません。 実際バグとして報告されているし、issue やら PR 眺めていると近い領域での修正が行われているように見受けられるので、しばらくしたら直っているでしょう。 直っているといいな...。

ちなみにリソースのライフサイクルに影響を受けながら、リソースの親子関係を示す IResourceWithParent という interface も提供されています。 この interface を用いる事で、親のリソースが起動したら子のリソースが起動し、親が落ちたら子も落ちる...というような挙動になるのですが、この起動のタイミングは親リソースが Healthy かどうか関係ありません。 なので Healthy になってから起動するようにしたい場合 WaitFor を用いようと考えるのが妥当ですが、IResourceWithParent を実装したリソースがその親のリソースを WaitFor で待機する事はできません (例外が上がってきます)。絶秒...。

関連記事

blog.neno.dev blog.neno.dev blog.neno.dev

References