166 lines
11 KiB
Markdown
166 lines
11 KiB
Markdown
# `im-connection-gateway` —— IM 连接层的**参考部署**(外部连接层)
|
||||
|
|
|
|||
|
|
本目录是 IM 线「连接层候选 B」的部署侧材料:**外部连接层进程的配置样例** + **边缘映射样例**
|
|||
|
|
+ **平台应用层帧 ↔ 网关协议的逐项映射表**。
|
|||
|
|
|
|||
|
|
> 🔴 本目录里的内容**只做部署**:平台的连接层抽象在 `src/im/connection-backend.ts`(中性命名),
|
|||
|
|
> 两个后端实现在 `src/im/backends/{native,gateway}.ts`。⛔ 平台内核(`hub.ts` / `ws.ts` /
|
|||
|
|
> `store.ts`)**不认识**本目录的任何概念,这是刻意的分层 —— 换掉下面这个组件不需要动内核。
|
|||
|
|
|
|||
|
|
参考部署用的是 **Centrifugo v6.9.6**(选择依据见 `dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md`
|
|||
|
|
§十二 执行回报(IM线-第3棒)的 E-2 对照读数)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
🔻 **第 15 棒部署实测修正(2026-09-23,⛔ 下方原文不改,只在此标注)** —— §3 / §4 / §6 有三处被实测改写:
|
|||
|
|
|
|||
|
|
| 原文 | 实测结论 | 依据 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| §3 / config:`ping_interval: "0s"`("⛔ 不启用服务端 WS ping") | 🔴 **该值会让客户端保活帧被判 `bad request`** —— 没有待回的 ping 时,协议里的 `{}` 是**空命令**、不是 pong ⇒ 连接被断开(code 3501)。已改为 **`25s`**:网关发协议级 ping,客户端**按连接**回 `{}` | 47 三向实测:off 80/80 断(3012 `no pong`)· conn **掉 0** · global 80/80 断(3501 `bad request`) |
|
|||
|
|
| §4 "本棒 ⛔ 未执行" | ✅ **已在 47 就地执行**(二进制 + 配置 + systemd 单元 + 边缘片段落盘 + `nginx -t`);⛔ 边缘**未接进 vhost**(不开流量) | 见 §7 |
|
|||
|
|
| §6-1 "在线态取数未接线" | ✅ **已接线**(平台 `presence-ingest.ts` + `POST /api/im/gateway/presence`);`stats().presenceIngest` 从 `not-wired` 变为 `gateway-join-leave` | 见 §7-3 |
|
|||
|
|
|
|||
|
|
⚠️ 关于 §3 那句「实测 `wsPingRecv = 0`」:**这条实测本身没错** —— 它数的是 **WS 控制帧**,
|
|||
|
|
而网关发的是**协议级 JSON ping**。错的是由它推出的结论("所以网关不管保活")。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 1. 拓扑
|
|||
|
|
|
|||
|
|
```
|
|||
|
|
浏览器 / 实例内客户端
|
|||
|
|
│ ws(s)://<平台域>/api/im/ws ← 客户端侧 URL **一个字都不变**
|
|||
|
|
▼
|
|||
|
|
边缘(nginx)── location = /api/im/ws ──────▶ 外部连接层(持有 10k+ 连接,做扇出)
|
|||
|
|
▲
|
|||
|
|
平台进程 ── POST http://<网关>/api/publish ───────────┘ (每条消息**一次**发布调用)
|
|||
|
|
│
|
|||
|
|
└─ 权威态:房间 / 成员 / 消息 / 游标 / 预算 —— 全在平台 `src/im/**`,一条都不外移
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
平台的 `/api/im/ws`(进程内自研 WS)**不摘**:
|
|||
|
|
① 实例内凭据通道(`x-dsh-im-instance-token`)走它;② 它是**一行回滚**的落点。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 2. 应用层帧 ↔ 网关协议:逐项映射
|
|||
|
|
|
|||
|
|
🔴 映射的判据是「**应用层帧不变**」:客户端 `JSON.parse(payload.frame)` 拿到的字节与
|
|||
|
|
native 模式**逐字相同**(`test/im-connection-backend.test.mjs` 的 B-18 就是断这条)。
|
|||
|
|
|
|||
|
|
| 应用层(`/api/im/ws` 的语义) | 网关侧 | 权威在哪 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 客户端连 `wss://…/api/im/ws` | 边缘 `location = /api/im/ws` → 网关 WS 端点 | 边缘 |
|
|||
|
|
| 鉴权(`sid` cookie / 实例凭据) | 网关侧的连接令牌(`client.token.hmac_secret_key` 签发) | 🔴 **仍是平台**:令牌只回答"你是谁",能不能读房由成员表判 |
|
|||
|
|
| `{op:'subscribe', roomId}` | 客户端 `subscribe` 频道 `im:<roomId>` | 平台成员表(`store.isMember`) |
|
|||
|
|
| `{op:'subscribe'}` 的应答 `{op:'subscribed', cursor}` | ⛔ 网关不产这个帧 ⇒ 由平台 REST 补(见下「游标」) | 平台 |
|
|||
|
|
| `{op:'send', roomId, payload}` | ⛔ 网关不参与:走平台 `POST /api/im/rooms/:id/messages` | 平台(预算 `decideWrite` + 落库) |
|
|||
|
|
| `{op:'resume', roomId, since}` | 平台 `GET /api/im/rooms/:id/messages?since=`(网关 `history` 只作**加速**) | 平台(游标权威) |
|
|||
|
|
| 服务端 `{op:'message', roomId, messages:[…]}` | 平台 `POST /api/publish` `{channel:'im:<roomId>', data:{frame:'<原字符串>'}}` | 平台产生、网关投递 |
|
|||
|
|
| `{op:'presence', roomId, members:[…]}` | 网关 `presence` / `join_leave`(**本轮未接线**) | ⚠️ 见 §4 未完成项 |
|
|||
|
|
| 保活 | **客户端**按 §3 的规则自建 | 客户端侧垫片 |
|
|||
|
|
|
|||
|
|
**负载信封**(`src/im/backends/gateway.ts` 的 `gatewayPayloadOf`):
|
|||
|
|
|
|||
|
|
```json
|
|||
|
|
{ "channel": "im:imr_<uuid>", "data": { "frame": "{\"op\":\"message\",\"roomId\":\"imr_…\",\"messages\":[…]}" } }
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 3. 🔴 硬实现项 ①:**必须**自建保活(实测必做,⛔ 不是可选优化)
|
|||
|
|
|
|||
|
|
E-2 判别实验(106 `/root/im-e2/hb.sh`)三条读数,本棒在 `E-7` 里做了三向复核:
|
|||
|
|
|
|||
|
|
| 口径 | 结果 |
|
|||
|
|
|---|---|
|
|||
|
|
| `off`:客户端**完全不发**协议 ping | 服务端在 **26 s** 处把连接判死(`wsPingRecv = 0`:网关**不发**服务端 WS ping) |
|
|||
|
|
| `global`:**整进程统一计时** | 同上(这是 E-2 记录到的**适配器缺陷**形态) |
|
|||
|
|
| `conn`:**每条连接认证完成后**才起自己的 ping 计时 | 保活成立(= 本棒的修正形态) |
|
|||
|
|
|
|||
|
|
⇒ 规则(`src/im/keepalive.ts` 是它的可执行形态):
|
|||
|
|
|
|||
|
|
1. 客户端每 **`intervalMs`** 发一次应用层 ping;`intervalMs` 由一个空窗里发 **3** 次反推
|
|||
|
|
(实测空窗 26 s ⇒ 8,666 ms;丢一次也不至于被判死)。
|
|||
|
|
2. 🔴 **计时起点 = 该连接自己的认证完成时刻**(⛔ 不是进程启动时刻、⛔ 不是 worker 启动时刻)。
|
|||
|
|
用统一计时会一次掉成批连接 —— 本棒的回归用例 B-12 / B-13 把这两种模型**结构性区分**开。
|
|||
|
|
3. 平台的 `/api/im/stats` 在 `gateway` 模式下会回带 `keepalive` 与
|
|||
|
|
`keepaliveAnchor: 'per-connection-auth-complete'`(运维据此核对客户端是否按规则做)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 4. 部署(示例,本棒 ⛔ 未执行)
|
|||
|
|
|
|||
|
|
```sh
|
|||
|
|
# 1) 放二进制与配置(版本与校验见 dsh-server-docs 的部署记录)
|
|||
|
|
install -m 0755 centrifugo /opt/dsh-gateway/centrifugo
|
|||
|
|
install -m 0644 config.json /opt/dsh-gateway/config.json # 凭据先替换 REPLACE_WITH_ROTATED_SECRET
|
|||
|
|
|
|||
|
|
# 2) 起进程(端口示例 18081;v6 的端口用命令行给,配置里已不再有 port 键)
|
|||
|
|
cd /opt/dsh-gateway && ./centrifugo --config=./config.json --http_server.port=18081
|
|||
|
|
|
|||
|
|
# 3) 平台侧(drop-in,⛔ 同名键写 /etc/dshs.env 会被静默压掉)
|
|||
|
|
# DSH_IM_BACKEND=gateway
|
|||
|
|
# DSH_IM_GATEWAY_URL=http://127.0.0.1:18081
|
|||
|
|
# DSH_IM_GATEWAY_KEY=<与 http_api.key 同值>
|
|||
|
|
# DSH_IM_GATEWAY_CHANNEL_PREFIX=im:
|
|||
|
|
|
|||
|
|
# 4) 边缘:把 nginx-location.conf 的 location 落进站点配置 → nginx -t → reload
|
|||
|
|
|
|||
|
|
# 5) 就绪探测与读数
|
|||
|
|
curl -s -X POST http://127.0.0.1:18081/api/info -H "Authorization: apikey <key>"
|
|||
|
|
# 平台侧:GET /api/im/stats(admin)⇒ backendKind=gateway / backend.degraded 为空
|
|||
|
|
```
|
|||
|
|
|
|||
|
|
**回滚**(顺序与上线相反,两步任一即可):
|
|||
|
|
|
|||
|
|
1. 去掉边缘的 `location = /api/im/ws` → reload ⇒ 流量立刻回到平台进程内通道;
|
|||
|
|
2. 平台 drop-in 里 `DSH_IM_BACKEND` 改回 `native`(或删掉该行)→ 重启 `dshs`。
|
|||
|
|
|
|||
|
|
⚠️ 两个方向的**都已具备**:平台的 `/api/im/ws` 两种后端都保留(`ImConnectionBackend.dispatch`),
|
|||
|
|
所以第 1 步是**零重启**回滚。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 5. 容量测算的输入(⛔ 本目录不承诺数值,数值见回报的容量表)
|
|||
|
|
|
|||
|
|
| 输入 | 值 | 来源 |
|
|||
|
|
|---|---|---|
|
|||
|
|
| 边际内存 / 连接 | **70.1 KiB**(自研对照 5.9 KiB,**11.9×**) | E-2 实测(本棒 E-6 复核) |
|
|||
|
|
| 10k 连接稳态 RSS | 750.2 MiB(自研对照 116.0 MiB) | E-2 实测(本棒 E-6 复核) |
|
|||
|
|
| 客户端观测 P99(1k/5k/10k) | 28.9 / 68.1 / 111.9 ms(自研 268.6 / 337.6 / 359.4 ms) | E-2(本棒 E-6 复核) |
|
|||
|
|
| 扇出离散中位 | 17.2 / 57.9 / 97.8 ms(自研 171.8 / 227.6 / 225.5 ms) | E-2(本棒 E-6 复核) |
|
|||
|
|
| 平台侧 CPU | 每条消息 **1 次** HTTP 发布调用;`lastPublishMs` 见 `/api/im/stats` | 本棒实现口径 |
|
|||
|
|
|
|||
|
|
⚠️ 内存类读数**跨跑方差 ±12 MiB**(E-5 教训)⇒ 任何内存判据**重复跑 ≥3 次取中位**。
|
|||
|
|
|
|||
|
|
## 6. 未完成项(⛔ 与 §3 的"已做"分开看)
|
|||
|
|
|
|||
|
|
> 🔻 **第 15 棒状态标注(2026-09-23,⛔ 原文不改)**:① **已接线**(平台侧 `presence-ingest.ts`
|
|||
|
|
> +回调面 `POST /api/im/gateway/presence`,读数 `gateway-join-leave`);② **配置与边缘片段
|
|||
|
|
> 已落 47** 并通过 `nginx -t`,**仍未接进生效 vhost**(不开流量);③ 客户端侧垫片**仍未接进
|
|||
|
|
> E 单 bundle**(第 13 棒已修掉帧格式 P0,但"换连接层后客户端要按连接回 `{}`"这层尚未做)。
|
|||
|
|
|
|||
|
|
1. **在线态取数未接线**:平台的 `presence` 需要网关的 `presence` / `join_leave` 面;
|
|||
|
|
本棒只把现状暴露在 `stats().presenceIngest = 'not-wired'`,⛔ 不假装完成。
|
|||
|
|
2. **本目录的 nginx / config 未应用**(本棒边界:⛔ 不部署到 47 / 106 生产面)。
|
|||
|
|
3. **客户端侧垫片未接进 E 单 bundle** —— 见回报里的 P0 发现(客户端 bundle 与平台
|
|||
|
|
`/api/im/ws` 的帧格式**当前不一致**,接线前先要修那一处)。
|
|||
|
|
|
|||
|
|
---
|
|||
|
|
|
|||
|
|
## 7. 第 15 棒:47 就地部署实况(2026-09-23)
|
|||
|
|
|
|||
|
|
🔴 本目录只记**部署落点**;权威读数与判据在 `dsh-server-docs/交接单/IM群组-稳定性机制与框架选型.md` **§15.8**。
|
|||
|
|
|
|||
|
|
| 项 | 落点 / 值 |
|
|||
|
|
|---|---|
|
|||
|
|
| 二进制 | `/opt/dsh-gateway/centrifugo`(v6.9.6,sha256 `f4869bf9c0028ed5253b74479b1dc70e3b916dae2ddad5bb03504b1eb07a848c`,与 106 测试床同源) |
|
|||
|
|
| 配置 | `/opt/dsh-gateway/config.json`(**600**,密钥现场 `openssl rand -hex 32` 生成,⛔ 不入库) |
|
|||
|
|
| 单元 | `dsh-gateway.service`(`enabled` + `active`),`--http_server.address=127.0.0.1 --http_server.port=18081`,`LimitNOFILE=200000` |
|
|||
|
|
| 边缘片段 | `/opt/dsh-gateway/nginx-location.conf`(**已落盘 + 临时配置 `nginx -t` rc=0**;⛔ 未接进 vhost) |
|
|||
|
|
| 切流 | `DSH_IM_BACKEND` / `DSH_IM_GATEWAY_URL` / `DSH_IM_GATEWAY_KEY` / `DSH_IM_GATEWAY_CHANNEL_PREFIX` ⇒ drop-in `/etc/systemd/system/dshs.service.d/im-gateway.conf`(模板 = `/opt/dsh-gateway/dshs-im-gateway.conf.example`) |
|
|||
|
|
| 当前状态 | **native**(drop-in 已删,`restart dshs` 复验 101 ✅)⇒ 线上行为回到改动前 |
|
|||
|
|
|
|||
|
|
**只读取证脚本**(本次用的)= `/opt/dsh-gateway/im15-drill.mjs`:起 N 个客户端订阅一个临时频道,
|
|||
|
|
三组分口径(off / conn / global)计断开数,并读平台 `/api/im/stats` 与网关 `/api/channels`。
|
|||
|
|
|