ASP.NET Core と Entity Framework Core を使った Web アプリケーションの初回アクセスが非常に遅かったので、Go や Node.js と比較してみました。
なお、初回アクセスが遅い要因としては、DB 接続のような処理を 起動時ではなく初回アクセス時に実施している
点にあると考えられます。
基本的には(今のところ)どうしようも無さそうだったので、場合によっては運用等でカバーする事になりそうです。(Kubernetes の Startup Probe 利用とか)
1. ASP.NET の場合
まずは、ASP.NET 単体で確認してみます。
(1a) ASP.NET Core サンプル
サンプルの内容は次の通りです。
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.Map("/", () => "aspnet-" + DateTimeOffset.UtcNow.ToUnixTimeMilliseconds());
app.Run();
{
"Logging": {
"LogLevel": {
"Default": "Error"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://0.0.0.0:3000"
}
}
}
}
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
このアプリケーションを Ubuntu でコンテナ実行しました。
実行
$ nerdctl run -it --rm -p 3001:3000 -v $(pwd)/sample1_aspnet:/app mcr.microsoft.com/dotnet/sdk:7.0 bash
/# cd /app
/app# dotnet run
curl によるアクセスを 2回実施すると次のような結果となりました。
アクセス結果
$ time curl http://127.0.0.1:3001/
aspnet-1685191500163
real 0m0.081s
user 0m0.000s
sys 0m0.011s
$ time curl http://127.0.0.1:3001/
aspnet-1685191514600
real 0m0.013s
user 0m0.005s
sys 0m0.006s
この処理内容で、初回アクセスに 0.081 秒もかかるのは遅いように思います。
(1b) Go サンプル
同様の処理を Go で実装してみました。
package main
import (
"log"
"net/http"
"strconv"
"time"
)
func main() {
http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
rw.Write([]byte("golang-" + strconv.FormatInt(time.Now().UnixMilli(), 10)))
})
log.Fatal(http.ListenAndServe(":3000", nil))
}
同じように実行します。
実行
$ nerdctl run -it --rm -p 3002:3000 -v $(pwd)/sample1_go:/app golang bash
/go# cd /app
/app# go run main.go
アクセス結果
$ time curl http://127.0.0.1:3002/
golang-1685192370027
real 0m0.010s
user 0m0.000s
sys 0m0.009s
$ time curl http://127.0.0.1:3002/
golang-1685192382231
real 0m0.011s
user 0m0.005s
sys 0m0.005s
(1c) Node.js サンプル
Node.js で実装してみました。
const http = require('http')
const server = http.createServer((_req, res) => {
res.end('nodejs-' + Date.now())
})
server.listen(3000)
こちらも同様に実行します。
実行
$ nerdctl run -it --rm -p 3003:3000 -v $(pwd)/sample1_node:/app node bash
/# cd /app
/app# node index.js
アクセス結果
$ time curl http://127.0.0.1:3003/
nodejs-1685192755965
real 0m0.025s
user 0m0.011s
sys 0m0.001s
$ time curl http://127.0.0.1:3003/
nodejs-1685192769965
real 0m0.013s
user 0m0.005s
sys 0m0.006s
結果
結果をまとめると次のようになります。
言語 | 利用ライブラリ | 初回アクセス | 2回目アクセス |
---|---|---|---|
C# | ASP.NET Core | 0.081s | 0.013s |
Go | 0.010s | 0.011s | |
Node.js | 0.025s | 0.013s |
ASP.NET だけが、たまたま遅かった可能性もあるので数回試してみましたが、それでも初回アクセスは 0.058 秒
や 0.065 秒
で明らかに他とは見劣りする結果でした。
2. ASP.NET + Entity Framework の場合
次に、MySQL を検索するアプリケーションで試してみます。
MySQL 実行
まずは、コンテナで MySQL を実行し、テーブル作成やデータ登録をしておきます。
CREATE DATABASE sample;
USE sample;
CREATE TABLE items (
id VARCHAR(10) NOT NULL,
price INT NOT NULL,
PRIMARY KEY (id)
);
INSERT INTO
items(id, price)
VALUES
('item-1', 20),
('item-2', 68),
('item-3', 15),
('item-4', 43),
('item-5', 37);
検証に使うだけなので、パスワードは無しにします。
MySQL 実行
$ nerdctl run -d --name mysql1 -p 3306:3306 -e MYSQL_ALLOW_EMPTY_PASSWORD=1 mysql
データ登録 (SQL ファイル実行)
$ nerdctl run -it --rm -v $(pwd):/work mysql sh -c "mysql -h mysql1 -u root -e 'source /work/db.sql'"
(2a) ASP.NET Core + Entity Framework サンプル
MySQL で簡単な検索を実施するサンプルです。
using Microsoft.EntityFrameworkCore;
using System.ComponentModel.DataAnnotations.Schema;
var dsn = "server=mysql1;database=sample;user=root";
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<ItemContext>(opt => {
opt.UseMySQL(dsn);
});
var app = builder.Build();
app.MapPost("/find", (ItemContext db, FindItem input) =>
db.Items.Where(r => r.Price >= input.Price).OrderBy(r => r.Price)
);
app.Run();
record FindItem(int Price);
[Table("items")]
public class Item
{
[Column("id")]
public required string Id { get; set; }
[Column("price")]
public int Price { get; set; }
}
public class ItemContext : DbContext
{
public DbSet<Item> Items => Set<Item>();
public ItemContext(DbContextOptions<ItemContext> opts) : base(opts) {}
}
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MySql.EntityFrameworkCore" Version="7.0.2" />
</ItemGroup>
</Project>
先ほどと同じように実行します。
実行
$ nerdctl run -it --rm -p 3001:3000 -v $(pwd)/sample2_aspnet:/app mcr.microsoft.com/dotnet/sdk:7.0 bash
/# cd /app
/app# dotnet build
/app# dotnet run
アクセス結果
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3001/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real 0m0.528s
user 0m0.004s
sys 0m0.007s
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3001/find -d '{"price":20}'
[{"id":"item-1","price":20},{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real 0m0.034s
user 0m0.007s
sys 0m0.000s
Entity Framework を組み合わせる事で初回アクセスが非常に遅くなりました。
ちなみに、このようなシンプルなモデルでは dotnet ef dbcontext optimize
によるコンパイル済みモデルを適用してもほんの少し改善する程度でした。
他にも EnableThreadSafetyChecks
を false にするとか、高度なパフォーマンスのトピック 等に書かれているような事も試してみましたが、このサンプルでは微々たる効果しか得られないようでした。
Entity Framework 初回処理のオーバーヘッド
次に、HostedService を使って起動時にダミーの検索を実施しておくと、どの程度変わるのか試してみました。
...省略
var builder = WebApplication.CreateBuilder(args);
// WarmUpService の適用
builder.Services.AddHostedService<WarmUpService>();
builder.Services.AddDbContext<ItemContext>(opt => {
opt.UseMySQL(dsn);
});
...省略
public class WarmUpService : IHostedService
{
private readonly ItemContext _db;
public WarmUpService(ItemContext db)
{
_db = db;
}
public Task StartAsync(CancellationToken cancellationToken)
{
// ダミーの検索処理
_db.Items.SingleOrDefault(r => r.Id == "");
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
初回アクセスの結果は次のようになり、かなり改善しました。
初回アクセス結果
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3001/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real 0m0.114s
user 0m0.000s
sys 0m0.009s
(2b) Go サンプル
MySQL 接続に Go-MySQL-Driver を使いました。
package main
import (
"database/sql"
"encoding/json"
"log"
"net/http"
_ "github.com/go-sql-driver/mysql"
)
type Item struct {
ID string `json:"id"`
Price int `json:"price"`
}
type FindItem struct {
Price int `json:"price"`
}
func main() {
dsn := "root@tcp(mysql1)/sample"
db, err := sql.Open("mysql", dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
http.HandleFunc("/find", func(rw http.ResponseWriter, r *http.Request) {
var input FindItem
err := json.NewDecoder(r.Body).Decode(&input)
if err != nil {
rw.WriteHeader(http.StatusBadRequest)
} else {
rows, err := db.Query(
"SELECT id, price FROM items WHERE price >= ? ORDER BY price",
input.Price,
)
if err != nil {
rw.WriteHeader(http.StatusInternalServerError)
return
}
defer rows.Close()
var (
res []Item
id string
price int
)
for rows.Next() {
err := rows.Scan(&id, &price)
if err == nil {
res = append(res, Item{id, price})
}
}
json.NewEncoder(rw).Encode(res)
}
})
log.Fatal(http.ListenAndServe(":3000", nil))
}
実行結果は次の通りです。
実行
$ nerdctl run -it --rm -p 3002:3000 -v $(pwd)/sample2_go:/app golang bash
/go# cd /app
/app# go run main.go
アクセス結果
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3002/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real 0m0.015s
user 0m0.005s
sys 0m0.006s
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3002/find -d '{"price":20}'
[{"id":"item-1","price":20},{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real 0m0.014s
user 0m0.000s
sys 0m0.011s
(2c) Node.js サンプル
fastify と MariaDB Node.js connector で実装してみました。
const fastify = require('fastify')()
const mariadb = require('mariadb')
const pool = mariadb.createPool({ host: 'mysql1', user: 'root', database: 'sample' })
fastify.post('/find', async (req, _rep) => {
const input = req.body
const conn = await pool.getConnection()
try {
const rows = await conn.query(
'SELECT id, price FROM items WHERE price >= ? ORDER BY price',
[ input.price ]
)
return Object.values(rows)
} finally {
conn.release()
}
})
fastify
.listen({ host: '0.0.0.0', port: 3000 })
.catch(err => {
fastify.log.error(err)
process.exit(1)
})
実行結果は次の通りです。
実行
$ nerdctl run -it --rm -p 3003:3000 -v $(pwd)/sample2_node:/app node bash
/# cd /app
/app# npm install
/app# node index.js
アクセス結果
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3003/find -d '{"price":30}'
[{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real 0m0.017s
user 0m0.008s
sys 0m0.000s
$ time curl -H "Content-Type: application/json" http://127.0.0.1:3003/find -d '{"price":20}'
[{"id":"item-1","price":20},{"id":"item-5","price":37},{"id":"item-4","price":43},{"id":"item-2","price":68}]
real 0m0.013s
user 0m0.000s
sys 0m0.010s
結果
結果をまとめると次のようになります。
言語 | 利用ライブラリ | 初回アクセス | 2回目アクセス |
---|---|---|---|
C# | ASP.NET Core, Entity Framework Core | 0.528s | 0.034s |
Go | Go-MySQL-Driver | 0.015s | 0.014s |
Node.js | fastify, MariaDB Node.js connector | 0.017s | 0.013s |
更に、上記とは別のもっと低性能な環境(Ubuntu で podman 利用)で試してみたところ次のような結果となりました。
こちらでは、より顕著な差が出ています。
言語 | 利用ライブラリ | 初回アクセス | 2回目アクセス |
---|---|---|---|
C# | ASP.NET Core, Entity Framework Core | 2.302s | 0.170s |
Go | Go-MySQL-Driver | 0.022s | 0.019s |
Node.js | fastify, MariaDB Node.js connector | 0.047s | 0.022s |
O-R マッパーというハンデがあるとはいえ、Go や Node.js に大きく見劣りする結果となりました。
特に初回アクセスの遅さは要注意だと思います。