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-serverやajvなどは間接的に依存するパッケージです。

ロックファイルは、あるパッケージをインストールしたときのバージョンと、そのパッケージを導入したときに推移的にインストールされたパッケージのバージョンを記録します。これにより、同じプロジェクトを別の環境でセットアップしたときに、同じバージョンのパッケージがインストールされることを保証します。
NuGetのロックファイル
NuGetにもロックファイルを利用する機能がありますが、デフォルトでは無効になっています。ロックファイルを利用するには.csprojで<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>に設定して、プロジェクトをリストア(dotnet restore)します。すると、.csprojがあるパスにpackages.lock.jsonというファイルが生成されます。
<PropertyGroup> <RestorePackagesWithLockFile>true</RestorePackagesWithLockFile> </PropertyGroup>
試してみましょう。プロジェクト追加 → 初回のパッケージ追加 → リストア → ロックファイル追加後のリストアを順に実行します。今回は私の書いているライブラリであるSkiaSharp.QrCodeパッケージを使用します。NuGetを見るとSkiaSharpやSkiaSharp.NativeAssets.macOS/SkiaSharp.NativeAssets.Win32に依存していることがわかります。

まずはコンソールプロジェクトを作成し、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ライブラリが依存しているSkiaSharpやSkiaSharp.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.propsでSkiaSharp.QrCodeのバージョンを一元管理します。ロックファイルpackages.lock.jsonは、Directory.Packages.propsのパスではなく各プロジェクトに生成されることを確認します。
まずはルートにDirectory.Build.propsとDirectory.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でパッケージをインストール・アップグレードする際もバージョンを指定するようになっており、レンジ指定をサポートしていません。

npmのようにバージョンをレンジ/ワイルドカード指定をするには.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ツールのsynkやCycloneDXはNuGetに対してはproject.assets.jsonを参照しています。
まとめ
C#においてロックファイルはデフォルトで無効になっており、実際に使われている例もあまり見かけません。個人的には、パッケージをバージョン直指定する文化と、決定論的なバージョン解決の仕組みにより、ロックファイルを使うメリットは小さいと考えています。今後ソフトウェアサプライチェインのセキュリティがより重要になる中で、ロックファイルの役割も見直される可能性はあります。しかし現時点では、C#のエコシステムにおいて決定論的な保証ができないケースを思いつきません。
ただし、以下のようなケースでは検討の余地があります。
- 推移的な依存関係がどう変わったかを細かく追いかけたい場合
- CIでのキャッシュ戦略として活用する場合(ただし、Central Package Management使用時は
Directory.Packages.propsで十分)
参考
ドキュメント
- Enable repeatable package restores using a lock file | .NET Blog
- NuGet PackageReference in project files | Microsoft Learn
- Do you protect your NuGet config against supply chain attacks? | Reddit
- Central Package Management | Microsoft Learn
- NuGet Package Version Reference | Microsoft Learn
- SBOM | Snyk User Docs
ブログ
GitHub













