1. 这轮革新,动的不是语法而是交付链路
前两篇我们分别聊了为什么要持续折腾 .NET 的构建发布方式,以及整体方案是怎么一步步演进的。这篇直接落到实操:把我手里一个给内部同事用的订单查询服务,再加一个命令行小工具,从旧的“拷目录部署”模式整体迁到“Native AOT + 裁剪 + 单文件 + 容器化”这条链路上,每一步怎么配、参数怎么调、坑怎么避,都记在这里。
先说结论:这一轮革新的核心,不是某个新语法,也不是某个新框架特性,而是把“部署结果”从一整目录的 dll、配置、依赖文件,压缩成单个可执行文件,甚至是一个可以直接扔进镜像里的产物。表面看只是打包方式变了,背后牵动的却是编译策略、运行时行为、依赖裁剪、启动性能这一整套变化。项目越大,改动越不是“勾一个选项”那么简单。
你可能会问:有必要吗?如果你的服务还跑在虚拟机里,发布就是改配置、重启服务,那确实不急。但只要你开始接触容器、云原生、灰度发布,或者要给同事、给用户发一个“双击就能跑”的工具,这套新的构建发布方式就绕不开了。下面我按“选型 - 配置 - 实测 - 容器化 - 排坑”五个环节展开,前三个是给单机部署用的,后两个是上容器和 CI 时真正容易翻车的地方。
1.1 从“拷一堆文件”到“拷一个文件”
传统的dotnet publish产物是一个目录,里面有主程序 dll、一堆依赖 dll、配置文件,运气不好还有原生库。依赖多了之后,目录里有几百个文件都很正常。旧时代部署就是把这个目录压缩、传到服务器、解压、停服务、替换、启动。问题在于:传大目录容易漏文件,多实例并行发布容易不一致,突发扩容时新机器要下载几百 MB 的包,启动链路又长又脆。
单文件发布把这一切收敛成一个二进制。做工具类应用时收益最明显:发给同事一个文件,双击能跑,不用装运行时,不用告诉他“你要装环境”。做服务类应用时,配合容器镜像,镜像层数少了,传输体积小了,每次扩容要拉的资源也少了。这个方向本身没有争议,争议在于怎么保证这个“单文件”在裁剪依赖之后还能正常工作。
1.2 构建发布链路的四个关键环节
我习惯把整条链路切成编译、裁剪、打包、部署四段,分析问题时就一段一段排查,不会混在一起。
- 编译:IL 如何生成,是否要做 AOT 预编译,目标运行时选哪个 RID。
- 裁剪:用不到的托管代码能不能删掉,反射和动态代码会不会被误删。
- 打包:单文件怎么组织,原生库是否外置,要不要压缩、要不要带符号。
- 部署:产物落到虚拟机、容器还是对象存储,启动方式是什么。
这四个环节是强耦合的。比如你为了减小体积打开裁剪,结果某个库用反射拿类型,运行时报MissingMethodException;你又想用 AOT 解决启动速度,结果某个库依赖动态生成代码,直接编译期报错。这种“连锁踩雷”是常态,网上很多项目也是栽在这里。所以下文的核心思路就一句话:先明确应用类型,再决定组合策略,不要什么选项都往上堆。
2. 方案选型:Native AOT 不是默认答案,组合拳才是
2.1 三种发布形态怎么挑
.NET 现在有三种常见发布形态:框架依赖(FDD)、自包含(SCD)、原生 AOT。三者不是替代关系,是使用场景不同。
框架依赖发布是最老的方式,产物小,但目标机器必须装对应版本的 .NET 运行时。适合你能完全控制服务器镜像、且多个服务共用同一运行时的场景。缺点是版本管理混乱,服务器升运行时可能把所有服务一起带崩。
自包含发布把运行时一起打进产物,机器上什么都不用装。代价是产物体积大,主程序加运行时通常 70MB 起步。适合给外部用户分发工具,或者目标环境装不了运行时。
原生 AOT 是 .NET 7 开始正式支持、.NET 8 和 9 不断完善的方向。它的原理不是解释执行 IL,而是在发布时把 IL 直接编成目标平台的机器码,运行时不再承担 JIT 和大部分加载工作。启动速度快、内存占用低、产物体积比自包含小得多,而且不需要目标机器装运行时。代价是:编译时间变长,某些依赖 JIT 动态生成代码或大量反射的库会不兼容,编译时需要对这类用法做声明和引导。
我自己的选择规则是:
| 应用类型 | 推荐组合 | 理由 |
|---|---|---|
| 命令行工具 / 内部小工具 | Native AOT + 单文件 | 启动快,分发简单,一个文件搞定 |
| 对外提供的中小型 Web API | 自包含 + 单文件,局部开裁剪 | 兼容性优先,动态功能多时不开 AOT |
| 无状态微服务,追求冷启动速度 | Native AOT + 最小镜像 | 冷启动几十 ms 级别,扩容体验好 |
| 存量大型系统 | 框架依赖 + 发布流水线标准化 | 改动最小,先把流程稳定再说 |
2.2 裁剪不是“越小越好”
裁剪(Trimming)的原理是在编译期分析程序集之间的引用图,把没被引用到的代码从最终产物里去掉。省空间的效果立竿见影,但它是静态分析的——分析不到的地方就可能误删。
最典型的是反射。你用Assembly.GetType("某命名空间.某类")这种字符串形式拿类型,静态分析根本不知道“某类”在哪,直接把它当成没引用给剪了。程序一运行就抛异常。
所以裁剪的选型不是打开就完事,你得知道自己项目里有没有反射、动态加载、序列化、表达式树这些东西。序列化尤其危险,很多 ORM、JSON 库底层都是反射或动态生成代码。现在主流库大多提供了裁剪标注,比如在公开 API 上打了[RequiresUnreferencedCode]之类的特性,编译时会给你警告,这些警告别忽略,一个个处理掉是基本要求。
2.3 ReadyToRun 是什么定位
如果你不想碰 AOT 的编译期限制,又想要启动加速,还有一个折中选项:ReadyToRun(R2R)。它是在发布阶段用交叉编译器把 IL 预编译成目标平台的机器码,放进程序集里,运行时加载时不再做 JIT,直接拿来用。
R2R 和应用级 AOT 的区别在于:R2R 之后运行时还是需要的,只是省掉 JIT 时间;AOT 则把运行时的大部分工作也“预做”了,最终产物不依赖托管运行时。R2R 适合那种库比较传统、反射很多、你又不想花时间改代码,但确实想把启动提一提的存量项目。代价是产物变大,所以只对启动性能敏感的程序集开启 R2R 更划算。我的实际体会是,对需要大量反射和动态代码的项目,先上 R2R,别急着硬上 AOT,否则排坑时间会超出预期。
3. 实操:把项目从“正常发布”迁移到“新式发布”
3.1 项目层面的基础准备
先说项目结构。不管是 Web API 还是控制台项目,关键改动集中在.csproj里,依赖的库尽量更新到支持裁剪标注的版本。我迁移前会做三件事:
- 全局搜索
Assembly.Load、Activator.CreateInstance、typeof(...).GetMethod这类反射调用,列一个清单。 - 确认序列化框架的版本,检查是否发布了支持 AOT 的版本。
- 用
-p:PublishTrimmed=true试着发一次,把编译告警数量先摸清楚,再看是不是大面积爆红。
项目文件里最基础的配置是这些:
<PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net8.0</TargetFramework> <Nullable>enable</Nullable> <PublishSingleFile>true</PublishSingleFile> <SelfContained>true</SelfContained> <PublishAot>true</PublishAot> <StripSymbols>true</StripSymbols> <InvariantGlobalization>true</InvariantGlobalization> </PropertyGroup>其中PublishAot打开后会自动打开裁剪,会自动设PublishTrimmed=true,也会自动把PublishReadyToRun关掉。这几项不需要重复写。
InvariantGlobalization是个容易被忽略的大头。不开它,产物里要带整个全球化数据,体积能差出十几到几十 MB。前提是你的应用不依赖区域化、不处理各种语言的数字格式。比如内部工具、只服务固定时区的服务,打开它基本没影响。如果你要做多语言展示,那这项不要开。
StripSymbols会去掉调试符号,产物更小。注意真出问题时排查会难一点,所以自己调试用dotnet publish不要带这个参数,发正式包再带上。
3.2 发布配置用 pubxml 管理,别裸敲参数
命令行裸敲dotnet publish很容易漏参数,而且团队里每人敲法不一样,构建结果就不一样。我建议把发布参数写进Properties/PublishProfiles下的发布配置文件里,这样一条干净的dotnet publish命令就够了。
配置文件是这样写的:
<Project> <PropertyGroup> <Configuration>Release</Configuration> <PublishAot>true</PublishAot> <PublishSingleFile>true</PublishSingleFile> <SelfContained>true</SelfContained> <RuntimeIdentifier>linux-x64</RuntimeIdentifier> <PublishTrimmed>true</PublishTrimmed> <TrimMode>full</TrimMode> <InvariantGlobalization>true</InvariantGlobalization> <DebugType>none</DebugType> <StripSymbols>true</StripSymbols> <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> </PropertyGroup> </Project>然后发布时只需要:
dotnet publish ./你的项目.csproj -p:PublishProfile=aot-linux这里有几个容易踩的细节:
RuntimeIdentifier建议写死,不要依赖构建机器的默认值。跨平台发布时尤其重要。比如在 Windows 上给 Linux 发布,不写 RID 就会生成一堆 win-x64 的内容,而 RID 一旦写错,AOT 的交叉编译直接失败。TrimMode有两个常用值。partial是默认的保守裁剪,只剪掉确定没用的部分;full更激进,会把所有程序集都做裁剪分析。full有更高的体积收益,但报错概率也高。对依赖复杂的老项目,建议从partial起步,稳定以后再尝试full。EnableCompressionInSingleFile会压缩单文件里内嵌的托管程序集和原生库,代价是启动时要解压,启动时间会长一点。对体积敏感但对冷启动速度不那么敏感的工具有奇效。Web 服务不建议用,冷启动是命门。
3.3 AOT 对编译环境的要求心里要有数
这是最容易卡住新手的一道坎:Native AOT 不是随便哪个机器都能编的,它需要平台原生编译工具链。
- Windows 上需要安装带 C++ 桌面开发工作负载的编译环境,因为发布时要调用链接器。
- Linux 上需要
clang和zlib1g-dev,比如apt install clang zlib1g-dev。 - macOS 上需要 Xcode Command Line Tools。
我踩过的坑是:在一台干净的 Linux CI 机器上直接dotnet publish -p:PublishAot=true,结果报找不到clang。一开始以为是 .NET 的问题,看了半天日志,最后发现就是没装工具链。这个先装好,能省一个小时的排查时间。
另外,交叉编译 AOT 的支持比普通发布受限。比如你在 Windows 上给 Linux ARM64 编 AOT,虽然 .NET 8 之后支持一部分,但没有本机编译那么顺畅。如果 CI 里要出 Linux ARM 镜像,我建议直接上 Linux ARM 构建机,不要指望一路交叉编到底。
3.4 产物体积和启动时间的实测数据
这块拿我手头的订单查询服务来说,功能不复杂,依赖了几十个 NuGet 包,包含数据库访问、消息队列、通用工具库。迁移前的旧链路是框架依赖发布,约 430 个文件,压缩包 38MB;启动时间因环境而异,平均在 800ms 左右。
迁移到“自包含 + 单文件 + 裁剪”之后,产物变成一个 52MB 的文件,启动时间降到 320ms 左右。再切到 Native AOT,产物压到 21MB,冷启动时间基本在 30ms 到 60ms,内存占用也比原来低。这个数据在 Web API 和命令行工具上的表现不完全一样,但趋势是一致的:AOT 对体积和启动的改善是实打实的。
不过编译时间从原来的 20 秒变成了 1 分多钟。这是 AOT 的正常代价,毕竟它多了完整的原生编译和链接过程。如果项目很大或 CI 资源紧张,编译耗时会成为痛点,这时要配合后续讲的增量构建和缓存来缓解。
4. 容器化和 CI 里的构建发布实践
4.1 先搞清楚三种镜像基底
容器镜像这一步最容易踩坑的地方是选错基础镜像。不同发布形态对应不同镜像。
- 框架依赖发布 → 用
aspnet运行时镜像(Web)或runtime镜像(非 Web)。 - 自包含发布 → 可以用
runtime-deps镜像,它只带原生依赖和系统库,不带托管运行时。 - Native AOT 发布 → 运行时都不需要了,直接用一个你信任的 Linux 基础镜像,比如基于 Debian 的
debian:stable-slim,或者更极端的 distroless 镜像。
很多人习惯于把所有 .NET 服务都塞进aspnet镜像,这在旧链路里没错,但到了 AOT 时代就不合适了。AOT 产物体积已经很小,再塞一个运行时基础镜像等于把省下来的空间全还回去,还多了一层攻击面。选镜像的思路应该跟着发布形态走,而不是“我们一直这么用”。
4.2 多阶段构建的推荐写法
多阶段构建的核心原则是:构建阶段负责出产物,运行阶段只放产物和必要文件。参考 Dockerfile:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY . . RUN dotnet restore \ && dotnet publish -c Release -p:PublishProfile=aot-linux \ -o /app/publish FROM debian:stable-slim AS final RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates \ && rm -rf /var/lib/apt/lists/* COPY --from=build /app/publish /app WORKDIR /app ENTRYPOINT ["./MyService"]几个细节:
- 运行镜像里
ca-certificates一定要装,否则 HTTPS 调外部接口会全部失败,表现为“证书验证错误”。这个问题非常隐蔽,因为它发生在容器里,逻辑上代码没错。 - AOT 产物是单个可执行文件,所以
COPY --from=build只拷贝需要的文件,不要整个 publish 目录一把梭,否则配置文件、符号、冗余资源全进去了。 - 如果你在单文件里启用了压缩,注意解压发生在启动时,容器资源给太小的内存限制可能解压都完不成,实测要在运维侧给一点余量。
4.3 CI 里加速和稳定构建的几个技巧
CI 上跑这套构建,最痛的是时间和不稳定性。我实践下来有几条比较有效的经验。
dotnet restore的缓存要单独处理。每次从零 restore 会把所有 NuGet 包重新拉一遍,通常占掉构建时间的一半以上。CI 里把 NuGet 仓库目录挂出来做持久化缓存,命中之后 restore 几秒钟就能完成。注意缓存 key 一般用packages.lock.json或项目文件哈希,不要整个项目目录变动都触发全量失效。
AOT 编译自身很吃 CPU,构建机资源不充沛时容易被拖垮。我会把 AOT 构建和无 AOT 的普通构建分成两个任务,避免一个任务里既编 AOT 又跑测试,测试用普通发布,出产物再用 AOT 发布,互不拖累。
还有一个稳定性技巧:CI 里构建前先执行一次dotnet build-server shutdown。这套构建服务器进程如果长时间不重启,偶发锁文件和私有程序集冲突。加这一条之后,我的构建失败率明显下降。原因不深究,现象就是偶发MSB4018这类内部错误,重启构建服务器基本就好。
5. 常见问题与排查技巧实录
5.1 AOT 编译失败和 IL 告警怎么读
切到 AOT 后,编译期会多出一堆IL2000到IL3050之类的警告。很多人看到就慌,其实要分两类:一类是告知性的,比如说明某段代码在 AOT 下行为变化,但不阻断;另一类是明确的风险提示,比如IL2026表示某 API 在没有反射支持时可能不正常。
我的处理顺序是:先把warningsaserrors打开,让告警直接变成错误,逼着自己一个个处理。处理不了、确认运行时不会走到的路径,才用[UnconditionalSuppressMessage]抑制。绝对不能一开始就全部抑制,否则等于没开 AOT。
真遇到基础类库不兼容 AOT,比如某个库在初始化时就动态生成代码,[RequiresDynamicCode]属性会给你提示。这种情况不要硬扛,直接查该库有没有 AOT 兼容版本或替代品。曾经有个库我花了两天去跟它搏斗,最后换了官方支持 AOT 的版本,编译一次通过,时间全白花在“你以为代码有问题”上。
5.2 运行时才暴露的反射吞噬
最坑的一类错误是发布时一切正常,运行期某个功能崩溃。出现MissingMethodException或FileNotFoundException,第一反应就是某个反射调用没被裁剪分析识别。
解决办法有几个:
- 给需要用反射访问的类型打
[DynamicDependency],明确告诉裁剪器“这个类型虽然表面没引用,但不能剪”。 - 用
Type.GetType时优先改成泛型方式,让编译器能看到引用关系。比如把写死的字符串类型换成语义明确的接口调用,既安全又利于裁剪。 - 项目级解决:在发布配置或裁剪描述文件里把关键程序集列入保留名单。
举一个我实际踩过的例子:某个服务用配置文件驱动动态加载不同报表实现,配置文件里写类型名,代码用Assembly.Load(类型名)加载。裁掉后配置文件里的报表全部加载失败。最后的方案不是去加一堆DynamicDependency,而是把实现类手动注册到启动代码里,配置文件只存 key 不存类型名。这样裁剪器能看到全部引用,问题根治。
5.3 体积没变小、速度反而变慢?
有几种情况会让 AOT 的表现不及预期。
- 打开了
EnableCompressionInSingleFile,所有程序集在启动时都要解压,冷启动比不压缩还慢。这是体积换速度的取舍,要自己掂量。 - 把
InvariantGlobalization保持默认开(即没关),全球化资源一大坨全进产物,体积自然减不下来。确认不需要本地化后,大胆关掉。 - 为了兼容把
PublishAot和PublishReadyToRun都开着,模式冲突导致行为混乱,性能无法发挥。这两个模式要明确选一个,别叠着开。 - 代码里有大量 AOT 兼容性差的高层抽象,比如动态代理、运行时生成表达式树。这类代码没法预编译,被迫退回解释路径,速度优势就没了。
5.4 问题速查表
| 现象 | 常见原因 | 优先检查项 |
|---|---|---|
| 发布时找不到 clang/linker | 构建机缺工具链 | 安装对应平台原生工具链 |
| 运行时报 MissingMethodException | 反射引用被裁剪 | 加 DynamicDependency 或改造注册方式 |
| 容器内 HTTPS 全失败 | 基础镜像缺 ca-certificates | 运行阶段装证书包 |
| 单文件启动明显变慢 | 单文件压缩导致解压开销 | 关闭 EnableCompressionInSingleFile |
| AOT 编译警告成片 | 项目依赖大量动态代码 | 优先升级库版本,再考虑抑制警告 |
| 发布平台和运行平台不符 | RID 未写死或写错 | 检查发布配置中的 RuntimeIdentifier |
6. 迁移顺序和落地建议
如果你也想把现有项目迁过来,我最想强调的是一条顺序:先单文件、再裁剪、最后再上 AOT。每一步都单独验证,别一次全改。
第一步单文件,产物变化大,但运行行为基本不变,回归成本低。第二步裁剪,会有性能问题和反射问题冒出来,这时候集中处理最合适,因为问题还在托管层面,排查工具多、手段多。第三步再切 AOT,此时反射问题已经被第二步清理得差不多了,AOT 编译失败的风险就小很多。
这个顺序我前后带过几个项目验证,不是理论上好听,而是实际省时间。有个项目直接三合一改,结果出了问题不知道该怪单文件还是裁剪还是 AOT,排查时间翻了一倍。分步走之后,每个阶段的问题都定位得很干净。
再提一个小技巧:迁移期间在每次发布产物的文件名里带上提交哈希和构建时间,比如MyService-linux-x64-8f3a2c1-20250612。排查问题时能直接对应到代码版本,不用猜哪个包是哪次构建出来的。这个习惯成本几乎为零,但能救命的次数非常多。
这套链路跑顺之后,“发个版本”对我来说已经从“传几百个文件上去”变成了“提一个文件或一个镜像”。工具链层面的复杂度不少,但换来的是部署结果可控、产物稳定、扩容变快。下一步我准备继续做的是把 Windows ARM 平台也纳入同一套发布流水线里,到时候再单独写一篇。