
1. 为什么要用Unix socket跑JSON-RPC先掰扯清楚架构动机这段时间在折腾Microduck一个很有意思的常驻服务编排框架。它的核心概念不太像Kubernetes那种面向集群的大家伙反而更接近一群守规矩的守护进程住在一台机器上彼此通过Unix socket通信用JSON-RPC协议对话。我第一次看到“Microduck的守护进程军团”这个说法时还愣了一下跑通之后才反应过来这个命名相当贴切——它就是把一堆微服务进程像小兵一样管起来让它们通过本地socket组网。先说结论再拆细节Microduck选的这套组合拳——守护进程 Unix socket JSON-RPC——非常适合单机场景下的模块化拆解。你不需要为了把一个工具拆成三个组件就去引入Kafka、gRPC那一套重武器Unix socket在本地通信里延迟极低没有TCP握手和端口占用问题JSON-RPC又足够轻量直观调试的时候你甚至能用curl直接跟服务对话。对于做嵌入式、桌面客户端、边缘网关这类资源的场景这个架构思路是相当能打的。我自己用Microduck跑通了一个监控采集器的拆分原来是一个巨大的脚本进程里面同时干着读传感器、写日志、对外暴露指标三件事。后来把它拆成三个守护进程——采集端、存储端、指标端每个进程独立崩溃恢复通过固定的socket路径互相调用。整个过程踩了不少坑这篇文章就把完整思路、配置方法、协议约定和排错经验一次性写清楚。这套架构适合谁来参考一种是手头有单体脚本或单体服务、想拆又怕惹上K8s和容器依赖的开发者另一种是在做多进程或插件化架构、需要一套可靠本地通信方案的人。Microduck的文档不算完善GitHub上的示例也比较简单很多细节是试出来的。我尽量把实用的都留在下面。2. Microduck整体设计拆解守护进程军团的管理闭环2.1 守护进程在Microduck里的角色划分Microduck里每个“小兵”就是一个守护进程承担某个独立任务。它的管理方式可以看作三环结构编排器、通信层、任务进程。编排器负责启动顺序和崩溃拉起通信层统一走Unix socket任务进程只关心自己的业务逻辑。我拆的三个服务是这样的角色角色名称职责socket路径采集服务collector.sock轮询传感器数据上抛原始事件/tmp/microduck/collector.sock存储服务storage.sock接收并归档事件写入本地库/tmp/microduck/storage.sock指标服务metrics.sock聚合数据对外提供查询接口/tmp/microduck/metrics.sock分开之后最明显的好处是故障域隔离。采集进程是C语言写的最容易因为野指针崩掉以前它一崩整个脚本就没了现在崩了之后编排器自动拉起存储和指标服务根本无感知继续挂在那等数据。这对稳定性要求高的场景非常关键。而且独立进程还有个好处——可以分别指定资源限制和重启策略。比如采集服务崩了可以立刻重启最多重试三次存储服务磁盘满了就停止写入并告警指标服务只读永远不用重启。这些策略在每个守护进程的启动配置里单独声明互不干扰。2.2 为什么选Unix socket而不是TCP或HTTP我刚开始也有个疑问JSON-RPC明明可以跑在HTTP上Microduck默认却建议走Unix socket为什么实际对比测试之后才明白原因。Unix socket在本机通信上优势太明显了。它不走网络协议栈没有TCP三次握手和四层封包的开销数据是直接在内核里做文件描述符级传递。我简单压测过同样一次JSON-RPC调用走回环地址的TCP耗时大约在0.3到0.6毫秒而Unix socket通常能做到0.05毫秒以内差距接近一个数量级。对于高频小消息的交互场景比如采集数据不停涌入这个优势会被放大得非常大。还有一个隐藏收益安全性。Unix socket用文件权限控访问你可以把socket文件权限设为750指定只有microduck用户组的进程能连TCP端口一旦监听在0.0.0.0则意味着局域网内任何人都能尝试访问在本地多租户环境里这是完全不同的威胁模型。不过Unix socket也有它的脾气最典型的是socket文件路径不能太长大约限制在108字节内还有socket文件残留会影响重启。这些坑我放到后面的排查部分详细聊。2.3 JSON-RPC作为通信协议轻量、可控、易调试选JSON-RPC而不选gRPC我自己总结有三个核心原因协议透明、实现成本低、跨语言友好。JSON-RPC 2.0的规范非常简单一个请求就是带jsonrpc、method、params、id四个字段的JSON对象响应就是result或error加上同一个id。没有protobuf编译、没有IDL生成代码、没有HTTP路由注册你在任何一门语言里都能手写一个最小实现5分钟就能搞定。这对快速原型和后期维护都是极大的友好。调试体验更是碾压级的。因为请求就是纯文本JSON我直接开一个终端窗口用socat或nc -U连接到socket文件手敲JSON就能立刻测接口socat - UNIX-CLIENT:/tmp/microduck/metrics.sock然后输入{jsonrpc:2.0,method:query.avg,params:{window_min:5},id:1}如果服务端逻辑正确立刻就能看到返回的指标数据。这种“所见即所得”的感觉在被gRPC的二进制流折磨过之后格外珍贵。JSON-RPC的另一层优势在于它的可扩展性不算差。虽然有params传参和result返回但你还可以在自定义的结构里塞扩展字段比如追踪用的request_id。我习惯在自定义扩展里给每个请求加一个请求ID和时间戳这样在日志里排查调用链时非常舒服。Microduck自身也支持在请求里面带meta字段透传给后台服务做链路标记。3. 核心细节与实操配置把守护进程军团拉起来3.1 环境准备与目录约定在动手之前先确定Microduck的版本。目前GitHub上发布比较活跃的版本是v0.4.x系列我跑通用的是0.4.3。安装方式很简单从源码构建或者用官方提供的二进制包都行git clone https://github.com/microduck/microduck.git cd microduck make build sudo cp bin/microduck /usr/local/bin/注意v0.3.x和v0.4.x的配置文件格式略有变化v0.4.x统一改用YAML语法。如果你看到一些旧教程用的还是INI格式直接套用会报parse错误建议以官方仓库的最新示例为准。目录结构我这边约定如下方便统一管理和备份/opt/microduck/ ├── bin/ # 各服务可执行文件 ├── config/ # 服务配置文件 ├── sockets/ # socket文件生成目录 ├── logs/ # 各守护进程日志 └── data/ # 数据存储目录这个结构本身不是Microduck强制的但建议不管做什么项目都保持一致的约定脚本化部署和排障都会省很多事。3.2 服务定义与启动编排Microduck的编排器读取一个总配置文件多个服务可以定义在同一个文件里也可以按目录拆分加载。我采用的是按服务拆文件再统一include的方式# /opt/microduck/config/main.yaml services: - include: collector.yaml - include: storage.yaml - include: metrics.yaml global: socket_dir: /opt/microduck/sockets log_dir: /opt/microduck/logs pid_dir: /opt/microduck/run每个服务文件的格式类似# collector.yaml service: name: collector command: /opt/microduck/bin/collector --config /opt/microduck/config/collector.ini listen: collector.sock respawn: true max_respawn: 3 respawn_interval: 2s user: microduck group: microduck env: - KEYvalue dependencies: - storage几个配置参数我解释一下listen这个守护进程监听在哪个socket上Microduck会负责创建并维护这个socket文件的属主和权限。respawn崩溃后是否自动拉起。max_respawn周期性崩溃时最大重启次数防止“疯狂重启死循环”。dependencies启动顺序依赖。比如collector依赖storage编排器会先启动storage再启动collector。注意dependencies只保证启动顺序不保证运行可用。如果storage开始监听慢了collector启动后立即调用可能连接被拒。我的做法是在服务代码里加启动自检确认依赖服务socket可写后再往外发数据。这个在分布式系统里叫“就绪门控”Microduck自己没有内置需要业务侧自己实现。3.3 服务注册与发现约定优于配置Microduck的“服务发现”机制非常简单——就是一系列固定的socket路径。每个服务启动时在指定的socket上监听客户端通过完整路径去连接。没有注册中心没有服务网格这是它轻量级的根本原因。这带来的一个问题就是路径约定必须非常谨慎。一旦某个服务的socket路径写死在多个配置里改起来就要全局搜索替换。我的做法是把所有路径常量收拢到每个服务自己的配置文件中比如collector.yaml里只写storage的路径代码里不硬编码任何路径。Microduck还支持每个服务注册自己的“健康检查”接口。我是这样约定的每个服务都实现health.ping方法返回服务名、版本号、最近活跃时间。编排器在启动完所有服务后会统一对所有socket发健康检查请求把不响应的服务标记为failed状态并触发respawn逻辑。{jsonrpc:2.0,method:health.ping,params:{},id:0}3.4 生成证书与权限管理socket不是保险箱Unix socket虽然不走网络但也不能裸奔。Microduck默认会把socket文件放在编排器进程的umask环境下权限可能是755这意味着同一台机器上的任何用户都能连上来调用你的服务。在多用户服务器上这是个不小的安全隐患。我建议在总配置里全局设置socket的权限位global: socket_dir: /opt/microduck/sockets socket_umask: 027 socket_user: microduck socket_group: microduck这样socket文件的默认权限是750只有microduck这个用户和组内的成员能访问。然后所有业务进程也以microduck身份运行通信链路就限制在了自己的组内。另外一个容易忽略的点不要让socket目录整个挂在/tmp下。/tmp目录通常是1777任何人都能创建文件、清理文件攻击者可以抢先创建同名的socket文件做中间人劫持或者直接删掉合法socket导致服务拒绝。这也是我坚持把socket放到/opt/microduck/sockets的原因目录权限控制在0750只有管理员能进。4. JSON-RPC通信层的实现从协议到工具链4.1 协议规范与请求结构设计JSON-RPC 2.0本身很简洁但真正落到多进程协作中还需要自己设计一套业务级的方法命名和参数约定。Microduck官方示例里只给了一些基础方法我在这里扩展出一套比较通用的模板。每个服务对外暴露的方法按照资源.动作的命名习惯来。比如采集服务方法名参数返回说明data.collect{target:sensor_1}{value:23.5,ts:1698765432}同步采集一个传感器的当前值data.subscribe{target:sensor_1,interval:1000}{stream:sub_1}开启周期上报返回流IDdata.unsubscribe{stream:sub_1}{ok:true}关闭上报流health.ping{}{service:collector,version:1.0.0}用于编排器探活这套命名规则的好处是方法名即文档而且日志过滤时用method字段直接grep就能很快定位到某个业务行为。Microduck的服务端日志格式默认会输出method名和耗时排查问题时非常高效。4.2 服务端接收与派发流程我用Go语言写了其中一个服务做测试。Go标准库没有自带Unix socket的JSON-RPC实现但是官方有个net/rpc/jsonrpc包限制比较大不支持方法名动态派发只适合定死方法签名。所以我直接基于net包自己封装了一层。核心部分是这样的func (s *Server) handleConnection(conn net.Conn) { defer conn.Close() decoder : json.NewDecoder(conn) encoder : json.NewEncoder(conn) for { var req Request if err : decoder.Decode(req); err ! nil { if err ! io.EOF { log.Printf(decode error: %v, err) s.writeError(conn, nil, -32700, parse error) } return } // 每收到一个请求就起一个goroutine处理避免某个慢请求阻塞后面所有请求 s.wg.Add(1) go func() { defer s.wg.Done() s.dispatch(encoder, req) }() } }派发逻辑考虑到了并发。如果采集服务内部实际只有一个数据源多个并发请求可能互相覆盖状态所以我在真正执行采集操作的地方加了一把互斥锁。这个细节如果不处理压测时很容易出现数据错乱。4.3 客户端调用与超时控制Microduck的服务端没有内置超时控制客户端如果不做超时遇到一个卡死服务比如磁盘IO阻塞请求会永远挂在那占着goroutine和连接。我封装客户端调用时强制使用带超时的Dialfunc Call(sockPath, method string, params interface{}) (interface{}, error) { conn, err : net.DialTimeout(unix, sockPath, 2*time.Second) if err ! nil { return nil, err } defer conn.Close() conn.SetDeadline(time.Now().Add(5 * time.Second)) req : Request{ JsonRPC: 2.0, Method: method, Params: params, ID: int64(time.Now().UnixNano()), } if err : json.NewEncoder(conn).Encode(req); err ! nil { return nil, err } var resp Response if err : json.NewDecoder(conn).Decode(resp); err ! nil { return nil, err } if resp.Error ! nil { return nil, errors.New(resp.Error.Message) } return resp.Result, nil }提示Unix socket默认没有读超时设置deadline非常关键否则TCP连接至少还有内核探测Unix socket上的坏连接能坑很久。5. 实战案例监控采集器完整拆解过程5.1 拆分前单体脚本的问题我最初手上是一个完整的大Python脚本每5秒读取一次传感器值把数据往SQLite里写同时起一个Flask端口供内网查询最新指标。图片上看着还可以但实际运行问题很多采集模块是用libusb直接和USB设备通信的偶尔一个中断会导致整个进程卡死几分钟连同HTTP查询接口一起不可用。数据写入失败时会抛异常异常如果没处理好整个循环退出服务就静默下线了。查询和采集两个模块共享一个进程的全局锁查询量大的时候采集间隔被拉长。这些问题本质上是“一个进程干太多事”导致的耦合。5.2 拆分成三个守护进程的代码变更拆成Microduck架构后我做了这些事采集服务Go只负责周期性调用libusb读取数据然后把原始JSON事件推到stdout由编排器重定向到storage.sock。为了让两个服务解耦采集服务并没有直接依赖storage的socket而是通过Microduck的“事件总线”机制把消息发布到总线存储服务订阅对应主题。这比硬编码调用更弹性后续要加新的消费服务就不用改采集逻辑。存储服务Python从总线上拉取事件批量写入SQLite。因为单次写入成本高我做了个简单的批量缓冲每50条或每3秒flush一次大幅降低了写放大。指标服务Node.js既不是生产者也不是消费者而是作为存储服务的查询代理对外暴露HTTP接口给仪表盘拉取。这个拆分带来的代码量对比很明显原始脚本大约1800行拆分后最大的存储服务也只有600行逻辑清晰程度完全不在一个量级。5.3 启动顺序与依赖协调配置好之后首次启动我特意观察了Microduck的编排日志。它按依赖顺序拉起服务先storage再collector最后metrics。没有任何一个服务因为乱序启动而报连接错误。不过有一个小坑storage是用Python的pysqlite3写库的启动时要先打开数据库文件并建表这个初始化过程大约需要0.8秒。在这个窗口内如果collector已经发来写入请求会直接连接失败。解决方式是在collector里加了重试逻辑每秒重试一次直到storage监听返回成功。def wait_for_service(sock_path, method, max_retries15): for i in range(max_retries): try: result call(sock_path, health.ping, {}) if result: return except Exception: pass time.sleep(1) raise RuntimeError(fservice {sock_path} not ready after {max_retries}s)Microduck的编排器本身也有重试机制但业务侧再兜底一层双保险实测下来启动成功率百分之百。6. 常见问题与排错技巧实录6.1 socket文件残留导致的启动失败第一次重启Microduck时我遇到一个很奇怪的现象进程并没有崩但所有客户端连接都被拒绝。排查半天发现是上次正常停止时socket文件没有清理干净旧文件还在但监听它的进程已经没了。新进程起来时尝试重新创建同名socket文件Unix系统不允许覆盖已有socket文件于是启动失败。排查命令很简单ls -l /opt/microduck/sockets/ file /opt/microduck/sockets/collector.sock如果显示是socket类型但对应的进程不存在就是残留。清理办法是删除旧文件再启动microduckctl stop collector rm /opt/microduck/sockets/collector.sock microduckctl start collector为了避免这个问题频繁出现我是用Systemd的ExecStartPre钩子每次启动前自动清理对应socket文件。6.2 并发请求导致的数据竞争有一次压测数据订阅功能结果发现多个订阅者同时请求时返回的数据顺序错了。原因是我在服务端对每个请求起了goroutine但底层读取传感器数据时共享同一个指针多个goroutine往同一个buffer写数据相互覆盖了。这个问题的排查离不开一个技巧在请求体里加一个自增序号压力测试时记录每个序号对应的响应时间戳和数据内容。我把请求与响应序号对不上时的日志打出来很快就定位到了竞争窗口。最终修复就是在数据采集操作外加了互斥锁简单粗暴但有效。同时把采集结果改成值拷贝避免共享底层数组。6.3 JSON-RPC批次请求的坑别盲目开启batchingJSON-RPC 2.0规范支持一次发送多个请求batchMicroduck的服务端默认也支持解析数组格式的请求。刚上手时我以为这是性能优化手段把一批采集指令打包成一个batch发过去结果所有批内请求被串行处理延迟没有下降反而上升了。排查后才发现Microduck的示例服务端对batch的处理是单线程循环派发。如果要真正的并发还是得自己并发发多个请求每个请求一个独立连接。batch适合的是协议层面的批量调用不是并发优化工具。这里的经验是先压测再决定用不用不要被规范的功能迷惑。6.4 方法不存在与服务端内部错误实际对接中典型的报错长这样{jsonrpc:2.0,error:{code:-32601,message:Method not found},id:1}最常见的原因不是方法名拼错了而是服务端还没加载新增的方法。Microduck的守护进程默认是不会热更新的方法改动后必须重启服务配置才生效。有一次我改了Python存储服务的代码忘了重启直接在socat里调新方法折腾了10分钟才反应过来。如果是代码逻辑抛异常JSON-RPC标准错误码是-32603但Microduck的服务端允许自定义错误码。我习惯把业务错误码放在-32000到-32099区间内并带上具体的错误信息字符串这样客户端一行日志就能看到根因{jsonrpc:2.0,error:{code:-32004,message:storage write failed: disk full,data:{path:/opt/microduck/data}},id:2}7. 利用Microduck的能力边界与其他微服务框架的取舍用了一段时间后我也踩过边界。Microduck不是万能的它有几个明确的能力边界不能跨机器部署没有分布式协调能力。所有socket都是本机的。没有服务网格的流量治理能力没有灰度发布、熔断降级这种高级特性。服务规模大了以后配置文件管理会很痛苦缺少自动依赖分析和状态可视化。所以需要跟Kubernetes这一类的框架划清界限。Microduck的定位应该是单机版微服务管理器。它在边缘计算、嵌入式设备、桌面客户端后台这些场景下非常舒服轻量、低依赖、易调试。但如果你要做一个几十个节点的高可用分布式系统那还是老老实实上K8s或者Nomad不要拿Microduck硬扛。我自己选框架的参考逻辑很简单场景推荐方案单机多进程服务编排Microduck需要跨机器部署K8s / Nomad需要一个独立的本地插件系统Microduck需要RPC多语言互通的完整生态gRPC 注册中心需要消息持久化和重放独立消息队列如RabbitMQ等在架构不复杂的时候越简单的工具越可靠。Microduck这套组合的另一个好处是后续如果要迁移到gRPC或HTTP可以保留JSON协议结构把传输层换掉就行改造量集中在网络层而不是业务层。8. 跑通之后的总结与小技巧Microduck这套“守护进程军团”我实际用了一个多月稳定性确实让人放心。三个服务跑了整整三周没有一次意外崩溃其中采集服务被USB设备主动断开触发过一次电量异常但也很快被编排器拉起来接续工作。相比之前单脚本时不时整个挂掉的情况运维省心不是一个量级。最后分享几个我踩过坑之后沉淀的小技巧。第一所有socket路径和依赖关系必须写成配置项不要硬编码在代码里。否则一旦调整目录结构十几个文件名一遍遍替换既容易漏也容易错。第二把每个服务的stdout和stderr分文件保存。Microduck默认会合并输出但业务日志混在一起看的时候非常头疼。做法是给每个服务独立配一个日志文件service: name: storage stdout_log: /opt/microduck/logs/storage.stdout.log stderr_log: /opt/microduck/logs/storage.stderr.log这样排查问题时直接按进程查日志不用在总日志里大海捞针。第三测试自己实现的JSON-RPC服务器时用socat模拟客户端比写测试代码更快。一条命令连上去手敲请求看返回10秒完成一轮验证。socat命令sudo -u microduck socat - UNIX-CLIENT:/opt/microduck/sockets/storage.sock注意要以服务运行用户身份去连socket否则权限可能不够。第四定期清理旧的socket文件。编排器正常stop时会清理但进程被kill -9杀掉时不会。结合Systemd的ExecStartPre钩子可以保证每次重启都是干净环境。第五业务服务的幂等性设计放在第一位。因为编排器可能会在任意时刻重启服务如果你的操作是有副作用的比如“扣库存”或“发送次数”必须确保重复调用不会产生重复效果。Microduck只保证“服务能起来”不保证“业务不重复”这点自己在业务代码里兜住。Microduck的守护进程军团架构确确实实让单机服务拆分解耦变成了一件低成本的事。如果你也面临类似的单体脚本耦合问题不妨按这套思路动手拆一拆。跑通之后回过头看无非就是配置几个服务、约好一套JSON-RPC规则、管理好socket生命周期的那些事但整个系统的健壮度和可维护性是实实在在提升了一个档。