我前后差不多把十几种部署组合都折腾过一遍了,现在只要你提“Blazor上Linux”,我条件反射就是三件套:dotnet publish发布、systemd托管进程、Nginx做反向代理。但很多朋友恰恰就卡在第三步,尤其是WebSocket这个代理链路上,本地跑得好好的,一放到服务器上,页面能打开,SignalR却一直连不上,四五个浏览器标签页里全是红色的Failed to connect。
这篇文章我就把Blazor应用发布到Linux、用Nginx作为WebSocket代理这条链路从头到尾梳理一遍。不光是给配置,还会把Nginx为什么要这么配、Upgrade头到底干了什么、后端代码里需要动哪个开关这些问题讲透。适合两种人看:一个是第一次把Blazor往服务器上部署的新手,另一个是部署过但遇到WebSocket莫名其妙断连、想搞清楚底层原理的开发者。
1. 为什么Blazor应用部署Linux必须考虑Nginx
先别急着碰配置文件,我们得先把场景理清楚。
1.1 两种Blazor运行形态,部署策略完全不同
Blazor其实包含两种形态,很多刚接触的朋友容易混在一起。
一种是Blazor Server。在这个模型下,浏览器里跑的UI逻辑、事件处理其实都在服务器上,浏览器通过SignalR连接跟服务器保持实时通信,界面上用户点了按钮,这个“点击”要通过WebSocket传到服务器,服务器把渲染后的UI补丁再推回浏览器。这个过程依赖长连接,而且是持续性的。
另一种是Blazor WebAssembly,也叫Blazor WASM。这种形态是把整个.NET运行时和程序集下载到浏览器里,C#代码在浏览器里跑,UI交互基本不需要服务器参与。这种部署跟普通静态站点差不多,Nginx只需要托管静态文件。
但标题里说的“使用Nginx作为WebSocket代理”显然是针对Blazor Server(或者混合方案)的场景。因为只有Blazor Server才需要Nginx转发WebSocket,而WASM模式压根用不上SignalR。搞清楚自己项目属于哪一种,后面的所有决策才有意义。如果你是Blazor Server项目,直接对外暴露Kestrel是不现实的,必须有一个正向代理层。
1.2 为什么非要用Nginx来接Kestrel
我见过有人直接把Kestrel绑定0.0.0.0:5000对外开放,说实话能跑,但不是待遇问题,而是安全和稳定性问题。
第一,Kestrel的性能和抗压上限靠的是本身,但它对外暴露后,面对恶意请求探测、不规范的UA扫描、慢速连接攻击等等,没有一层防护,进程很容易被打满。Nginx处理并发连接的方式是事件驱动,在静态资源和连接处理上极其强悍,天然是一道闸。
第二,你的应用如果还有静态资源(CSS、JS、图片),Kestrel也会去处理,但Kestrel处理静态文件的能力比起Nginx带sendfile的静态文件响应要弱,在高并发下CPU开销相差很大。
第三,证书终结的问题。HTTPS证书配置在Nginx这一层,内部的Kestrel保持HTTP明文就足够了。这个叫TLS终结(TLS Termination),既省去了在.NET程序里操心证书链续期的麻烦,也方便未来加负载均衡和集群。
第四,也是本文核心——WebSocket支持。Nginx原生支持HTTP Upgrade机制,转发WebSocket只需要几行配置。加上它对连接空闲超时的处理非常灵活,这是做长连接代理的底线能力。
所以标准链路就是:浏览器连Nginx的80/443端口,Nginx按规则把普通HTTP请求转发到Kestrel,把升级成WebSocket的请求也转发到Kestrel,Kestrel处理Blazor应用和SignalR通信。链路一旦建立,Nginx就扮演纯粹的“管道”,双向流量都透传。
1.3 完整的请求链路长什么样
我们一步步拆解一次典型的Blazor Server页面交互:
- 用户在浏览器输入网址,向Nginx发起HTTPS请求。
- Nginx根据
location规则把这个普通HTTP GET请求转发给http://127.0.0.1:5000。 - Kestrel收到请求,返回Blazor Server应用的HTML页面和SignalR连接相关的初始化脚本。
- 浏览器解析页面,脚本执行后向服务端发起一个带有
Upgrade: websocket头的请求。 - Nginx识别到这个请求头,知道这是一个WebSocket握手请求。
- Nginx把这个带Upgrade头的请求传给Kestrel,Kestrel找到SignalR对应的Endpoint,完成握手并升级连接。
- 浏览器和服务器之间建立了真正的WebSocket长连接,后续的UI交互都走这条隧道。
整个过程里,Nginx最关键的角色转换就是从“请求分发员”变成“透明隧道”。它必须正确处理Upgrade头,否则在步骤5就断了。
2. Nginx代理WebSocket的核心配置拆解
很多教程喜欢直接甩一段配置然后说“照抄就行”。我建议各位还是花两分钟把这段配置的每一行吃透,否则出了问题根本不知道去哪排查。
2.1 HTTP Upgrade机制到底在做什么
普通HTTP请求是“短命”的,请求一次,响应一次,连接关闭。WebSocket不一样,它希望这条TCP连接能一直开着,用来双向推送数据。但WebSocket的握手还是基于HTTP协议来完成的,这就靠Upgrade头来“升舱”。
客户端会在握手请求里带上两类关键Header:
Upgrade: websocket Connection: UpgradeUpgrade声明客户端希望切换协议到websocket,Connection告诉代理服务器:这个请求不是普通的请求,你要把Upgrade这个Header逐跳传递下去,不能吞掉。
Nginx默认是不转WebSocket的。如果你配了一个普通的反向代理,没有显式声明这两个Header,Nginx会在代理转发时直接丢掉它们,然后Kestrel看到的是一个普通HTTP GET请求,自然就不会升级成WebSocket,SignalR只能用Server-Sent Events降级,体验上有延迟且不稳定。
所以必须显式声明:
location / { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }$http_upgrade是Nginx内置变量,它取的是客户端请求里Upgrade头的值。这么写就是“客户端传什么Upgrade头,我就原样传给后端”。Connection "upgrade"则是让Nginx和后端交互时也使用Connection: upgrade,整个升级链路在后端视角里是连续的。
2.2 除了Upgrade,这几行配置同样关键
再来看看完整的server块:
server { listen 80; server_name your-domain.com; location / { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; proxy_send_timeout 3600s; } }一个一个过:
proxy_http_version 1.1这行是硬性的。HTTP/1.0协议不支持Upgrade机制,如果你不给Nginx指定HTTP版本,默认用的就是1.0,WebSocket握手必失败。Nginx官方文档中明确写了“HTTP/1.1 is required for WebSocket proxying”,没有商量余地。
proxy_set_header Host $host这行是把客户端请求的Host头原样传给后端。如果不设置,后端收到的Host可能是127.0.0.1:5000,这会导致Kestrel生成的重定向地址和Cookie域名错乱,页面跳转全乱套。
proxy_set_header X-Forwarded-For和X-Forwarded-Proto是给后端传递真实的客户端来源和协议。这样你用app.UseForwardedHeaders()的时候,应用才能正确生成Scheme相关的链接。比如页面里如果需要拼接https://开头的回调地址,靠的就是这个Header。
proxy_read_timeout 3600s和proxy_send_timeout 3600s这两个超时参数容易被忽略。WebSocket连接建立后会长时间闲置,比如你打开页面不操作,浏览器和服务器之间可能几分钟都没有数据流动。Nginx默认的proxy_read_timeout是60秒,这就意味着一个连接超过60秒没有任何响应,Nginx就主动断开它。对Blazor Server这种靠心跳维持的长连接应用来说,这个默认值就是坑。你需要把超时时间拉长到至少1小时,配合SignalR自带的心跳机制,才能保证连接稳定不闪断。
上文我用$connection_upgrade而不是写死的"upgrade"。这样写更灵活,可以配合一个map定义处理非WebSocket请求的情况:
map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { # ... }这段map的意思是:客户端带了Upgrade头,就转发upgrade;没带Upgrade头,就转发close。好处是同一个location既能处理普通HTTP请求,又能处理WebSocket升级请求,互不干扰。这是我实操最喜欢用的配置组合,推荐你也这么写。
2.3 一个更完整的HTTPS方案
如果是生产环境,我建议把证书直接配在Nginx上:
server { listen 443 ssl; http2 on; server_name your-domain.com; ssl_certificate /etc/nginx/certs/fullchain.pem; ssl_certificate_key /etc/nginx/certs/privkey.pem; location / { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 3600s; proxy_send_timeout 3600s; proxy_buffering off; } } server { listen 80; server_name your-domain.com; return 301 https://$host$request_uri; }这里面我特意加了proxy_buffering off。为什么要关缓冲?因为WebSocket是双向实时通信,Nginx默认会对后端响应做缓冲,这在普通HTTP场景下有利于性能,但对WebSocket来说,它希望数据一到就能立即转发给客户端,而不是积攒到一定大小才吐出去。关闭缓冲可以减少实时消息的延迟。实测下来,弥补这一行带来的体感差异是有的,尤其是频繁刷新UI组件的Blazor页面。
3. 发布Blazor应用到Linux的完整流程
配置讲清楚了,现在走一遍完整的部署流程。我尽量把每一步的关键点都备注清楚,按这个流程走,基本不会翻车。
3.1 发布Blazor应用
先说发布。在开发机上执行:
dotnet publish -c Release -r linux-x64 --self-contained false -o /path/to/publish这里有几个参数说一下:
-r linux-x64指定运行时标识符。如果你是ARM64的服务器,改成linux-arm64。不确定服务器架构可以用uname -m查一下。
--self-contained false生成的是依赖框架的部署包,服务器上需要安装.NET运行时。如果服务器上不方便装运行时(或者你想彻底避免运行时版本冲突),可以改用--self-contained true,这样发布出来是自带运行时的大包,目录体积会多出100MB左右,但真的“一把梭”,怎么拷过去都能跑。
发布完成后,输出目录里能看到:
BlazorApp.dll(主程序集)appsettings.jsonwwwroot(静态文件目录).deps.json、.runtimeconfig.json- 一大堆依赖的DLL
把这个目录整个拷贝到服务器的/var/www/blazorapp即可。
3.2 安装.NET运行时
如果发布用的是framework-dependent模式,服务器上需要装对应版本的运行时。以.NET 8为例:
sudo apt update sudo apt install -y aspnetcore-runtime-8.0这里装的是aspnetcore-runtime而不是dotnet-runtime,前者包含了ASP.NET Core相关的库,Blazor Server离不开它。装好后可以用dotnet --info验证。
3.3 创建systemd服务
直接用dotnet /var/www/blazorapp/BlazorApp.dll这种裸命令启动是不靠谱的,进程一退出就没了。生产环境必须用systemd托管,让Linux系统负责守护这个进程。
新建服务文件/etc/systemd/system/blazorapp.service:
[Unit] Description=Blazor Server APP After=network.target [Service] WorkingDirectory=/var/www/blazorapp ExecStart=/usr/bin/dotnet /var/www/blazorapp/BlazorApp.dll Restart=always # RestartSec=10 Environment=ASPNETCORE_URLS=http://127.0.0.1:5000 Environment=DOTNET_PRINT_TELEMETRY_MESSAGE=false [Install] WantedBy=multi-user.target注意Environment=ASPNETCORE_URLS=http://127.0.0.1:5000这一行。你绑定的是内网回环地址,Nginx和后端都在这台机器上,这个地址可以被Nginx访问,但外网无法直接访问,安全上多了一道保障。
启动服务:
sudo systemctl daemon-reload sudo systemctl enable blazorapp sudo systemctl start blazorapp查状态:
sudo systemctl status blazorapp看到Active: active (running)就是成功了。我习惯查完status再用curl http://127.0.0.1:5000直接打一下后端,确认服务真的在本机端口上响应了,再去配Nginx,这样能隔离“后端问题”和“反代问题”。
3.4 配置Nginx站点
把站点配置放在/etc/nginx/sites-available/blazorapp,再软链到/etc/nginx/sites-enabled/:
sudo ln -s /etc/nginx/sites-available/blazorapp /etc/nginx/sites-enabled/配置内容就用上面第三节那段HTTPS方案。保存后测试语法:
sudo nginx -t然后重载配置:
sudo systemctl reload nginx到这里整套链路就通了。浏览器访问域名,页面加载,SignalR握手,UI交互实时刷新,一切正常。
3.5 别忘了后端代码里对转发头的处理
很多人配置完成后发现页面虽然出来了,但SignalR的请求地址是http://127.0.0.1:5000/hubs/xxx而不是https://你的域名/hubs/xxx,这就是后端代码没有识别反向代理的转发头。
在Program.cs里加上:
using Microsoft.AspNetCore.HttpOverrides; var builder = WebApplication.CreateBuilder(args); builder.Services.Configure<ForwardedHeadersOptions>(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownNetworks.Clear(); options.KnownProxies.Clear(); }); var app = builder.Build(); app.UseForwardedHeaders();这一步的思路是:Nginx转发过来的请求里有X-Forwarded-For和X-Forwarded-Proto头,app.UseForwardedHeaders()会读取这些头并修正请求的Scheme和客户端IP,这样应用层面就知道“用户是通过HTTPS访问的”,从而生成正确的WebSocket地址和跳转链接。
在只有一层Nginx代理的情况下,按上面这段代码配置没有问题。如果Nginx后面还有如负载均衡之类的中间层,需要注意KnownProxies的配置语义,别再不加甄别地Clear,否则IP伪造风险会变大。
4. 常见问题与排查技巧实录
这部分是大家最关心的。我整理了部署过程中最容易踩的坑,每一个都是我或者身边同事亲手踩过、排查过、解决过的。
4.1 WebSocket握手失败排查
问题表现:页面能打开,但浏览器的Console里反复出现Error: Failed to start the transport 'WebSockets': undefined或者Error: Failed to connect to the server via WebSockets。
排查顺序:
第一步,确认SignalR请求到底走的是不是WebSocket。浏览器按F12打开开发者工具,切到Network面板,筛选WS类型的请求,手动刷新页面,看请求列表里有没有ws://或wss://开头、状态码101的请求。
第二步,用Nginx日志确认反代是否把Upgrade头带上去了。在你Nginx配置里加上:
log_format upgrade_log '$remote_addr - $request_method $request_uri ' 'upgrade: $http_upgrade connection: $http_connection ' 'return: $status'; access_log /var/log/nginx/upgrade.log upgrade_log;重载Nginx后再刷新页面,看日志。如果upgrade:后面是空,说明浏览器没发Upgrade请求过来,后端问题;如果upgrade: websocket正常是有的,但后端返回的不是101而是404或400,多半是SignalR路由没匹配上。
第三步,检查客户端脚本中用到的hub地址,跟服务端实际注册的hub地址是否一致。Blazor内置的SignalR连接通常走/_blazor这个路径,注意Nginx有没有奇怪的location规则把/_blazor劫持到静态文件或其它处理去了。我遇到过最隐蔽的一个案例是,有人配了location ~ \.cshtml$之类的正则匹配,把SignalR的请求误伤成了404。
4.2 页面刷新直接404,路由纵深问题
这是另一个高发问题,明明首页正常,但浏览器一刷新,如果访问的是/counter这种子路由,Nginx直接返回404。
原因还是Nginx配置不完整。Blazor Server的路由默认是服务端处理,但刷新时浏览器会向服务器重新请求/counter这个路径。如果Nginx对这个路径没有任何匹配规则,找不到对应文件,就直接404了。
正确做法是加一条try_files指令,把不存在的路径回退到Kestrel:
location / { try_files $uri $uri/ @kestrel; } location @kestrel { proxy_pass http://127.0.0.1:5000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; }try_files $uri $uri/ @kestrel的含义是:先试试直接找磁盘上有没有这个路径对应的静态文件,找不到就回退到@kestrel这个命名location,把请求交给后端进程,由Blazor的路由系统决定返回哪个界面。这是Blazor部署必备的配置,少了它刷新一次404一次,特别狼狈。
4.3 连接断连频繁,服务不稳定
表现为操作界面时频繁“尝试重新连接”,等待一分钟后才知道要不要丢连接。基本就是超时参数没调好。
常见原因一:Nginx的proxy_read_timeout默认60秒,但你忘了改。连接一闲置,Nginx就带着连接走人了。
常见原因二:网络设备链路层有NAT超时,公网环境下如果是你自己的网络环境或某类不受信任的线路中间节点把空闲连接回收了。这种情况光调Nginx没用,需要应用层心跳来兜底。SignalR有默认的KeepAliveInterval,默认是15秒发一次心跳包。如果你的应用改过这个配置,调回15秒就好。
我通常在Nginx层面对WebSocket场景统一设置:
proxy_read_timeout 1h; proxy_send_timeout 1h;这样就算心跳机制偶尔慢半拍,空闲连接也不会被Nginx掐断。
4.4 应用能访问但始终提示“无法连接后端进程”
这种最诡异,前端页面和WebSocket链路看起来都正常,但一操作就提示后端进程掉了。
先查systemd服务状态:
sudo systemctl status blazorapp sudo journalctl -u blazorapp -f大概率是后端进程根本没起来,或者刚起来又崩了。最经典的坑是发布机器和服务器运行时版本不一致。比如开发机用的.NET 8,服务器只装了.NET 6运行时的服务器,启动时会立刻报错,日志里写着framework 'Microsoft.NETCore.App', version '8.0.0' was not found。这种问题发布前先做一次版本对齐:dotnet --list-runtimes看服务器上装了啥,同时看看发布目录里的.runtimeconfig.json,确认目标框架版本。
另一个跟内存相关的知识点:默认Kestrel在服务器上能接收的连接数和内存使用上限,systemd服务的MemoryMax没设的话,默认是不限制的。如果部署的服务器内存本身并不富余(比如1GB的小机器),我建议在systemd配置里加上:
[Service] MemoryLimit=500M但超了会发生什么,各位心里要有数,Kestrel遇到OOM时可能整个进程被杀掉。相关预算要根据应用规模自己测算。
4.5 WebSocket消息延迟大,界面不跟手
排查思路分两条线。一条是Nginx的proxy_buffering应设置为off,我在前面已经提过。另一条是检查浏览器到服务器的链路延迟,可以用ping大致测一下RTT。在云服务器跨地域部署时,比如服务器在异地机房、用户在本地开发的场景下,每帧交互要经历一次完整的网络往返,体感就是每点一下按钮,几百毫秒后才出来响应,这是网络距离的物理成本。
如果项目对实时性要求高,部署地域应该尽量贴近用户。这是架构层面的考量,往往比调几个参数更有效。
5. 部署后的自检清单与体验优化
最后分享一份我自己动手部署完一定过一遍的检查清单,以及一些让整体体验更顺滑的进阶思路。
5.1 部署完成后的五步自检
第一步:浏览器无痕窗口打开域名,地址栏必须显示HTTPS,并带锁定图标。
第二步:F12打开Network面板,刷新页面,确认/_blazor(SignalR)请求返回的是一堆消息,而非404、400。
第三步:切换WS筛选方式,定位WebSocket连接,确认Connection Status是101切换成功的,没有频繁刷新重建。
第四步:把页面挂到一边,完完整整等十分钟,期间不刷新页面,回来直接在界面上操作,确认依然响应流畅。
第五步:用systemctl status确认服务是active (running)状态,顺手看一眼nginx -t输出是ok和successful。
这套流程走完,整套部署才算真正验收通过。
5.2 后端配置上的两个优化点
一个优化点是关闭静态文件在Kestrel侧的处理。既然Nginx已经托管了静态文件,Kestrel侧的静态文件中间件就没必要开了。用Nginx直接服务wwwroot下的资源,你会发现页面加载速度快一截。做法是在location /上面再加一个静态文件的location:
location /css/ { alias /var/www/blazorapp/wwwroot/css/; expires 30d; } location /js/ { alias /var/www/blazorapp/wwwroot/js/; expires 30d; }但注意,Assets的指纹版本是自动生成的,这个路径直接代理到wwwroot即可,Nginx缓存策略别把带哈希的静态文件也设成短缓存,否则每次发版都要等缓存过期才能看到新样式。我把expires 30d和try_files组合使用,对带指纹的静态文件效果很好,文件名变了就是新请求,文件名不变说明确实没改。
另一个是压缩。Blazor Server页面里涉及的SignalR协议和框架脚本,如果不压缩体积可不小。Nginx开启gzip压缩:
gzip on; gzip_types text/plain text/css application/json application/javascript application/octet-stream; gzip_min_length 1024;尤其是SignalR消息内容大多是JSON文本,压缩率很可观。我实测过,开启后每个UI增量消息包能压缩到原来的三分之一左右,在弱网环境下对比非常明显。
5.3 升级到HTTP/3和TLS优先的实践
其实到这一步,你的部署已经能稳定运行了。再往深走,把listen指令改成:
listen 443 ssl; http2 on; quic_listen 443 quic;HTTP/3基于UDP的QUIC协议对弱网和高延迟链路特别友好,对Blazor这种实时交互应用价值很大,不过HTTP/3需要服务端和客户端同时支持,目前浏览器端条件基本成熟,服务器端如果网络环境支持UDP端口,可以直接启用。我在另一个放在移动网络较多的生产环境里开了HTTP/3,应用重连率大约下降了快三成。
5.4 调了很久还是不行,怎么办
真到了山穷水尽的地步,给你一个最笨但最有效的思路:层层拆开验证。先临时让Kestrel直接绑定0.0.0.0:5000,关掉Nginx,用浏览器直接访问http://服务器IP:5000,看应用能不能连上WebSocket。
如果这一步就失败,说明问题在应用或网络环境,先查防火墙、云厂商安全组、服务器端口放行策略。如果这一步成功,说明是Nginx代理层的问题,回到配置逐行排查。
把这个思路记住,运维里最怕的不是配置复杂,而是你同时改了Nginx、页面、代码、端口这么多东西却理不清哪一环出的问题,更怕第一反应是推翻重来。剥洋葱一样一层层拆,问题往往藏在你最后怀疑的那个环节。
我自己部署过很多次这套架构,最大的体会是:Nginx对WebSocket的支持本身是成熟可靠的,出错基本都在配置细节上,尤其是HTTP版本、Upgrade跳转、超时这几项。多花十分钟把配置逐行看懂,比出问题时干瞪眼翻半天文档强得多。希望这篇写下来能给准备把Blazor应用放到Linux上的朋友省下一些不该踩的坑。