
统信UOS上部署DotNet(Core)服务这件事我从几年前的第一批国产化替代项目开始就一直在做前后落地过内部管理系统、数据同步服务、定时任务宿主这几类场景。踩过的坑从运行时装不上到服务跑起来但半夜掉线基本把能遇到的都遇了一遍。这篇就把整套流程从头到尾捋清楚讲的是怎么在统信UOS上把一套.NET服务稳定地跑起来、被systemd托管、被反向代理接住、出了问题能快速定位。如果你手上有现成的.NET代码想迁到统信UOS或者刚接手一台国产化服务器不知道从哪下手这篇可以直接照着做。1. 先把需求摸清楚为什么要在统信UOS上托管DotNet服务1.1 统信UOS这套系统底子长什么样动手装东西之前得先搞清楚你手上这台机器到底是哪个版本。统信UOS不同产品线的底子差异挺大包管理器和依赖生态都不一样搞错了后面所有命令都是白费力气。桌面专业版这条线底层是Debian系包管理走apt和dpkg配置文件放在/etc/apt/sources.list依赖库命名带版本后缀比如libicu72、libssl1.1。而服务器版这条线底层更接近RHEL系包管理走dnf或yum服务管理是systemd防火墙默认是firewalld。这两套体系的差异不只是命令换个名字依赖库的包名、路径、版本策略全都不一样。我自己吃过一次亏在一台服务器版UOS上照着一篇Debian系的教程敲apt install libicu-dev折腾了十几分钟才发现根本没有apt。所以第一步永远是先确认系统身份cat /etc/os-release uname -mos-release里会明确写出ID、VERSION_ID和PRETTY_NAMEuname -m确认架构是x86_64还是aarch64。这一步结论直接决定后面下载哪个安装包、配哪个包源、选哪个RIDRuntime Identifier。1.2 迁移过来之前得先算清楚三笔账很多人一上来就问怎么装.NET但真正该先问的是我这套代码值不值得迁。我一般会先算三笔账。第一笔是依赖账。你的项目里有没有引用Windows专属的库比如System.Drawing.Common在.NET 6之后已经不支持非Windows平台Microsoft.Win32.Registry这类注册表操作更是直接没法用。还有那些包装了COM组件的第三方SDK基本等于判了死刑。这类东西必须在动手之前列清单能替换的替换不能替换的就得考虑拆成独立进程跑。第二笔是路径账。代码里凡是写死了D:\、C:\或者反斜杠拼路径的地方全都得改。Path.Combine是安全的但字符串拼接data\\ fileName在Linux上会直接生成一个文件名里带反斜杠的怪东西读文件时必然找不到。我习惯用一条命令把所有硬编码路径翻出来搜\\\\和[A-Za-z]:这两类模式基本能覆盖九成。第三笔是权限账。Windows下大家习惯用Administrator跑服务Linux下没人这么干。你得提前规划好这个服务用哪个账号跑、需要读写哪些目录、监听哪个端口。端口小于1024的还需要额外授权所以内部服务我一般直接让Kestrel监听5000以上的端口前面用Nginx做转发。1.3 三条部署路线的取舍在统信UOS上跑.NET服务实际可选路线有三条各有各的适用面。第一条是框架依赖发布服务器上装好.NET运行时发布产物只包含你自己的dll和第三方依赖。好处是发布包小通常几MB到几十MB、多服务共用一套运行时省内存、升级运行时统一升级。坏处是服务器环境需要单独维护换台机器就得重装一遍。第二条是自包含发布把运行时一起打包进发布目录产物一般70MB起步。好处是不依赖服务器环境拷过去就能跑环境一致性极强。坏处是每个服务都背一份运行时磁盘和内存都有额外开销。第三条是容器化用Docker镜像封装隔离性最好。但在国产化环境里镜像仓库、基础镜像来源、Docker本身在UOS上的适配都是额外工作量很多内网项目根本拉不到公网镜像。我的选择习惯是内网长期运行的稳定服务走框架依赖发布临时性的或跨多台机器分发的工具走自包含发布容器只在已经有成熟K8s或Docker运维体系的项目里用。后面几章我主要以框架依赖发布为主线讲自包含的差异点会单独说明。注意不管选哪条路线都别把发布目录直接放在/root或者用户家目录下。一旦后续运维换人、账号被禁用服务会因为目录权限问题起不来而且排查时很容易被忽略。2. 环境准备运行时安装与依赖补齐2.1 系统版本、架构与包管理器确认正式装运行时之前再确认一次几个关键信息这几条命令我每次部署都会跑一遍cat /etc/os-release uname -m which apt dnf yum 2/dev/null openssl version getconf GNU_LIBC_VERSIONopenssl version这条特别重要。.NET不同版本对libssl的依赖不一样.NET 6/7/8在Linux上依赖的libssl版本有差异如果系统里的libssl版本和运行时预期的不匹配启动时会直接抛Failed to load libssl或者更隐晦的SSL初始化失败。国产系统的软件源更新节奏和上游发行版不完全同步这一步的确认千万不能省。GLIBC版本也要看一眼。.NET 8要求glibc 2.17以上正常情况下UOS都满足但如果是从很老的系统升级上来的可能会卡在这里。2.2 .NET运行时的三种安装方式实测方式一官方离线tarball最稳我最常用这是我最推荐的方式原因是它完全不依赖系统包源。统信UOS的软件源里不一定有.NET即使有版本也未必是你需要的那个。从官方下载对应架构的tar.gz包解压到固定目录就完事sudo mkdir -p /usr/share/dotnet sudo tar -zxf dotnet-runtime-8.0.10-linux-x64.tar.gz -C /usr/share/dotnet sudo ln -sfn /usr/share/dotnet/dotnet /usr/bin/dotnet如果你需要编译能力就把dotnet-runtime换成dotnet-sdk包名类似dotnet-sdk-8.0.10-linux-x64.tar.gz。解压路径我固定用/usr/share/dotnet因为官方文档和很多工具的默认查找路径就是这个省得后面配环境变量。配好软链之后还要让环境变量生效。在/etc/profile.d/dotnet.sh里写export DOTNET_ROOT/usr/share/dotnet export PATH$PATH:/usr/share/dotnet export DOTNET_CLI_TELEMETRY_OPTOUT1 export DOTNET_NOLOGO1DOTNET_ROOT这个变量看着可选实际上在systemd托管场景下基本是必需的后面讲unit文件时会再提。后两个变量是关掉遥测和启动banner生产环境没必要每次都打印。方式二系统包管理器安装如果是服务器版UOSRHEL系可以尝试挂载对应版本的包源。但说实话这条路在国产系统上成功率不高经常会遇到依赖冲突或者包找不到。我目前的经验是能不折腾就别折腾直接用官方tarball可控性高得多。方式三自包含发布不装运行时如果你选了自包含路线服务器上确实不用装任何东西。但有个细节要注意自包含发布出来的可执行文件仍然依赖系统的基础库glibc、libstdc、zlib、libicu只是不依赖.NET运行时本身。所以依赖检查这一步还是得做。2.3 那些最容易翻车的依赖库.NET在Linux上跑绕不开三个依赖libicu、libssl、libstdc。libicu负责全球化和本地化中文环境里它是刚需。缺了它最典型的报错是启动时抛Couldnt find a valid ICU package。补装命令按发行版分支系统类型安装命令说明Debian系桌面版sudo apt-get install -y libicu-dev通常会连带装好libicu具体版本RHEL系服务器版sudo dnf install -y libicu部分版本需要开额外仓库离线环境手动下载对应deb/rpm包dpkg -i或rpm -ivh注意版本要匹配系统如果实在装不上ICU还有个保底方案设置环境变量DOTNET_SYSTEM_GLOBALIZATION_INVARIANT1。但我要把话说重一点——这个方案能不碰就别碰。打开invariant模式后所有CultureInfo相关的行为全部失效中文排序会变成按码点排、日期格式变成固定格式、货币符号全乱、ToString(N)这类格式化输出也不对。如果你的服务里只做JSON序列化、不碰任何本地化逻辑勉强能用一旦涉及金额、日期、排序线上肯定出问题。libstdc的版本也得看一眼。有些老系统上的libstdc版本偏低跑新版本.NET时会在加载原生库阶段挂掉报错信息通常是version GLIBCXX_3.4.xx not found。这种情况要么升级libstdc要么退到更老的.NET版本没有第三条路。用ldd可以快速做一次依赖体检ldd /usr/share/dotnet/dotnet | grep -i not found输出为空说明基础依赖齐了。这条命令建议写在部署文档的第一屏。2.4 目录规划与专用服务账户目录结构我固定用一套几年下来没改过/opt/myapp/ ├── current - releases/20250115_143022/ # 软链指向当前版本 ├── releases/ # 每次发布一个独立目录 │ ├── 20250115_143022/ │ └── 20250110_093011/ └── shared/ # 跨版本共享的数据 ├── logs/ └── data/用软链切换版本的好处是回滚极其简单一条ln -sfn就回去了不需要重新传包。shared目录放日志和业务数据不起版本号这样发布新版本时数据不会丢。服务账号单独建一个别用root也别用运维自己的账号sudo useradd -r -s /sbin/nologin -d /opt/myapp -M dotnetapp sudo chown -R dotnetapp:dotnetapp /opt/myapp-r表示系统账号-s /sbin/nologin禁止登录-M不建家目录。这样做的好处是服务被攻破时影响面被限制在这个账号权限内而且万一系统里其他服务的日志文件权限配置有问题也不会互相干扰。生产环境的服务账号原则就一条——给到刚好够用的权限多一点都不给。3. 发布与首次落地从开发机到服务器3.1 发布参数到底怎么选发布命令看着简单但每几个参数都对应一个实际影响。dotnet publish MyApp.csproj -c Release -r linux-x64 --self-contained false -o ./publish-c Release不用解释生产环境用Debug跑等于自己给自己埋雷。-r linux-x64指定RID。统信UOS的x86_64机器用linux-x64ARM机器用linux-arm64。这个参数看起来可有可无实际上它会决定发布产物里包含哪些原生依赖指定了才能保证跨平台正确性。--self-contained false就是前面说的框架依赖模式。如果要自包含改成true同时目录会大很多。关于裁剪Trimming和单文件打包PublishSingleFile我的态度比较保守。PublishSingleFiletrue会把所有东西塞进一个可执行文件启动时先解压到临时目录启动时间会变长而且在部分文件系统上会有性能损耗。PublishTrimmedtrue在.NET 8上虽然有改进但如果项目用了反射或者依赖注入了动态加载的程序集裁剪很容易裁掉实际用到的代码运行时报找不到类型。这两个开关除非你有明确的需求和完整的回归测试否则生产环境我不建议开。appsettings.Production.json这种分环境配置文件用-p:EnvironmentNameProduction或者在发布后手动拷贝进去都行我一般是在发布脚本里从shared目录软链过来避免每次发布覆盖掉线上配置。3.2 文件传输与目录结构传包我一般用scp或者内部的文件服务情况简单时直接从Jenkins的产物目录拉。上传到/tmp之后解压到新的release目录注意解压时的用户和权限sudo mkdir -p /opt/myapp/releases/$(date %Y%m%d_%H%M%S) sudo tar -zxf /tmp/myapp.tar.gz -C /opt/myapp/releases/$(date %Y%m%d_%H%M%S) sudo chown -R dotnetapp:dotnetapp /opt/myapp/releases/$(date %Y%m%d_%H%M%S) sudo ln -sfn /opt/myapp/releases/$(date %Y%m%d_%H%M%S) /opt/myapp/current有个细节值得说tar解压出来的文件默认带着打包时的权限位如果打包环境是Windows可执行位可能会丢。发布产物里的主程序是个dll不需要可执行位用dotnet MyApp.dll的方式启动就不会有问题。但如果你用自包含发布那个可执行文件必须有x权限否则起不来sudo chmod x /opt/myapp/current/MyAppchown -R这一步经常被漏掉。如果你是root解压的文件属主是root服务账号跑起来读不到某些文件报错信息还特别隐晦比如文件不存在而不是权限不足。养成习惯解压完立刻改属主。3.3 配置文件分环境与环境变量覆盖.NET的配置系统优先级是命令行参数 环境变量 环境相关的appsettings appsettings.json。这个顺序在设计上很合理意味着你可以在发布包里放一份通用的appsettings.json把敏感信息数据库密码、第三方密钥通过环境变量注入。环境变量的命名规则是把层级用双下划线连接比如配置里的ConnectionStrings:Default对应的环境变量是ConnectionStrings__Default。这个规则一定要记牢写错一个下划线它就不生效而且不会报错只会静默地用了默认值——这是最坑的一类问题。我一般把配置分三份appsettings.json放非敏感的默认值appsettings.Production.json放生产环境的常规配置日志级别、端口、超时敏感信息全部通过systemd的Environment或EnvironmentFile注入。最后一种更规范把变量写在一个权限600的文件里sudo install -m 600 -o dotnetapp -g dotnetapp /dev/null /opt/myapp/shared/app.env然后在里面写ConnectionStrings__Defaultxxxunit文件里用EnvironmentFile引用。这样配置文件不会进代码仓库也不会被ps -ef看到。3.4 第一次裸跑验证在配systemd之前一定要先手动裸跑一次。这一步的作用是把环境问题和服务配置问题分离开裸跑失败说明是环境的事裸跑成功但systemd起不来说明是unit文件的写法问题。sudo -u dotnetapp bash -c cd /opt/myapp/current DOTNET_ROOT/usr/share/dotnet ASPNETCORE_ENVIRONMENTProduction ASPNETCORE_URLShttp://127.0.0.1:5000 /usr/bin/dotnet MyApp.dll注意这里用sudo -u dotnetapp是为了验证服务账号确实有权限跑起来。用root跑成功没有任何参考意义。如果程序正常启动并打印出监听地址本地curl一下确认接口通curl -v http://127.0.0.1:5000/health建议在项目里加一个/health健康检查接口不查数据库、不做任何外部调用只返回200。这个接口在后面的滚动更新脚本里会成为一个关键的判断依据。提示裸跑时如果报The type initializer for System.Globalization...九成是libicu的问题报Failed to load libssl就是openssl版本的事报找不到文件 XXX.dll检查一下WorkingDirectory和发布产物是否完整。这三类占了启动失败的绝大多数。4. 用systemd把服务托管起来4.1 unit文件逐行拆解裸跑通了之后就可以交给systemd托管。这份unit文件我基本是当模板用的[Unit] DescriptionMyApp DotNet Service Documentationhttps://internal.wiki/myapp Afternetwork-online.target Wantsnetwork-online.target [Service] Typenotify WorkingDirectory/opt/myapp/current ExecStart/usr/bin/dotnet /opt/myapp/current/MyApp.dll Userdotnetapp Groupdotnetapp Restartalways RestartSec5 KillSignalSIGINT TimeoutStopSec30 EnvironmentFile/opt/myapp/shared/app.env EnvironmentDOTNET_ROOT/usr/share/dotnet EnvironmentASPNETCORE_ENVIRONMENTProduction EnvironmentASPNETCORE_URLShttp://127.0.0.1:5000 EnvironmentLANGzh_CN.UTF-8 EnvironmentDOTNET_CLI_TELEMETRY_OPTOUT1 NoNewPrivilegestrue PrivateTmptrue LimitNOFILE65535 SyslogIdentifiermyapp [Install] WantedBymulti-user.target几个关键点展开说。Typenotify需要程序主动通知systemd自己启动完成。ASP.NET Core要支持这个得装Microsoft.Extensions.Hosting.Systemd包并且在Program.cs里调用builder.Host.UseSystemd()。配好之后systemctl start会等到应用真正就绪才返回配合After依赖关系能避免服务之间抢启动顺序。如果懒得改代码就把Type改成simple效果是start命令立刻返回应用在后台慢慢起。WorkingDirectory必须写否则程序按/为基准找appsettings.json直接找不到。这个是新手最容易漏的一行。KillSignalSIGINT配合TimeoutStopSec30是为了优雅停机。.NET的Host默认监听SIGTERM但ASP.NET Core在容器和systemd场景下更推荐SIGINT能让IHostApplicationLifetime的ApplicationStopping回调正常触发。如果你在StopAsync里写了把内存里的数据刷到数据库这类逻辑这两个参数没配好就会导致数据丢失。Restartalways是保命配置。服务因为任何原因退出包括被OOM Killer干掉都会自动重启RestartSec5是重启间隔避免疯狂重启把日志刷爆。NoNewPrivilegestrue和PrivateTmptrue是安全加固。前者禁止服务通过setuid提权后者给服务一个独立的/tmp多个服务之间不会互相看到临时文件。LimitNOFILE65535这个我要单独强调。默认的文件描述符上限在很多系统上是1024一个处理并发HTTP请求的服务很容易打满打满之后的表现是新连接被拒绝日志里看不到明显错误特别难查。改成65535基本就够用了。4.2 日志收集与journald配置好之后sudo systemctl daemon-reload sudo systemctl enable --now myapp.service sudo systemctl status myapp.servicestatus输出的最后十几行就是最近的日志片段。看完整日志用sudo journalctl -u myapp.service -n 200 --no-pager sudo journalctl -u myapp.service -f # 实时跟踪 sudo journalctl -u myapp.service --since 10 min agoSyslogIdentifier决定了journal里的标识配了之后可以按标识过滤。默认的journal容量是有限制的长时间运行的服务日志会被轮转掉。如果要做长期归档两个选择一是调大journal的SystemMaxUse二是把应用日志单独写到/opt/myapp/shared/logs/下面自己管理。我一般两个都做。应用层用Serilog或者NLog写文件日志按天滚动、保留30天systemd这边只负责捕获标准输出和崩溃堆栈。这样查业务问题看文件日志查启动失败看journal分工明确。有一点要提醒应用里往标准输出打日志的性能开销不小。高并发场景下如果每个请求都打一行日志到stdoutjournald的写入会成为瓶颈。生产环境的日志级别至少是Information起步不要开Debug。4.3 开机自启与优雅停机systemctl enable就是配开机自启本质是在/etc/systemd/system/multi-user.target.wants/下建一个软链。验证一下systemctl is-enabled myapp.service优雅停机的验证方法很简单启动服务后往数据库写一条数据然后systemctl restart看数据是否完整落盘。如果发现有丢失说明StopAsync里的逻辑没执行完需要检查应用代码里CancellationToken的传递是否正确传递到了所有异步操作。我自己踩过一次StopAsync里写了个等待3秒的循环但TimeoutStopSec配的是2秒systemd一看超时就发SIGKILL前面等的过程全废了。所以这两个时间一定要对齐——TimeoutStopSec至少要比应用自身的停机时间多出10秒余量。4.4 滚动更新与回滚脚本更新流程我封装成了一个脚本放在/usr/local/bin/deploy-myapp.sh#!/bin/bash set -euo pipefail APP_NAMEmyapp APP_ROOT/opt/myapp PKG$1 TS$(date %Y%m%d_%H%M%S) NEW_DIR$APP_ROOT/releases/$TS PREV_LINK$(readlink -f $APP_ROOT/current) echo [1/5] 解压到 $NEW_DIR mkdir -p $NEW_DIR tar -zxf $PKG -C $NEW_DIR chown -R dotnetapp:dotnetapp $NEW_DIR echo [2/5] 切换软链 ln -sfn $NEW_DIR $APP_ROOT/current echo [3/5] 重启服务 systemctl restart $APP_NAME.service sleep 8 echo [4/5] 健康检查 if curl -fsS --max-time 5 http://127.0.0.1:5000/health /dev/null; then echo 发布成功当前版本 $TS else echo 健康检查失败开始回滚到 $PREV_LINK ln -sfn $PREV_LINK $APP_ROOT/current systemctl restart $APP_NAME.service exit 1 fi echo [5/5] 清理旧版本保留最近5个 ls -1dt $APP_ROOT/releases/*/ | tail -n 6 | xargs -r rm -rfset -euo pipefail这三件套是脚本保命符任何一步失败立刻退出不会带着错误状态继续往下走。健康检查失败自动回滚这一条是这套脚本的核心价值——线上发布最怕的不是出错是出错了没人发现或者发现了要花十分钟手动回退。保留最近5个版本的策略既能应对连续发布后需要回退两个版本的情况也不会把磁盘塞满。你可以按自己项目的发布频率调整这个数字。5. 对外暴露反向代理、端口与网络策略5.1 Kestrel直接对外还是套一层NginxKestrel本身性能很好直接监听80端口对外提供服务也不是不行。但在实际项目里我基本都会在前面套一层Nginx原因有几个。一是证书管理。HTTPS证书在Nginx上配置和轮换比在应用代码里搞要方便太多尤其是需要配置多个域名、多个证书的情况。应用层完全不用关心TLS只管处理HTTP请求。二是请求限制。限流、限速、黑名单、请求体大小限制这些在Nginx层配几行就搞定写在应用里要写一堆中间件。三是静态资源。前端打包出来的JS/CSS/图片Nginx直接读文件比走应用快得多能省掉一大批无意义的请求。四是多实例负载均衡。以后要起两个进程做负载均衡Nginx改个upstream配置就行应用代码一行不用动。所以我的标准架构是Kestrel监听127.0.0.1:5000只接受本机连接Nginx监听对外端口反代到Kestrel。这样即使应用层有漏洞外部也没法直接访问到Kestrel。5.2 Nginx反代配置的关键项upstream myapp_backend { server 127.0.0.1:5000; keepalive 32; } server { listen 80; server_name api.internal.example; location / { proxy_pass http://myapp_backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_connect_timeout 5s; proxy_send_timeout 300s; proxy_read_timeout 300s; client_max_body_size 100m; proxy_buffering off; } }upstream里配keepalive 32是为了复用连接避免每个请求都重新建TCP连接。配上之后proxy_http_version 1.1和proxy_set_header Connection 这两行必须一起写否则keepalive不生效——这是Nginx配置里非常经典的坑Connection头不清空的话Nginx会一直发Connection: close。X-Forwarded-*那一组头光配Nginx是不够的应用侧还得启用转发头处理否则拿到的客户端IP永远是127.0.0.1。在Program.cs里app.UseForwardedHeaders(new ForwardedHeadersOptions { ForwardedHeaders ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, KnownNetworks { new IPNetwork(IPAddress.Parse(127.0.0.1), 32) } });这一段必须在UseRouting之前调用。KnownNetworks是在.NET 6之后加的安全限制只有来自白名单网络的转发头才会被信任。不配这个的话.NET会直接忽略转发头你会觉得配置明明对但就是拿不到真实IP。proxy_buffering off这个开关要看场景。如果接口返回的是大文件或者SSE流式数据必须关掉否则Nginx会先缓冲完再一口气发出去流式效果就没了。如果是普通的JSON接口开着反而更好能减轻后端压力。5.3 防火墙与端口占用排查服务起来了但外面访问不到排查顺序我固定按这三步走# 1. 应用层有没有在监听 ss -lntp | grep 5000 # 2. Nginx有没有在监听对外端口 ss -lntp | grep :80\|:443 # 3. 防火墙规则 sudo firewall-cmd --list-all # RHEL系 sudo iptables -L -n | head -30 # 通用ss -lntp比netstat快得多而且默认就有不用额外装包。输出里的Local Address:Port如果是0.0.0.0:5000表示监听全部网卡如果是127.0.0.1:5000只能本机访问。很多外部访问不了的问题根因就是应用绑定了127.0.0.1而Nginx配置又写错了转发目标。还有个国产环境特有的坑部分UOS版本默认启用了额外的安全模块会对非常规端口的入站连接做限制。如果确认防火墙没拦、应用也在监听0.0.0.0但外部就是连不上可以查一下dmesg里有没有拒绝记录或者临时换一个常用端口比如8080试试。6. 踩坑实录与排查速查表6.1 启动阶段最典型的几类报错You must install or update .NET to run this application这是框架依赖模式下最常见的一条。通常有三种原因运行时压根没装装了但版本不够比如应用要8.0服务器上是6.0装了但DOTNET_ROOT没配dotnet命令找得到运行时、但应用启动时找不到。先跑dotnet --list-runtimes确认再看DOTNET_ROOT。Failed to load libssl或SSL初始化异常系统的libssl版本不匹配。这种时候不要试图通过设置什么环境变量绕过老老实实按运行时要求的版本补装对应的libssl库。升级前先在测试环境验证一遍libssl是很多系统组件的公共依赖动它有可能影响其他服务。服务启动后立刻退出状态码0这种优雅退出最迷惑人因为退出码是0看起来像是正常结束。实际上多半是Program.cs里的主逻辑跑完了就退出了——比如你写的是个控制台程序而不是Web宿主没有app.Run()这类阻塞调用。确认一下Main方法最后是不是在等一个永远不会结束的Task。中文乱码分两种情况。如果你在应用里看到的是????这种问号是locale没配检查systemctl show myapp -p Environment看LANG到底生效没有以及系统里有没有生成zh_CN.UTF-8这个localelocale -a | grep zh_CN。如果看到的是乱码方块多半是文件本身的编码问题跟系统无关。6.2 运行期问题服务跑一段时间后无响应但进程还在第一反应查内存和文件描述符cat /proc/$(pgrep -f MyApp.dll)/status | grep -E VmRSS|Threads ls /proc/$(pgrep -f MyApp.dll)/fd | wc -l线程数持续增长说明有线程泄漏通常是某个Task.Run或者new Thread没有正确结束。文件描述符接近上限默认1024说明连接或者文件流没释放检查所有HttpClient、FileStream、DbContext是不是都用了using或者注册到DI容器里。HttpClient这块我要多说一句。在.NET Core里频繁new HttpClient()会耗尽socket连接TIME_WAIT堆积正确做法是用IHttpClientFactory或者至少用一个静态单例。这个问题在压测时表现特别明显但日常低流量下看不出来上线后才爆。定时任务不按预期执行IHostedService里写的定时逻辑如果用的是Task.Delay循环注意它不受系统时间调整的影响而且如果循环体里抛异常没有捕获整个后台服务会静默停止宿主进程却还在跑。建议用BackgroundService并在ExecuteAsync里加一层全局try-catch记录异常后继续循环。JWT或者签名验证突然开始失败先看系统时间。服务器时间漂移超过几分钟所有基于时间戳的签名校验都会失败。配置chronyd或者systemd-timesyncd做时间同步并且在部署检查清单里加一条时间偏差检查。6.3 排查速查表现象最可能的原因处理方式启动报缺少运行时运行时未装或版本不符dotnet --list-runtimes确认补装对应版本报Couldnt find a valid ICU package系统缺libicu按发行版装libicu避免用invariant模式报 libssl 加载失败libssl版本不匹配补装匹配版本的libssl服务启动后立刻退出主逻辑没有阻塞检查Main里是否有Run()或等价阻塞找不到appsettings.jsonWorkingDirectory未配置unit文件补WorkingDirectory外部访问返回502后端未监听或Nginx转发目标错ss -lntp确认监听地址和端口拿到的客户端IP是127.0.0.1未启用转发头处理配UseForwardedHeaders并加白名单中文显示为问号locale未生成或未透传生成zh_CN.UTF-8并在unit里设LANG运行一段时间后无响应文件描述符或线程耗尽调大LimitNOFILE检查资源释放时间相关的校验失败系统时间漂移开启NTP时间同步6.4 稳定性与性能调优的几个旋钮.NET在Linux上有几个环境变量会影响运行时行为值得按场景调一下。DOTNET_gcServer1开启服务端GC。默认的工作站GC在单核或低并发场景下够用但如果是多核机器上跑高吞吐服务服务端GC能显著降低GC暂停对响应时间的影响。代价是内存占用会上升小内存机器比如2GB慎开。DOTNET_GCHeapHardLimit可以给GC堆设一个硬上限单位是十六进制字节。在容器或者多服务共存的机器上这个参数能防止某个服务把内存吃光导致整机OOM。比如限制到1GBDOTNET_GCHeapHardLimit0x40000000DOTNET_TieredPGO1是分层PGO.NET 7之后默认开启如果因为某些历史原因被关掉了建议打开对长时间运行的服务有持续的性能收益。Kestrel本身的Limits也建议按实际情况调整builder.WebHost.ConfigureKestrel(options { options.Limits.MaxConcurrentConnections 5000; options.Limits.MaxRequestBodySize 100 * 1024 * 1024; options.Limits.KeepAliveTimeout TimeSpan.FromMinutes(2); options.Limits.RequestHeadersTimeout TimeSpan.FromSeconds(30); });MaxConcurrentConnections默认是null不限制但在内存有限的机器上设一个上限能起到保护作用。KeepAliveTimeout默认是130秒如果你的场景里客户端连接复用率很高可以适当调长减少建连开销。调优这件事我自己的原则是先有监控再谈调优。没有准确的内存、线程、响应时间数据光凭感觉改参数很可能把一个能跑的服务改成跑不动的。至少把dotnet-counters用起来它能实时看到GC次数、线程池队列长度、异常率这些关键指标dotnet-counters monitor --process-id $(pgrep -f MyApp.dll) --counters System.Runtime,Microsoft.AspNetCore.Hosting这个工具在排查服务为什么变慢这类问题时比翻日志效率高得多。最后分享一个我自己的习惯每次部署完成后除了看服务状态我一定会做一次完整的重启验证——systemctl restart然后等30秒再curl健康检查接口再翻一遍journal看有没有WARN级别的日志。这套动作花不了两分钟但能提前发现90%的隐性配置问题。很多服务在手工启动时一切正常一交给systemd就各种异常根源都在这两分钟的验证里。另外发布目录的属主和权限最好也写进部署检查清单里因为这类问题往往在换人接手、目录被重建之后才暴露那时候排查起来最费时间。