不修改nginx接收websocket
以前只记得要支持websocket 需要 nginx 改:
location /ws-service-a {
proxy_pass http://backend_a;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
}
今天折腾忘记这茬了,一直在调 FastAPI ,@app.websocket() 死活不识别 wscat 的请求
WebSocket 协议的工作方式是:
1.客户端发 GET + Upgrade: websocket 头
2.服务端识别这个特殊 GET,返回 101 Switching Protocols
3.连接升级完成
opus-4.6 说:既然路由确实注册了,那问题几乎可以确定是:前面的反向代理/LB 没有透传 WebSocket
我就来劲了,让AI写一份 特殊 GET,返回 101 Switching Protocols 。
结果还真成功了。wscat 返回 error: Invalid Sec-WebSocket-Accept header
折腾了几下,发现此路不通
问题不是 @app.websocket() 没挂载,它挂载了,但永远匹配不到。原因是 uvicorn 的路由分发发生在更底层:
请求到达 uvicorn
↓
uvicorn 检查 headers:有 Upgrade: websocket?
├── 是 → scope.type = "websocket" → 只匹配 @app.websocket() 路由
└── 否 → scope.type = "http" → 只匹配 @app.get/post() 路由
你的 nginx 把 Upgrade 头吃了,所以 uvicorn 永远把请求标记为 http scope。在 http scope 下,@app.websocket(...) 路由根本不参与匹配 — 就像它不存在一样。这就是为什么:
@app.get('/ws/test') 能匹配 — 它是 HTTP 路由,匹配 HTTP scope
@app.websocket('/ws/real') 匹配不到 — 它是 WebSocket 路由,只在 websocket scope 下才可见
两个都注册成功了,但 scope 类型不对,uvicorn 在路由匹配之前就把它们分到了不同的赛道。
接下来几轮错误复制粘贴大法:
- 最初想法:ASGI middleware 翻译协议 → 80 行,手搓 websockets 库
- 精简版:用 websockets 库做帧编码 → 还是 50 行 middleware
后来发现ASGI 这一层做不到。因为 标准 ASGI middleware 拿到请求时已经太晚了:
uvicorn TCP 收包
1. httptools 解析 headers
2. on_headers_complete 决定 scope type (http vs websocket) ← 需要在这里拦截
3. 创建 scope + receive/send(已经绑定为 HTTP 协议)
4. 调用 ASGI app (middleware 链) ← middleware 才在这里介入
middleware 只能看到已经定型的 scope['type'] = 'http',改不了底层的 receive/send 绑定。
然后尝试了一个 gunicorn.conf.py 的hack:
def post_worker_init(worker):
"""让 uvicorn 从 sec-websocket-key 识别 WebSocket,绕过 nginx 吞 Upgrade 头的问题"""
import httptools
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
_orig = HttpToolsProtocol.on_headers_complete
def _patched(self):
has_ws_key = any(n == b"sec-websocket-key" for n, _ in self.headers)
if has_ws_key and self._should_upgrade_to_ws():
self.headers.append((b"upgrade", b"websocket"))
self.headers.append((b"connection", b"Upgrade"))
self.scope["headers"] = self.headers
self.scope["method"] = self.parser.get_method().decode("ascii")
raise httptools.HttpParserUpgrade(b"")
return _orig(self)
HttpToolsProtocol.on_headers_complete = _patched
我也觉得,ws这协议是不是有病。如果有 sec-websocket-key 就认定为 ws 不就完了。搞那么复杂。
然后这个办法在 ASGI 里还是行不通。最终版:直接篡改 uvicorn 收到的 raw TCP 字节
def post_worker_init(worker):
import re
from uvicorn.protocols.http.httptools_impl import HttpToolsProtocol
_orig_data_received = HttpToolsProtocol.data_received
def _patched_data_received(self, data):
if not getattr(self, '_ws_patched', False) and b'\r\n\r\n' in data:
self._ws_patched = True
lower = data.lower()
if b'sec-websocket-key:' in lower and b'\nupgrade:' not in lower:
data = re.sub(rb'(?i)\r\nconnection:[^\r]*', b'\r\nConnection: Upgrade', data)
data = data.replace(b'\r\n\r\n', b'\r\nUpgrade: websocket\r\n\r\n', 1)
return _orig_data_received(self, data)
HttpToolsProtocol.data_received = _patched_data_received
居然成功了!不修改nginx兼容websocket!
这路子太野了。还是老老实实去改nginx了。
不过也学到一些姿势,比如 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ,以及ws居然是二进制流。
Posted
stdout
