.NET 10 在 aarch64 服务器上的部署与性能优化指南
2026/9/15 6:55:03 网站建设 项目流程

1. 先算清一笔账:aarch64 服务器上跑 .NET 10 到底图什么

最近接手了一批 ARM 架构的服务器,要把原来的 .NET 服务全部迁移过去。说实话,第一批 .NET 10 应用在上面跑起来之前,我心里是有点打鼓的——倒不是担心 .NET 本身不支持 aarch64,这个从 .NET 5 开始就已经是官方一等公民了,我真正担心的是部署链路里的各种细节会不会拖后腿。

跑通之后回头复盘,我的结论是:.NET 10 在 aarch64 下面部署并没有想象中那么玄乎,但如果你用 x64 上的那套习惯直接照搬,大概率会在几个地方卡住。这篇就按我实际操作的顺序,把整个部署和运行过程里值得讲的东西全部摊开来说。无论是云上的 ARM 实例、机房里的鲲鹏或 Ampere 机器,还是手头的树莓派 5、RK3588 开发板,思路基本都通用。

先说一个最容易被忽略的大背景:你部署 .NET 10 到 aarch64,图的是什么?如果回答不上来,后面所有优化都是盲目的。

1.1 服务端 ARM 的真实覆盖面

很多人对 ARM 服务器的认知还停留在“低功耗开发板”阶段,实际上这几年服务端 ARM 早就不是玩具了。公有云里能开出一堆基于 Graviton 的实例,机房这边也有大量基于鲲鹏、飞腾、Ampere Altra 的整机在跑生产业务。更别提边缘计算、信创适配、CDN 节点、容器集群这些场景,aarch64 的出货量相当可观。

从我自己的使用体感来说,当前这一代 ARM 服务器 CPU 在多核吞吐和能效比上已经能和同价位 x86 正面对抗。.NET 10 在 aarch64 上跑,不是“能用”的水平,而是“可以上生产”的水平。特别是 .NET 10 对 ARM64 的 JIT 和 GC 又做了一轮优化,我后面会专门讲实测数据。

这里还牵扯到一个生态问题。最近总能看到类似“nginx aarch64 移植”“phantomjs aarch64 下载”这样的搜索词,说明很多团队在往 ARM 迁移时卡在了周边组件上。但我的经验是,到了 2026 年这个时间点,aarch64 的软件生态已经非常完整,尤其是 .NET 相关的工具链,运行时、SDK、诊断工具都有官方 ARM64 版本。真正要小心的反而是一些“看起来纯托管、实际带原生库”的 NuGet 包,后面第五节我会详细讲。

1.2 成本账、性能账和维护账

从 x64 迁到 aarch64,最直接的动机通常是成本。ARM 服务器在同等性能下的采购价格和功耗都更低,机房电费能省一截。公有云上的 ARM 实例按核计费往往也比 x86 实例便宜两到三成。如果服务是 CPU 密集型的,这笔账很容易算过来。

性能账要分场景说。.NET 10 的 aarch64 JIT 已经比较成熟,纯计算型负载和 x64 的差距通常能控制在 10% 以内,部分场景甚至打平。我实际测过一个吞吐量敏感的后端服务,在相同核心数下,ARM 机器的 QPS 约为 x64 的 90% 多一点,延迟没有明显劣化。对大多数业务来说,这个差距完全可以通过加一两核补回来,但省下的采购成本是实打实的。

维护账反而是最容易被低估的一块。迁移到 aarch64 后,CI 流水线需要增加一个 ARM 构建节点,宿主机上的监控采集器、日志采集器、Nginx 反向代理都要换成 ARM64 版本,团队里没人熟悉 ARM 的话,初期排障效率会降。这一块成本需要在立项时就算进去,否则迁移到一半会发现“省下的钱都变成了加班”。

我的建议是:如果团队服务规模不大、依赖组件简单,直接迁;如果服务里有一堆涉及硬件指令集或偏门原生库的组件,先拿非核心服务做灰度试点,跑一两个迭代再全面铺开。

2. 安装环节最容易翻车的三件事:架构参数、glibc 和 RID

我一开始以为装 .NET 10 到 aarch64 就是个三条命令的事,结果实际操作时还是踩了坑。下面把安装环节最容易出问题的三个点单独拎出来说。

2.1 我推荐的安装方式:不走发行版源

在 aarch64 的 Linux 上装 .NET,有两类途径:一是用发行版自带的包管理器,比如 apt、dnf,直接装dotnet-sdk-10.0;二是用微软官方的dotnet-install.sh脚本。两种我都试过,结论是优先用官方脚本,别依赖发行版源。

为什么这么说?发行版源里的 .NET 版本往往有滞后,而且打包方式在不同发行版之间有差异。比如 Debian 系会把 SDK 和运行时拆成不同的包,Ubuntu 的源里可能只有运行时没有 SDK,你又得额外配 Microsoft 的 apt 源。在 ARM 服务器上,问题会被放大——部分国产发行版的软件源里压根没有 dotnet-sdk-10.0,或者版本是旧的 8.0。

dotnet-install.sh脚本的方式要干净得多:

curl -sSL https://dot.net/v1/dotnet-install.sh -o dotnet-install.sh chmod +x dotnet-install.sh ./dotnet-install.sh --channel 10.0 --architecture arm64 --install-dir /usr/share/dotnet

注意--architecture arm64这个参数,很多人会漏掉。如果不指定,脚本在 ARM 机器上通常能自动检测到 arm64,但如果你是在 CI 的 x64 构建机上给 ARM 目标生成部署包,就必须显式传--architecture arm64,否则装出来的是 x64 版本。

装完后把 dotnet 目录加进 PATH:

export PATH="$PATH:/usr/share/dotnet"

建议写进/etc/profile.d/dotnet.sh,这样所有用户登录后都能直接用,避免 systemd 服务里因为 PATH 不对找不到 dotnet 的尴尬。

2.2 验证安装是否真的是 arm64 版本

装完别急着跑项目,先验证一下:

dotnet --info

输出里要重点看两处:RIDArchitecture。正常情况下应该是:

  • Architecture: arm64
  • RID: linux-arm64

如果 RID 显示的是linux-x64,说明你装错了二进制版本,或者机器上存在多个 dotnet 版本,PATH 优先级覆盖了。可以用which dotnet看看实际调用的路径,确认是不是/usr/share/dotnet/dotnet

还有一种常见情况:系统里既有 x64 版本又有 arm64 版本,环境变量DOTNET_ROOT指向了 x64 目录。检查一下:

echo $DOTNET_ROOT

如果是空的,手动设置成/usr/share/dotnet反而最稳。

2.3 glibc 太老导致的一类启动失败

这是 aarch64 部署 .NET 时最隐蔽的坑:.NET 运行时对 glibc 版本有最低要求。.NET 10 需要的 glibc 版本相对较新,如果你跑在 CentOS 7 一类老系统上(aarch64 版本也有),会出现下面这种报错:

./MyApp: /lib64/libc.so.6: version `GLIBC_2.34' not found (required by ./MyApp)

这不是 .NET 特有的问题,所有动态链接的二进制在旧系统上都会遇到。但 .NET 10 官方支持的发行版范围里,CentOS 7 早就不在列表里了,所以排查思路是:先看系统版本,再看 glibc 版本:

cat /etc/os-release ldd --version

如果确认是 glibc 版本不满足,二选一:升级系统的 glibc(风险高,不建议自己编译替换),或者改用完全静态的 Native AOT 发布形态。第三种方案是换一个受支持的新系统,比如 Ubuntu 22.04/24.04、Debian 12、openEuler 22.03 LTS 这些主流的 aarch64 发行版,这是最省心的路子。

安装这块总结一张避坑表:

报错现象常见原因处理方式
dotnet: command not foundPATH 未配置添加 export 到 profile
RID 显示 linux-x64装成 x64 版本重装 arm64 并检查 DOTNET_ROOT
GLIBC_2.34 not found系统 glibc 过老换新发行版或改用 AOT
Failed to load runtime运行时文件损坏清空安装目录重新跑脚本

3. 三种发布形态怎么选:FDD、SCD 和 Native AOT

.NET 应用发布到 aarch64 有几种形态,选错形态会直接影响部署难度和运行表现。我先说结论:默认优先考虑 Native AOT 或自包含发布,不要用传统的框架依赖发布去赌目标环境的运气。

3.1 框架依赖发布(FDD):轻但依赖执行环境

dotnet publish -c Release默认就是框架依赖发布,产出很小,只有程序集和配置文件。运行时依赖目标机器上装好的 .NET Runtime。

FDD 在 x64 服务器上没什么问题,因为很多团队本来就在服务器上统一装了运行时。但到了 aarch64 上,问题就出现了:你不能保证每台 ARM 服务器上都恰好装了匹配版本的 .NET 10 运行时。如果运维给服务器打了补丁、清了缓存,或者换了一台镜像不一样的机器,应用可能就启动不了了。

FDD 还有一个坑:在 aarch64 上,如果服务器上同时装了 SDK 和运行时,dotnet MyApp.dll启动时没问题,但如果你用 systemd 的ExecStart=dotnet /path/MyApp.dll这种方式,而 systemd 环境里 PATH 不对,就会报dotnet: command not found。我实际遇到过一次,排查了半天,最后发现是 systemd 的服务文件里没有继承登录 shell 的 PATH。

所以我的建议是:FDD 适合开发环境、容器构建阶段、以及对目标环境有绝对掌控的场景。生产环境还是往下看。

3.2 自包含发布(SCD):部署最省心的默认项

自包含发布把运行时一起带过去,目标机器上只需要满足 glibc 等系统底层库即可。命令是:

dotnet publish -c Release -r linux-arm64 --self-contained true -p:PublishSingleFile=true

这里-r linux-arm64必须明写,不能只写-r arm64或者不写。RID 写错会导致发布产物拿到 aarch64 上直接报failed to load runtime一类的错误。

SCD 的优点是部署包拿到机器上就能跑,完全不依赖全局 dotnet 环境。缺点是包体积大,单文件模式下也有 70MB 到 90MB 左右(取决于你用不用裁剪)。但我觉得这一点体积在服务器场景完全可以接受,换来的是极强的环境隔离性。

单文件模式有个细节要注意:IncludeNativeLibrariesForSelfExtract这个属性。.NET 10 里默认单文件会把原生库也嵌进可执行文件,启动时会解压到临时目录。如果服务器临时目录空间不足(比如/tmp挂载得很小),启动会失败或变很慢。我建议在发布参数里显式加上:

dotnet publish -c Release -r linux-arm64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true

这样虽然首次启动会解压原生库,但后续运行稳定,不会出现库文件找不到的诡异问题。

3.3 Native AOT:启动快、内存低,但约束多

Native AOT 是这几代 .NET 里我最看重的特性之一,因为它把 .NET 程序直接编译成原生机器码,不再需要 JIT,启动时间可以压到几十毫秒级别,内存占用也低不少。

发布命令:

dotnet publish -c Release -r linux-arm64 -p:PublishAot=true

注意 AOT 发布隐含了自包含,所以不用再加--self-contained true。在 aarch64 上,AOT 的跨平台编译支持得很好,你甚至可以在 x64 的开发机上交叉编译出 linux-arm64 的 AOT 版本,只要目标机器 glibc 版本不太老就行。

AOT 的核心收益有三个:启动极快、内存占用低、不需要 JIT 预热。我实测过一个 ASP.NET Core 空壳服务,AOT 后启动时间从 700ms 降到 30ms 左右,内存从 90MB 降到 45MB。对于容器密集部署的场景,这个优势非常明显。

代价是 AOT 不能覆盖所有 .NET 功能。最常见的限制是动态反射:如果你的代码里用了大量Activator.CreateInstanceAssembly.Load、反射调泛型、或者依赖System.Reflection.Emit,AOT 会编译失败或在运行时抛异常。EF Core 在 AOT 下虽然能用,但需要配NativeAot的编译模型,配置繁琐,有些 Provider 依然不完整。

所以选择逻辑很清晰:

形态启动内存部署体积环境依赖兼容风险
FDD需要运行时
SCD
Native AOT极快极低

如果你的服务是 REST API、gRPC、队列消费者这类“标准负载”,强烈建议直接上 AOT;如果有复杂的插件体系、动态脚本化需求,老老实实用 SCD。

4. 运行阶段的调优参数:从 GC 到容器内存限制

部署完能跑只是第一步,真正考验人的是运行阶段。aarch64 服务器上跑 .NET 10,有几个运行时参数值得认真调一调。

4.1 调好 Server GC 的并发堆数

.NET 的服务端应用默认开的是 Server GC,因为 Server GC 会给每个逻辑核心分配一个堆。在 aarch64 机器上,这种架构的核数往往很多(动不动 64 核、128 核),如果你不管,GC 会给每个核都建堆,内存开销会迅速膨胀。

举个例子,一台 64 核的 ARM 服务器上,Server GC 默认会创建 64 个堆段。每个堆段初始保留的内存加起来,即使业务对象很少,也会吃掉几个 GB 的内存。这对本来想省内存的 ARM 场景来说是反效果的。

解决办法是显式设置 GC 堆数:

export DOTNET_gcServer=1 export DOTNET_gcHeapCount=8

或者写进runtimeconfig.json

{ "configProperties": { "System.GC.Server": true, "System.GC.HeapCount": 8 } }

DOTNET_gcHeapCount=8的意思是只保留 8 个 GC 堆,避免堆数跟着核数线性膨胀。堆数设置多少合适?我一般按“业务预期并发量 / 8”来粗估,或者干脆从 8 起步,观察 GC 停顿时间再调整。如果堆数太少,高并发下 GC 竞争会变明显,延迟上升;堆数太多,内存浪费又会回来。实验出真知。

4.2 Docker 容器里跑 .NET 10 的内存边界

容器的内存限制和 GC 配置必须一起看。很多团队在 aarch64 上跑容器化 .NET,但容器里 GC 看不到宿主机的内存限制,容易超卖。

在 x64 上这个问题很早就被讨论过,到了 aarch64 依然存在。.NET 10 的运行时支持读取 cgroup v2 的内存限制,前提是容器把限制正确透传。Docker 默认能透传,但如果你用了一些自定义的容器运行时,或者容器里挂载了奇怪的/sys/fs/cgroup,GC 就可能误判内存总量。

我的习惯是双保险:一是设置容器内存限制时强制把 cgroup 挂载好,二是直接在应用侧设置硬上限:

export DOTNET_GCHeapHardLimit=0x80000000 # 2GB,单位字节,16进制

DOTNET_GCHeapHardLimit的作用是告诉 GC“你最多用这么多内存做托管堆”。设置后,GC 会更主动地回收,避免内存涨破容器限制导致 OOM Kill。这个值按容器内存限制的 50% 到 70% 来设比较稳妥,剩下的留给原生堆、栈、JIT 代码等。

还有一点容易被忽略:AOT 发布的应用默认没有 JIT,代码段内存占用固定且小,GC 硬上限可以留得更宽裕;而 JIT 模式的应用,原生代码占用会随着方法数动态增长,尤其首个请求进来时会触发大量 JIT 编译,内存会有一个脉冲式上涨。上线前最好压测一轮,摸清峰值再定硬上限。

4.3 几个值得常驻的运行时开关

除了 GC 相关参数,下面这几个开关在 aarch64 上我建议都看一眼:

  • DOTNET_TieredPGO:分层编译 + 动态 PGO。.NET 10 对这个特性的支持已经比较成熟,开启后长时间运行的服务吞吐能提升 5% 到 15%。代价是最开始几分钟有额外 JIT 开销,CPU 会高一些,但收益值得。对短期运行的 CLI 工具就别开了,收益不大。

  • DOTNET_ReadyToRun:在 SCD 模式下,发布时默认会生成 ReadyToRun 预编译代码。如果你用 AOT,这个参数可以忽略;如果用 SCD,我建议保留默认,减少首次请求的 JIT 压力。

  • DOTNET_GCConcurrent:并发 GC 在容器场景建议明确开启,默认值在 .NET 10 里虽然已经是 true,但显式写出来能避免某些部署平台覆盖配置。

  • DOTNET_SYSTEM_NET_HTTP_SOCKETSHTTPHANDLER_HTTP2SUPPORT:如果你服务间调用走 HTTP/2,确保为 true,aarch64 下的 SocketsHttpHandler 对 HTTP/2 支持很成熟,默认开启,不需要额外操作。

这些开关我统一放在 systemd 服务文件或容器启动脚本里,集中管理,不散落在代码里。维护起来清爽很多。

5. x64 迁移到 aarch64 的兼容性排查清单

从 x64 迁到 aarch64,代码层面大部分时候不用动,但“大部分时候”不等于“总是”。我整理了一份排查清单,按重要性排序。

5.1 NuGet 包里的原生库要重新评估

这是迁移过程中最大的坑。很多 NuGet 包看起来是纯托管代码,实际上内部嵌了原生库,比如:

  • SQLitePCLRaw.bundle_e_sqlite3:内嵌 SQLite 原生库
  • System.Drawing.Common:在 Linux 上依赖 libgdiplus 原生库
  • SkiaSharpImageSharp(部分版本)依赖原生图像库
  • OpenTKSilk.NET这类图形库一定有原生部分

这些包在 x64 上跑得好好的,一迁移到 aarch64,可能遇到两种结果。一是包本身带了runtimes/linux-arm64/native/目录下的 ARM64 原生库,这种情况换 RID 发布即可;二是包根本没提供 ARM64 原生库,那就麻烦了,多半要换替代方案。

这里教大家一个排查方法。发布后检查输出目录:

find publish/ -name "*.so" -o -name "*.a"

.so文件挨个看一下架构:

file publish/libsqi.so

如果显示ELF 64-bit LSB shared object, ARM aarch64,说明平台正确;如果显示x86-64,说明这个原生库是 x64 的,运行时大概率会崩。

再一个隐蔽问题:NuGet 包里有些原生库只有 x64 版本,但包本身不报错,而是在调用某些功能时才抛出DllNotFoundExceptionBadImageFormatException。排查这类问题没有捷径,只能通过真实请求路径去测。我的建议是迁移后把核心调用链路的单元测试全部跑一遍,尤其涉及文件处理、加密、图像、数据库的路径。

5.2 区分“编译期”和“运行期”的架构判断

如果你的代码里有架构相关逻辑,比如根据 CPU 架构走不同的算法,注意分清楚判断时机。

编译期用条件编译符号:

#if ARM64 // aarch64 专用逻辑 #else // x64 逻辑 #endif

运行期用RuntimeInformation

using System.Runtime.InteropServices; if (RuntimeInformation.ProcessArchitecture == Architecture.Arm64) { // 当前进程是 Arm64 } else if (RuntimeInformation.ProcessArchitecture == Architecture.X64) { // 当前进程是 X64 }

这里有个细节:RuntimeInformation.OSArchitecture返回的是操作系统架构,RuntimeInformation.ProcessArchitecture返回的是当前进程架构。在 ARM 服务器上用 x64 模拟器跑 x86 程序时,这两个值会不一致。比如你把 x64 发布的程序跑在 aarch64 的兼容层里,OSArchitecture是 Arm64,但ProcessArchitecture是 X64。判断基准不同,行为就不同。生产环境推荐直接看ProcessArchitecture,因为它决定你能调用哪些指令集。

5.3 工具链和周边组件的 aarch64 支持度

除了应用本身,周边工具链也要一起排查。比如:

  • Nginx:官方已经提供 aarch64 的二进制包,直接apt install nginx就是 ARM64 版本。网上还有不少“nginx aarch64 移植”的旧教程,那是几年年前的事了,现在完全可以直接用发行版包,不用自己编译。
  • 监控 Agent:很多监控平台的传统 agent 只有 x64 二进制,换平台后不一定有 ARM64 版本,这个要提前和厂商确认。
  • CI 构建节点:如果团队还在用 x64 的构建机,那么 CI 产物需要交叉编译。.NET 跨平台发布已经支持的很好,但原生库需要额外处理,建议直接在 ARM 机器上搭一个 build agent,省事得多。
  • 定时任务、Shell 脚本:检查脚本里有没有硬编码的uname -m判断,或者基于x86_64的路径假设。

我之前遇到一个案例:某个服务的部署脚本里用arch命令判断机器架构,在 x64 上返回x86_64,脚本一切正常;到 aarch64 上返回aarch64,但脚本里没有这个分支,直接报错退出。这类问题是纯工程问题,但排查起来非常费时间。

6. 上线后的性能与稳定性验证思路

部署完之后,别急着宣布迁移完成。aarch64 上的 .NET 10 和 x64 的行为模式不完全一致,尤其是 GC 和线程调度,需要一段时间观察。我习惯用下面这套工具和方法。

6.1 诊断命令三板斧:counters、trace、dump

第一板斧是dotnet-counters,看实时指标:

dotnet counters monitor --process-id $(pgrep -f MyApp.dll) --counters System.Runtime

重点看cpu-usageworking-setgc-heap-sizethreadpool-thread-count这几个指标。如果在 aarch64 上出现 working-set 持续上涨但不回落,优先怀疑 GC 堆配置问题,回到第四节的方法调。

第二板斧是dotnet-trace,抓性能快照:

dotnet trace collect --process-id $(pgrep -f MyApp.dll) --duration 00:05:00 --format speedscope

抓完把 trace 文件下载到本地,用 Speedscope 打开。主要看两类问题:一是 CPU 热点分布是否合理,二是 GC 占比是否过高。如果 GC 时间占比超过 20%,说明内存分配压力很大,结合dotnet-countersgen-0-gc-countgen-1-gc-count是否在疯狂增加。

第三板斧是dotnet-dump,用于抓进程崩溃和死锁现场:

dotnet dump collect --process-id $(pgrep -f MyApp.dll) dotnet dump analyze /path/to/dump

在 aarch64 上,dotnet-dump的 ARM64 版本一般会自动匹配,不需要特殊处理,但在老版本工具上需要确认dotnet-dump本身是不是 arm64 构建。诊断工具的安装走 dotnet tool 就行:

dotnet tool install -g dotnet-counters dotnet tool install -g dotnet-trace dotnet tool install -g dotnet-dump

如果机器在内网无法访问 NuGet,提前在 x64 开发机上把工具包下好,用dotnet tool install的本地包源方式装过去。

6.2 性能基准到底该看哪些数字

迁移完成后的压测,我建议至少记录这四组数据:

  • 延迟:P50、P95、P99,重点看 P99 是否因为 GC 抖动而抬升。
  • 吞吐:相同并发下的 QPS 或 RPS。
  • 内存:稳态工作集,观察 24 小时内的趋势,确认没有泄漏。
  • 启动时间:从进程启动到第一个请求可用的时间,AOT 模式应该显著优于 JIT。

把这些数据和 x64 上的历史数据做对比。如果 ARM 上 P99 比 x64 高 20% 以上,优先看 GC 停顿,用dotnet tracegc-dump子命令分析。如果同样并发下 CPU 使用率偏高,可能是指令集差异导致的 JIT 生成代码效率不同,这时可以试试调整DOTNET_TieredPGO看有没有改善。

还有一点容易被忽视:不要完全相信纯模拟环境下的跑分结果。用 gem5 这类模拟器跑 SPEC 一类的基准测试只能得到理论参考值,真实硬件上的缓存行为、内存带宽和模拟器差距很大,性能调优必须以真实机器上的数据为准。

最后说点个人体会。把整套服务从 x64 迁到 aarch64 并稳定运行之后,最深的感受是:.NET 10 在这个平台上的成熟度已经足够支撑生产业务,过程中的大部分坑不是来自于 .NET 本身,而是来自部署习惯和周边生态的惯性。如果你正在做同样的迁移,先把发布形态定下来——我推荐 AOT 优先、SCD 兜底,然后再去处理系统库和 NuGet 原生依赖,少走很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询