tech.guitarrapc.cóm

Technical updates

NuGetのロックファイルは使うべきなのか

NuGetにはロックファイル(packages.lock.json)を用いてリストアする機能があります。npmではpackage-lock.jsonが当たり前に使われていますが、C#のプロジェクトでロックファイルを使っている例はあまり見かけません。

最近SBOMについて調べる中で、なぜNuGetのロックファイルがあまり使われていないのか、そもそも使うべきなのかを考えてみました。この記事では、NuGetのロックファイルの仕組みと、C#におけるパッケージ管理の文化的な背景から、ロックファイルの必要性について考察します。

ロックファイルとは

ロックファイルとは、プロジェクトが依存するパッケージのバージョンを固定化するためのファイルです。

Microsoft Learnを見ると、プロジェクトが依存するパッケージには、「直接依存するもの(トップレベル・直接/Top-level or Direct)」と「間接的に依存するもの(トランジティブ・推移的/Transitive)」があります。 イメージしやすいようにnpmで例えると、@modelcontextprotocol/sdkパッケージを入れるとします。 この場合、@modelcontextprotocol/sdkが直接依存するパッケージで、@modelcontextprotocol/sdkが依存している@hono/node-serverajvなどは間接的に依存するパッケージです。

npmで@modelcontextprotocol/sdkの間接的に依存するパッケージが確認できる

ロックファイルは、あるパッケージをインストールしたときのバージョンと、そのパッケージを導入したときに推移的にインストールされたパッケージのバージョンを記録します。これにより、同じプロジェクトを別の環境でセットアップしたときに、同じバージョンのパッケージがインストールされることを保証します。

NuGetのロックファイル

NuGetにもロックファイルを利用する機能がありますが、デフォルトでは無効になっています。ロックファイルを利用するには.csproj<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>に設定して、プロジェクトをリストア(dotnet restore)します。すると、.csprojがあるパスにpackages.lock.jsonというファイルが生成されます。

  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>

試してみましょう。プロジェクト追加 → 初回のパッケージ追加 → リストア → ロックファイル追加後のリストアを順に実行します。今回は私の書いているライブラリであるSkiaSharp.QrCodeパッケージを使用します。NuGetを見るとSkiaSharpSkiaSharp.NativeAssets.macOS/SkiaSharp.NativeAssets.Win32に依存していることがわかります。

NuGetで確認できるSkiaSharp.QrCodeパッケージの依存関係。SkiaSharpやNativeAssetsパッケージに依存していることがわかる

まずはコンソールプロジェクトを作成し、SkiaSharp.QrCodeパッケージを追加してリストアします。この時点ではロックファイルは生成されていません。

$ mkdir -p ConsoleApp3 && cd ConsoleApp3
$ dotnet new console -n ConsoleApp3
$ dotnet package add SkiaSharp.QrCode
$ dotnet restore
Restore complete (0.9s)

Build succeeded in 1.1s

$ ls -la
ls -la
total 16
drwxrwxr-x    3 guitarrapc   guitarrapc      0 Jan 14 16:58 .
drwxrwxr-x    8 guitarrapc   guitarrapc   4096 Jan 14 16:58 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    356 Jan 14 16:59 ConsoleApp3.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc    105 Jan 14 16:58 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:00 obj

続けて、<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>を追加して再度リストアします。すると、packages.lock.jsonファイルが生成されます。

$ cat <<EOF > ConsoleApp3.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>

    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="SkiaSharp.QrCode" Version="0.12.0" />
  </ItemGroup>

</Project>
EOF

$ dotnet restore
Restore complete (0.6s)

Build succeeded in 1.1s

$ ls -la
ls -la
total 24
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:03 .
drwxrwxr-x    8 guitarrapc   guitarrapc   4096 Jan 14 16:58 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    427 Jan 14 17:03 ConsoleApp3.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc    105 Jan 14 16:58 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:03 obj
-rw-rw-r--    1 guitarrapc   guitarrapc   1314 Jan 14 17:03 packages.lock.json  # <- 追加!

ロックファイルの中身を見ると、プロジェクトで直接参照しているパッケージと、間接的に参照しているパッケージが区別されつつ、各パッケージのバージョンが記録されています。

  • プロジェクトで直接参照させたパッケージSkiaSharp.QrCodeには"type": "Direct"が指定され、最新バージョンが利用
  • SkiaSharp.QrCodeライブラリが依存しているSkiaSharpSkiaSharp.NativeAssets.Win32などのパッケージには"type": "Transitive"が指定
$ cat packages.lock.json
{
  "version": 1,
  "dependencies": {
    "net10.0": {
      "SkiaSharp.QrCode": {
        "type": "Direct",
        "requested": "[0.12.0, )",
        "resolved": "0.12.0",
        "contentHash": "DTSyBl/rJXcGbSuIzkv20pkTTPUaZbFmouWrOtHG0a2Ide0IsbU9o1mUJb1HsiOgUEK6aAX2+MzP0n7GPssiSA==",
        "dependencies": {
          "SkiaSharp": "3.119.1",
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "+Ru1BTSZQne3Vp+vbSb50Ke3Nlc3ZnItxx4+751J9WZ8YzLKAV/n+9DAo4zFTyeCI//ueT63c+VybmTTpYBEiw==",
        "dependencies": {
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp.NativeAssets.macOS": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "6hR3BdLhApjDxR1bFrJ7/lMydPfI01s3K+3WjIXFUlfC0MFCFCwRzv+JtzIkW9bDXs7XUVQS+6EVf0uzCasnGQ=="
      },
      "SkiaSharp.NativeAssets.Win32": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "8C4GSXVJqSr0y3Tyyv5jz6MJSTVUyYkMjeKrzK+VyZPGLo89MNoUEclVuYahzOCDdtbfXrd2HtxXfDuvoSXrUw=="
      }
    }
  }
}

ロックファイルを使ったリストア

ロックファイルを使用している場合、dotnet restoreコマンドはpackages.lock.jsonファイルを参照して、NuGetの依存を再評価しつつ指定されたバージョンのパッケージをインストールします。この時パッケージが取得できなかったなど必要があれば、ロックファイルのバージョンは更新されます。不変じゃないのはnpmのpackage-lock.jsonと同じです。

npm ciのように、ロックファイルに記録されたバージョンを厳密に再現する場合、dotnet restore --locked-modeコマンドを使うか、<RestoreLockedMode>true</RestoreLockedMode>を設定します。npm同様、CIではこのオプションを有効にするのがいいでしょう。

ローカルでは通常のdotnet restoreを実行し、CI(GitHub Actionsを想定)ではロックファイルに厳密に従うようにするなら次のように設定します。これにより、異なる環境であっても同じバージョンのパッケージが保証されます。

  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
    <RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
  </PropertyGroup>

ロックファイルとCentral Package Managementの組み合わせ

Central Package Management(以降CPM)は、複数プロジェクトのパッケージバージョンをDirectory.Packages.propsで一元管理する機能です。ロックファイルとCPMを組み合わせた場合の動作を確認してみましょう。

ロックファイルはCPMが有効でも特別な対応はしません。つまり、Directory.Packages.propsでバージョンを一元管理していても、ロックファイルは個々の.csprojパスに生成されます。実際に試してみます。

ConsoleApp4とConsoleApp5の2つのプロジェクトを持つソリューションを作成し、Directory.Packages.propsSkiaSharp.QrCodeのバージョンを一元管理します。ロックファイルpackages.lock.json、Directory.Packages.propsのパスではなく各プロジェクトに生成されることを確認します。

まずはルートにDirectory.Build.propsDirectory.Packages.propsを作成し、ロックファイルとCentral Package Managementを有効にします。

$ cat <<EOF > Directory.Build.props
<Project>
  <PropertyGroup>
    <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
    <RestoreLockedMode Condition="'$(CI)' == 'true'">true</RestoreLockedMode>
  </PropertyGroup>
</Project>
EOF

$ cat <<EOF > Directory.Packages.props
<Project>
  <PropertyGroup>
    <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
    <CentralPackageTransitivePinningEnabled>true</CentralPackageTransitivePinningEnabled>
  </PropertyGroup>
  <ItemGroup>
    <PackageVersion Include="SkiaSharp.QrCode" Version="0.12.0" />
  </ItemGroup>
</Project>
EOF

続いて、2つのコンソールプロジェクトを作成し、SkiaSharp.QrCodeパッケージを追加してリストアします。ロックファイルはDirectory.Packages.propsではなく各プロジェクトに生成されます。1

$ mkdir -p src/ConsoleApp4 && cd src/ConsoleApp4
$ dotnet new console
$ dotnet package add SkiaSharp.QrCode

$ cd ../../
$ mkdir -p src/ConsoleApp5 && cd src/ConsoleApp5
$ dotnet new console
$ dotnet package add SkiaSharp.QrCode

$ cd ../../
$ dotnet new sln -f slnx
$ dotnet sln add src/ConsoleApp4/ConsoleApp4.csproj
$ dotnet sln add src/ConsoleApp5/ConsoleApp5.csproj
$ dotnet restore
Restore complete (1.4s)

Build succeeded in 1.7s

$ ls -laR
.:
total 20
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:41 .
drwxrwxr-x    8 guitarrapc   guitarrapc   4096 Jan 14 16:58 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    210 Jan 14 17:38 Directory.Build.props
-rw-rw-r--    1 guitarrapc   guitarrapc    327 Jan 14 17:39 Directory.Packages.props
-rw-rw-r--    1 guitarrapc   guitarrapc    181 Jan 14 17:43 lockfile.slnx
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 src

./src:
total 12
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 .
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:41 ..
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 ConsoleApp4
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 ConsoleApp5

./src/ConsoleApp4:
total 20
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 .
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    324 Jan 14 17:36 ConsoleApp4.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc         105 Jan 14 17:35 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:43 obj
-rw-rw-r--    1 guitarrapc   guitarrapc     66 Jan 14 17:43 packages.lock.json

./src/ConsoleApp5:
total 20
drwxrwxr-x    3 guitarrapc   guitarrapc   4096 Jan 14 17:43 .
drwxrwxr-x    4 guitarrapc   guitarrapc      0 Jan 14 17:35 ..
-rw-rw-r--    1 guitarrapc   guitarrapc    324 Jan 14 17:36 ConsoleApp5.csproj
-rw-rw-r--    1 guitarrapc   guitarrapc    105 Jan 14 17:36 Program.cs
drwxrwxr-x    2 guitarrapc   guitarrapc   4096 Jan 14 17:43 obj
-rw-rw-r--    1 guitarrapc   guitarrapc     66 Jan 14 17:43 packages.lock.json

CPMなので.csprojファイルの中身を見てもパッケージのバージョン指定はありません。

$ cat ./src/ConsoleApp4/ConsoleApp4.csproj
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

  <ItemGroup>
    <PackageReference Include="SkiaSharp.QrCode" />
  </ItemGroup>

</Project>

$ cat ./src/ConsoleApp4/packages.lock.json
{
  "version": 2,
  "dependencies": {
    "net10.0": {
      "SkiaSharp.QrCode": {
        "type": "Direct",
        "requested": "[0.12.0, )",
        "resolved": "0.12.0",
        "contentHash": "DTSyBl/rJXcGbSuIzkv20pkTTPUaZbFmouWrOtHG0a2Ide0IsbU9o1mUJb1HsiOgUEK6aAX2+MzP0n7GPssiSA==",
        "dependencies": {
          "SkiaSharp": "3.119.1",
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "+Ru1BTSZQne3Vp+vbSb50Ke3Nlc3ZnItxx4+751J9WZ8YzLKAV/n+9DAo4zFTyeCI//ueT63c+VybmTTpYBEiw==",
        "dependencies": {
          "SkiaSharp.NativeAssets.Win32": "3.119.1",
          "SkiaSharp.NativeAssets.macOS": "3.119.1"
        }
      },
      "SkiaSharp.NativeAssets.macOS": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "6hR3BdLhApjDxR1bFrJ7/lMydPfI01s3K+3WjIXFUlfC0MFCFCwRzv+JtzIkW9bDXs7XUVQS+6EVf0uzCasnGQ=="
      },
      "SkiaSharp.NativeAssets.Win32": {
        "type": "Transitive",
        "resolved": "3.119.1",
        "contentHash": "8C4GSXVJqSr0y3Tyyv5jz6MJSTVUyYkMjeKrzK+VyZPGLo89MNoUEclVuYahzOCDdtbfXrd2HtxXfDuvoSXrUw=="
      }
    }
  }
}

プロジェクトごとに異なるパッケージを参照することもある2ので挙動としては理解できますが、packages.lock.jsonの役割的にはDirectory.Packages.propsのパスに1つだけ生成される方が自然な気はします。ただ、.csprojでパッケージをオーバーライドする場合もあるので、今の設計のままになりそうです。ソリューションレベルやリポジトリレベルのロックファイルについてIssueも立っていますが、現時点では対応の予定はないようです。

ロックファイルを使っている例

個人的にはロックファイルは使いませんが、ロックファイルが使われる例もあります。例えば、GitHub ActionsでNuGetのパッケージキャッシュを利用するactions/cacheがロックファイルを使ったサンプルを提示しています。サンプルは、ロックファイルをキャッシュキーに含めることで、パッケージの変更があった場合のみキャッシュを更新させます。

- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('**/packages.lock.json') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

ただ、先にあげたようにCentral Package Managementを使っている場合、プロジェクトごとにロックファイルができます。だったら、Directory.Packages.propsでバージョンが1.1.1のように指定されているはずなので、Directory.Packages.props自体をキャッシュキーに含めたほうがより明示的に更新タイミングが分かるのとキャッシュ効率もほぼ変わらないと予測できます。

- uses: actions/cache@9255dc7a253b0ccc959486e2bca901246202afeb # v5.0.1
  with:
    path: ~/.nuget/packages
    key: ${{ runner.os }}-nuget-${{ hashFiles('Directory.Packages.props') }}
    restore-keys: |
      ${{ runner.os }}-nuget-

実際にロックファイルをキャッシュキーにしている記事を見ても、キャッシュによる効果はあまり感じられなかったと書かれています。プロジェクトで利用しているパッケージのボリュームによりますが、GitHub Actionsのキャッシュリストアは早くないので、キャッシュヒット率が上がっても劇的に早くならないのは納得できます。このため私は、GitHub ActionsでNuGetのキャッシュは使っていません。

C#でロックファイルは必要か

本題です。C#でロックファイルは必要なのでしょうか? 個人的にはロックファイルはあまり必要ないと考えています。それは、C#はNuGetのパッケージをバージョン直指定する文化があり、推移的パッケージの解決も「競合した場合最も低いバージョンを選ぶルール」があるため、決定論的にパッケージバージョンが決定されるからです。

実際、GitHubでRestorePackagesWithLockFileをキーに検索すると7300件程度と、C#リポジトリ全体が6.9M件あることからすると少ないです。このことから、C#のプロジェクトでロックファイルを使う文化があまり根付いていないことがわかります。

npmとNuGetの文化の違い

ロックファイルが特に有効なのは、パッケージの依存関係がレンジ指定されている場合です。npmでは、^1.2.3~1.2.3のようにレンジ指定することが一般的です。このため、ロックファイルを使わないと、同じリポジトリをクローンしても、リストアタイミングで異なるパッケージバージョンがインストールされる可能性を持っています。ロックファイルを使うことで、同じバージョンのパッケージを確実にインストールできます。

一方、NuGetの文化としてレンジ指定することがなく、バージョンが直接指定されます。また、推移的な依存パッケージで競合があった場合、最も低いバージョンを選ぶよう解決されるルールです。このためロックファイルがなくとも、.csprojやDirectory.Packages.propsで直接バージョンが指定されている限りは決定論的(deterministic)にバージョンが決定されます。

C#でバージョン直指定なのはNuGetのUI/UXがそうであることに起因してそうです。NuGetにおいては、レンジ指定を維持するよりバージョン指定することを促す体験で一貫しています。

例えば、dotnet package addでパッケージを追加してもバージョンは指定されます。

# バージョン指定を省略した場合、自動的に最新バージョンが指定される
$ dotnet package add SkiaSharp.QrCode

# バージョンを指定することも可能だが、最新バージョンを指定するなら不要
$ dotnet package add SkiaSharp.QrCode --version 0.12.0

Visual StudioやRiderのNuGet Package Managerでパッケージをインストール・アップグレードする際もバージョンを指定するようになっており、レンジ指定をサポートしていません。

Visual StudioのManage NuGet Packageでもバージョンを指定する

npmのようにバージョンをレンジ/ワイルドカード指定をするには.csprojを直接手で編集する必要があり、ほとんどの人は使いません。

直接.csprojの編集が必要

レンジ指定していても、Dependabotで自動更新させるとバージョンは直指定されます。

SBOMの視点から

SBOMの視点から見ると、ロックファイルpackages.lock.jsonはSource SBOMであって補助的な役割に過ぎません。SBOMにおいて最も重要なのはBuild SBOMであり、C#でもビルド時にobj/project.assets.jsonへ出力します。

$ dotnet build -c Release
$ ls -l ./src/ConsoleApp4/obj
total 64
-rw-rw-r--    1 guitarrapc   guitarrapc  22356 Jan 14 17:46 ConsoleApp4.csproj.nuget.dgspec.json
-rw-rw-r--    1 guitarrapc   guitarrapc   1304 Jan 14 17:43 ConsoleApp4.csproj.nuget.g.props
-rw-rw-r--    1 guitarrapc   guitarrapc    150 Jan 14 17:43 ConsoleApp4.csproj.nuget.g.targets
drwxrwxr-x    3 guitarrapc   guitarrapc      0 Jan 14 18:14 Debug
-rw-rw-r--    1 guitarrapc   guitarrapc  28542 Jan 14 17:46 project.assets.json
-rw-rw-r--    1 guitarrapc   guitarrapc    684 Jan 14 17:46 project.nuget.cache

project.assets.jsonファイルには、ビルドに使用されるすべてのパッケージとそのバージョンが含まれています。これにより、SBOMを生成する際により正確な依存関係情報を取得できます3。実際、SBOMツールのsynkCycloneDXはNuGetに対してはproject.assets.jsonを参照しています。

まとめ

C#においてロックファイルはデフォルトで無効になっており、実際に使われている例もあまり見かけません。個人的には、パッケージをバージョン直指定する文化と、決定論的なバージョン解決の仕組みにより、ロックファイルを使うメリットは小さいと考えています。今後ソフトウェアサプライチェインのセキュリティがより重要になる中で、ロックファイルの役割も見直される可能性はあります。しかし現時点では、C#のエコシステムにおいて決定論的な保証ができないケースを思いつきません。

ただし、以下のようなケースでは検討の余地があります。

  • 推移的な依存関係がどう変わったかを細かく追いかけたい場合
  • CIでのキャッシュ戦略として活用する場合(ただし、Central Package Management使用時はDirectory.Packages.propsで十分)

参考

ドキュメント

ブログ

GitHub


  1. .NET SDK 10.0.102以降でdotnet new sln -f slnxが利用可能です
  2. CPMを使っていてプロジェクトでバージョンをオーバーライドすると、気づくことが難しいこともあり私は極力避けたほうがいいと考えています
  3. 他のファイルも組み合わせますが、ビルド時に入るファイル一覧として重要

AWS Lambdaの.NET10対応とC#のファイルベースプログラムのサポート

2025年11月に.NET 10がリリースされましたが、2026年1月6日にAWS Lambdaが.NET 10をサポートしました。 これに伴いファイルベースプログラムのC# Lambda関数もサポートされ、.csファイルだけ用意すればLambda関数をデプロイできるようになりました。

AWS LambdaでC#を書く体験が変わったので、今回はその魅力を紹介します。

ファイルベースプログラムのC# Lambda関数について、以前書いた記事も参考にしてください。

はじめに

ファイルベースプログラムのC# Lambda関数は、.csファイルだけでLambda関数をデプロイできます。

これまでは.csproj + .csファイルを用意 → nugetパッケージを追加 → ビルド → zip化 → デプロイ...といった手順が必要でしたが、簡単になりました。 IaCでは引き続きビルドが必要ですが、dotnet toolでデプロイする分にはビルドステップも不要で、体験的にはPythonやNode.jsのようにスクリプトファイルをそのままデプロイするのに近い感覚です。

  • dotnet toolでデプロイ: .csファイルだけ用意したらデプロイコマンド実行
  • IaCでデプロイ: .csファイルをコンパイル、バイナリを指定してデプロイ

次のコードはコメントを抜いたミニマムなサンプルC#コードで、入力された文字列を大文字に変換して返すだけの関数です。

#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4
#:property TargetFramework=net10.0

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// The function handler
var handler = (string input, ILambdaContext context) =>
{
    return input.ToUpper();
};

await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
  .Build()
  .RunAsync();

[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

これをToUpper.csという名前で保存し、dotnet lambda deploy-functionコマンドでデプロイするだけで、Lambda関数が作成されます。

File-based C# Lambda関数をデプロイする

もう少し詳しく見ていきましょう。

C#コードはAmazon.Lambda.Templatesで追加できるlambda.FileBasedテンプレートをベースに開始できますし、そんなのを入れなくてもコピー&ペーストでも大丈夫1です。

テンプレートを使う場合は、次のコマンドでプロジェクトを作成します。

dotnet new lambda.FileBased -n ToUpper

あるいは、空のディレクトリを作成して、.csファイルを作成します。今回はToUpper.csという名前にします。

$ tree
.
└── ToUpper.cs

C#コードを用意する

テンプレートから生成されるToUpper.csファイルの中身は先のコードとほぼ同じです。 コード全体を改めて示します。

// C# file-based Lambda functions can be deployed to Lambda using the
// .NET Tool Amazon.Lambda.Tools version 6.0.0 or later.
//
// Command to install Amazon.Lambda.Tools
//   dotnet tool install -g Amazon.Lambda.Tools
//
// Command to deploy function
//    dotnet lambda deploy-function <lambda-function-name> MyLambdaFunction.cs
//
// Command to package function
//    dotnet lambda package MyLambdaFunction.zip MyLambdaFunction.cs


#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4

// Explicitly setting TargetFramework here is done to avoid
// having to specify it when packaging the function with Amazon.Lambda.Tools
#:property TargetFramework=net10.0

// By default File-based C# apps publish as Native AOT. When packaging Lambda function
// unless the host machine is Amazon Linux a container build will be required.
// Amazon.Lambda.Tools will automatically initate a container build if docker is installed.
// Native AOT also requires the code and dependencies be Native AOT compatible.
//
// To disable Native AOT uncomment the following line to add the .NET build directive
// that disables Native AOT.
//#:property PublishAot=false

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// The function handler that will be called for each Lambda event
var handler = (string input, ILambdaContext context) =>
{
    return input.ToUpper();
};

// Build the Lambda runtime client passing in the handler to call for each
// event and the JSON serializer to use for translating Lambda JSON documents
// to .NET types.
await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
  .Build()
  .RunAsync();

// Since Native AOT is used by default with C# file-based Lambda functions the source generator
// based Lambda serializer is used. Ensure the input type and return type used by the function
// handler are registered on the JsonSerializerContext using the JsonSerializable attribute.
[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

コードはC#のトップレベルステートメントを使ったExecutable assembly handlersスタイル名になっているので、Mainメソッドは不要で代わりにawait LambdaBootstrapBuilder.Create()が必要です。

public async Task<string> HandleRequest(T input, ILambdaContext context)がハンドラーのシグネチャです。第一引数が受け取るイベントデータの型で、第二引数がILambdaContextです。上記コードは、文字列を受け取り大文字に変換して返すので、(string input, ILambdaContext context) => { ... }となっています。 要するに、ハンドラーは第一引数に受け取る型、第二引数にILambdaContextを指定すれば任意の非同期メソッドにできると考えればいいでしょう。

整理すると、開発者が書く部分は2か所です。

1. ハンドラーに処理の実装を書く

Lambdaで処理したい内容はこのハンドラー内に書きます。

var handler = (string input, ILambdaContext context) =>
{
    // ここに処理を書く
};

2. Lambdaで受け取る入力をシリアライズコンテキストに登録する

リフレクションなしでLambdaへの入力をC#オブジェクトに変換するため、System.Text.Json.Serializationのシリアライズコンテキストに型を登録します。これでソースジェネレーターがシリアライズ/デシリアライズコードを生成します。

今回は入力が文字列想定なのでJsonSerializable(typeof(string))をシリアライズコンテキストに登録、ハンドラーに来た文字列をToUpper()で大文字に変換してレスポンスを返します。

[JsonSerializable(typeof(string))] // ここで受け取る型のシリアライズを登録する
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

デプロイする

Lambdaに直接.csファイルをデプロイするdotnet tool Amazon.Lambda.Toolsがあります。 これを使うとCLIでデプロイできるのでインストールします。

$ dotnet tool install -g Amazon.Lambda.Tools
You can invoke the tool using the following command: dotnet-lambda
Tool 'amazon.lambda.tools' (version '6.0.3') was successfully installed.

AWS認証を取得しておきます。

$ aws sso login --profile your-profile

Lambda関数をデプロイします。

ここが従来に比べて大きく変わった点で、.csprojファイルを用意せずに.csファイルだけでデプロイできるようになっています。 これならGitHub ActionsなどのCI/CD環境でデプロイするのも簡単です。

$ dotnet lambda deploy-function ToUpper ToUpper.cs --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10 --profile your-profile
Amazon Lambda Tools for .NET Core applications (6.0.3)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet

Architecture not provided, defaulting to x86_64 for container build image.
Executing publish command
Starting container for native AOT build using build image: mcr.microsoft.com/dotnet/sdk:10.0-aot.
... invoking 'docker run --name tempLambdaBuildContainer-3fb48080-a444-499e-b6a4-16413be8c5d0 --rm --volume "C:\github\Lambda\DotnetFileAppFunction":/tmp/source/ -i mcr.microsoft.com/dotnet/sdk:10.0-aot dotnet publish "/tmp/source/ToUpper.cs" --output "/tmp/source\artifacts\ToUpper" --configuration "Release" --framework "net10.0" /p:GenerateRuntimeConfigurationFiles=true --runtime linux-x64 --self-contained True  /p:StripSymbols=true' from directory C:\github\Lambda\DotnetFileAppFunction
... docker run: Unable to find image 'mcr.microsoft.com/dotnet/sdk:10.0-aot' locally
... docker run: 10.0-aot: Pulling from dotnet/sdk
... docker run: 59287b3c3c70: Pulling fs layer
... docker run: 06762f394a85: Pulling fs layer
... docker run: e26f93cf9c70: Pulling fs layer
... docker run: 69c84e01b5c0: Pulling fs layer
... docker run: 47849234c411: Pulling fs layer
... docker run: 505db3b3094b: Pulling fs layer
... docker run: 95c4e06fe864: Pulling fs layer
... docker run: a3629ac5b9f4: Pulling fs layer
... docker run: 60fc5ac8adb0: Pulling fs layer
... docker run: da1a80ccb2fc: Pulling fs layer
... docker run: 46f592c23ae7: Pulling fs layer
... docker run: e26f93cf9c70: Download complete
... docker run: 95c4e06fe864: Download complete
... docker run: da1a80ccb2fc: Download complete
... docker run: 59287b3c3c70: Download complete
... docker run: 47849234c411: Download complete
... docker run: a3629ac5b9f4: Download complete
... docker run: 06762f394a85: Download complete
... docker run: 60fc5ac8adb0: Download complete
... docker run: 46f592c23ae7: Download complete
... docker run: a3629ac5b9f4: Pull complete
... docker run: 47849234c411: Pull complete
... docker run: 95c4e06fe864: Pull complete
... docker run: 46f592c23ae7: Pull complete
... docker run: e26f93cf9c70: Pull complete
... docker run: 59287b3c3c70: Pull complete
... docker run: 60fc5ac8adb0: Pull complete
... docker run: 505db3b3094b: Download complete
... docker run: 69c84e01b5c0: Download complete
... docker run: 69c84e01b5c0: Pull complete
... docker run: da1a80ccb2fc: Pull complete
... docker run: 06762f394a85: Pull complete
... docker run: 505db3b3094b: Pull complete
... docker run: Digest: sha256:d68a5e260330b659f7eae596a255bddbdc4e406e3579eb2d85d718ac58dd7dcb
... docker run: Status: Downloaded newer image for mcr.microsoft.com/dotnet/sdk:10.0-aot
... docker run:   Determining projects to restore...
... docker run:   Restored /tmp/source/ToUpper.csproj (in 13.86 sec).
... docker run:   ToUpper -> /root/.local/share/dotnet/runfile/ToUpper-6855d7fa7559aae751b6be03e6497d359004d36f9f6dae455063950209971e3d/bin/release_linux-x64/ToUpper.dll
... docker run:   Generating native code
... docker run:   ToUpper -> /tmp/source/artifacts/ToUpper/
Zipping publish folder C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper to C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper.zip
... zipping: ToUpper
Created publish archive (C:\github\Lambda\DotnetFileAppFunction\artifacts\ToUpper.zip).
Creating new Lambda function ToUpper
New Lambda function created

arm64で動作させることもできますが、NativeAOTでのarm64ビルドはarm64マシンが必要です。

# x86_64マシンでarm64デプロイしようとするとエラーになる
$ dotnet lambda deploy-function ToUpper-arm64 ToUpper.cs --function-architecture arm64 --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10
Amazon Lambda Tools for .NET Core applications (6.0.3)
Project Home: https://github.com/aws/aws-extensions-for-dotnet-cli, https://github.com/aws/aws-lambda-dotnet
Host machine architecture (X64) differs from Lambda architecture (Arm64). Building Native AOT Lambda functions require the host and lambda architectures to match.

また、引数を最後に持っていくとFunction名がarm64になるので注意です2

# X: なぜかx86_64でビルドされる上、Function名がarm64になる
$ dotnet lambda deploy-function ToUpper-arm64 ToUpper.cs --function-runtime dotnet10 --function-role arn:aws:iam::123456789012:role/lambda-function-Role --function-memory-size 256 --function-timeout 10 --function-architecture arm64

ビルド~デプロイ処理を見てみると、mcr.microsoft.com/dotnet/sdk:10.0-aotコンテナを使ってNative AOTビルドしています。 また、ビルドしたバイナリToUpperをzip化してLambda関数を作成しています。デプロイ後のファイルツリーを見てみると、artifactsディレクトリにアップロードしたToUpper.zipがあり、ToUpperディレクトリにNative AOTでビルドされたバイナリが入っています。

.
│  ToUpper.cs
│
└─artifacts
    │  ToUpper.zip
    │
    └─ToUpper
            ToUpper
            ToUpper.dbg

ToUpper.zipの中身はToUpperバイナリだけが入っています。

$ unzip -l artifacts/ToUpper.zip
Archive:  artifacts/ToUpper.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
  6925632  1980-00-00 00:00   ToUpper
---------                     -------
  6925632                     1 file

このことから、ファイルベースのC# Lambda関数は従来通りzip化されたバイナリをアップロードしていることがわかります。

動作確認する

デプロイされた結果です。

Lambdaコンソール

Lambda HandlerはToUpperに設定されます。通常C#の場合はNamespace.ClassName::MethodNameの形式ですが、ファイルベースの場合は名前空間やクラス名が省略され直接バイナリ名になります。

Lambda HandlerはToUpper

実行してみると、ToUpper関数が動作していることがわかります。テストイベントを以下のように設定します。

"foobar"

実行すると、以下のように返ってきます。

"FOOBAR"

Functionの実行結果

いい感じですね。

JSONを入力する

先ほどの例では文字列を受け取る単純なケースでしたが、実際のLambda関数ではJSONオブジェクトを扱うことが多いでしょう。 現在のコードのままJSONを与えると例外が発生します。

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}

Lambdaを実行すると例外ログが出ます。

START RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9 Version: $LATEST
2026-01-14T06:09:44.113Z    08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9    fail    Amazon.Lambda.Serialization.SystemTextJson.JsonSerializerException: Error converting the Lambda event JSON payload to type System.String: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
 ---> System.Text.Json.JsonException: The JSON value could not be converted to System.String. Path: $ | LineNumber: 0 | BytePositionInLine: 1.
 ---> System.InvalidOperationException: Cannot get the value of a token type 'StartObject' as a string.
   at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_ExpectedString(JsonTokenType) + 0x19
   at System.Text.Json.Utf8JsonReader.GetString() + 0xa5
   at System.Text.Json.Serialization.JsonConverter`1.TryRead(Utf8JsonReader&, Type, JsonSerializerOptions, ReadStack&, T&, Boolean&) + 0x1e8
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader&, T&, JsonSerializerOptions, ReadStack&) + 0x81
   --- End of inner exception stack trace ---
   at System.Text.Json.ThrowHelper.ReThrowWithPath(ReadStack&, Utf8JsonReader&, Exception) + 0x48
   at System.Text.Json.Serialization.JsonConverter`1.ReadCore(Utf8JsonReader&, T&, JsonSerializerOptions, ReadStack&) + 0x21b
   at System.Text.Json.Serialization.Metadata.JsonTypeInfo`1.Deserialize(Utf8JsonReader&, ReadStack&) + 0x26
   at System.Text.Json.JsonSerializer.ReadFromSpan[TValue](ReadOnlySpan`1, JsonTypeInfo`1, Nullable`1) + 0xd6
   at Amazon.Lambda.Serialization.SystemTextJson.SourceGeneratorLambdaJsonSerializer`1.InternalDeserialize[T](Byte[]) + 0x78
   at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream) + 0x183
   --- End of inner exception stack trace ---
   at Amazon.Lambda.Serialization.SystemTextJson.AbstractLambdaJsonSerializer.Deserialize[T](Stream) + 0x229
   at Amazon.Lambda.RuntimeSupport.HandlerWrapper.<>c__DisplayClass44_0`2.<GetHandlerWrapper>b__0(InvocationRequest invocation) + 0x45
   at Amazon.Lambda.RuntimeSupport.LambdaBootstrap.<>c__DisplayClass26_0.<<InvokeOnceAsync>b__0>d.MoveNext() + 0x1d5
END RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9
REPORT RequestId: 08c68f6d-02c2-4d55-b4e9-3f1aba3acbf9  Duration: 55.06 ms  Billed Duration: 56 ms  Memory Size: 256 MB Max Memory Used: 37 MB

これに対応するには、入力JSONを表すC#クラスを用意し、シリアライズコンテキストに登録します。

[JsonSerializable(typeof(HelloWorldEvent))]
//[JsonSerializable(typeof(string))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

public record HelloWorldEvent
{
    [JsonPropertyName("key1")]
    public required string Key1 { get; init; }
    [JsonPropertyName("key2")]
    public required string Key2 { get; init; }
    [JsonPropertyName("key3")]
    public required string Key3 { get; init; }
}

受け取ったHelloWorldEventオブジェクトを使うようにハンドラーを書き換えます。

var handler = (HelloWorldEvent input, ILambdaContext context) =>
{
    return $"{input.Key1}, {input.Key2}, {input.Key3}".ToUpper();
};

変更後のToUpper.csのコードは次の通りです。

#:package Amazon.Lambda.Core@2.8.0
#:package Amazon.Lambda.RuntimeSupport@1.14.1
#:package Amazon.Lambda.Serialization.SystemTextJson@2.4.4

#:property TargetFramework=net10.0

using Amazon.Lambda.Core;
using Amazon.Lambda.RuntimeSupport;
using Amazon.Lambda.Serialization.SystemTextJson;
using System.Text.Json.Serialization;

// 👇 HelloWorldEventを受け取るように書き換える
var handler = (HelloWorldEvent input, ILambdaContext context) =>
{
    return $"{input.Key1}, {input.Key2}, {input.Key3}".ToUpper();
};

await LambdaBootstrapBuilder.Create(handler, new SourceGeneratorLambdaJsonSerializer<LambdaSerializerContext>())
    .Build()
    .RunAsync();

// 👇 シリアライズコンテキストにHelloWorldEventを登録する
[JsonSerializable(typeof(HelloWorldEvent))]
public partial class LambdaSerializerContext : JsonSerializerContext
{
}

// 👇 JSONを表すC#クラスを追加
public record HelloWorldEvent
{
    [JsonPropertyName("key1")]
    public required string Key1 { get; init; }
    [JsonPropertyName("key2")]
    public required string Key2 { get; init; }
    [JsonPropertyName("key3")]
    public required string Key3 { get; init; }
}

これで受け取ったJSONの値を大文字に変換して返せます。

{
  "key1": "value1",
  "key2": "value2",
  "key3": "value3"
}

JSONを受け取って大文字で返す

IaCからデプロイする

PulumiのAWS SDKでもファイルベースプログラムのC# Lambda関数をデプロイできます。 ただ、dotnet toolからのデプロイと違って明示的に事前ビルドが必要なので微妙です。

ガワだけIaCで作っておいてCodeはIgnore、関数自体は別途デプロイしたほうが扱いやすいです。

var name = "dotnet-file-based-lambda";
var functionName = "ToUpper";
var handlerName = "ToUpper";
var functionCodePath = "Lambda/DotnetFileAppFunction/artifacts/ToUpper/ToUpper"; // 事前にビルドしておいたバイナリのパス
var roleArn = "arn:aws:iam::123456789012:role/lambda-function-Role";

var lambdaHash = HashHelper.CreateHashSha256(functionCodePath);

var cloudwatchLogs = new Pulumi.Aws.CloudWatch.LogGroup($"{name}-lambda-loggroup", new()
{
    Name = $"/aws/lambda/{functionName}",
    RetentionInDays = 7,
}, opt);

var lambda = new Pulumi.Aws.Lambda.Function($"{name}-lambda-function", new()
{
    Name = functionName,
    Description = AwsConstants.Descriptions.Default,
    Code = new FileArchive(Directory.GetParent(functionCodePath)!.FullName.NormalizePath()),
    Role = roleArn,
    Runtime = "dotnet10",
    Handler = handlerName,
    Timeout = 10,
    SourceCodeHash = lambdaHash,
}, opt);

パフォーマンスについて

AWS Blogで、.NET 8に比べて.NET 10がパフォーマンス低下するケース(#120288)について言及されています。これに関しては、.NET 10.0.2で解消したとのことなので、安心できそうです。

.NET 10.0.2でパフォーマンス低下が改善

まとめ

AWS Lambdaが.NET 10をサポートしたことで、ファイルベースプログラムのC# Lambda関数が使えるようになりました。 .csファイルだけでLambda関数をデプロイできるので、C#でのLambda開発がかなり手軽になります。

C#のラムダ関数は、PythonやNode.jsに比べてビルドが必要で、C#を意識するのはお手軽じゃないと感じていました。PythonやNode.jsのような手軽さでC#が書けるのは嬉しい変化です。 .NET 10におけるファイルベースプログラムは割と良い感じなので、ぜひ試してみてください。

参考


  1. 私はコピー&ペーストで書いてる
  2. コマンドの途中に--function-architecture arm64を入れると回避できます

リポジトリの.gitignoreを触らず自分だけのファイルを無視したい

最近、.git/info/excludeを使う方法を紹介している記事を読みました。 実際、.ideaや自分だけの設定ファイルなどは、うっかりコミットしそうになりますよね。 とはいえ、リポジトリの.gitignoreを触るとチームメンバーに影響が出るため、触りたくないことも多いでしょう。

同じような動機ですが、私が普段使っている他の方法も紹介します。

はじめに

gitは無視したいファイルを.gitignoreに書くことで、Gitの管理対象から外せます。 しかし、.gitignoreはリポジトリごとに存在し、チームメンバー全員に影響を与えます。自分だけの無視設定を書きたい場合、リポジトリの.gitignoreを変更するのは避けたくなります。

今回は、リポジトリの.gitignoreを触らずに自分だけの無視設定を書く方法を4つ紹介します。

  • そのリポジトリだけでファイルを無視する
  • リポジトリに無視ファイル設定を置く
    • リポジトリの.gitignoreに無視するファイルを書く
    • グローバルの.gitignoreに、リポジトリごとの無視ファイルを書く <- おすすめ!
  • グローバルの.gitignoreに直接無視設定を書く

方法1. そのリポジトリだけでファイルを無視する

そのリポジトリでだけファイルを無視するなら、.git/info/excludeを使えば自分だけの無視設定ができます。

$ echo ".idea/" >> .git/info/exclude

.git/info/excludeはリポジトリごとに存在するため、他のリポジトリには影響しません。 また、.gitignoreと同じ書式で無視設定ができて使い勝手が良いです。

一方で、.git/info/excludeはリポジトリごとに存在するため、複数のリポジトリで同じ設定をしたい場合は、各リポジトリで設定することになります。 また、リポジトリをクローンしなおしたときに設定が消えてしまう1ため、その点は注意が必要です。

これらが嫌で私は余り使わないようにしています。

方法2. リポジトリに無視ファイル設定を置く

リポジトリに.gitignore.local(ファイル名は何でもいい)を置き、そこに自分だけの無視設定を書く方法もあります。 これは2つの方法があります。

2-1. リポジトリの.gitignoreに無視するファイルを書く

リポジトリの.gitignoreに、.gitignore.localを一度だけ書いてしまうのもいいでしょう。 チーム全員が「リポジトリで自分だけの無視ファイル設定は.gitignore.localに書く」というルールを敷くスタイルです。 初回だけリポジトリの.gitignoreを触る必要がありますが、その後は各自が.gitignore.localに自分だけの無視設定を書けます。

$ echo ".gitignore.local" >> .gitignore

こうすることで、チームメンバー全員が、各自.gitignore.localに自分だけの無視設定を書けます。

$ echo ".idea/" >> .gitignore.local

無視されているか確認します。

$ git check-ignore -v -- .gitignore.local
.gitignore:139:.gitignore.local .gitignore.local

いい感じですね。

2-2. グローバルの.gitignoreに、リポジトリごとの無視ファイルを書く

リポジトリの.gitignoreを触りたくない場合、グローバルgitignoreに.gitignore.localを指定しておく方法もあります。 これなら、リポジトリの.gitignoreを一切触らずに自分だけの無視設定ができます。私はこれが好きです。

グローバルgitignoreの設定は、XDG_CONFIG_HOMEを使っているなら$XDG_CONFIG_HOME/git/ignoregit config --global core.excludesFileデフォルト設定です。 このパスにファイルを置けばexcludesFileにパスを設定しなくても有効になります。

XDG_CONFIG_HOMEは、Linux/macOS/Windows共通で設定ファイルを配置する場所を示す環境変数です。通常は$HOME/.configに設定されています。

例えば、XDG_CONFIG_HOME$HOME/.configなら、$HOME/.config/git/ignoreになります。

$ env | grep XDG_CONFIG_HOME
XDG_CONFIG_HOME=/home/foobar/.config

$ echo ".gitignore.local" >> "$XDG_CONFIG_HOME/git/ignore"

これで、リポジトリに.gitignore.localを置けば自分だけの無視設定を書けます。

$ echo .idea/ >> .gitignore.local

無視されているか確認します。

$ git check-ignore -v -- .gitignore.local
/home/foobar/.config/git/ignore:2:.gitignore.local       .gitignore.local

いい感じですね。

方法3. グローバルgitignoreを使う方法

リポジトリごとに無視設定を書くのが面倒な場合、グローバルgitignoreに直接無視したいファイルを書いておく方法もあります。

このやり方は特定のリポジトリだけ例外にできないので、あまり好きではありませんが、OS固有のファイルなどを無視するのに便利です。 グローバルにignoreする内容はgithub/gitignoreにあるテンプレートを参考にすると良いです。便利。

以下は、私が使っているグローバルgitignoreの例です。

$ cat "$XDG_CONFIG_HOME/git/ignore"

# --- git ---
.gitignore.local

# Gibo
.gitignore-boilerplates
.gitconfig.local

# --- macOS ---

# General
.DS_Store
__MACOSX/
.AppleDouble
.LSOverride
Icon[]

# Thumbnails
._*

# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent

# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk

# --- Windows ---

# Windows thumbnail cache files
Thumbs.db
Thumbs.db:encryptable
ehthumbs.db
ehthumbs_vista.db

# Dump file
*.stackdump

# Folder config file
[Dd]esktop.ini

# Recycle Bin used on file shares
$RECYCLE.BIN/

# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp

# Windows shortcuts
*.lnk

まとめ

リポジトリの.gitignoreを触らずに自分だけの無視設定を書く方法を4つ紹介しました。

方法 メリット デメリット
.git/info/exclude 設定が簡単 クローンしなおすと消える
.gitignore.local(リポジトリ) チーム全体で統一できる 一度だけ.gitignoreを触る必要がある
.gitignore.local(グローバル) リポジトリを一切触らない グローバル設定が必要
グローバルgitignore直接 OS固有ファイルに便利 例外設定ができない

リポジトリの.gitignoreは極力触りたくない場合、グローバルgitignoreにリポジトリごとの無視ファイルを指定する方法がおすすめです。

参考


  1. Unityプロジェクトだと、時々クローンしなおしたくなるのでありがちなんですよね

コンテナでGUIアプリケーションを動かしてホストからブラウザで操作する

前回の記事で、コンテナのGUIアプリケーションをホストからRDP/VNCで操作する方法を紹介しました。

今回は、それを一歩進めてブラウザ経由でコンテナのGUIアプリケーションにアクセスする方法を紹介します。引き続き、guitarrapc/docker-jmeter-guiリポジトリのDockerfileを利用します。

はじめに

前回まとめたコンテナのGUIアプリケーションをホストから操作する方法のうち、ブラウザ経由で接続する方法が今回です。RDP/VNCと違ってブラウザだけでアクセスできるため、かなり手軽ですし迷わず使えるのが嬉しいです。

方法 クロスプラットフォーム GPU不要 概要
X11転送 X O ホストにXサーバーをインストールし、コンテナからX11プロトコルで接続する
VNC O O コンテナ内にVNCサーバーをインストールし、ホストからVNCクライアントで接続する
RDP O O コンテナ内にRDPサーバーをインストールし、ホストからRDPクライアントで接続する
ブラウザ経由 O O コンテナのVNCをWebSocket化して差分ビットマップを送信、ホストからブラウザで接続する
WebRTC経由 O X コンテナをWebRTC化して動画ストリーム配信、ホストからブラウザで接続する

ブラウザ経由でのアクセスが他の方法と比べて優れている点は、次のようなものがあります。

  • クライアントソフト不要: RDPクライアントやVNCクライアントのインストールが不要
  • どこからでもアクセス: ブラウザさえあれば、OSを問わずアクセスできる
  • ファイアウォール的な扱いやすさ: HTTP/HTTPSポートを使うため、企業ネットワークでも接続しやすい

コンテナにブラウザ経由でアクセスできるようにする

まず、ホストマシンからブラウザで接続する様子を示します。ホストマシンからブラウザで接続するとデスクトップが表示、操作できます。ほしいのはこれだった。

Ubuntu 24.04 Alpine
Ubuntu24.04コンテナへのアクセスの様子 Alpine3.23コンテナへのアクセスの様子

コンテナ構成

Ubuntu 24.04ベース(KasmVNC)とAlpine Linuxベース(noVNC)で異なる方法を用意しています。いずれもブラウザでアクセスできるようにする手法は同じで、VNCサーバーをWebSocket化しています1

構築して動かした感じだと、KasmVNCは起動時の依存が少なく動かしやすいため好みですが通信帯域が大きめです。そう意味ではnoVNCはもう少し軽量なのでよい感じもします。

イメージサイズは次の通りです。

$ docker image ls
REPOSITORY  TAG                 IMAGE ID       CREATED          SIZE
jmeter-gui  5.6.3-alpine3.23    73616f724564   4 minutes ago    1.31GB
jmeter-gui  5.6.3-ubuntu24.04   6f04998c4e51   12 minutes ago   1.45GB
alpine      3.23                865b95f46d98   3 weeks ago      26.5MB
ubuntu      24.04               c35e29c94501   2 months ago     257MB

Ubuntu 24.04

Ubuntu 24.04ベースのコンテナは、KasmVNCを利用しています。ファイル配置は次の通りです。

$ tree
.
├── Dockerfile
└── kasmvnc.yaml

Dockerfileを示します。以下を考慮しています。

  • JMeterのインストールをマルチステージで分離
  • linux/amd64linux/arm64のマルチアーキテクチャ対応
  • バイナリダウンロード時にSHA512で整合性検証
FROM ubuntu:24.04 AS builder

ARG TARGETARCH

ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_PLUGINS_MANAGER_VERSION="1.10"
# SHA512 checksums for integrity verification
ENV JMETER_SHA512="5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083"
ENV JMETER_PLUGINS_MANAGER_SHA512="38af806a7c78473c032ba93c7a2e522674871f01616985a0b0522483977d58afdc444d18bd5590b8036c344ccf11d2fe61be807501d5edb6d4bdebc9050c43ae"

RUN apt-get update \
    && apt-get install -y --no-install-recommends wget ca-certificates \
    # Download JMeter
    && wget https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz -O /tmp/jmeter.tgz \
    # Verify JMeter checksum
    && echo "${JMETER_SHA512}  /tmp/jmeter.tgz" | sha512sum -c - \
    && tar -xzf /tmp/jmeter.tgz -C /opt \
    # Download JMeter Plugins Manager
    && wget https://repo1.maven.org/maven2/kg/apc/jmeter-plugins-manager/${JMETER_PLUGINS_MANAGER_VERSION}/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar -O ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar \
    # Verify Plugins Manager checksum
    && echo "${JMETER_PLUGINS_MANAGER_SHA512}  ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar" | sha512sum -c - \
    && rm -rf ${JMETER_HOME}/docs ${JMETER_HOME}/printable_docs /tmp/jmeter.tgz

FROM ubuntu:24.04

ARG TARGETARCH

LABEL version="5.6.3"
LABEL description="An Ubuntu based docker image contains Apache JMeter GUI to configure scenario. Enable connect container with browser."
LABEL maintainer="3856350+guitarrapc@users.noreply.github.com"

ENV DEBIAN_FRONTEND=noninteractive
ENV KASMVNC_VERSION="1.4.0"
ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_BIN=${JMETER_HOME}/bin
ENV PATH=${JMETER_BIN}:$PATH
ENV DISPLAY=":99"

# Install minimal packages
RUN apt-get update \
    && apt-get install -y --no-install-recommends ca-certificates fluxbox xterm openjdk-11-jre wget \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Install KasmVNC
RUN wget https://github.com/kasmtech/KasmVNC/releases/download/v${KASMVNC_VERSION}/kasmvncserver_jammy_${KASMVNC_VERSION}_${TARGETARCH}.deb -O /tmp/kasmvnc.deb \
    && apt-get update \
    && apt-get install -y --no-install-recommends /tmp/kasmvnc.deb \
    && rm /tmp/kasmvnc.deb \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

COPY --from=builder /opt/apache-jmeter-${JMETER_VERSION} /opt/apache-jmeter-${JMETER_VERSION}

# Configure KasmVNC
RUN mkdir -p /root/.vnc \
    && echo '#!/bin/sh' > /root/.vnc/xstartup \
    && echo 'fluxbox &' >> /root/.vnc/xstartup \
    && echo 'jmeter -Jjmeter.laf=CrossPlatform' >> /root/.vnc/xstartup \
    && chmod +x /root/.vnc/xstartup

COPY kasmvnc.yaml /root/.vnc/kasmvnc.yaml

EXPOSE 8080

WORKDIR /root

CMD ["bash", "-c", "echo '2' | vncserver ${DISPLAY} -DisableBasicAuth -select-de manual -fg"]

kasmvnc.yamlは以下の通りです。コマンドライン引数で指定もできるのですが、設定ファイルでまとめておくと見通しがよく、利用時にオーバーライドしやすいのでいい感じです。

desktop:
  resolution:
    width: 1366
    height: 768
  allow_resize: true
  pixel_depth: 24

network:
  interface: 0.0.0.0
  websocket_port: 8080
  ssl:
    pem_certificate:
    pem_key:
    require_ssl: false

encoding:
  max_frame_rate: 30

以下のコマンドでdockerイメージをビルドします。

docker buildx build --platform linux/amd64,linux/arm64 -t jmeter-gui:5.6.3-ubuntu24.04 .

以下のコマンドでコンテナを起動します。

docker run -it --rm -v ${PWD}/scenarios:/root/jmeter/ -p 8080:8080 jmeter-gui:5.6.3-ubuntu24.04

コンテナが起動したら、ブラウザで http://localhost:8080 にアクセスすることで、JMeterのGUI画面が表示されます。

Alpine Linux

Alpine Linuxベースのコンテナは、noVNCを利用しています。ファイル配置は次の通りです。こっちは依存が多いため、supervisordでプロセス管理しています。

$ tree
.
├── Dockerfile
└── supervisord.conf

Dockerfileを示します。以下を考慮しています。

  • JMeterのインストールをマルチステージで分離
  • linux/amd64linux/arm64のマルチアーキテクチャ対応
  • バイナリダウンロード時にSHA512で整合性検証
  • supervisordでプロセス管理
FROM alpine:3.23 AS builder

ARG TARGETARCH

ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_PLUGINS_MANAGER_VERSION="1.10"
# SHA512 checksums for integrity verification
ENV JMETER_SHA512="5978a1a35edb5a7d428e270564ff49d2b1b257a65e17a759d259a9283fc17093e522fe46f474a043864aea6910683486340706d745fcdf3db1505fd71e689083"
ENV JMETER_PLUGINS_MANAGER_SHA512="38af806a7c78473c032ba93c7a2e522674871f01616985a0b0522483977d58afdc444d18bd5590b8036c344ccf11d2fe61be807501d5edb6d4bdebc9050c43ae"

RUN apk add --no-cache curl \
    # Download JMeter
    && curl -L https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz -o /tmp/jmeter.tgz \
    # Verify JMeter checksum
    && echo "${JMETER_SHA512}  /tmp/jmeter.tgz" | sha512sum -c - \
    && tar -xvf /tmp/jmeter.tgz -C /opt \
    # Download JMeter Plugins Manager
    && curl -L https://repo1.maven.org/maven2/kg/apc/jmeter-plugins-manager/${JMETER_PLUGINS_MANAGER_VERSION}/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar -o ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar \
    # Verify Plugins Manager checksum
    && echo "${JMETER_PLUGINS_MANAGER_SHA512}  ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar" | sha512sum -c - \
    && rm -rf ${JMETER_HOME}/docs ${JMETER_HOME}/printable_docs /tmp/jmeter.tgz

FROM alpine:3.23

ARG TARGETARCH

LABEL version="5.6.3"
LABEL description="An Alpine based docker image contains Apache JMeter GUI to configure scenario. Enable connect container with browser."
LABEL maintainer="3856350+guitarrapc@users.noreply.github.com"

ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_BIN=${JMETER_HOME}/bin
ENV PATH=${JMETER_BIN}:$PATH
ENV DISPLAY=":99"

# Install minimal packages
RUN apk add --no-cache \
    ca-certificates bash fluxbox xterm x11vnc xvfb ttf-dejavu supervisor openjdk11-jre novnc \
    && ln -s /usr/share/novnc/vnc.html /usr/share/novnc/index.html \
    && rm -rf /var/cache/apk/*

COPY --from=builder /opt/apache-jmeter-${JMETER_VERSION} /opt/apache-jmeter-${JMETER_VERSION}

# Configure VNC
RUN mkdir -p /root/.vnc \
    && echo '#!/bin/sh' > /root/.vnc/xstartup \
    && echo 'fluxbox &' >> /root/.vnc/xstartup \
    && echo 'jmeter -Jjmeter.laf=CrossPlatform' >> /root/.vnc/xstartup \
    && chmod +x /root/.vnc/xstartup

# Configure supervisord
RUN mkdir -p /var/log/supervisor
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf

EXPOSE 8080

WORKDIR /root

CMD ["/usr/bin/supervisord", "-c", "/etc/supervisor/conf.d/supervisord.conf"]

supervisord.confは以下の通りです。

[supervisord]
nodaemon=true
logfile=/var/log/supervisor/supervisord.log
pidfile=/var/run/supervisord.pid
user=root

[program:xvfb]
command=/usr/bin/Xvfb :99 -screen 0 1280x1024x24 -ac +extension GLX +render -noreset
autorestart=true
priority=10
stdout_logfile=/var/log/supervisor/xvfb.log
stderr_logfile=/var/log/supervisor/xvfb.err
startretries=3

[program:x11vnc]
command=/usr/bin/x11vnc -display :99 -forever -shared -nopw -wait 5
autorestart=true
priority=20
stdout_logfile=/var/log/supervisor/x11vnc.log
stderr_logfile=/var/log/supervisor/x11vnc.err
startretries=3

[program:novnc]
command=/usr/bin/novnc_server --vnc localhost:5900 --listen 8080
autorestart=true
priority=30
stdout_logfile=/var/log/supervisor/novnc.log
stderr_logfile=/var/log/supervisor/novnc.err
startretries=3

[program:fluxbox]
command=/usr/bin/fluxbox
environment=DISPLAY=":99"
autorestart=true
priority=40
stdout_logfile=/var/log/supervisor/fluxbox.log
stderr_logfile=/var/log/supervisor/fluxbox.err
startretries=3

[program:jmeter]
command=/opt/apache-jmeter-5.6.3/bin/jmeter -Jjmeter.laf=CrossPlatform
environment=DISPLAY=":99"
autorestart=false
priority=50
stdout_logfile=/var/log/supervisor/jmeter.log
stderr_logfile=/var/log/supervisor/jmeter.err
startretries=0

以下のコマンドでdockerイメージをビルドします。

docker buildx build --platform linux/amd64,linux/arm64 -t jmeter-gui:5.6.3-alpine3.23 .

以下のコマンドでコンテナを起動します。

docker run -it --rm -v ${PWD}/scenarios:/root/jmeter/ -p 8080:8080 jmeter-gui:5.6.3-alpine3.23

コンテナが起動したら、ブラウザで http://localhost:8080 にアクセスすることで、JMeterのGUI画面が表示されます。

コンテナへの接続フロー

ホストからコンテナへの接続フローは次の通りです。前回のVNC接続のフローの前にWebSocket変換ブリッジが入っただけですが、これが体験に大きな差をもたらします。ブラウザアクセスは本当に手軽です。

クリックで接続フローのMermaidを開く

graph LR
  subgraph ホストマシン
    A[ブラウザ]
  end
  subgraph コンテナ内
    B[WebSocket<br/>接続]
    C[VNCサーバー<br/>KasmVNC/noVNC]
    E[仮想Xサーバー<br/>Xvfb :99]
    F[デスクトップ環境<br/>fluxbox]
    G[JMeter GUI]
  end

  A -->|Port 8080| B
  B -->|VNCプロトコル| C
  C -->|X11プロトコル| E
  E --> F
  F --> G

ブラウザ接続フロー

パッケージ一覧

Dockerfileで使用しているパッケージ一覧を説明します。これらを使い、GUI環境を持たないDockerコンテナ内でJMeterのGUIを実行し、外部から接続を受け付けています。記載以外のパッケージは、前回の記事で説明しています。

KasmVNC

VNCサーバーがHTTP/HTTPSとWebSocketを直接サーブでき、追加のwebsockifyが不要です。1コンポーネントで完結しやすいのが利点です。

noVNC

websockify(WSプロキシ)と組み合わせて、VNCをWebSocket化する機能を持っています。VNCをウェブ化といううたい文句そのままを提供します。

supervisord

AlpineでnoVNCを動かす際に利用しています。起動時にXvfb、x11vnc、noVNCサーバー、デスクトップ環境、JMeterを起動するのですが、それぞれに依存関係があるためCMDでは扱いきれません。supervisordでプロセス管理することで、起動順序や再起動ポリシーを人間が扱える程度に整理できます。

まとめ

コンテナのGUIをホストから操作する方法として、ブラウザで接続する方法を紹介しました。この方法には、クロスプラットフォームで動作する、GPU不要である、ブラウザだけでアクセスできるといった利点があります。

2026年現在、GUIコンテナと一緒に提供するならブラウザ経由なのは当然という感じがあります。イメージサイズが膨れがちなのは微妙ですが、マルチアーキテクチャでも提供できますし、利便性を考えると十分に許容範囲内です。

次にやりたくなるのはWebRTCって感じですが、これはまた別の機会にまとめます。

参考


  1. WebRTCベースだとさらに低遅延で動画ストリームを配信できますが、低遅延は不要なので選択していません。

コンテナでGUIアプリケーションを動かしてホストからVNC/RDPで操作する

JMeterはJavaで動作する負荷試験ツールです。その特徴はGUIでシナリオを作成できることです。しかし、JMeter GUIを動作させるためにはJava環境が必要であり、セットアップが面倒ですしインストールも避けたいものがあります。

これを解決するため、Dockerコンテナ上でJMeter GUIを動作させるコンテナイメージguitarrapc/docker-jmeter-guiを公開しています。このコンテナイメージを作成する過程で、コンテナのGUIをホストから操作する方法をいくつか試してみました。

今回はホストからVNC/RDPでコンテナに接続してGUI操作する方法を紹介します。次回はブラウザ経由で接続する方法を紹介します。

はじめに

コンテナのGUIをホストから操作する方法はいくつかあります。

方法 クロスプラットフォーム GPU不要 概要
X11転送 X O ホストにXサーバーをインストールし、コンテナからX11プロトコルで接続する1
VNC O O コンテナ内にVNCサーバーをインストールし、ホストからVNCクライアントで接続する
RDP O O コンテナ内にRDPサーバーをインストールし、ホストからRDPクライアントで接続する
ブラウザ経由 O O コンテナのVNCをWebSocket化して差分ビットマップを送信、ホストからブラウザで接続する
WebRTC経由 O X コンテナをWebRTC化して動画ストリーム配信、ホストからブラウザで接続する

コンテナでどう構成するにしても、ホストからなるべく使いやすいのは絶対条件です。このため、X11転送は候補になりえません。VNCやRDPはクライアントソフトが必要ですが、Windows(RDP)やmacOS(VNC)には標準でクライアントが付属しているため、比較的使いやすいです。ブラウザ経由・WebRTC経由はブラウザさえあればよいため、最も使いやすい方法です。

WebRTCはGPUがないと体験が悪く、GPUがない環境でもコンテナは使われえるのでWebRTCは除外します。

このため、クロスプラットフォームでの利用を考えると、VNC/RDPやブラウザアクセスできるようにするのがいい感じです。

すべてを書いていると記事が長くなるので、この記事ではVNC/RDPで接続する方法を紹介します。

コンテナにVNC/RDPでアクセスできるようにする

まず、ホストマシンからRDP・VNCクライアントで接続する様子を示します。ホストマシンからRDPで接続するとX Serverログイン画面が表示されるのでパスワードを入力します。ホストマシンからVNCで接続するとVNCパスワード入力画面が表示されます。

パスワードをrootに固定しています。今回はローカルコンテナであるという割り切りです。ご了承ください。

状態 RDPクライアント VNCクライアント
接続画面 WindowsからRemote Desktop Connectionで接続する VNCで接続する
ログイン画面 XServerログイン画面 VNCパスワードの入力

接続に成功するとJMeter GUIが表示されます。

JMeterの画面

コンテナ構成

VNC/RDPでアクセスできるようにするには、コンテナ内にデスクトップ環境とVNCサーバーまたはRDPサーバーをインストールします。ファイル配置は次の通りです。

$ tree
.
├── Dockerfile
└── xrdp.ini

Dockerfile2を示します。

FROM ubuntu:24.04

LABEL version="5.6.3"
LABEL description="An Ubuntu based docker image contains Apache JMeter GUI to configure scenario. Enable connect container with VNC and RDP."
LABEL maintainer="3856350+guitarrapc@users.noreply.github.com"

ENV DEBIAN_FRONTEND=noninteractive
ENV JMETER_VERSION="5.6.3"
ENV JMETER_HOME=/opt/apache-jmeter-${JMETER_VERSION}
ENV JMETER_BIN=${JMETER_HOME}/bin
ENV JMETER_PLUGINS_MANAGER_VERSION="1.10"
ENV PATH=${JMETER_BIN}:$PATH
ENV DISPLAY=":99" \
    RESOLUTION="1366x768x24" \
    PASS="root"

# Install minimal packages
RUN apt-get update \
    && apt-get install -y --no-install-recommends \
    wget ca-certificates \
    xvfb x11vnc \
    xrdp xorgxrdp \
    fluxbox xterm \
    openjdk-11-jre \
    && apt-get clean \
    && rm -rf /var/lib/apt/lists/*

# Download JMeter
RUN wget https://archive.apache.org/dist/jmeter/binaries/apache-jmeter-${JMETER_VERSION}.tgz -O /tmp/jmeter.tgz \
    && tar -xzf /tmp/jmeter.tgz -C /opt \
    && rm /tmp/jmeter.tgz \
    && rm -rf ${JMETER_HOME}/docs ${JMETER_HOME}/printable_docs
RUN wget https://repo1.maven.org/maven2/kg/apc/jmeter-plugins-manager/${JMETER_PLUGINS_MANAGER_VERSION}/jmeter-plugins-manager-${JMETER_PLUGINS_MANAGER_VERSION}.jar -O ${JMETER_HOME}/lib/ext/jmeter-plugins-manager.jar

# Configure VNC
RUN x11vnc -storepasswd ${PASS} /etc/x11vnc.pass

# Set root password for RDP login
RUN echo "root:${PASS}" | chpasswd

# Configure RDP
COPY xrdp.ini /etc/xrdp/xrdp.ini

EXPOSE 5900 3389

WORKDIR /root

CMD ["bash", "-c", "rm -f /tmp/.X99-lock /var/run/xrdp.pid /var/run/xrdp-sesman.pid \
    && /usr/bin/Xvfb ${DISPLAY} -screen 0 ${RESOLUTION} -ac +extension GLX +render -noreset & \
    sleep 3 \
    && fluxbox & \
    sleep 2 \
    && jmeter -Jjmeter.laf=CrossPlatform & \
    sleep 3 \
    && x11vnc -xkb -noxrecord -noxfixes -noxdamage -display ${DISPLAY} -forever -rfbport 5900 -rfbauth /etc/x11vnc.pass -shared & \
    sleep 2 \
    && xrdp-sesman & \
    sleep 1 \
    && xrdp --nodaemon"]

xrdp.iniは以下の通りです。

[Globals]
bitmap_cache=true
bitmap_compression=true
autorun=jmeter
port=3389

[jmeter]
name=jmeter
lib=libvnc.so
ip=127.0.0.1
port=5900
username=na
password=ask

以下のコマンドでdockerイメージをビルドします。イメージサイズは約1.1GBです。Docker Hubでは300MB程度です。JavaランタイムとJMeter本体が大きいことに加え、GUIライブラリも入れているのである程度のサイズになるのは仕方ないです。

$ docker build -t docker-jmeter-gui .
$ docker image ls
docker image ls
REPOSITORY         TAG       IMAGE ID       CREATED              SIZE
docker-jmeter-gui  latest    cd1a471f7207   About a minute ago   1.13GB
ubuntu             24.04     c35e29c94501   2 months ago         117MB

以下のコマンドでコンテナを起動します。

docker run -it --rm -v ${PWD}/scenarios:/root/jmeter/ -p 5900:5900 -p 3389:3389 docker-jmeter-gui

注意: この例では開発・検証用途を想定しており、パスワードをrootに固定しています。本番環境や公開ネットワークでの使用は避けてください。

コンテナへの接続フロー

ホストからコンテナへの接続フローを示します。

本Dockerfileでは、RDPクライアントから接続してもVNCで接続したときと同じJMeter GUIを表示させたいため、xrdp.iniでlibvnc.soモジュールを利用しています。libvnc.soを指定することで、RDPクライアントから接続してもx11vncサーバーが提供するXvfbの仮想ディスプレイにアクセスします。libvnc.soモジュールを使わずにxorgxrdpで直接Xvfbに接続すると、RDP接続時に別のセッションが作成され、VNC接続時とは異なるデスクトップ環境が表示されます。3

クリックでコンテナのVNC/RDP接続フローのMermaidを開く

graph LR
  subgraph ホストマシン
    A[VNCクライアント]
    B[RDPクライアント]
  end
  subgraph コンテナ内
    C[VNCサーバー<br/>x11vnc]
    D[RDPサーバー<br/>xrdp]
    D1[libvnc.so<br/>モジュール]
    E[仮想Xサーバー<br/>Xvfb :99]
    F[デスクトップ環境<br/>fluxbox]
    G[JMeter GUI]
  end

  A -->|Port 5900| C
  B -->|Port 3389| D
  D --> D1
  D1 -->|VNCプロトコル| C
  C -->|X11プロトコル| E
  E --> F
  F --> G

VNC/RDP接続フロー

パッケージ一覧

Dockerfileで使用しているパッケージ一覧を説明します。これらを使い、GUI環境を持たないDockerコンテナ内でJMeterのGUIを実行し、外部から接続を受け付けています。

xvfb (X Virtual FrameBuffer)

仮想ディスプレイを提供するXサーバーです。物理的なモニターがなくてもGUIアプリケーション(JMeter GUI)を動作させます。DISPLAY=:99で仮想ディスプレイを作成していますが、他の番号でも構いません。

x11vnc

Xサーバーの画面を外部に公開する、VNCサーバーです。VNCクライアントからの接続を受け付け、Xvfbの仮想ディスプレイをVNCプロトコルで提供します。 ポート5900で待ち受け、パスワード認証を設定しています。

xrdp + xorgxrdp

Xサーバーの画面を外部に公開する、RDP(Remote Desktop Protocol)サーバーです。RDPクライアントからの接続を受け付け、Xvfbの仮想ディスプレイをRDPプロトコルで提供します。xorgxrdpはXサーバーとxrdpを接続するドライバです。

ポート3389で待ち受け、ログイン時にユーザー認証します。

fluxbox

軽量なウィンドウマネージャーです。JMeterのウィンドウを管理・表示するために必要です。これがないとGUIウィンドウが正しく表示されません。

ウィンドウマネージャーはxfce4、lxde、mateなどがあり、好きなものを選択できます。以前はxfce4を使っていましたが、fluxboxのほうが軽量なので変更しました。

xterm

端末エミュレータです。RDPやVNC接続時にコマンドラインを使えるようにします。

端末エミュレータは他にもgnome-terminal、lxterminal、xfce4-terminalなどがあります。以前は、xfce4-terminalを使っていましたが、fluxboxに合わせて軽量なxtermに変更しました。

まとめ

コンテナのGUIをホストから操作する方法として、VNC/RDPで接続する方法を紹介しました。この方法には、クロスプラットフォームで動作する、GPU不要である、既存のVNC/RDPクライアントをそのまま使えるといったメリットがあります。一方、VNCやRDPが必要になるのが明確な欠点です。

しばらく公開して自分でも使っていて感じるのですが、2026年現在、ブラウザ経由でないアクセスは使い勝手がいまいちです。ブラウザ経由であれば、ホストに特別なクライアントソフトをインストールする必要がなく、接続も簡単です。現代においてほとんどの操作はブラウザで完結するため、ブラウザ経由での接続を提供するほうがいいなぁというのが正直な感想です。

ということで、次回はブラウザ経由で接続する方法を紹介します。

参考


  1. LinuxホストであればXサーバーは構成しやすいですが、WindowsやMac OSではXサーバーのセットアップが必要です。
  2. 現在はブラウザベースに移行しているため、構成が変わっています。
  3. 5年前はAlpineで構成していたのですが、現在のAlpine Linuxのxrdpではlibvnc.soモジュールがうまく動作しなかったため、Ubuntuベースに変更しました。