みなさん、こんにちは。
私は最近、既存のオンプレミスシステムをAWS Lambdaによるサーバーレス構成へ移行するプロジェクトに携わっています。その中で「Lambda特有のレイテンシ(コールドスタートなど)」に直面し、アプリケーション側でレスポンスタイムを改善できないか模索していました。
C#の高速化といえば、クエリの改善やLINQの最適化が定石ですが、マイクロサービスのように機能数が多い分散システムでは、個別のロジックを修正するよりも**「実行基盤そのものを効率化する」**アプローチの方が全体への波及効果が高いと考え、調査を進めていました。
そこで辿り着いたのが、**.NETの「Native AOT」**です。
Native AOTとは?
Native AOT(Ahead-of-Time)とは、.NETアプリケーションを中間言語(IL)ではなく、ターゲット環境(LinuxやWindowsなど)で直接実行可能なネイティブバイナリに事前にコンパイルする技術です。
Lambdaで使えるかもと思った理由
Native AOTのドキュメントを参照しながら、Lambdaの高速化に生かせないかなと考えていたところ、どうやら下記のメリットがあることが分かりました。
-
起動速度(コールドスタート)の向上:
実行時のJITコンパイルが不要になるため、Lambda関数がインスタンス化されてからリクエストを処理し始めるまでの時間が大幅に短縮。 -
メモリ使用量の削減:
JITコンパイラ自体をメモリにロードする必要がなくなるため、メモリフットプリントが減少する。 -
デプロイパッケージの軽量化:
実行に必要なランタイムをバイナリに含めつつ、使用されていないコードを削除(トリミング)するため、最終的なバイナリサイズが抑えられる。
導入にあたっての注意点
非常に強力なNative AOTですが、以下の制約があることも分かりました。
- リフレクションの制限: 実行時に動的にコードを生成するリフレクションの一部が使用できません(ビルド時に全コードが確定している必要があるため)。
- クロスコンパイルの制約: ターゲットとなるOS環境でビルドを行う必要があります(例:Lambdaで動かすならAmazon Linux環境でのビルドが推奨)。
参考:公式ドキュメント
Native AOT デプロイ - .NET | Microsoft Learn
今回の検証の中身
Native AOTによる速度向上の効果を測定するにあたり、今回は実機(AWS Lambda)にデプロイする前段階として、ローカル環境で疑似的なコールドスタートを再現したベンチマークを実施しました。
検証環境の構成
-
比較対象のアプリケーション:
- JIT版: 通常のビルド設定で作成した ASP.NET Core API
-
Native AOT版:
PublishAot=trueを指定し、ネイティブバイナリとしてビルドした API
※ いずれも「固定値のJSONをレスポンスとして返却する」シンプルなエンドポイントを実装。
-
計測方法(Pythonスクリプトによる自動化):
Lambdaの「コールドスタート(インスタンスの立ち上げ+初期化処理)」を疑似再現するため、以下の1サイクルを10,000回繰り返し、実行時間を計測しました。- Step 1: Dockerコンテナの立ち上げ
- Step 2: APIエンドポイントへのリクエスト送信
- Step 3: レスポンスの受信(疎通確認)
- Step 4: コンテナのシャットダウン・破棄
なぜこの方法で検証したのか
AWS Lambdaにおいて、最もパフォーマンスの差が出るのは「実行環境の初期化」が発生する瞬間です。通常のJIT実行では、初回リクエスト時にランタイムのロードとメソッドのコンパイルが走りますが、Native AOTではそれらが不要になります。
今回は、コンテナのライフサイクルを1リクエストごとに完結させることで、Native AOTが持つ**「起動の速さ」と「バイナリの軽量さ」が、システム全体のレイテンシにどれほど寄与するかを数字で検証するのが狙いです。
**念のため検証を行った環境は以下に記載しておきます。
- MacBook Air M4 24GB
- .NET8
- コンテナはOrbStackを利用
JIT版のコードは以下となります。
using Amazon.Lambda.Core;
[assembly: LambdaSerializer(typeof(Amazon.Lambda.Serialization.SystemTextJson.DefaultLambdaJsonSerializer))]
namespace RegularJitApp;
public class Function
{
public object FunctionHandler(object input, ILambdaContext context)
{
return new { message = "Hello World" };
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Amazon.Lambda.Core" Version="2.5.0" />
<PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.4" />
</ItemGroup>
</Project>
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -o /app
FROM --platform=linux/amd64 public.ecr.aws/lambda/dotnet:8
COPY --from=build /app ${LAMBDA_TASK_ROOT}
CMD ["RegularJitApp::RegularJitApp.Function::FunctionHandler"]
Native AOT版のコードは以下です。
using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace NativeAotApp;
public record HelloResponse(string message);
[JsonSerializable(typeof(HelloResponse))]
[JsonSerializable(typeof(JsonElement))]
public partial class AppJsonContext : JsonSerializerContext { }
class Function
{
static async Task Main()
{
Func<JsonElement, ILambdaContext, HelloResponse> handler = Handler;
await LambdaBootstrapBuilder
.Create(handler, new SourceGeneratorLambdaJsonSerializer<AppJsonContext>())
.Build()
.RunAsync();
}
static HelloResponse Handler(JsonElement input, ILambdaContext context)
{
return new HelloResponse("Hello World");
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
<AssemblyName>bootstrap</AssemblyName>
<PublishAot>true</PublishAot>
<StripSymbols>true</StripSymbols>
<InvariantGlobalization>true</InvariantGlobalization>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Amazon.Lambda.Core" Version="2.5.0" />
<PackageReference Include="Amazon.Lambda.RuntimeSupport" Version="1.10.0" />
<PackageReference Include="Amazon.Lambda.Serialization.SystemTextJson" Version="2.4.4" />
</ItemGroup>
</Project>
FROM --platform=linux/amd64 mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
RUN apt-get update && apt-get install -y \
clang \
zlib1g-dev \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN dotnet restore
RUN dotnet publish -c Release -r linux-x64 --self-contained true -o /app
FROM --platform=linux/amd64 public.ecr.aws/lambda/provided:al2023
COPY --from=build /app/bootstrap /var/runtime/bootstrap
RUN chmod 755 /var/runtime/bootstrap
CMD ["function.handler"]
検証の結果
検証にざっくり3時間ほどかかり、Youtubeで2023年のWBCの試合を見ながら暇つぶししていました。
検証の結果は以下の表となります。(JIT版では1回エラーが出ていたためサンプル数が9,999となっています。)
| JIT | Native AOT | |
|---|---|---|
| サンプル数 | 9,999 | 10,000 |
| 平均 | 710 ms | 283 ms |
| 中央値 | 708 ms | 281 ms |
| P99 | 769 ms | 321 ms |
| 最小 | 657 ms | 247 ms |
| 最大 | 1,679 ms | 1,240 ms |
この表を見ると、Native AOTに対応させることで400msくらい削ることができるのかなぁというところです。
JITだとクラスとメソッドを書くだけでランタイムが良い感じにやってくるのですが、Native AOTでは面倒だった割にこれだけかという個人の感想になります。(Main関数を作って、LambdaBootstrapBuilderを呼んでCustom Runtimeのお作法を強制されたり、型定義の強制、JSON Contextの管理など手間かかりますね。)
速くはなりますが、多分Native AOT対応だけで相当時間も必要になりますし、若干デバッグも面倒になるので費用対効果といいますか、時間対効果は微妙だなという結論になりました。
(コードの書き換え面倒なので、個人的にはEC2で既存資産をそのまま使う方が楽だなという感想です。)
気になったので追加検証
Native AOTではバイナリをビルドするのですが、バイナリというとGo言語が浮かび、Native AOT vs Goの検証も追加でやってみました。
コードは以下となります。
package main
import (
"context"
"github.com/aws/aws-lambda-go/lambda"
)
type Response struct {
Message string `json:"message"`
}
func handler(ctx context.Context) (Response, error) {
return Response{Message: "Hello World"}, nil
}
func main() {
lambda.Start(handler)
}
FROM --platform=linux/amd64 golang:1.22 AS build
WORKDIR /src
COPY . .
RUN go mod tidy && \
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -ldflags="-s -w" -o bootstrap .
FROM --platform=linux/amd64 public.ecr.aws/lambda/provided:al2023
COPY --from=build /src/bootstrap /var/runtime/bootstrap
RUN chmod 755 /var/runtime/bootstrap
CMD ["function.handler"]
追加検証の結果
10,000回も回すのは時間的に辛かったので、サンプル的に取れればいいなと思い100回のサイクルで値を取得しました。
| JIT | Native AOT | Go | |
|---|---|---|---|
| サンプル数 | 9,999 | 10,000 | 100 |
| 平均 | 710 ms | 283 ms | 236 ms |
| 中央値 | 708 ms | 281 ms | 236 ms |
| P99 | 769 ms | 321 ms | 253 ms |
| 最小 | 657 ms | 247 ms | 216 ms |
| 最大 | 1,679 ms | 1,240 ms | 261 ms |
このように値を比較してみると、改めてGoの威力といいますか速さを実感しました。既存の.NET資産があったとしても、Native AOT化対応では大幅な書き直しが必要となりますし、局所的に切り出していけるならGoで少しづつリライトした方が楽かもなと思った次第です。
参考:MS公式ドキュメント
ストラングラー フィグ パターン | Microsoft Learn