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ツールの[sbom-tool]や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. 他のファイルも組み合わせますが、ビルド時に入るファイル一覧として重要