銀の光と碧い空

クラウドなインフラとC#なアプリ開発の狭間にいるエンジニアの日々

.NET 7で導入されたビルトインコンテナサポートを試してみた

.NET 7になって .NET SDKだけでコンテナのビルドができるようになりました。

devblogs.microsoft.com

このブログの最初にある通り、Microsoft.NET.Build.Containers というライブラリを追加すると利用できる機能ですが、(temporary)とあるのでそのうち不要になる(SDKなどに組み込まれる)かもしれません。dotnet publish --os linux --arch x64 -c Release -p:PublishProfile=DefaultContainerというようにdotnet publishコマンドだけでコンテナイメージのビルドが実行されます。

% dotnet publish --os linux --arch x64 -c Release -p:PublishProfile=DefaultContainer

MSBuild version 17.4.0+18d5aef85 for .NET
  復元対象のプロジェクトを決定しています...
  復元対象のすべてのプロジェクトは最新です。
  my-awesome-container-app -> /Users/ttanaka/Repos/my-awesome-container-app/bin/Release/net7.0/linux-x64/my-awesome-container-app.dll
  my-awesome-container-app -> /Users/ttanaka/Repos/my-awesome-container-app/bin/Release/net7.0/linux-x64/publish/
  Building image 'my-super-awesome-app' with tags 1.2.3-alpha2,latest on top of base image mcr.microsoft.com/dotnet/aspnet:7.0-alpine
  Pushed container 'my-super-awesome-app:1.2.3-alpha2' to Docker daemon
  Pushed container 'my-super-awesome-app:latest' to Docker daemon

プロジェクトのどこかにDockerfileが作成されるわけでもなく、内部でビルドが行われます。

また、ビルドしたイメージはデフォルトでdocker://のdocker daemonへとpushされます。

ビルドされるイメージをカスタマイズしたい場合、現時点で利用可能な設定がこちらにあります。PropertyGroupに指定するものと、ItemGroupに指定するものがあります。

github.com

例えばこのように指定します。

  <PropertyGroup>
    <ContainerBaseImage>mcr.microsoft.com/dotnet/aspnet:7.0-alpine</ContainerBaseImage>
    <ContainerImageName>my-super-awesome-app</ContainerImageName>
    <ContainerImageTags>1.2.3-alpha2;latest</ContainerImageTags>
    <ContainerRegistry>registry.mycorp.com:1234</ContainerRegistry>
  </PropertyGroup>
  • ContainerBaseImage:ビルドするイメージのベースイメージ。デフォルトではプロジェクトの種類に応じて決められる。
  • ContainerImageName:ビルドしたイメージの名前
  • ContainerImageTagあるいはContainerImageTags:ビルドしたイメージに付与するタグ。1つの場合はContainerImageTagで、複数の場合はContainerImageTags。ただし、ContainerImageTagsは後述のバグがあり回避策が必要。
  • ContainerRegistry:pushするレジストリ。今の所認証不要なレジストリのみサポート。

デフォルトでベースとなるイメージはプロジェクトの種類に応じて以下のようになります。

  • ASP.NET Core アプリの場合、mcr.microsoft.com/dotnet/aspnet
  • 自己完結型アプリの場合、mcr.microsoft.com/dotnet/runtime-deps
  • 他のすべてのアプリでは、mcr.microsoft.com/dotnet/runtime

ContainerImageTagsでタグを複数指定したい場合、既知の不具合があるため以下の設定をcsprojに追加して回避します。

  <ItemGroup>
    <!-- Work around https://github.com/dotnet/sdk-container-builds/issues/236 -->
    <ContainerImageTags Include="$(ContainerImageTags)" />
  </ItemGroup>

便利なようですが、いくつか現時点での制約があります。

  • Linux x64アーキテクチャのイメージのみサポート
  • 認証なしのリポジトリへのpushのみサポート

また、run commandの変更はサポートしない方針と記載されています。

以上をふまえると現状ではブログにある通り、ローカル開発環境や認証なしのリポジトリにプッシュするようなCI/CDでの利用がメインとなりそうです。

.NET 7についてまとめてみた

自分の勉強がてらまとめています。公式ブログを日本語で、すぐ目を通せることを目的にしています。この記事では.NET 7のテーマについてまとめています。

devblogs.microsoft.com

.NET 7の新機能

フレームワークごとの新機能はそれぞれ公式ブログより提供されています。

devblogs.microsoft.com

devblogs.microsoft.com

devblogs.microsoft.com

devblogs.microsoft.com

devblogs.microsoft.com

devblogs.microsoft.com

シナリオ

.NET でできることになったことの一例が公式ブログやドキュメントで紹介されています。

ユニファイド

1つの基本ライブラリ

.NET 7 を使用すると、1 つの SDK、1 つのランタイム、1 つの基本ライブラリ セットを使用してさまざまな種類のアプリ (クラウド、Web、デスクトップ、モバイル、ゲーム、IoT、AI) を構築できます。

.NET 7をターゲットにする

プロジェクトで次のように指定すると.NET 7で利用可能なすべてのAPIが利用できます。

<TargetFramework>net7.0</TargetFramework>

これに加えて、以下のターゲットフレームワークモニカー(TFM)を指定すると、OS固有のAPIが利用可能になります。

  • net7.0-android
  • net7.0-ios
  • net7.0-maccatalyst
  • net7.0-macos
  • net7.0-tvos
  • net7.0-windows

ARM64

L3キャッシュサイズの読み取りの改善や、LSEアトミック命令の導入によりランタイムのパフォーマンスを改善した。組み込み関数を利用するライブラリを最適化するため、Vector64, Vector128, Vector256などのクロスプラットフォーム ヘルパーを導入した。これらによるパフォーマンスの改善の詳細については以下のブログを参照。

devblogs.microsoft.com

Linux での .NET サポートの強化

.NET 6 は Ubuntu 22.04 (Jammy) に含まれており、apt install dotnet6 コマンドでインストールできます。さらに、すぐに使用できる最適化されたビルド済みの超小型コンテナーイメージも提供しています。

64 ビット IBM Power のサポート

RHEL8.7およびRHEL9.1をターゲットとするppc64le (64-bit IBM Power) アーキテクチャをサポートしました。

モダン

.NET MAUIが.NET 7に含まれ、Blazorも改善を進めました。

Announcing .NET MAUI for .NET 7 General Availability - .NET Blog

Blazor - .NET Blog

また、.NET Upgrade Assistantにより.NET FrameworkやUWPなアプリを、.NET 6および.NET 7に移行する手助けをしてくれます。

.NET 6への移行

LTSである .NET 6がリリースされてから、Microsoftのサービスを中心に多数のサービスが.NET 6への移行を行い、その記事が公開されています。

クラウドネイティブ

dotnet publishコマンドにより直接コンテナイメージを生成できるなどのコンテナのネイティブサポートの強化、オブザーバビリティ獲得のためOpenTelemetry対応の強化など、クラウドネイティブへの投資を続けています。

devblogs.microsoft.com

シンプル

C# 11

C#11、F# 7が追加されました。

devblogs.microsoft.com

devblogs.microsoft.com

特にC#11では、新機能の静的仮想インターフェイス メンバーも活用したGeneric Mathが導入され、数値演算をもつクラスを定義するのが簡単になります。

learn.microsoft.com

また、エスケープ シーケンスを必要とせずに、空白、改行、埋め込み引用符、およびその他の特殊文字を含む任意のテキストを含めることができる生の文字列リテラル(raw string literal)が導入されています。

.NET ライブラリ

.NET ライブラリにも以下のものを初めてとする機能追加が行われています。

  • Microsoft.Extensions の Null 許容注釈
  • System.Composition.Hostingでコンテナに単一のオブジェクトインスタンスを許可
  • TimeStamp、DateTime、DateTimeOffset、TimeOnly へのマイクロ秒とナノ秒の追加
  • Microsoft.Extensions.Cachingにメトリクスサポートを追加
  • tar.gzを扱うSystem.Formats.Tar API
  • DateOnlyなど新しく追加された型に対応した型コンバーター
  • System.Text.Json コントラクトのカスタマイズ
  • System.Text.Json 型がユーザー定義型階層のポリモーフィック シリアル化と逆シリアル化をサポート

.NET SDK

.NET CLIにパーサーとタブ補完が追加されました。また、.NET テンプレートに制約の概念が追加され、どのテンプレートが表示されるかをユーザー環境に応じて制御することができます。

NuGet

NuGet6.4がリリースされました。

devblogs.microsoft.com

その中の新機能として、多数のプロジェクトがあるソリューションにおいて利用するパッケージのバージョンをソリューションで制御できる、NuGet の中央パッケージ管理機能が追加されました。

devblogs.microsoft.com

パフォーマンス

.NET 7でもパフォーマンス改善が引き続き行わています。

TL;DR: .NET 7 is fast. Really fast. A thousand performance-impacting PRs went into runtime and core libraries this release, never mind all the improvements in ASP.NET Core and Windows Forms and Entity Framework and beyond. It’s the fastest .NET ever. If your manager asks you why your project should upgrade to .NET 7, you can say “in addition to all the new functionality in the release, .NET 7 is super fast.” Stephen Toub - MSFT

一言で言うと: .NET 7は速いです。本当に速いです。今回のリリースでは、ASP.NET CoreやWindows Forms、Entity Frameworkなどの改良を差し置いて、ランタイムとコアライブラリに1000ものパフォーマンスに影響を与えるPRが投入されました。これは史上最速の.NETです。もしあなたの上司が、あなたのプロジェクトが.NET 7にアップグレードすべき理由を尋ねたら、「リリースに含まれるすべての新機能に加えて、.NET 7は超高速です」と言えばよいのです。

詳細はこのブログにまとめらています。

devblogs.microsoft.com

OpenTelemetry .NETを理解する (8) 手動でのトレースの接続例: Azure Service Busを経由したアプリ間でトレースをつなげる

複数サービス間での分散トレースは、必要な情報をサービス間で伝搬するContext Propagationによって実現されています。

opentelemetry.io

HTTPでサービスを呼び出している場合、HTTPヘッダーを利用して伝搬させることがほとんどで、最近になってW3C Trace Contextという規格で標準化が進められています。

www.w3.org

それ以外の方法、たとえばService Busなどを介している場合、トレースをつなげるためにはContext Propagationをこちらで実装する必要があります。ただ、Service Busなどのツール・サービスによってはツール・サービス側が提供していることもあり、今回はその一例としてAzure Service Busを使った例を試してみました。

Azure Service Busがどのようなしくみを提供しているかについてはドキュメントがあります。

learn.microsoft.com

ドキュメントによると、トレースをつなげるのに必要な情報(Context)としてW3C Trace Contextのtraceparentヘッダーを利用しているとあります。具体的には、<version>-<trace id>-<parent id>-<trace flags> というフォーマットです。例えば 00-182984d0d8c4fa391e63fcf168413636-797cf612be994b1c-00 という値があったとき、versionは00、trace idは182984d0d8c4fa391e63fcf168413636、parent idは797cf612be994b1c、trace flagsは00となります。この値を、Service BusのメッセージのDiagnostic-Idというプロパティに格納して伝搬させます。送信側が格納する処理を、Azure Service Bus SDKを使っていれば自動で行ってくれるとのことです。

ドキュメントでは「OpenTelemetry を使用した追跡」という部分では具体的な方法が書いていないので、今回試してみました。

まず、メッセージを送るプロデューサー側はASP.NET CoreのWebアプリで、メッセージを受け取るコンシューマー側は.NET Coreのコンソールアプリケーションです。コンシューマー側のWebアプリはOpenTelemetryでの計装がセットアップされていれば、それ以上の手間が必要ありません。送信処理は次のようになりますが、これはドキュメントにあった処理をそのまま使っています。

var clientOptions = new ServiceBusClientOptions() { TransportType = ServiceBusTransportType.AmqpWebSockets };
client = new ServiceBusClient(connectionString, clientOptions);
sender = client.CreateSender(queueName);

// create a batch 
using ServiceBusMessageBatch messageBatch = await sender.CreateMessageBatchAsync();

//デバッグ用に出力(計装そのものには不要)
var activity = Activity.Current;
Console.WriteLine($"activity id: {activity?.Id}");
Console.WriteLine($"activity Trace id: {activity?.TraceId}");
Console.WriteLine($"activity Span id: {activity?.SpanId}");

for (int i = 1; i <= numOfMessages; i++)
{
    // try adding a message to the batch
    if (!messageBatch.TryAddMessage(new ServiceBusMessage($"Message {i}: {MessageBody}")))
    {
        // if it is too large for the batch
        throw new Exception($"The message {i} is too large to fit in the batch.");
    }
}

try
{
    await sender.SendMessagesAsync(messageBatch);
    Console.WriteLine($"A batch of {numOfMessages} messages has been published to the queue.");
}
finally
{
    await sender.DisposeAsync();
    await client.DisposeAsync();
}

受信側はいままで紹介していないコンソールアプリでのOpenTelemetryの計装になるのですべてのコードを載せておきます。tracerProvider を作る部分はASP.NET Coreの場合とほぼ同じですが、ActivitySourceを生成し、インスタンスを保持したうえで、トレースの計測を行う部分でActivitySourceからActivityを生成・起動する必要があります。今回のように親SpanのIDを渡したい場合は引数で渡すことができます。

using Azure.Messaging.ServiceBus;
using OpenTelemetry.Resources;
using OpenTelemetry;
using System.Diagnostics;
using OpenTelemetry.Trace;
using OpenTelemetry.Exporter;
using System.Reflection;

//Service Busへの接続文字列
var connectionString = "<replace_your_conncetion_string>";

//Service Bus queueの名前
var queueName = "<replace_your_queue_name>";

ServiceBusClient client;
ServiceBusProcessor processor;

var serviceName = "OpenTelemetryLabs.AzureIntegrations.Console";
var assemblyVersion = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
Action<ResourceBuilder> configureResource = r => r.AddService(
    serviceName, serviceVersion: assemblyVersion, serviceInstanceId: Environment.MachineName);

using var tracerProvider = Sdk.CreateTracerProviderBuilder()
    .AddSource(serviceName)
    .ConfigureResource(configureResource)
        .AddOtlpExporter(opt =>
        {
            //OTLPのエクスポート先の指定
        })
    .Build();

var MyActivitySource = new ActivitySource(serviceName);

var clientOptions = new ServiceBusClientOptions() { TransportType = ServiceBusTransportType.AmqpWebSockets };
client = new ServiceBusClient(connectionString, clientOptions);

processor = client.CreateProcessor(queueName, new ServiceBusProcessorOptions());

try
{
    processor.ProcessMessageAsync += ProcessMessageAsync;

    processor.ProcessErrorAsync += (args) =>
    {
        Console.WriteLine(args.Exception.ToString());
        return Task.CompletedTask;
    };


    while (true)
    {
        Console.WriteLine("Starting the receiver...");
        await processor.StartProcessingAsync();
        await Task.Delay(TimeSpan.FromSeconds(1));
        Console.WriteLine("Stopping the receiver...");
        await processor.StopProcessingAsync();
        Console.WriteLine("Stopped receiving messages. Waiting 60 seconds.");
        await Task.Delay(TimeSpan.FromSeconds(60));
    }
}
finally
{
    await processor.DisposeAsync();
    await client.DisposeAsync();
}

async Task ProcessMessageAsync(ProcessMessageEventArgs args)
{
    string body = args.Message.Body.ToString();
    //メッセージの本文
    Console.WriteLine($"Received: {body}");
    if (args.Message.ApplicationProperties.TryGetValue("Diagnostic-Id", out var objectId) && objectId is string diagnosticId)
    {
        //デバッグ用にDiagnosticId(traceparentヘッダー)の値を出力
        Console.WriteLine($"DiagnosticId: {diagnosticId}");
        //traceparentヘッダーを送信したSpanを親のスパンとして子スパンを開始
        using var activity = MyActivitySource.StartActivity("ProcessMessageAsync", ActivityKind.Consumer, parentId: diagnosticId);
        //デバッグ用に子スパンの情報を出力
        Console.WriteLine($"activity id: {activity?.Id}");
        Console.WriteLine($"activity Trace id: {activity?.TraceId}");
        Console.WriteLine($"activity Span id: {activity?.SpanId}");
        Console.WriteLine($"activity Parent id: {activity?.ParentId}");
        //タグを追加
        activity?.SetTag("message", body);

        await Task.Delay(1000);
        await args.CompleteMessageAsync(args.Message);
    }
}

さて、実際に実行するとプロデューサー側ではこのようにActivityのTrace IDとSpan IDが出力されます。

activity id: 00-59ed36599d26220d1362c2ac04e66cb3-394930dbfcb529b3-01
activity Trace id: 59ed36599d26220d1362c2ac04e66cb3
activity Span id: 394930dbfcb529b3

すると、コンシューマー側ではこのように出力されます。DiagnosticIdで上と同じ値が渡ってきており、これを親としてSpanを作成するとTrace Idは同じで、Span Idが別のものになっています。また、Parent Idが設定されていることも確認できます。

DiagnosticId: 00-59ed36599d26220d1362c2ac04e66cb3-394930dbfcb529b3-01
activity id: 00-59ed36599d26220d1362c2ac04e66cb3-0066bfbb9bc123a8-01
activity Trace id: 59ed36599d26220d1362c2ac04e66cb3
activity Span id: 0066bfbb9bc123a8
activity Parent id: 00-59ed36599d26220d1362c2ac04e66cb3-394930dbfcb529b3-01

これで目的は達成できましたが、最後にオブザーバビリティバックエンドでどのように見えるかの一例をあげます。上で出力されたTrace Idをもつ1つのトレースとして表示されます。また、プロデューサー側は1回の処理=1つのトレースで3つのメッセージを送信するため、3つのコンシューマー側のスパンが確認できます。

プロデューサー側のSpanのIdも確認でき、

コンシューマー側のParent Idに設定されていることがわかります。

なお、コンシューマー側のSpanが完了するまでがトレースの継続時間となるため、場合によっては非常に長くなる場合があります。サービスによっては1つのトレースでつなげるのではなく、複数のトレースに共通の属性の値としてもたせておき、必要に応じて関連付けを検索できるようにしたほうがいい場合もあります。また、複数の異なるプロデューサーのメッセージを、1つのコンシューマーで処理する場合、複数の親をもった子スパンを現在のOpenTelemetryの分散トレースでは表現できないため、複数のトレースとして表現する必要があります。