03-守护进程军团:Unix socket上的JSON-RPC微服务架构
引子:为什么一只 800 克的鸭子要拆 7 个进程?
大家好,我是黒漂技术佬。
上一篇文章我们聊了 robotd 的 50Hz 控制环。今天往后退一步,看看全局:一只 800 克、只有一块 RK3566 板子的鸭子,为什么要跑 7 个独立的守护进程?
按"简单"的思路,全部代码塞进一个进程,不是更省事吗?少了进程间通信的麻烦,少了启动顺序的编排,内存还更省。很多小设备确实就是这么干的——一个 main 函数,一堆 if-else。
microduck 偏偏没有。它的选择是:
7 个 Rust daemon,通过 Unix socket 上的 JSON-RPC 2.0(NDJSON)协议通信。
这不是炫技。每一个拆分都有具体的、写在文档里的理由。这篇文章我们就来拆解这套"单板微服务"架构——你会发现,微服务的核心思想(服务边界、故障隔离、接口契约)在单板机上同样成立,只是实现方式更轻。
一、军团点将:7 个 daemon 各司其职
先把阵容摆出来(上一篇给过表格,这里带职责重新梳理一遍):
| 服务 | 一句话职责 | 关键点 |
|---|---|---|
| robotd | 控制核心:电机、传感、策略、安全 | 唯一能碰 Dynamixel 总线的进程 |
| configd | WiFi、身份、配对 PIN、手柄绑定、重启 | robotd 挂了它也要能干活 |
| updaterd | OTA:验证、安装、切换、健康门控、回滚 | 升级时它说了算 |
| btd | BLE 传输通道(手机通过蓝牙控制) | 纯传输层,不持有机器人状态 |
| padd | 手柄输入通道 | 只转发原始输入 |
| mediad | 摄像头/音频管线 + WebRTC 远程访问 | 媒体崩溃不能拖垮电机 |
| tofd | 头部 ToF 深度传感器 | 独立传感器服务,发布即完事 |
这个拆分不是拍脑袋定的,它遵循几条非常清晰的边界原则:
原则一:按"崩溃半径"切分
这是最重要的原则。每个进程都是故障隔离的单元——一个进程崩溃,不能拖垮其他进程。
- mediad 为什么独立?因为媒体处理(视频采集、编码、WebRTC)是最容易崩、最吃资源的。如果媒体和电机控制在一个进程里,视频管线一崩,机器人直接失去平衡摔地上。分开之后:媒体崩了,鸭子最多"瞎"一会儿,但不会"瘫"。
- tofd 为什么独立?因为 ToF 传感器的固件升级要经过 I²C 总线传约 90KB 数据,需要秒级时间,而且很多板子根本没有这个传感器。如果把它塞进 mediad,就得让整个媒体管线陪着它等;塞进 robotd,就得让控制环陪着它等——都不行。独立出来,爱传多久传多久,谁都不影响。
- padd 为什么不并入 robotd?因为手柄配对需要 root 权限操作 BlueZ(蓝牙栈),而 padd 故意设计成非特权进程。权限边界和功能边界要匹配,这是安全设计的基本功。
原则二:恢复路径必须独立于被救对象
robotd 死了 → configd 还能配 WiFi ✓ robotd 死了 → updaterd 还能刷固件 ✓ robotd 死了 → btd 还能让你连上蓝牙 ✓细品这三行。configd/updaterd/btd 的代码里没有对 robotd 的依赖(不依赖它的 socket、不调用它的 API、不需要它的库)。为什么?因为最需要"配网"“刷机”"应急连接"的时刻,恰恰是 robotd 出问题的时刻。如果一个系统只能在健康时被维修,那它一旦病了就永远好不了。
这让我想起无人售货柜项目的一个教训:我们的设备管理通道一开始依赖业务主进程,结果主进程 OOM 时,远程维护通道也连不上了,只能跑现场。后来把"管理通道"和"业务进程"彻底分离,才根治。microduck 把这个原则直接刻进了架构。
原则三:传输层不持有状态
btd(蓝牙)、padd(手柄)、mediad(媒体)都是传输层——它们只负责把外部世界的输入送进来,或者把机器人状态送出去,本身不拥有机器人状态的任何部分。机器人状态(关节角度、姿态、任务)只存在于 robotd。
这保证了"传输层随便换":今天手柄走 BLE,明天改 USB,后天加一个 UDP 通道——只要接口契约不变,robotd 完全无感。
二、接口契约:JSON-RPC 2.0 over Unix socket
进程拆开了,怎么通信?microduck 的方案:
每个服务一个 Unix socket,客户端直连;线协议是 JSON-RPC 2.0,NDJSON 格式(每行一个 JSON 对象);全异步 + 超时,订阅 = 开放连接上的 notification 流。
2.1 为什么选 Unix socket 而不是 TCP localhost?
这是本篇最值得抄作业的一个决策。官方文档列了理由,我展开讲:
第一,文件系统权限 = 免费的鉴权。
Unix socket 有文件权限位。microduck 的做法:
- socket 权限设为
0660+ 专属 group → 只有属于该组的进程能连上来"交谈"; - 再配合
SO_PEERCRED获取对方的 uid/gid/pid → 用于审计,并做调用级门控:allow_uids/allow_gids只限制"写操作"(mutating calls),只读调用不设限。
这套组合拳意味着:进程间通信的鉴权在操作系统层面就完成了——一个不属于机器人群组的进程,根本连不上 socket,连握手都谈不上。如果走 localhost TCP,你得自己实现 token、TLS、权限校验……还容易出漏洞。
第二,永远不可能被外部网络访问到。
监听 localhost TCP 的 127.0.0.1 端口,虽然比 0.0.0.0 好,但依然有被本机其他进程访问的风险。Unix socket 天然只对本机文件系统可见,根本不存在"误绑 0.0.0.0 把固件升级接口暴露到公网"这种事故。SDK 里的第三方代码再烂,也够不着这些 socket。
第三,性能和语义都好。Unix socket 比 TCP loopback 少一层协议栈,延迟更低;而且是"连接即身份",配合 SO_PEERCRED 拿进程信息毫无成本。
对做嵌入式/边缘设备的读者,这条经验可以直接搬:本机多进程通信,优先 Unix socket,别动不动就上 HTTP。
2.2 为什么是 JSON-RPC 而不是 HTTP/WebSocket?
文档里写得很直白:
- 需要服务端 → 客户端主动推送(订阅机制:robotd 状态变化要实时推给手机 App)。HTTP 是典型的请求-响应模型,做推送要么轮询(浪费)要么上 WebSocket(复杂);
- NDJSON 持久连接:一行一个 JSON 对象,请求/响应和通知(notification)用同一套格式,没有 HTTP 那套头、状态码、握手的仪式感;
- 未来想加只读的
GET /status也很容易——在同一个 socket 上叠加一个 HTTP 语义即可。
2.3 为什么不是 D-Bus?
D-Bus 在 Linux 桌面系统里是标准 IPC,但 microduck 只在两个地方用了它:操作 BlueZ(蓝牙)和 NetworkManager(WiFi)——因为这两个是系统服务,只能用 D-Bus 去调。
自己的服务间通信则明确不用 D-Bus,理由也很实在:
- zbus(Rust 的 D-Bus 绑定)要拉 66 个依赖,而自研 JSON-RPC/NDJSON 只要约 30 个;
- 消息还要经过 BLE、WebRTC 传出去(手机 App 也要能发同样的指令),用 plain serde + JSON 让每一条消息从进程内到远端手机走的都是同一种格式——“每个客户端——App、控制台、手柄、你的脚本——发送完全相同的调用”。这一点非常优雅。
2.4 为什么不用 ROS 2?
这个问题肯定有人问。官方没有专门论述,但从架构可以推断:ROS 2 是为多机、异构、需要丰富工具链的机器人生态设计的;microduck 是单板、7 个自有进程、需要极致轻量的场景。在单板机上引入 ROS 2 的 DDS 中间件,等于开着一台重卡去送快递。
选型的第一原则是匹配规模。这不是说 ROS 不好——恰恰相反,做复杂机器人系统 ROS 2 几乎是必选。但 microduck 的规模用不上它,就像我们的智慧农业网关不需要上 Kubernetes 一样。
三、控制平面 vs 数据平面:27MB/s 的教训
这是上一篇提过、但值得单独拎出来的一条:数据平面绝不跨 socket。
| 平面 | 内容 | 大小/频率 | 传输方式 |
|---|---|---|---|
| 控制平面 | 命令、配置、状态、感知事件 | 几十字节,≤100Hz | Unix socket RPC |
| 数据平面 | 视频/音频帧 | 640×480 RGB @30fps ≈ 27MB/s | 不跨 socket |
27MB/s 的原始视频帧如果走 Unix socket,会发生什么?
- 延迟:帧传输耗时会被打进控制链路的等待里(虽然 robotd 用 last-value-wins 不阻塞,但带宽被挤占是实打实的);
- CPU 开销:序列化、拷贝、内核往返,全在烧 CPU;
- 架构腐化:一旦开了"传原始数据"的口子,后面就会有越来越多的大块数据走 socket,控制平面的低延迟保证就没了。
microduck 的做法是:数据流在进程内就近处理,跨进程只传特征。
- robotd 不需要视频帧,它只需要 mediad 里视觉模块推导出的结果——“球在 (x,y)”,10~30Hz,几十字节;
- 视频帧只在 mediad 内部流转(采集 → 编码 → WebRTC 发出);
- 真到了万不得已要跨进程传帧,文档里的兜底方案是shm/dmabuf 环形缓冲——共享内存,不是 socket。
这条原则,做监控、做无人零售、做任何"摄像头 + 控制"组合的工程师都该背下来:进程间只传特征,不传原始数据。
四、状态所有权:谁写的,就得写清楚
微服务架构最怕什么?状态散落各处,没人知道谁该为哪份状态负责。microduck 的文档把状态的所有权画得清清楚楚:
| 状态 | 存放位置 | 拥有者 |
|---|---|---|
| 板级配置(robotd.toml、updater.toml) | /etc/robot/ | 安装时写入,永不覆盖 |
| 机器人名、配对 PIN | /var/lib/robot/config/config.json | configd(用文件 + flock 锁) |
| WiFi 凭据 | NetworkManager 配置 | 从不自己存储 |
| 二进制/策略/默认配置 | /opt/robot/daemon/releases/<版本>/ | updaterd,原子替换 |
| 当前版本链接 | /opt/robot/daemon/current(symlink) | updaterd |
| 各 daemon 实际运行身份 | /run/<service>/identity.json | 各 daemon 自己 |
注意两个细节:
配置即文件,不是服务。configd 管理配置用的是"文件 + flock + rename(2)“,而不是搞一个配置数据库服务。文档明确说没加 inotify 监听——因为只有一个写者(configd),不需要事件通知机制。能用文件解决的,不上服务——这是嵌入式开发的"奥卡姆剃刀”。
版本目录设计。固件以版本号目录整体存放,
current是个软链接指向当前版本。这个设计直接支撑了后面的 OTA 原子切换和回滚(第 04 篇细讲)。“软链接 = 状态切换”是 Unix 哲学里极其经典的手法:切换状态不搬数据,只改一个链接。
五、我们能学到什么?
把这一篇的精华提炼成可迁移的经验:
- 进程边界 = 故障隔离边界。拆进程的第一理由永远是"崩溃半径",其次是权限边界和生命周期边界。别为了"省事"把所有东西塞进一个进程,也别为了"微服务"盲目拆。
- 单板机也能玩微服务思想。服务边界、接口契约、状态所有权、故障隔离——这些概念不依赖 K8s,一套 Unix socket + JSON-RPC 就够。
- 本机 IPC 优先 Unix socket。权限免费、外部不可达、性能更好。除非需要跨机器,否则别轻易上 HTTP/TCP。
- 进程间只传特征,不传原始数据。这条救过我们的售货柜项目,也会救你的视频类项目。
- 状态所有权必须白纸黑字。谁拥有、存哪里、怎么更新,都要写清楚。配置能用文件解决就别上服务。
小结
7 个 daemon、一套 JSON-RPC、一个 Unix socket 网络——microduck 用最轻的方式,把"微服务架构"的核心思想完整地实践在了一块单板上。
它的拆分逻辑清晰到可怕:崩溃半径、恢复路径、权限边界、状态所有权,每个进程的"为什么存在"都有文档可查。这种"每个决策都有理由"的工程文化,比任何一行代码都珍贵。
但这里还埋着一个伏笔:7 个进程里有一个"管生杀大权"的家伙——updaterd。它负责把新固件刷进鸭子,还要保证刷不坏。这背后的签名验证、原子切换、健康门控、自动回滚,是嵌入式 OTA 的教科书级设计。
下一篇,就拆它。
我是黒漂技术佬,咱们下篇见。