dotnet-5.0のシングルファイルアプリについて


はじめに

dotnetはその仕組み上、普通に構築すると多数のアセンブリを伴う実行ファイルとなる場合が多い。
これはウェブアプリとして展開する場合等は問題にならないが、コンソールアプリケーションとして展開したい場合、不便になることが多い。

そこで、dotnetではシングルファイルのバイナリとして生成物をまとめる機能がついているが、dotnet-5.0でどうなっているか、使う上でポイントとなる部分を書いていこうと思う。

事前知識: msbuildのプロパティについて

記事中msbuildのプロパティについて言及するが、設定方法がいくつかあるので、簡単に書く。
msbuildプロパティについては他にもいくつかルールがあったりするが、詳細は公式ドキュメントを見てほしい

プロジェクト単位のプロパティ

プロジェクト単位でプロパティを設定する場合、ビルド時にコマンドラインから指定する方法と、予めprojファイルに入れておく方法がある。
コマンドライン指定の場合、msbuild /p:[プロパティ名]=[プロパティの値]のように設定する。
dotnet publishコマンドでビルドする場合、"dotnet publish -p:[プロパティ名]=[プロパティ値]`のように指定する

projファイルに書き込む場合、下記のようにする

app.csproj
<PropertyGroup>
  <プロパティ名>[プロパティ値]</プロパティ名>
</PropertyGroup>

同じプロパティ名が複数指定された場合、最後に読まれたものが採用される

アイテム単位のプロパティ

例えば個別のファイルごとにプロパティを設定する場合は、以下のようにする。

app.csproj
<ItemGroup>
  <!-- 新しく指定する場合 -->
  <アイテム種別 Include="[globパターン]" プロパティ名1="プロパティ値1">
    <プロパティ名2>プロパティ値2</プロパティ名2>
  </アイテム種別>
  <!-- 既存のアイテムに追加したい場合 -->
  <アイテム種別 Update="[globパターン]" プロパティ名1="プロパティ値1>
    <プロパティ名2>プロパティ値2</プロパティ名2>
  </アイテム種別>
</ItemGroup>

PublishSingleFile

この件について、まず挙げられるのがPublishSingleFileフラグだろう。
dotnet publishの実行時、PublishSingleFile=trueプロパティを追加すれば、付随するアセンブリファイルが全て一つの実行可能ファイルにまとめられる。
また、必ずネイティブバイナリを含むため、publish時のRuntimeIdentifierプロパティ指定(またはdotnet publishコマンドの--runtimeオプション)は必須となる
ユーザーの見た目的にはあまり変わった所は無いが、その実dotnet core-3.1とdotnet 5ではその中身が異なっている。

フレームワーク依存型と自己完結型について

dotnetのpublishには二つのまとめ方があり、フレームワーク依存と自己完結がある。PublishSingleFileは両方に対応している。
前者は、ベースクラスライブラリとランタイムをバイナリに含まないことで、サイズを小さくできるのに対し、
後者はランタイムを含めることで、ランタイムがシステムに無くても動作できるようになるが、サイズは飛躍的に増大する。

サイズについて具体的に言うと、HelloWorldレベルでは前者が精々150kb弱程度に対し、後者は何もしないと60MB超まで大きくなる。

なお、PublishSingleFileを設定した場合、デフォルトでは自己完結型になるので、フレームワーク依存型にしたい場合は、
dotnet publish--self-contained=falseオプションを追加するか、msbuildプロパティでSelfContained=falseを指定する。

また、PublishSingleFileを設定しない場合、デフォルトはRuntimeIdentifierを指定しない場合はフレームワーク依存型、指定する場合は自己完結型になるので、この辺りの違いにも注意すること。

デフォルト値の関係をまとめると、以下のようになる。

除外

例えばプラグインとして利用しているdll等、まとめたくないようなファイルがある場合、個別のアイテムにExcludeFromSingleFile=trueプロパティを設定することで、統合されるのを回避することができる。

csproj
<ItemGroup>
  <None Update="hoge.txt" ExcludeFromSingleFile="true"/>
</ItemGroup>

dotnet core-3.1の方式(ディスク展開方式)

この方式の場合、publish時に実行可能ファイルにアセンブリ群が圧縮されて格納される。
この実行可能ファイルを実行すると、以下のような挙動をする

  1. テンポラリフォルダに圧縮されたファイル群を展開する
    • `[ベースパス]/[アプリケーション名]/[ID]"に展開される。ベースパスは、"DOTNET_BUNDLE_EXTRACT_BASE_DIR"環境変数が設定されていればそこを起点にするが、未設定の場合は以下のようになる
      • Windows: %TEMP%\.net
      • Linux,macOS: $TMPDIR/.net/$UID,/var/tmp/.net/$UID,/tmp/.net/$UIDのどれか
  2. 圧縮されたファイル群の中に含まれる起点となる実行可能ファイルを実行する
  3. 後は通常の実行と同じ

この方式では、最初に展開の手間はかかるものの、既存の仕組みから大きな変更をすることなくアプリを実行することが可能になる。
ただし、いくら一時フォルダとはいえ実行時にファイルを展開するのは、ディスク圧迫、あるいは余計なIO負荷につながるという欠点がある。

また、dotnet 5でも IncludeAllContentForSelfExtract=true をmsbuildのプロパティに追加することで、self extractedにすることができる。

dotnet 5.0の方式(埋め込み方式)

依存アセンブリ等の付随するファイルを生成された実行可能ファイルに格納するのは3.1と同じだが、テンポラリフォルダに展開せずに、ファイルから直接アセンブリを読みだして実行できるようにした。(実際は
詳細は dotnetのドキュメントに記載されている が、通常のものと比べて若干動作が違ってくる機能がある。

埋め込み方式に必要なランタイムについて

dotnet core-3.1(netcoreapp3.1)の時は、特に何も設定せずとも単一の実行可能ファイルができるだけだったが、net5.0では、以下のネイティブバイナリが依存バイナリとして同じフォルダに存在する必要がある。

  • coreclr.dll: ランタイム本体
  • clrjit.dll: JITコンパイラ
  • clrcompression.dll: 解凍/圧縮用ライブラリ

基本的に必要なものは上記で、他にデバッグ用バイナリが付随することがある。

更にこれらのファイルもまとめたい場合は、msbuildのビルドプロパティに IncludeNativeLibrariesForSelfExtract=true を追加する(+5~10MB程度)。
このフラグを指定すると、ネイティブライブラリはテンポラリディレクトリに展開されるようになる。
https://docs.microsoft.com/en-us/dotnet/core/deploying/single-file#other-considerations

サイズの削減について

特に追加設定せずにPublishSingleFileすると、HelloWorldレベルのアプリでも60MB以上になる。
このほとんどは実際は使われていないようなライブラリのファイルで占められている。
コンパイル時にPublishTrimmed=trueをビルド時のプロパティに追加すれば、使っていないと判断されたDLLを生成物から除外してくれる。
これにより、HelloWorldレベルならば20MB弱まで削減できる。まだ大きいと言われればまあそれはそうなんだけど。

ただし、リフレクション等を多用するようなライブラリを使用する場合は、誤判断が発生して除外される可能性もあるため、必ず生成物をテストするように推奨されている。

また、dotnet 5.0からは、"TrimMode"プロパティに"Link"を設定することによって、従来のアセンブリ単位の削減から更に踏み込んで、フィールドやメソッド等のメンバー情報も削減するという動作をするようになる。
実際これを設定してHelloWorldレベルのアプリをpublishしたところ、10MB超程度になった。
ただし、こちらは更に積極的に削減を行うオプションのため、従来からは起きなかったトラブルが起きる可能性もあるため、使用する際は要注意。

その他、明示的に削減されるのを回避したい場合の設定などは、MSの公式ドキュメントを参照のこと

Publishの違いによるAPIの動作変化

シングルファイルの方式により、一部のAPIが以下のような影響を受ける

方式 Assembly.Location AppContext.BaseDirectory
PublishSingleFileしない 該当アセンブリ(dllまたはexe)のアセンブリパス 実行可能ファイルがあるフォルダ
ディスク展開方式 展開されたフォルダにあるアセンブリパス 展開されたフォルダ
埋め込み方式 常に空文字列 実行可能ファイルがあるフォルダ

特にAssembly.Locationが空になるのは注意したい。
その他の影響としては、 MSの公式ドキュメントを参考にしてほしい

終わりに

一通りなぞってみたが、実際内部の仕組み等に目を向けると色々と面白い所もあるので、気が向いたらその辺りの話も書けたらいいなと思っている。
また、HellOWorldで10MBという数字はコンソールアプリとしてはまだ大きい方というのは確かで、更に不満がある人はNative AOT(Ahead Of Time)コンパイルという手法もあるので、興味のある人は調べてみると良いと思う。

参考リンクまとめ