用 .NET NativeAOT 構建完全 distroless 的靜態鏈接應用
前言
.NET NativeAOT 想必不少開發者都已經很熟悉了,它可以將 .NET 程序集直接編譯到原生的機器代碼,從而可以脫離 VM 直接運行。簡單的一句 dotnet publish -c Release -r <rid> /p:PublishAot=true 就可以做到。
在編寫 C++ 程序之類的原生程序時,我們可能需要做靜態鏈接,這樣編譯出來的程序無需在目標環境上安裝使用到的庫就能運行起來。這對 Linux 這種環境多變的系統非常有用。
那 .NET 的 NativeAOT 是否也做到這一點呢?
答案是:可以!
P/Invoke
在 .NET 中,想要調用原生庫(.dll、.so、.dylib等等),我們常用的方法是 P/Invoke。
例如現在我有一個 C++ 庫 foo.dll,導出了一個函數 int add(int x, int y),那在 .NET 中,我只需要簡單的編寫一句 P/Invoke 創建一個靜態方法就能夠調用它:
[DllImport("foo", EntryPoint = "add")]
extern static int Add(int x, int y);
Console.WriteLine(Add(3, 4));
這極大地簡化了我們的工作量,我們只需要知道函數簽名就能輕而易舉地導入 .NET 程序中使用,甚至可以借助各種代碼生成工具自動生成 P/Invoke 方法,例如 CsWin32 就是其中之一。
當調用 P/Invoke 方法時,.NET 運行時會在我們第一次調用它的時候查找并打開對應的庫文件,然后獲取導出符號拿到調用地址進行調用。
NativeAOT 下的 Direct P/Invoke
你會發現在 .NET 中,attribute 都是常量,而函數簽名更是編譯時已知的,那么 NativeAOT 下的 P/Invoke 會不會有什么編譯時的針對性優化呢?
那當然是...沒有的!NativeAOT 中的 P/Invoke 工作原理和非 NativeAOT 時基本上是完全一致的:也就是在運行時調用的時候才進行綁定。這么做當然是因為兼容性更好,因為即使你有一些 P/Invoke 方法在庫中實際不存在,只要不去調用它也不會出現問題,因為它們都是在你調用的時候才進行綁定的。(畢竟你也不希望在 .NET 中遇到 C++ 里各種各樣的構建時 unresolved symbol 鏈接錯誤)
但是!正如前面所說,NativeAOT 既然直接產生最終二進制,那么其實是可以在編譯時利用到這些常量信息的。
這就是我接下來說的 Direct P/Invoke:
Direct P/Invoke 不同于 P/Invoke,它會對 P/Invoke 的方法生成直接調用,并且將函數綁定放到程序啟動時由操作系統來進行。這種情況下,P/Invoke 方法會直接進入編譯出的二進制的導入表,如果啟動時缺失了對應的方法會直接啟動失敗。
使用 Direct P/Invoke 的時候,我們不需要更改任何的代碼,只需要在項目文件中按照 模塊名!入口點名 的格式加入需要編譯成 Direct P/Invoke 的方法即可。例如我們前面 foo.dll 里面的 add,我們只需要在我們的項目文件中寫:
<ItemGroup>
<DirectPInvoke Include="foo!add" />
</ItemGroup>
導入了 foo 模塊中 add 函數的 P/Invoke 就全都會被自動編譯成 Direct P/Invoke。
在這里入口點名甚至可以被省略,如果省略的話則表示對這個模塊所有的 P/Invoke 都是 Direct P/Invoke:
<ItemGroup>
<DirectPInvoke Include="foo" />
</ItemGroup>
進一步,我們可以直接導入 libc:
<ItemGroup>
<DirectPInvoke Include="libc" />
</ItemGroup>
甚至如果列表太長的話,我們還可以單獨創建一個文本文件里面一行一個,然后直接用 DirectPInvokeList 來導入:
<ItemGroup>
<DirectPInvokeList Include="NativeMethods.txt" />
</ItemGroup>
Direct P/Invoke 不僅有著更好的性能優勢,而且允許我們對 P/Invoke 方法進行靜態鏈接。
靜態鏈接
有了 Direct P/Invoke,我們需要調用的符號已經躺在了我們二進制的導入表里,那么我們其實只要把靜態庫鏈接到我們的二進制里去,我們的應用就能無需任何的依賴直接啟動了。
做到這一點也是非常的簡單,在項目文件里加入 NativeLibrary 即可:
<ItemGroup>
<NativeLibrary Include="foo.lib" />
</ItemGroup>
如果我們需要支持多平臺,例如同時支持 Windows 和 Linux,那我們也只需要條件導入即可:
<ItemGroup>
<NativeLibrary Condition="$(RuntimeIdentifier.StartsWith('win'))" Include="foo.lib" />
<NativeLibrary Condition="$(RuntimeIdentifier.StartsWith('linux'))" Include="libfoo.a" />
</ItemGroup>
這樣我們就可以把靜態庫直接鏈接到我們的程序當中來了。
進一步,我們還可以給鏈接器傳遞各種參數實現自定義鏈接行為:
<ItemGroup>
<LinkerArg Include="/DEPENDENTLOADFLAG:0x800" Condition="$(RuntimeIdentifier.StartsWith('win'))" />
<LinkerArg Include="-Wl,-rpath,'/bin/'" Condition="$(RuntimeIdentifier.StartsWith('linux'))" />
</ItemGroup>
我們還可以通過 LinkerFlavor 屬性來設置想要使用的 linker(例如 ldd、bfd 等等):
<PropertyGroup>
<LinkerFlavor>ldd</LinkerFlavor>
</PropertyGroup>
Distroless 應用
到了這里,其實我們已經能夠做到靜態鏈接任何的第三方庫了。如果是 Windows 的話到此為止,因為 NativeAOT 程序自身只依賴 ucrt,Windows API 自身就已經提供了全部的 API 支持;但如果是 Linux 的話則還差一點,因為依賴外部的 libicu 和 OpenSSL,這個時候就需要我們使用官方為我們提供的屬性來切換到靜態鏈接了。
對于 libicu 而言,這個庫主要提供國際化支持,如果不需要的話可以直接設置 <InvariantGlobalization>true</InvariantGlobalization> 這樣就會關閉這個支持。但如果你需要的話則可以選擇把它靜態鏈接了:
<PropertyGroup>
<!-- 靜態鏈接 libicu -->
<StaticICULinking>true</StaticICULinking>
<!-- 嵌入 ICU data -->
<EmbedIcuDataPath>/usr/share/icu/74.2/icudt74l.dat</EmbedIcuDataPath>
</PropertyGroup>
而對于 OpenSSL 而言,只需要:
<PropertyGroup>
<StaticOpenSslLinking>true</StaticOpenSslLinking>
</PropertyGroup>
即可。
注意你用來構建的機器需要有 cmake 以及對應的原生靜態庫才能完成構建,具體而言, libicu-dev 和 libssl-dev。
最后一步,將我們的應用設置成純靜態應用即可:
<PropertyGroup>
<StaticExecutable>true</StaticExecutable>
</PropertyGroup>
用一個簡單程序試試
首先我們拉下來 alpine 的鏡像。這里之所以不用 Ubuntu 之類的是因為 alpine 自帶的 muslc 相對于 glibc 而言對靜態鏈接更加友好。當然你也可以用 Ubuntu 和 glibc,只不過 glibc 在靜態鏈接環境下可能會出問題。
docker pull mcr.microsoft.com/dotnet/sdk:9.0-alpine
啟動容器后安裝我們需要的第三方依賴,注意這里要把靜態庫也一并安裝:
apk add cmake make clang icu-static icu-dev openssl-dev openssl-libs-static
這里我們首先準備我們的靜態庫:新建一個 foo.c 文件,里面編寫
__attribute__((__visibility__("default")))
int add(int x, int y)
{
return x + y;
}
然后我們創建一個靜態庫:
clang -c -o libfoo.o foo.c -fPIC -O3
ar r libfoo.a libfoo.o
緊接著我們創建一個 C# 控制臺項目:
mkdir Test && cd Test
dotnet new console
然后編輯 Program.cs 添加 P/Invoke 并調用 foo 導出的函數 add:
using System.Runtime.InteropServices;
Console.WriteLine(Add(2, 3));
[DllImport("foo", EntryPoint = "add"), SuppressGCTransition]
extern static int Add(int x, int y);
這里我們知道 add 的調用很快,因此無需讓 .NET runtime 切換 GC 工作模式,因此我們添加 [SuppressGCTransition] 以提升互操作性能。
然后編輯 Test.csproj 添加 Direct P/Invoke 和 NativeLibrary,并且設置其他屬性:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<StaticOpenSslLinking>true</StaticOpenSslLinking>
<StaticExecutable>true</StaticExecutable>
</PropertyGroup>
<ItemGroup>
<DirectPInvoke Include="foo" />
<DirectPInvoke Include="libc" />
<NativeLibrary Include="../libfoo.a" />
</ItemGroup>
</Project>
然后用 NativeAOT 發布我們的程序!
dotnet publish -c Release -r linux-musl-x64 /p:PublishAot=true
大功告成,看看發布出了什么:
ls -s bin/Release/net9.0/linux-musl-x64/publish/
total 3956
1360 Test 2596 Test.dbg
可以看到,生成的二進制體積僅僅只有 1360 KB!(順帶一提這個體積在 .NET 10 下還會更小)。這一個二進制包含了運行程序所需要的所有東西,無需任何的額外依賴,甚至連 libc 都不需要。
讓我們看看最終到底生成了什么代碼:
objdump -d -S -M intel bin/Release/net9.0/linux-musl-x64/publish/Test
找到 Main 函數:
00000000000d50e0 <Test_Program___Main__>:
using System.Runtime.InteropServices;
Console.WriteLine(Add(2, 3));
d50e0: 55 push rbp
d50e1: 48 8b ec mov rbp,rsp
d50e4: bf 02 00 00 00 mov edi,0x2
d50e9: be 03 00 00 00 mov esi,0x3
d50ee: e8 8d 02 f3 ff call 5380 <add>
d50f3: 8b f8 mov edi,eax
d50f5: e8 86 0a fc ff call 95b80 <System_Console_System_Console__WriteLine_7>
...
可以發現生成的代碼非常的高效。另外,這里之所以能 dump 出 C# 源碼信息是因為 NativeAOT 編譯會自動生成調試符號文件,也就是我們的 Test.dbg,如果刪掉了的話那就沒有這些信息了。
而我們接著往上翻找到 5380 地址處的 <add>,則可以看到:
0000000000005380 <add>:
5380: 8d 04 37 lea eax,[rdi+rsi*1]
5383: c3 ret
...
如果此時我們 dump 一下我們之前編譯出來的原生庫的代碼的話:
objdump -d -S -M intel ../libfoo.o
會得到如下結果:
0000000000000000 <add>:
0: 8d 04 37 lea eax,[rdi+rsi*1]
3: c3 ret
發現了么?我們用 C 編寫的靜態庫被我們徹底靜態鏈接進了 C# 程序中!如此一來,我們不需要配置任何的環境,也不需要保留任何的依賴項,更不需要安裝任何的第三方庫,只需要把我們構建出來的 Test 這個可執行程序拷貝到任何一臺 x64 的 Linux 機器上,就能運行輸出我們想要的結果。
試著運行一下:
./Test
5
再試試 Web 服務器程序
這次我們可以試著創建一個叫做 Test 的 Web API 項目:
mkdir Test && cd Test
dotnet new webapiaot
創建好之后我們需要編輯一下項目文件 Test.csproj:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<StaticOpenSslLinking>true</StaticOpenSslLinking>
<StaticExecutable>true</StaticExecutable>
</PropertyGroup>
<ItemGroup>
<DirectPInvoke Include="libc" />
</ItemGroup>
</Project>
然后簡單一句:dotnet publish -c Release -r linux-musl-x64 /p:PublishAot=true,項目自動編譯生成,我們最終在 bin/Release/net9.0/linux-musl-x64/publish 下即可找到我們最終的二進制。
我們拷貝出來在其他機器上執行一下 ldd 看看:
ldd ./Test
statically linked
完美。這么一來你哪怕扔到軟路由上,不需要配置任何環境都能運行。
執行一下看看:
./Test
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[0]
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Production
info: Microsoft.Hosting.Lifetime[0]
Content root path: /root/Test
訪問一下看看:
curl -X GET http://localhost:5000/todos
[{"id":1,"title":"Walk the dog","dueBy":null,"isComplete":false},{"id":2,"title":"Do the dishes","dueBy":"2025-04-07","isComplete":false},{"id":3,"title":"Do the laundry","dueBy":"2025-04-08","isComplete":false},{"id":4,"title":"Clean the bathroom","dueBy":null,"isComplete":false},{"id":5,"title":"Clean the car","dueBy":"2025-04-09","isComplete":false}]
完美!
結語
有了 NativeAOT 和 Direct P/Invoke,我們能夠創建完全靜態鏈接的 .NET NativeAOT 程序,從而允許我們把二進制直接分發到任意的 Linux 發行版上,無需配置環境或依賴項就能運行。如此一來,.NET 解鎖了構建完全 distroless 的二進制的能力。
并且,這同樣適用于 Avalonia 這類桌面應用程序!你只需要利用 Direct P/Invoke 和 NativeLibrary 把 libSkiaSharp 和 ANGLE 靜態鏈接進去(libSkiaSharp 需要自己從源碼構建匹配的版本,ANGLE 可以用 vcpkg 直接下載安裝靜態庫),你用 NativeAOT 構建出來的 Avalonia app 將能夠在隨便一個兼容的硬件架構上跑的任意的 Linux 發行版上跑起來。

浙公網安備 33010602011771號