本文へスキップ
増えすぎたマイクロサービスを .NET Aspire で整えた、ある開発チームの記録のアイキャッチ画像
Architecture

増えすぎたマイクロサービスを .NET Aspire で整えた、ある開発チームの記録

公開: 更新: 約7分で読めます

「開発環境を立ち上げるだけで、午前中が半分終わる」。最初の相談は、ある B2B SaaS を運営する開発チームからのそんな一言で始まった。顧客の了承を得たうえで、特定につながる情報を伏せた匿名の事例として、そのチームが .NET Aspire を採り入れていった一年ほどの道のりを振り返ってみたい。派手な数字を並べる話ではない。日々の開発と運用が、少しずつ地に足のついたものへ変わっていった記録だと思って読んでほしい。

相談は「誰も全体を起動できない」ところから始まった

そのチームが抱えていたのは、成長したプロダクトにありがちな複雑さだった。数年かけて機能ごとにサービスを切り出してきた結果、API、認証、通知、集計バッチと数が増え、それぞれが依存する Redis や PostgreSQL、メッセージブローカーまで含めると、ローカルで一式を立ち上げるのは相当な儀式になっていた。

手順は Wiki に十数ステップ並んでいたが、環境変数の食い違いやポートの衝突は日常茶飯事で、新しく参加したメンバーが最初のプルリクエストを出すまでに数日かかっていた。誰かの手元では動くのに別の手元では動かない、という定番のやり取りが、朝会のたびに繰り返されていた。

さらに厄介だったのは運用のほうだ。サービスごとにログの書式もヘルスチェックの作法もばらばらで、本番で何かが遅いとき「どのサービスのどこが遅いのか」を突き止めるまでに時間を溶かしていた。一次切り分けは詳しい一人に集中していて、その人が休むと調査が止まる。技術的には健全に見えるマイクロサービス構成が、開発体験と運用の両面で静かにチームの体力を削っていた。

現代的なオフィスで、開発チームと顧客が並んで大きな画面のダッシュボードと稼働中のアプリを笑顔で確認している情景のイラスト
顧客と開発チームが並び、整えたばかりの開発・運用基盤を一緒に眺めている一場面。

なぜ .NET Aspire を選んだのか

置き換え先の候補はいくつかあった。Docker Compose を全面的に整備し直す案もあれば、Kubernetes を前提に組み替える案もあった。最終的に .NET Aspire を主軸に据えたのは、チームの現在地といちばん噛み合っていたからだ。

ひとつは、既存資産をほぼそのまま活かせること。サービスの実体は C# のプロジェクト群で、それを別物へ書き換えるのではなく、構成の記述だけをコードに寄せられる。Compose の長い YAML を人力で保守する必要も、クラスタ運用の学習コストを一度に払う必要もない。

もうひとつは、可観測性と回復性が最初から前提になっていること。OpenTelemetry のトレース・メトリクス・ログや、HTTP 呼び出しのリトライ・タイムアウト・サーキットブレーカといった要素が、各サービスに散らばった自作コードではなく共有の作法として組み込める。移行のいちばん不安定な時期に「何が起きているか分からない」状態を避けられる見込みが立ったことが、決め手になった。

AppHost が、散らばった起動手順を一枚に畳んだ

最初に着手したのは AppHost の導入だった。これまで Wiki に散らばっていた「何をどの順番で立ち上げるか」を、一つのプロジェクトのコードとして書き下す作業だ。依存するインフラも、サービス同士の参照関係も、ここに宣言としてまとまる。

var builder = DistributedApplication.CreateBuilder(args);

var cache = builder.AddRedis("cache");
var db = builder.AddPostgres("pg").AddDatabase("orders");

var api = builder.AddProject<Projects.Api>("api")
    .WithReference(cache)
    .WithReference(db);

builder.AddProject<Projects.Worker>("worker")
    .WithReference(db);

builder.Build().Run();

効果は思っていたより手前で表れた。接続文字列やエンドポイントは WithReference で受け渡されるので、環境変数を手で合わせる作業が消える。実行するとダッシュボードが立ち上がり、どのサービスが起動していて、相互にどう呼び合い、ログとトレースがどう流れているかが一画面で見えるようになった。

いちばん反応が大きかったのは、新しく入ったメンバーだった。リポジトリを取得して一度実行するだけで全体が立ち上がる。数日かかっていた立ち上げが、初日の午後には最初の変更を動かせるところまで縮んだ。既存のメンバーにとっても、朝いちばんに環境の食い違いを直す時間が消えたことは小さくない。派手な機能ではないが、毎朝の摩擦が積み重ならなくなることの価値は、続けるほど静かに効いてくる。この段階で、チームの空気が少しだけ前を向いたのを覚えている。

ServiceDefaults で、可観測性と回復性を一箇所に集める

次に取り組んだのが ServiceDefaults の整備だった。各サービスに少しずつ違う書き方で散らばっていた計測とヘルスチェックを、共有プロジェクトの一つの拡張メソッドに集約していく。

public static IHostApplicationBuilder AddServiceDefaults(this IHostApplicationBuilder builder)
{
    builder.ConfigureOpenTelemetry();     // トレース・メトリクス・ログを標準化
    builder.AddDefaultHealthChecks();     // liveness / readiness を統一

    builder.Services.AddServiceDiscovery();
    builder.Services.ConfigureHttpClientDefaults(http =>
    {
        http.AddStandardResilienceHandler();  // リトライ・タイムアウト・サーキットブレーカ
        http.AddServiceDiscovery();
    });
    return builder;
}

あとは各サービスの起動時に一行差し込むだけで、計測も回復性も同じ土台に乗る。

var builder = WebApplication.CreateBuilder(args);
builder.AddServiceDefaults();   // これ一行で観測と回復性の作法が揃う
// 以降はサービス固有の設定に集中できる

この標準化が効いたのは、本番で問題が起きたときだった。すべてのサービスが同じ形式でトレースを吐くので、リクエストがどのサービスを通り、どこで時間を使っているかを一本の線として追える。以前は、サービスごとに違うログの読み方を思い出すところから調査が始まっていた。それが、ダッシュボードを開けば誰でも同じ手順で切り分けを始められるものに変わった。これまで属人化していた一次切り分けから「詳しい人待ち」の時間が減ったことは、数字にはしづらいが、当番に入るメンバーの安心感として確かに表れた変化だった。

Azure Container Apps への移行と、そこでぶつかった壁

ローカルの開発体験が整った段階で、本番の載せ替えに着手した。選んだのは Azure Container Apps(ACA)で、理由は運用の重さを増やしたくなかったからだ。クラスタそのものを面倒見るのではなく、コンテナとスケール条件を渡せば動く、という粒度が、この規模のチームには合っていた。AppHost に記述した構成は、そのまま azd によるデプロイの下敷きになった。

# azure.yaml(抜粋)
name: orders-platform
services:
  api:
    project: ./src/Api
    host: containerapp
    language: dotnet
  worker:
    project: ./src/Worker
    host: containerapp
    language: dotnet

順調に見えた移行は、本番に寄せた直後につまずいた。発注を受け付けるエンドポイントで、まれに同じ注文が二重に登録される。原因はしばらく見えなかったが、標準の回復性ハンドラが自動でリトライを行うことと、そのエンドポイントがまだ冪等でなかったことの組み合わせだった。一度目の応答が遅れた際に再送が走り、下流では二件として処理されていた。

ここで、先に整えておいた観測基盤が効いた。トレースを見ると、同じリクエスト ID に対して再試行が連鎖している様子がそのまま見えて、原因の切り分けは数日ではなく数時間で片づいた。対処は二段構えで、書き込み系のエンドポイントを冪等キー付きで受けられるようにしたうえで、リトライ方針をエンドポイントの性質ごとに分けた。読み取りは積極的に再試行し、副作用のある書き込みは慎重にする、という当たり前の線引きを、共有の作法として引き直した形だ。

ACA 特有の学びもあった。負荷の低い時間帯に worker をゼロまで縮められる一方、久しぶりの起動には立ち上がりの時間がかかる。遅延に敏感な経路だけ最小インスタンス数を確保し、それ以外はコスト優先で縮める、という住み分けに落ち着いた。スケールの判断をインフラ側に委ねられるようになったこと自体が、当番のチームにとっては大きな肩の荷下ろしだった。

残ったのは、数字よりもチームの余裕だった

一年ほどの道のりを終えて、いちばん変わったのは指標そのものよりチームの空気だった。応答時間のばらつきは目に見えて落ち着き、本番の不調に気づくのはアラートより先にダッシュボードの異変から、という順番になった。障害の一次切り分けは特定の一人に頼らなくなり、週末のオンコールが実際に鳴る回数は、体感でずいぶん減ったという。

この事例から持ち帰れることは、いくつかに整理できる。第一に、マイクロサービスの苦しさは往々にして設計そのものではなく、開発体験と運用の作法がばらついているところに宿る。AppHost で起動を一枚に畳み、ServiceDefaults で観測と回復性を一箇所に集めるだけで、日々の摩擦は驚くほど減る。第二に、可観測性は「余裕があれば」ではなく最初に入れておくもので、移行でいちばん揺れる時期に最も効く。そして第三に、回復性の仕組みは万能ではなく、冪等性のような足場と組み合わせて初めて安全に働く。

クラウドネイティブへの移行は、一度に全部を置き換える大工事である必要はない。開発体験の摩擦をひとつ減らし、観測できる範囲をひとつ広げる。その積み重ねが、気づけばチームの体力を取り戻していた、というのがこの事例のいちばん正直な結論だ。

エンハンスドでは、.NET Aspire を軸にしたクラウドネイティブ開発の内製化支援を行っています。AppHost や ServiceDefaults による開発・運用基盤の標準化から、Azure Container Apps への段階的な移行、そして自走できるチームづくりまで、現場に伴走しながら設計をお手伝いします。マイクロサービスの複雑さに手を焼いている方は、選択肢のひとつとして相談してみてほしい。

この記事をシェア

コピーしました

この記事に関連する支援サービス

記事で扱っている領域について、支援内容と事例をまとめたページがあります。

関連記事