上个月我把那个内部订单导出工具从“编译后手工拷 dll”的老办法,改成了一条dotnet publish直接出 linux-x64 单文件、再顺手生成容器镜像的新链路。整个过程没有重写一行业务代码,但构建和发布的体验完全是两个时代。这也是我动手写这篇第三部分的原因——.NET 这几年在构建和发布方式上的进化,不是加一两个新参数那么简单,而是从产物形态到发布流程的一整套革新。如果你也正在维护一个多项目解决方案,或者只是想把发布链路弄得干净一点,这篇内容应该能帮你少走不少弯路。
1. 先说痛点:为什么我会觉得发布这套老流程该换了
我见过太多项目把“构建和发布”这件事做成了玄学:本机明明能跑,换台机器就报错;测试环境好好的,一上生产就缺依赖;CI 脚本里堆满了复制 dll、配置替换、远程拷贝的步骤,出了问题根本不知道是哪一步炸的。
1.1 旧发布方式的四大痛点
第一,产物形态不干净。默认dotnet build出来的一大堆 dll、json、pdb,部署时到底要拷哪些文件全靠人肉记。漏一个依赖在本地看不出来,部署完就是 500 错误。第二,运行时环境不一致。开发机上装的是某个补丁版本的 .NET,服务器上装的是另一个,框架依赖型应用很容易被这种差异坑到。第三,发布过程没有标准答案。有人用批处理,有人用 PowerShell,有人直接远程桌面拷文件,每次上线都是现场表演。第四,出了问题没法追溯。没有清晰的可重复发布动作,想回滚就只能靠备份文件夹,时间长了连备份在哪都记不住。
这些痛点其实从 .NET Framework 时代就存在,但到了 .NET Core 之后,工具链给了我们很多以前不敢想的解法,只是很多人没有真正用起来。
1.2 革新到底“革”在哪一层
我的理解是,这次革新的核心不在“加了哪些命令”,而在三件事:发布产物更加独立、发布流程更加自动化、构建过程更加透明。
对应到实际技术点就是:单文件发布和 Native AOT 改变了产物形态;SDK 原生容器发布改变了部署方式;终端日志、中央包管理、统一输出目录改变了构建过程的体验。下面我一个个拆开讲。
2. 发布形态重构:单文件、自包含与 Native AOT 的区别与选型
这一节可能是新手最容易混淆的地方。很多人问我:单文件是不是就是 AOT?其实不是,这是两条完全不同的技术路线。
2.1 三种发布形态的底层逻辑
- 框架依赖型:只产出你的代码和依赖程序集,运行时由目标机器提供。好处是产物小,坏处是目标机器要装对应版本运行时,配置不一致就会炸。
- 自包含型:把运行时和你的程序一起带过去。好处是目标机器不需要预装运行时,坏处是体积大,而且一堆文件散落一地。
- 单文件发布:在自包含或框架依赖的基础上,把多个程序集打包成一个可执行文件。启动时由宿主负责解压或直接映射,用户看到的只有一个 exe。
- Native AOT:把 IL 直接编译成本机机器码,不需要运行时 JIT,也不再依赖 IL 解释执行。这才是真正的“原生”可执行文件。
表格对比一下更直观:
| 发布方式 | 运行时依赖 | 启动速度 | 产物体积 | 可裁剪性 | 适合场景 |
|---|---|---|---|---|---|
| 框架依赖 | 需要 | 中 | 小 | 无 | 内部部署、环境可控 |
| 自包含 | 不需要 | 中 | 大 | 部分 | 服务器环境不统一 |
| 单文件 | 看配置 | 中 | 中大 | 部分 | 分发给用户的工具 |
| Native AOT | 完全不需要 | 快 | 较大 | 强 | CLI 工具、无反射场景 |
2.2 单文件发布的配置与实测结果
单文件发布只需要在 csproj 里加几个属性,或者发布时传参。我习惯在项目文件里写死,这样谁发布都一样:
<PropertyGroup> <PublishSingleFile>true</PublishSingleFile> <SelfContained>true</SelfContained> <RuntimeIdentifier>linux-x64</RuntimeIdentifier> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> </PropertyGroup>然后用一行命令发布:
dotnet publish -c Release这里几个属性我解释一下:
IncludeNativeLibrariesForSelfExtract会把原生库也打进单文件,避免发布目录里残留 .so 或 .dll。不加这个,单文件旁边可能还会出现几个原生库文件。EnableCompressionInSingleFile会压缩内容,产物小一些,但启动时需要解压,会稍微多占一点 CPU。对几十兆的工具来说,性价比还是可以的。
实测一个中等规模的 Web API,自包含不压单文件大约是 70 多 MB,开了压缩和单文件之后能压到 50 多 MB。启动速度比完全自包含容器里慢一点点,但可以接受。核心收益是部署目录干净了,拷一个文件就能跑。
2.3 Native AOT 很香,但别高兴太早
AOT 的配置也简单:
<PropertyGroup> <PublishAot>true</PublishAot> </PropertyGroup>发布命令不用变。但要注意,一旦打开 AOT,你可能会遇到几个非常现实的问题:
反射受限。AOT 后程序集基本信息没了,运行时的Assembly.GetType、Activator.CreateInstance大多不能正常工作。很多第三方库、插件系统、反射序列化方案都会在这一步翻车。解决方向是改用源生成器,比如 System.Text.Json 的JsonSerializerContext:
[JsonSerializable(typeof(MyPayload))] internal partial class MyJsonContext : JsonSerializerContext { }然后序列化时指定上下文:
JsonSerializer.Serialize(payload, MyJsonContext.Default.MyPayload);平台工具链要求。在 Windows 上编译 AOT 需要安装 VS 的 C++ 开发工具;在 Linux 上需要 clang、zlib 等原生依赖。CI 环境里这些不一定会预装,第一次构建经常在这种地方卡住。
编译时间变长。一个小项目可能从几秒变成几十秒,大项目更夸张。所以 AOT 不适合作为常规开发期的默认发布方式,更适合只在正式发布和需要零依赖分发的场景下用。
我的建议是:内部服务优先考虑自包含单文件,对外分发的命令行工具可以考虑 AOT,但前提是代码里没有动态反射。
3. 容器发布成为一等公民:dotnet publish 直接出镜像
如果说单文件和 AOT 解决了“部署物不干净”的问题,那么容器发布解决的则是“发布流程不标准”的问题。
3.1 为什么要关注 SDK 原生容器发布
从 .NET 7 开始,SDK 内置了直接生成容器镜像的能力。你不需要手动写 Dockerfile,不需要先 publish 再 docker build 两步走,只需要一条dotnet publish就能得到一个可运行的镜像。
这个能力我觉得特别适合两类场景:一类是团队里没有专门的 Dockerfile 维护人,希望所有项目都用同一套发布逻辑;另一类是 CI 流程里想尽量少写 shell 脚本,把镜像构建纳入 .NET 工具链统一管理。
更重要的是,它让“发布一个镜像”和“发布一个文件夹”在概念上统一了。以前docker build的各种 COPY、EXPOSE、ENTRYPOINT 细节,现在大部分收敛到 MSBuild 属性里,版本、仓库名、标签都能在工程文件里一眼看到。
3.2 一分钟出镜像的命令与配置
最简用法是在项目目录下执行:
dotnet publish -c Release -r linux-x64 -p:PublishProfile=DefaultContainer但我会建议把常用属性写进 csproj:
<PropertyGroup> <ContainerImageName>internal-tool</ContainerImageName> <ContainerImageTag>1.0.0</ContainerImageTag> <ContainerRegistry>registry.internal.example</ContainerRegistry> </PropertyGroup>然后发布:
dotnet publish -c Release -r linux-x64SDK 会自己拉取基础镜像、把应用文件放进去、设置入口点,最终直接产出镜像。你可以通过docker images看到结果,也可以直接docker run验证。
这里有一点要说明:发布时需要本机有可用的容器引擎,比如 Docker Desktop 或 Podman,SDK 会通过它们来构建镜像。没有容器引擎是转不了的。
3.3 手写 Dockerfile 可以退休了吗
不能完全退休,但对大多数普通服务来说,手写 Dockerfile 确实意义不大了。
以前手写 Dockerfile 最需要关心的是:基础镜像选哪个、文件该拷哪些、非 root 用户怎么处理、时区、文化、证书同步等问题。现在 SDK 默认把常见问题处理好了,而且还能配置成chiseled 镜像,也就是只包含运行所需最小内容的 Ubuntu 镜像,既减小体积又减少攻击面。
比如说:
<PropertyGroup> <ContainerFamily>jammy-chiseled</ContainerFamily> </PropertyGroup>这样产出的镜像体积会明显变小,安全上也不用自己去裁剪老半天。当然,如果项目里有复杂的多阶段构建、需要装额外的系统包、或者要执行一些自定义步骤,那还是得写 Dockerfile。容器发布不是取代 Dockerfile,而是把 90% 的常规场景给覆盖了。
4. 构建过程本身的效率优化:终端日志、中央包管理、统一输出目录
发布方式变了,但构建过程本身也值得重新审视。这三样东西不花里胡哨,但对日常开发体验的提升是立竿见影的。
4.1 终端日志让构建变“可见”
你们有没有遇到过dotnet build刷出几百行输出、真正的错误信息被淹没的情况?我遇到过,而且不止一次。
现在的 SDK 提供了终端日志生成器,把它开起来之后,输出会变得非常干净:
dotnet build -tl dotnet publish -tl开启后你会看到一个交互式的进度视图,错误信息会以更清晰的方式展示,而且构建耗时一目了然。对于经常要在终端和 IDE 之间切换的人来说,这个功能能省下不少眼睛的负担。
不过这也不是万能的:如果用了某些自定义 MSBuild Task 或复杂的日志适配器,有可能看不到完整输出。遇到奇怪的问题时,先关掉终端日志再看完整信息:
dotnet build -tl:off4.2 统一依赖版本:中央包管理
做过大型解决方案的人都知道,多个项目引用同一个第三方库的不同版本,最后合并部署时会出现各种诡异问题。中央包管理就是来解决这个的。
在仓库根目录建一个Directory.Packages.props:
<Project> <PropertyGroup> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> </PropertyGroup> <ItemGroup> <PackageVersion Include="Newtonsoft.Json" Version="13.0.1" /> <PackageVersion Include="Serilog.AspNetCore" Version="8.0.2" /> </ItemGroup> </Project>然后各项目文件里只写包名,不写版本:
<ItemGroup> <PackageReference Include="Serilog.AspNetCore" /> </ItemGroup>版本统一在根文件里维护。升级依赖时只改一处,所有项目一起生效,不会再出现“A 项目引用 12.0,B 项目引用 11.0”的混乱。这个机制对于解决依赖冲突和可重复构建特别重要。
4.3 统一输出目录:ArtifactsPath
默认情况下,每个项目都有自己的bin和obj目录。项目一多,清理和 CI 缓存都麻烦。用 ArtifactsPath 属性可以让所有中间输出和最终产物都汇到一个目录下。
在仓库根目录放一个Directory.Build.props:
<Project> <PropertyGroup> <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath> </PropertyGroup> </Project>配置之后,编译产物会统一输出到artifacts/bin和artifacts/obj。好处很多:路径短、清理方便、CI 里缓存只挂一个目录、打包发布也只用盯一个位置。
我第一次用的时候差点把脚本里所有路径都重新理了一遍,因为老脚本还在按原来的src/xxx/bin/Release/net8.0去取文件。但理清楚之后,收益确实很大,发布脚本里再也没有一串一长串的“魔法路径”。
5. 实操案例:一个 Web API 从 zip 部署到容器发布的完整迁移
理论说了不少,我拿一个实际的内部工具做例子,走一遍完整迁移。项目就是一个订单导出 Web API,业务代码没什么特殊的,就是查询数据库、生成 Excel、丢到共享目录。以前发布流程是:build -> 拷贝文件 -> 打包 zip -> 远程上传解压 -> 重启服务。
5.1 迁移前状态
项目是 .NET 8,多项目解决方案里除了 Web API,还有一个公共类库和一个数据访问层的类库。以前最痛苦的是每次发布都要从 bin 目录里挑文件,忘了带某个依赖库是常事。
5.2 先改成自包含单文件发布
我在 Web API 的 csproj 里加了单文件发布相关属性:
<PropertyGroup> <PublishSingleFile>true</PublishSingleFile> <SelfContained>true</SelfContained> <RuntimeIdentifier>linux-x64</RuntimeIdentifier> <IncludeNativeLibrariesForSelfExtract>true</IncludeNativeLibrariesForSelfExtract> <EnableCompressionInSingleFile>true</EnableCompressionInSingleFile> </PropertyGroup>然后执行:
dotnet publish -c Release --no-restore产物目录里只有一个orders-export可执行文件,加一个 appsettings.json。上传到服务器后,直接/opt/orders-export/orders-export就能跑。
这一步把“拷 dll”变成了“拷一个文件”,发布脚本里删除了一堆文件复制步骤。
5.3 再接入容器发布
第二步是把镜像发布也接进来。我新建了一个单独的发布配置目录,里面放了一个容器发布脚本,核心就一句:
dotnet publish src/OrdersExport.Api/OrdersExport.Api.csproj -c Release -r linux-x64 -p:PublishProfile=DefaultContainer -p:ContainerImageName=orders-export -p:ContainerImageTag=2.3.1第一次执行时,SDK 会拉基础镜像,耗时稍长。之后再次发布会快很多。生成的镜像直接推送到内部镜像仓库:
docker push registry.internal.example/orders-export:2.3.1服务器那边只需要docker compose up -d就能完成更新。回滚也不再是还原文件夹,而是docker compose指定上一个镜像标签。
5.4 没有重写代码的迁移
关键的一点是,整个迁移过程我没有改一行业务代码。ASP.NET Core 的启动逻辑、配置加载、日志、数据库连接全部原样。真正改动的只是工程文件和发布脚本。这也说明这套新发布方式对存量项目其实是相当友友好的,并不需要大版本重写。
6. 迁移中的典型问题与排查
下面是我自己踩过、或者帮同事排查过的一些典型问题,整理成速查表,遇到类似情况可以直接对号入座。
| 现象 | 常见原因 | 排查思路 |
|---|---|---|
| 单文件发布后运行报找不到程序集 | 某个 dll 没被打进去,或原生库没有包含 | 确认IncludeNativeLibrariesForSelfExtract已开启;查发布目录是否有残留 .dll/.so |
| AOT 发布时编译不过,提示反射或裁剪问题 | 代码里有动态类型加载或第三方库不支持裁剪 | 打开详细日志-p:TrimMode=full看具体类型;改用源生成器或禁用裁剪 |
| 容器发布时提示无法连接容器引擎 | 本机 Docker 未启动或 Docker CLI 不可用 | 先试docker info;确认 Docker Desktop / Podman 正常运行 |
| 容器发布拉取基础镜像失败 | 网络受限或镜像源未配置 | 配置内网镜像仓库,或设置ContainerBaseImage指定可用镜像 |
| 多个项目版本不一致导致冲突 | 项目各自引用不同版本号 | 使用中央包管理,统一在Directory.Packages.props维护版本 |
| 发布脚本取的产物路径不对 | 使用 ArtifactsPath 之后路径变了 | 从artifacts/bin/项目名/Release/net8.0/下取文件,而不是原有 bin 目录 |
除了表格里的这些问题,还有三个独特经验值得单独说:
第一,单文件发布不要把 Web 根目录文件误关。如果你的 Web API 用到了wwwroot下的静态文件,单文件发布可能无法把这些静态项嵌进去。需要在 csproj 里显式设置内容项,或者仍然保留发布目录中的wwwroot文件夹。
第二,容器发布和单文件可以组合使用,但要注意体积。SDK 出容器镜像时如果发现你开了单文件,镜像里可能只有一个可执行文件,这很整洁。但因为运行时要解压,冷启动会增加一点时间。对追求极致启动速度的对时服务,我会建议直接面向容器发布普通自包含版本,而不要强制单文件。
第三,AOT 的坑大多是第三方库带来的,排查时先看库再怀疑自己。我们项目里用到一个 Excel 导出库,在 AOT 下无法工作。最后方案不是硬怼反射支持,而是把 Excel 生成部分拆成一个独立的小进程,用普通运行时跑,主程序还是 AOT。该妥协的地方妥协比硬撑更省时间。
最后再分享一点个人经验
如果你也是从老一套发布方式迁移过来,我的建议是从最小范围开始,不要在一个大项目上直接三件套全上。先拿一个内部工具试试单文件发布,确认产物干净可运行;再把 CI 里的构建脚本切到统一的 ArtifactsPath 输出;觉得没问题了,再接容器发布。每一步都是可回退的,这样心理压力会小很多。
我自己这次迁移最大的感受是,.NET 的构建发布能力已经不再是“能用”的水平,而是真的可以做到“一条命令把活干完”。但话说回来,工具再好也只是工具,真正决定发布质量的还是你有没有把流程理顺。建议大家先花一个下午把现有发布脚本梳理一遍,找到最痛的那个环节,再用新方案去替换它。这样比从头到尾换工具链更稳,也更容易被团队接受。