🔬

C#の理解が10年古い、と言われたのでCloud Runにデプロイしてみた

に公開

はじめに

ちょっとSNSで 「.NETはLinuxでも本格的に動く!」 とか 「Web系エンジニアは.NETの理解が10年前で止まってる」 とか話題ですね。一方で、「そもそも.NETでLinuxをメインにした開発方法をWebの記事等で見かけない」 という話もあり、確かに自分もあんまり見かけないなー、と思ったので 「無いなら作る」 の精神で作りながら書いてみる事にしました。Weekly ITニュースで扱ってるから概要は知ってるんですがエアプ気味でしたので良い機会。まあ、お仕事だとJavaがメインだけど、自称 Web系エンジニアなので対よろです!

TL;DR

  • 最新の.NETはWindows専用だった.NET Frameworkと違いLinuxも一級市民
  • VS Code + DevContainer, VueやReactと組合せ安いREST API, コンテナ, GCPのCloud Runデプロイを実施
  • DevOpsと統合しやすいCLIやOtelもサポートされているのでGCPのo11yとも統合が出来る。Azureは専用でもない
  • Linux/コンテナ時代のWeb開発にあった、良くも悪くも昔とは違うランタイムとクラスライブラリ

何を作る?

とりあえず何を作るか決める必要があります。そうですね、Webの開発のチュートリアルといえばやはり 「TODO管理」 にしましょう!

シンプルなREST APIとして実装します。これならフロントエンドはReactとかVueとか使えるのでいつも通り。.NETにはBlazorがあり、クライアントもサーバサイドも.NETで書いてしまうユニバーサルなFWが真骨頂かもしれません。ただ、今回は新しい概念を入れずに私を含めた.NETミリ知ら勢が困惑しないように普通のRET APIサーバにします。

DB周りとかMySQLとかは環境準備が面倒なので組み込みのインメモリDBを使います。動作環境はGCPのCloud Run、サーバレスです。Javaだとこの辺が起動速度の点で不利なんですが.NETはどうでしょう?
開発環境はVS Code + DevContainer。AzureとかVisualStudioとかではあえて固めない方向で。

Deploy先がCloud RunなのでCloud MonitoringやTraceとの連携も重要ですね。そこも見てみましょう。

コーディングの前に、現在の.NETのおさらい

.NETと一口に言っても、実は統廃合を繰り返しています。初期のWindowsのみを想定した .NET Frameworkとは、実は今はコードベースからして別物なのです。というわけで、おさらいをしてみましょう。少し長くなるので、御託は良いからコードを見せろ!って人は飛ばしてください。

alt text

はじまりの.NET Framework

.NETはまずWindows向けの.NET Frameworkから始まります。これは、COMの複雑さやJ++の訴訟問題もあり、優れた言語とランタイムを欲していたMicrosoftがWindows向けに開発したのが .NET Framework です。Javaと同様に中間言語方式ですが当初から複数の言語の実装が設計されており、C#はもちんVB.NETやOCamlベースのF#、かつてはJ#とかIronRubyもIronPythonありました。これが4.7まで主流の.NET環境として使われます。

オープンソースの非公式実装Mono

C#は人気があったのでLinuxでも動作させたい、というニーズが早くからありLinuxを想定したOSS実装としてMonoが登場しました。Beagleとかで採用されてた気がするので懐かしいですね。C#/CLIはECMAやISOで標準化されたのでOSS化しやすい部分もあったかもしれません。

正直、Linux上での開発ツールとしてはそこまで印象にないMonoですが、大人気のゲーム開発ツールの Unityで採用されました。そのためUnityではC#をメインの開発言語としつつもクロスプラットフォームで動くゲームが作れるわけですね。その他にもiOSもAndroidも同一のコードベースで開発するXamarinでも採用されています。

このXamarinが歴史的には重要で、元々Monoの開発チームが設立した会社なのですが2016年にMicrosoftに買収されます。そして別にMicrosoftも彼らを解体したり製品ラインを潰したわけではなく、iOS/Androidのクロスプラットフォーム環境として自社のラインナップに組み込みます。OSSなのでMSの持ち物という表現は微妙ですが、開発者という意味では公式と非公式の両方のC#実装をMicrosoftが抱える奇妙な状態になりました。

新たなるOSS、.NET Core

Microsoftにとって転機となったのが、AzureとCEOがSatya Nadellaになった事です。これにより、かつては 「Linuxは癌」 とまで言ったWindowsの会社から、 Microsoft♡Linux と言ってのけるクラウドとOSSの会社になりました。実際、2017年時点でAzure上のLinuxマシンのシェアは40%を超えてますし、Java系のエンジニアもMicrosoftに入社したりと大きな流れの変化を外からも感じれました。今はもっと加速しているでしょう。

この転換は、C#を含めた .NETにも大きなインパクトがあり、多くのエコシステムと統合するためにもC#をLinuxで動かす必要がありました。そのために生まれたのが、現在の.NETの祖となる .NET Core です。

alt text
ref: オープンソースの「.NET Core 1.0」、マイクロソフトが正式リリース。Windows/Linux/macOSに対応。Red HatがRHELなどで正式サポート開始

元々、Windows向けに作ってあった .NET Frameworkではレガシーが過ぎてLinux対応が困難 だったのでしょう。また、Monoも元が非公式なリバースエンジニアリングから始まっている ので、最適な実装では無かったのかもしれません。そのため、新規にOSSとして開発されました。主に Linuxを含むOSSのエコシステムを取り込むために.NET Coreは誕生します。LinuxやMacをサポートし、Dockerコンテナで動かすことも出来るモダンなランタイム の登場です。Red Hatとも提携をしてビジネスでも使いやすく したりと様々な対応を進めていました。

大・統・一!! 唯一にして真なる.NET

.NET Coreが出た2016年からの数年間はMicrosoftは公式に、.NET Framework、Mono、.NET Coreという3つのランタイムを抱えることになりました。用途は一応、Windows向けモバイル開発向けサーバサイド向けと棲み分けする事は出来ますが、Microsoft的にもユーザ的にも混乱するだけなので、これはかなり初期の段階から統合が言われていたような気がします。混乱を緩和するための1つの試みが最小公倍数となる仕様を定義する.NET Standardです。

そして、.NET Coreのバージョンが上がるごとに、どんどんと.NET Framework固有の機能を取り込んでいきました。ただ、UIフレームワーク周りがやはり従来との互換性を保ちつつクロスプラットフォームする難易度のためか難産だった印象です。

その苦労が実って2019年.NET5として、メインストリーム.NET Frameworkから .NET Core切り替わりました。つまり、.NET5.NET Core 4とも言える存在なのですが混乱を避けるために .NET5としてメインのナンバリングがされています。これで、Windowsも、Linuxを含むサーバサイドも、モバイル開発も、.NETという唯一のランタイム実装に統合されたわけです。

alt text
ref: [速報]オープンソースの「.NET 5」がすべての.NETを引き継ぐ。.NET Frameworkと.NET CoreとXamarinは「.NET 5」に。Microsoft Build 2019

そのため、元はWindowsで動かすための環境を無理やりLinuxで動かしているというWINEのような印象とは異なり、 現在の.NETLinuxやDockerコンテナでC#等を本格的に動かすために公式が設計したランタイムということですね。とはいえ、実際のところは触ってみないと分かりませんので、その辺はこの後やってみましょう。

余談ですが、ついでに調べてみたら、UnityはMonoを結構カスタムして使っていた都合もあり、.NETへの移行は難航してるみたいですね。現在のUnity 6でも.NET 8の採用は出来てなさそう。予定はもちろんしてるみたいですが。がんばれ!

開発環境の構築とHello World

まずは.NET向けの開発環境を作ります。VisualStudioのインストーラ―をダウンロードしてきて... と初めても良いのですが、やっぱりDevContainerが手軽で良いですよね?
という分けで、DevContainerで環境を作っていきます。
alt text

まずはC#の開発環境を選びます。今回は.NET9にしました。
alt text

つづいてデプロイ環境はGCPを想定しているのでgcloudもインストールしておきます。
alt text

あとはコンテナをReopenして環境の開発プロビジョニング。DevContainerは生成されたものを少し修正して最終的には以下のようにしました。

{
	"name": "C# (.NET)",
	"image": "mcr.microsoft.com/devcontainers/dotnet:1-9.0",
	"features": {
		"ghcr.io/dhoeric/features/google-cloud-cli:1": {}
	},
	"customizations": {
		"vscode": {
			"extensions": [
				"ms-dotnettools.csdevkit"
			]
		}
	}
}

環境を作ると.NET SDKがインストールされています。例えばバージョンのチェックは以下の通り。

vscode ➜ /workspaces/example-dotnet $ dotnet --version
9.0.304

Visual Studioで書くのもリッチな環境でとても良さそうですが、CLIベースの作業が出来るとCI/CDとの統合やClaude CodeなどのAIの活用がやり易くて便利ですね。

続いてプロジェクトを作っていきましょう。これもCLIから実施できます。dotnet newでテンプレートを選択して環境を構築できるようです。どんなテンプレートがあるかはdotnet new listで確認できます。

vscode ➜ /workspaces/example-dotnet $ dotnet new list
These templates matched your input: 

Template Name             Short Name                  Language    Tags                              
------------------------  --------------------------  ----------  ----------------------------------
API Controller            apicontroller               [C#]        Web/ASP.NET                       
ASP.NET Core Empty        web                         [C#],F#     Web/Empty                         
ASP.NET Core gRPC Ser...  grpc                        [C#]        Web/gRPC/API/Service              

- 略 -     

今回はWebAPIを作りたかったので以下のように構築しました。ASP.NET Coreベースのテンプレートです。

dotnet new webapi

そうすると、プロジェクトの中にコードが色々できてきます。Program.csがエントリーポイントで、{プロジェクト名}.csprojがパッケージの依存関係とかプロジェクトの情報が記載されてるみたい。Javaでいうところのpom.xmlかな。それにしても、あえてmain.csじゃなくてProgram.csってところにCの系譜というよりはPascal味を感じるのは私だけでしょうか?

Program.csを以下の様に書き替えたHello Worldを作成しました。

var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();

app.MapGet("/", () => "Hello World!");
app.Run();

これを実行するにはdotnet runを使います。

vscode ➜ /workspaces/example-dotnet $ dotnet run
Using launch settings from /workspaces/example-dotnet/Properties/launchSettings.json...
Building...
warn: Microsoft.AspNetCore.Hosting.Diagnostics[15]
      Overriding HTTP_PORTS '8080' and HTTPS_PORTS ''. Binding to values defined by URLS instead 'http://localhost:5195'.
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://localhost:5195

ブラウザからアクセスすると以下のように無事Hello Worldが表示されました。

alt text

良いですね! コードもとてもシンプルでモダンなフレームワークです。 新規に作る場合古のASP開発と全然違いそう。まあ、この辺の事情はJavaも一緒ですね。

ちなみに、binを覗いてみるとLinuxであっても.dllが普通にたくさんありますね。まあ、ネイティブバイナリというわけでも無いし、.soじゃないのは仕方ない。Javaのjarと同じですからね。

alt text

TODOアプリを作ろう!

概要

やはり、Web開発のチュートリアルの華はTODOアプリです。ガンジス川の砂の粒ほどあることでしょう。1粒増やしときます。

今回、データベースは.NETに組み込まれているインメモリDBを使います。そのためにNuGetからORMのEntityFrameworkCoreとインメモリDB実装のEntityFrameworkCore.InMemoryを以下のコマンドで取得します。

dotnet add package Microsoft.EntityFrameworkCore --version 9.0.0
dotnet add package Microsoft.EntityFrameworkCore.InMemory --version 9.0.0

パッケージ管理システムがNuGetなのに、CLIがnugetコマンドじゃなくて、あくまでdotnet add packageメインコマンドに統合されてるのは今っぽいですね。

今回は以下のようなディレクトリ構成となります。ASP.NET CoreRuby on Railsと同様に 「CoC (Convention over Configuration / 設定より規約)」 をサポートしてるぽくて、原則このディレクトリ名にして置く必要がありそうです。カスタマイズは出来るんでしょうが、 命名規則とか余計なことに頭を悩まさなくて良いのは嬉しいですね。

example-dotnet/
├── Controllers/              <--  コントローラクラスを置く場所
│   └── TodoItemsController.cs
├── Models/                   <-- モデルクラスを置く場所
│   ├── TodoContext.cs
│   └── TodoItem.cs
├── Properties/
│   └── launchSettings.json
├── appsettings.json
├── MyApi.csproj              <-- プロジェクトファイル
└── Program.cs                <-- アプリケーションの起動ファイル

モデル - ContextとEntity

まずは、TODOリストのモデルを作成します。EntityFrameworkCoreEntityRepositoryを分けるようで、TodoContextがDBの具体的な操作をするRepositoryTodoItemが実際のデータが格納されるEntityです。

Models/TodoItem.cs

using System.ComponentModel.DataAnnotations;

namespace TodoApi.Models;

public class TodoItem
{
    public long Id { get; set; }
    [Required]
    public string? Name { get; set; }
    public bool IsComplete { get; set; }
}

TodoContextはDbContextを継承していて、基本的な操作しか今回はしていないので特に記述はありません。また、標準的なDBアクセスのI/F非同期I/Oとして実装されているようなので中々良さそうです。

Models/TodoContext.cs

using Microsoft.EntityFrameworkCore;

namespace TodoApi.Models;

public class TodoContext : DbContext
{
    public TodoContext(DbContextOptions<TodoContext> options)
        : base(options)
    {
    }

    public DbSet<TodoItem> TodoItems { get; set; } = null!;
}

コントローラー - TodoItemsController.cs

コントローラはJAX-RSのようにシンプルに記述することが出来ます。先ほど作成したモデルを使って、登録一覧取得のシンプルなREST APIを実装しています。今回のケースではURLはapi/TodoItems/になるらしくクラス名がそのままURLになるのでルーティングのための情報を別に記載する必要はなさそうです。

個人的に興味深いのはRESTのためのメソッドが全てasync非同期呼び出しになっていることです。JavaでいうNettyのような非同期IOをサポートしているっぽくて効率的なリソース利用が出来そうです。

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

namespace TodoApi.Controllers;

[ApiController]
[Route("api/[controller]")] // URLは "api/TodoItems"
public class TodoItemsController : ControllerBase
{
    private readonly TodoContext _context;
    public TodoItemsController(TodoContext context)
    {
        _context = context;
    }
    // GET: api/TodoItems
    [HttpGet]
    public async Task<ActionResult<IEnumerable<TodoItem>>> GetTodoItems()
    {
        return await _context.TodoItems.ToListAsync();
    }
    // GET: api/TodoItems/5
    [HttpGet("{id}")]
    public async Task<ActionResult<TodoItem>> GetTodoItem(long id)
    {
        var todoItem = await _context.TodoItems.FindAsync(id);
        if (todoItem == null)
        {
            return NotFound();
        }
        return todoItem;
    }
    // POST: api/TodoItems
    [HttpPost]
    public async Task<ActionResult<TodoItem>> PostTodoItem(TodoItem todoItem)
    {
        _context.TodoItems.Add(todoItem);
        await _context.SaveChangesAsync();
        return CreatedAtAction(nameof(GetTodoItem), new { id = todoItem.Id }, todoItem);
    }
}

エントリーポイント - Program.cs

最後にエントリーポイントです。先ほどProgram.csを修正します。エントリーポイントではDIへの登録やミドルウェアの設定をします。

今回は、AddDbContext<TodoContext>で先ほど作成したTodoContextをDIに登録し、かつInMemoryDatabaseでインメモリデータベースをTodoListという名前で作成して紐づけています。

ちなみに、仮にMySQLを使う場合は以下のようになるようです。

builder.Services.AddDbContext<TodoContext>(opt =>
    opt.UseMySql(
        connectionString: "Server=localhost;Database=TodoList;Uid=root;Pwd=password;",
        serverVersion: ServerVersion.AutoDetect("Server=localhost;Database=TodoList;Uid=root;Pwd=password;")
    ));

ここで渡したコンテキストが、コントローラ側でDIされるコンテキストそのものです。

それ以外にも、AddEndpointsApiExplorerAddOpenApiMapOpenApiの一連の機能でOpenAPIの設定もしています。必要に応じて認証機能のミドルウェア等を登録していきます。

using Microsoft.EntityFrameworkCore;
using TodoApi.Models;

var builder = WebApplication.CreateBuilder(args);

// DIへのサービスの登録
builder.Services.AddDbContext<TodoContext>(opt =>
    opt.UseInMemoryDatabase("TodoList"));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddOpenApi();

// Appの作成
var app = builder.Build();

// ミドルウェアの設定
app.MapOpenApi();
app.MapControllers();

app.Run();

動作確認

こちらをdotnet runで起動して、以下のようにcurl等で叩くと簡単なTODOアプリとして動作します。フロントエンドはReactなり、Vueなり、Blazorなりで良い感じに作りましょう。

# 追加
vscode ➜ /workspaces/example-dotnet $ curl -X POST http://localhost:5195/api/TodoItems -H "Content-Type: application/json" -d "{\"name\":\"犬の散歩\",\"isComplete\":false}"
{"id":1,"name":"犬の散歩","isComplete":false}
vscode ➜ /workspaces/example-dotnet $ curl -X POST http://localhost:5195/api/TodoItems -H "Content-Type: application/json" -d "{\"name\":\"FGOをプレイ\",\"isComplete\":false}"
{"id":2,"name":"FGOをプレイ","isComplete":false}

# TODOの一覧を取得
vscode ➜ /workspaces/example-dotnet $ curl -X GET http://localhost:5195/api/TodoItems
[{"id":1,"name":"犬の散歩","isComplete":false},{"id":2,"name":"FGOをプレイ","isComplete":false}]

# TODOの取得
vscode ➜ /workspaces/example-dotnet $ curl -X GET http://localhost:5195/api/TodoItems/1
{"id":1,"name":"犬の散歩","isComplete":false}

さらに、先ほどOpenAPIも登録したので、以下のようにJSONを取得することも可能です。

vscode ➜ /workspaces/example-dotnet $ curl http://localhost:5195/openapi/v1.json
{
  "openapi": "3.0.1",
  "info": {
    "title": "example-dotnet | v1",
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "http://localhost:5195/"
    }
  ],
  "paths": {
    "/api/TodoItems": {
      "get": {
        "tags": [
          "TodoItems"
        ],
~ 略 ~

これでアプリケーションの開発は出来ました。今回は本題ではないのであまり深掘りしてないですが、ASP.NET Coreは中々モダンで良さそうなFWですね。

Dockerコンテナにしよう!

さて、アプリケーションは出来たのでCloud Runにデプロイする前準備としてコンテナにしましょう。

本来であれば、こうしたシンプルなアプリはDockerfileよりもCloud Native Buildpacksを使うほうが、簡単ですしセキュリティ的にもベストプラクティスです。.NETもBuildpacksでサポートされており.csprojを見て判断されています。そのため、Dockerfileの作成は通常は不要です。

ただ、今回 Cloud Runで動かすにあたって、最新の.NET9は未だサポートされておらず、.NET8のサポートだったためBuildpackではバージョン不一致でエラーになりました。最新ランタイムあるあるだけど、この辺はLTSじゃないからと割り切るしかないかな。
もちろん、.NET8で書き直しても特に問題は無いのですが、Native AOTとかも今度試したいのでDockerfileを作る手順を試します。以下のようにマルチステージビルドで作成することが出来ます。

先ほどまではdotnet runで起動させていましたが、本番環境での動作は一度dotnet publishdllにコンパイルしてdotnetコマンドで実行するみたいですね。

# 1. ビルドステージ
FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /source

COPY *.sln .
COPY example-dotnet.csproj .
RUN dotnet restore

COPY . .
RUN dotnet publish "example-dotnet.csproj" -c Release -o /app/publish

# 2. 公開ステージ
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS final
WORKDIR /app
COPY --from=build /app/publish .

# コンテナ起動時に実行するコマンドを指定します。
ENTRYPOINT ["dotnet", "example-dotnet.dll"]

以下のようにdocker runをした後に簡単にcurlで挙動を確認することが出来ます。

$ docker build -t koduki/example-dotnet .
$ docker run -it -p 8080:8080 koduki/example-dotnet
info: Microsoft.Hosting.Lifetime[14]
      Now listening on: http://[::]:8080
info: Microsoft.Hosting.Lifetime[0]
      Application started. Press Ctrl+C to shut down.

GCP Cloud Runにデプロイしてみる

きっと、多くの.NETの記事はここでAzureにデプロイするでしょう。でも、今回はGCPです。だって私はGCPの方が好きだから!

半分冗談ですが、これは割と大事な話で同じMicrosoftのAzureでしか良い感じに動かないのであれば困ります。今回は、 .NET on Linuxを試してみるのが最大の目的なので、あえて違うエコシステムに入れます。これが出来るのが.NET Core誕生の目的でしょうしね。まあ、コンテナになってる時点で既に懸念はないのですが。

ではCloud Runにデプロイしましょう。Cloud RunはBuildpacks及びDockerfileをサポートしているので、以下のようにすれば自動的にビルドしてデプロイされます。

$ gcloud run deploy example-dotnet-app --source . --region asia-northeast1

正常にデプロイされたら以下のようにアクセスして動作確認をしてみます。

$ curl -X POST https://example-dotnet-app-xxxx.asia-northeast1.run.app/api/TodoItems -H "Content-Type: application/json" -d "{\"name\":\"犬の散歩\",\"isComplete\":false}"
$ curl -X GET https://example-dotnet-app-xxxx.asia-northeast1.run.app/api/TodoItems
[{"id":1,"name":"犬の散歩","isComplete":false}]

バッチリ動きました! 初回アクセスはコールドスタートになるはずなんですが、Javaと比べて何の工夫もしなくてもメッチャ速くて良いですね!
alt text

インメモリでGETしてるだけなので4.8ms前後ですね。良いレスポンス。998ms - 1.86sと非常に遅いのが混じってますが、これはコールドスタート分ですね。Cloud Runのようなサーバレスは初回リクエストはプロセスを立ち上げるスピンアップタイムがレスポンスに乗るため極端な起動速度が求められます。

ここがJITでもこれだけ速いのは驚きですね。JavaだとHelidonとか使っても一工夫必要だったしな...

o11y - .NETアプリの深淵をもっとよく見る!

Cloud Traceでトレース情報を見る

とりあえず、デプロイしましたが運用を考えると様々なメトリクストレースを扱うo11y(Observability/可観測性) が重要です。AzureであればApplication InsightsなどのAPMで色々な情報を自動で取れそうですが、GCPの場合はどこまでお手軽に良いモニタリングが出来るかですね。

とりあえず、Cloud Runで動いているので特に何もしなくても以下のようにCloud Traceから情報を見る事は出来ます。ただし、デフォルトの状態ではレスポンス時間が見えるだけでアプリケーションに踏み込んだ情報は一切読めません

alt text

まあ、これは当たり前ではあります。Cloud Runの普通の挙動ですね。Cloud TraceはOpenTelemetryをサポートしているので、そちらから情報を送ってみましょう。

まず、NuGetから以下のパッケージを追加します。

# OpenTelemetryをホスティング環境に統合するための基本パッケージ
dotnet add package OpenTelemetry.Extensions.Hosting
# ASP.NET Coreのリクエストを自動でトレースするためのパッケージ
dotnet add package OpenTelemetry.Instrumentation.AspNetCore
# Entity Framework Coreのクエリを自動でトレースするためのパッケージ
dotnet add package OpenTelemetry.Instrumentation.EntityFrameworkCore --prerelease
# 収集したトレースをGoogle Cloud TraceにOtelで送信するためのパッケージ
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol
# ローカルでのデバッグ用にコンソールへトレースを出力するパッケージ
dotnet add package OpenTelemetry.Exporter.Console

Program.csで、DIにTelemetryサービスを追加します。ExporterとしてはCloud TraceやCloud Monitoringに連携するためのExporter.OpenTelemetryProtocolとローカルでの開発に便利なExporter.Consoleを指定しています。

builder.Services.AddOpenApi();
// ここから
builder.Services.AddOpenTelemetry()
    .ConfigureResource(resource => resource.AddService(builder.Environment.ApplicationName))
    .WithTracing(tracing =>
    {
        tracing
            .AddAspNetCoreInstrumentation()
            .AddEntityFrameworkCoreInstrumentation(options =>
            {
                options.SetDbStatementForText = true;
            })
            .AddOtlpExporter(); 
        // 開発環境でのみ、コンソールにもトレースを出力する
        if (builder.Environment.IsDevelopment())
        {
            tracing.AddConsoleExporter();
        }
    });
// ここまで
builder.Services.AddDbContext<TodoContext>(opt =>
    opt.UseInMemoryDatabase("TodoList"));

さて、あとはデプロイするだけ! と言いたいところですが、これだけでは上手くCloud Traceに連携できません。

というのも、認証情報を渡してやる必要があるのですが、通常はCloud RunからCloud Traceに連携するときにはIAM権限をベースにして、ADCという仕組みやります。ただ、.NETのOTelライブラリはADCに直接対応してないので、Google向けの認証の仕組みをGoogle.Apis.Authあたりを使って実装してやる必要がありそうです。

この方法でも良いのですが、せっかくのベンダーニュートラルなOTelなので、あんまり 固有のコードを入れたくない ので今回はマルチコンテナを利用したサイドカーパターンにします。

サイドカーパターンであれば認証周りの情報はアプリ側に記述する必要がなく、シンプルにlocalhostのCollectorに連携するだけなので簡単です。認証はCollector側で実施 するのでADCで簡単にIAMベースの認証が出来ます。まあ、この辺は.NET云々というよりはGCPの話ですね。

以下を作成して、gcloud builds submitでコンテナのビルドとRunへのデプロイを実施します。

これでアプリケーション側が出しているトレースも取り込むことが出来ました。

alt text

OTelで連携しているので必要に応じてDatadogでもJaegerでもUptraceなんでも好きなAPMを使う事が出来ます。

Cloud Monitoringでメトリクスを見る

OTelはトレース情報だけではなく、メトリクスも取得できます。トレースとメトリクスが別の画面になってしまうのは、個人的にはGoogle Cloud ObservabilityのイマイチなところですがCloud Monitoringのダッシュボードに連携されます。

まずは、メトリクスの計装をするためのパッケージを追加します。今回はいったんCPUのようなOSメトリクス、GCのような.NETランタイムのメトリクス、そしてリクエスト数などのASP.NET Coreのメトリクスを収集します。ASP分は既にあるので残り二つのパッケージを追加しましょう。

# CPUや物理メモリ使用量を収集
dotnet add package OpenTelemetry.Instrumentation.Process --prerelease
# GCやJITなど.NETランタイムの情報を収集
dotnet add package OpenTelemetry.Instrumentation.Runtime --prerelease

Program.csを改修して、トレースだけではなくメトリクスも収集できるようにします。

    .WithMetrics(metrics =>
    {
        metrics
            .AddAspNetCoreInstrumentation() 
            .AddProcessInstrumentation()  
            .AddRuntimeInstrumentation();
        metrics.AddOtlpExporter();

        // 開発環境でのみ、コンソールにもメトリクスを出力する
        if (builder.Environment.IsDevelopment())
        {
            metrics.AddConsoleExporter();
        }
    });

これで、メトリクスもOTelで連携することが出来るようになりました。例えば主には以下のような値が取得できます。

メトリクス名 パッケージ 説明
process.cpu.time Process CPU使用時間。プロセスのCPU負荷を監視します。
process.memory.usage Process 物理メモリ使用量。メモリリークの発見に利用します。
process.runtime.dotnet.gc.collections.count Runtime GC実行回数。メモリ割り当ての効率を監視します。
process.runtime.dotnet.thread_pool.threads.count Runtime スレッドプール数。スレッド枯渇を監視します。
http.server.request.duration AspNetCore リクエスト処理時間。性能監視の最重要指標です。
http.server.active_requests AspNetCore アクティブなリクエスト数。サーバーの負荷状況を示します。
http.client.request.duration Http 外部API応答時間。外部サービスの性能を監視します。
db.client.commands.duration EntityFrameworkCore DBコマンド実行時間。DB性能のボトルネックを特定します。

例えばGCの値は以下のようにCloud MonitoringのMetrics Exploreから取得できます。

alt text

ここからダッシュボードを作っていくのはいつも通りのGCPのやり方ですね。ただ、.NET向けのテンプレートはGCPには用意されてないので、ちょっとめんどくさいから今回は割愛。

まあ、この辺はAzureとかを使えば解決するところでしょうし、Datadogだったり商用のAPMや監視ツールだと充実してるかもですね。

まとめ

とりあえず、.NETで簡単なWebアプリを作って、GCPにデプロイをしてみました。一応、Webエンジニアの端くれの感想としては、開発環境も運用環境も違和感が無く触れる感じかな? 開発もVS Code.dlldotnetコマンドに渡すのは慣れの問題で違和感が凄いですがw

.NET周りは歴史が複雑で、最近Linux周りもネイティブに対応したのは知ってたのですが、横から眺めるだけで触って無かったので良い機会でしたね。所感としては、良く作りこまれてるし、モダンな感じだなー、と。Javaと比べて起動のフットプリントがCloud Runでも良いのは素敵ですねー。

ただ、今回はあくまで新規に最新のアーキテクチャで作った場合の話です。SNSで元々言われてたニュアンスは既存の資産に対しての部分もごっちゃでありそうだから、そういうのをコンテナ化やLinuxで対応させるとなるとまた違うんでしょうね。

個人的にはC#よりはF#のが気になってるので、今度はF#を触ったり今回は試せなかったAOT機能をチェックしてみたいと思いますー。

それではHappy Hacking!

Discussion