Microduck这个项目,我接触到纯属偶然。当时手头正好要做一个多进程后台系统,需要把采集、解析、上报、告警这些模块拆开,又不希望引入太重的中间件和网络栈。刚开始看到“守护进程军团”这个名字我还笑了一下,后来发现它实际上是把“多个守护进程 + Unix socket + JSON-RPC 2.0”这三样东西组合成了一套非常务实的本地微服务架构。
先说它能解决什么问题。当你的程序需要拆成多个独立模块独立运行、独立崩溃、独立升级,但所有模块其实又都跑在同一台机器上时,通常会面临两个选择:一是用HTTP接口互相调用,简单但开销大;二是用消息队列解耦,可靠但部署复杂。Microduck走的是中间路线——每个功能模块都是一个守护进程,进程间通过本地Unix socket通信,协议用JSON-RPC 2.0。它无端口、无网络栈、无外部依赖,本地调用的性能损耗几乎可以忽略不计,调试又非常直观,适合想做模块化改造、或者正在研究本地IPC与远程调用协议如何结合的开发者参考。
这篇文章我打算从架构选型、协议细节、核心实现、完整实操,再到排障经验,完整讲一遍这套“守护进程军团”是怎么设计和跑起来的。
1. 为什么是“守护进程军团”:架构选型的真实动因
1.1 单体服务拆分的真实痛点
我最初接触的版本是个单体程序,所有逻辑塞在一个进程里:数据采集模块、文本解析模块、数据落库模块、异常告警模块,全部揉在一起。平时不觉得有什么,但线上出问题就很尴尬:解析模块处理了某个畸形输入导致内存异常,整个进程崩溃,采集和告警也跟着一起挂了。想单独升级解析模块,就得停掉整个服务,重新编译、重启。更要命的是,每个模块的生命周期不一样,有的需要常驻监听,有的只需要每天凌晨跑一次,把它们强行放在同一个进程里,调度逻辑会变得越来越绕。
后来我意识到,问题的本质是隔离和控制。进程本身就是操作系统提供的最好的隔离边界:栈、堆、文件描述符、信号处理,全都是进程级的。与其在一个进程里用线程和协程模拟隔离,不如直接把模块拆成独立的进程。Microduck这个设计思路就是在回答一个很朴素的问题:既然都是跑在同一台机器上的小服务,那为什么不让它们各跑各的进程,然后用最轻量的方式互相调用?
“守护进程军团”这个说法其实很形象。每个守护进程是一个独立作战单元,有自己的生命周期、自己的日志、自己的重启策略,它们通过一套约定的协议协作,而不是靠共享内存或者全局变量。这种架构让每个模块的开发思路都非常简单——我只需要关心自己的输入和输出,不需要知道其他模块内部在干什么。
1.2 为什么用守护进程,而不是同一个进程内的worker
可能有人会问,模块化不一定非要拆进程吧,一个进程里搞几个线程、用线程池调度不也行吗?行,但要分场景。Microduck这套架构里,不同的守护进程需要的运行时长、资源配额、失败处理策略完全不同。采集进程可能需要长时间运行,反复连接传感器;解析进程可能消耗大量CPU和内存;上报进程则非常轻量,只需要确保数据能送达。这些进程如果放在同一个进程里,CPU密集型的解析任务就会拖垮IO密集型的采集任务,一个模块的谁内存也会影响全局的稳定性。
拆成独立进程之后,隔离效果立竿见影。解析进程哪怕把内存打满,也可以单独把它杀掉、调参、重启,其他进程完全不受影响。而且守护进程的启动、停止、状态检查都有非常成熟的操作系统机制可以用,比如pid文件、systemd服务单元、supervisor、runit,不需要自己造轮子。对运维来说,systemctl restart microduck-parser这种操作比在一个大进程里找某个模块的重启入口要直观太多。
还有一个容易被忽略的点:权限隔离。Microduck里有些进程需要访问设备节点,有些进程需要读写数据库,有些进程只需要读配置文件。如果所有逻辑都在一个进程里,权限就是取最大集,任何一个模块被攻破都等同于整个程序被攻破。拆成独立的守护进程之后,可以给每个服务单独建系统用户,设置独立目录权限,把安全风险控制在最小范围内。
1.3 为什么偏偏选Unix socket + JSON-RPC,而不是HTTP或gRPC
这个选择值得说一下。本地进程间通信的手段不少:共享内存、管道、Unix socket、TCP loopback、DBus,等等。共享内存性能最好但同步复杂,管道只能做简单流向通信,DBus本身是另一个复杂的协议体系。Microduck选Unix socket + JSON-RPC,核心原因就是“够用且好用”。
Unix socket相比TCP loopback有几点天然优势:第一,它不占用网络端口,不会碰到端口冲突、防火墙拦截、网络栈性能损耗这些问题;第二,它的通信范围被限制在本机文件系统内,外部机器想连也连不上,安全性更好;第三,它可以通过文件系统权限来控制访问,把socket文件的权限设成750,就只有特定用户组的进程能调用;第四,读写Unix socket不走完整的网络协议栈,延迟更低、吞吐更稳,对本地高频调度非常合适。
协议层面,用JSON-RPC 2.0而不是gRPC,也是刻意做的选择。gRPC功能强大,但需要管理.proto文件、生成代码、处理HTTP/2帧,整个链路重很多。对于Microduck这种所有服务都在一台机器上的场景,用JSON-RPC 2.0反而更舒服:协议规范少,读一遍RFC就能掌握,消息就是一段带method和params的JSON文本,没有复杂的序列化规则,出问题时抓一段日志直接就能看懂。
提示:如果你想快速验证某个Unix socket服务是否正常,不需要写客户端代码,直接用
curl --unix-socket /path/to/socket配合-d参数就能发JSON-RPC请求。这一步带来的调试便利性,是选择JSON-RPC最超值的地方。
2. 通信协议设计:Unix socket上的JSON-RPC细节
2.1 JSON-RPC 2.0 消息模型:请求、响应、通知与错误码
JSON-RPC 2.0的完整规范其实很短,核心就是请求对象和响应对象。请求方发一个JSON对象,里面必须有四个字段:jsonrpc固定是"2.0",method是被调用方法名,params是参数(可以是数组也可以是对象),id用于将请求和响应对应起来。服务端处理完之后返回一个对象,包含jsonrpc、result或error、以及和请求一致的id。
一个最简单的请求长这样:
{ "jsonrpc": "2.0", "method": "system.health", "params": {}, "id": 1 }对应的成功响应:
{ "jsonrpc": "2.0", "result": { "status": "ok", "uptime": 3600 }, "id": 1 }如果出错,响应里的result要替换成error对象。error必须带code、message和可选的data字段。JSON-RPC2.0规范里预定义了几个错误码:-32700是解析错误,也就是收到的JSON文本本身不合法;-32600是无效请求;-32601是方法不存在;-32602是参数无效;-32603是内部错误。Microduck在预定义错误码之上,把自定义的业务错误码从-32000到-32099这段保留区间分配给各个具体模块,比如写入失败、上游超时、数据格式异常等。
Microduck利用通知机制来做“发完即走”的调用。通知格式和普通请求几乎一样,只是没有id字段,服务端收到通知后不返回任何响应。一些非关键的事件上报,比如“采集循环开始”“配置已重载”,完全可以用通知来发,省去等待响应的开销。不过要注意,通知没有响应就意味着调用方无法知道对端是否真的处理成功,所以只适合不关心结果或者结果可以异步确认的场景。
2.2 Unix socket的地址、传输语义与权限模型
Unix socket的地址不是一个IP加端口,而是一个文件系统路径。服务进程在启动时bind到一个路径上,比如/var/run/microduck/collector.sock,客户端进程通过connect这个路径来建立连接。这条路径有长度限制,Linux上sun_path通常最多108字节,所以路径不能取得太深长,否则会报unix(7) related address error。
socket文件的权限非常关键,因为任何能读写这个文件的进程都能发起调用。我的习惯是在/var/run/microduck目录下创建所有socket文件,目录权限设为750,属主是microduck用户,属组是microduck组。只有受信任的进程在运行时会切换到microduck用户或加入这个组,从而保证只有它们能访问socket。这种权限模型是TCP端口完全没有的,也是本地服务能做到的最直观的访问控制方案。
CC++编程里创建Unix socket的代码我写一下,大家能直观看到这个过程:
struct sockaddr_un addr; int fd = socket(AF_UNIX, SOCK_STREAM, 0); addr.sun_family = AF_UNIX; strncpy(addr.sun_path, "/var/run/microduck/collector.sock", sizeof(addr.sun_path) - 1); unlink(addr.sun_path); bind(fd, (struct sockaddr *)&addr, sizeof(addr)); listen(fd, 32);注意这里有个细节:bind之前必须先unlink一次旧文件。因为如果上次进程异常退出,socket文件会残留在文件系统里,而有效socket的判定服务端是重启了还是没重启,光看文件是否存在是看不出来的,必须先把它删掉,才能确保后续的bind不会失败。
2.3 消息边界:读一半和粘包是怎么解决的
很多第一次接触Unix socket的开发者会踩同一个坑:直接模仿HTTP里“请求-响应”的思路,写一个recv收一次数据,但实际上TCP流协议根本没有消息边界,UDP才有。Unix socket也分两种,SOCK_DGRAM数据报模式是保留消息边界的,但数据报有长度上限,且不可靠,而SOCK_STREAM流模式就像字节流一样,一条消息可能分多次收到,也可能多条消息一次全到。microduck选的是SOCK_STREAM,因为要复用成熟的select-poll-event loop模型,也需要长连接,所以必须自己解决边界问题。
Microduck的解法很朴素:每条JSON-RPC消息用换行符\n作为分隔符。发送时在消息体后面追加一个\n,接收方维护一个缓冲区,读到换行符就认为一条完整消息到达。这种方式实现成本极低,而且天然支持逐行调试,用日志工具抓数据流时一眼就能看出消息内容。唯一的限制是消息体内部不能包含裸换行符,这对JSON-RPC完全不是问题,因为JSON字符串里的控制字符都会被转义。
为了防止某个进程恶意或异常地发送超大消息把接收方的内存吃满,Microduck在接收逻辑里设置了一个硬限制:单条消息最大不超过1MB,超过这个长度直接断开连接并记录告警日志。1MB对于JSON-RPC调用几乎永远够用,但足够拦截绝大多数异常场景。缓冲区则是动态扩容加最大上限的组合:
ssize_t n = read(fd, buf + used, buf_size - used - 1); // 检查 n > 0 used += n; char *pos; while ((pos = memchr(buf, '\n', used)) != NULL) { *pos = '\0'; handle_message(buf, pos - buf); memmove(buf, pos + 1, used - (pos - buf) - 1); used -= (pos - buf) + 1; }这块逻辑是socket编程里最容易出错的地方之一,凡是出现“服务端偶尔收到空数据”“多条消息挤在一起解析失败”的,基本都是边界处理没做好。
3. 核心实现:进程管理、socket生命周期与请求分发
3.1 socket文件的创建与清理:防止残留文件引发的诡异问题
Unix socket文件的生命周期管理是整个Microduck体系里最常见的故障源之一。正常退出时,服务进程捕获退出信号,在退出回调里unlink自己的socket文件,这是大多数人会写到的。但线上系统最怕的是异常退出:进程被SIGKILL杀死、机器突然断电、或者进程崩溃来不及清理,socket文件就留在磁盘上了。
残留文件带来的问题是:下次服务启动时,bind会失败,提示“Address already in use”。有些开发者遇到这个问题会怀疑是端口被占,但Unix socket不占用端口,其实只是旧文件的残留。Microduck的解法是:服务启动时,先尝试connect一下这个socket路径,如果connect失败,说明旧的socket已经没人监听了,就大胆unlink后重新创建;如果connect成功,说明有一个活着的实例在运行,直接报错退出,避免启动两个相同服务互相干扰。这段逻辑配合pid文件的检查效果更好,我会同时在/var/run/microduck/下维护一个pid文件,启动时先读pid文件判断进程是否存在,双保险。
3.2 请求分发、超时与错误处理的统一约定
每个守护进程内部都保留一个轻量的路由表,把JSON-RPC里的method字符串映射到本地函数指针。比如,采集守护进程注册了collector.start、collector.stop、collector.status三个方法,上报守护进程注册了reporter.push、reporter.flush、reporter.pending_count。在框架层,收到一条JSON-RPC请求后,先解析JSON,再查路由表,找到就调用,找不到就返回-32601错误。这个路由结构其实就是一个哈希表,却提供了完整的微服务边界。
超时处理要分两层看。客户端在发送请求时可以设置socket的接收超时,比如5秒内拿不到响应就返回错误。但更关键的是服务端也要有运行超时。Microduck的做法是:每个方法在注册时可以声明自己的超时阈值,服务端在独立的工作线程里执行具体方法,主线程用poll同时监控所有健壮的接口和超时定时器。一旦某个方法执行时间超过声明阈值,这次调用的响应就直接返回“timeout”,执行线程本身不会被强杀,而是被标记为“孤立执行”,它最终跑完的结果会直接丢弃。这种方式可以避免为了止损杀掉线程而破坏共享数据结构的风险。
错误处理也有统一约定。Microduck约定所有方法在内部只能返回“成功data”或“业务错误”,框架层负责把业务错误转换成JSON-RPC的error对象。方法内部不允许自行捕获异常后返回非JSON对象,因为这样会把格式搞乱。这个约定让整个架构的调用方只需要面对两类结果:标准的成功响应或标准的错误响应,不会有第三种情况。
3.3 多服务进程之间的依赖关系与启动顺序
“守护进程军团”里,各个服务并不是完全对等的。有些服务是纯上游,有些是纯下游,还有的既接收请求又向其他服务发请求。比如采集服务从传感器读数据,然后把原始数据发给解析服务,解析服务再把结构化结果发给上报服务。这就形成了调用链:上报服务启动后,要确保解析服务的socket文件已经存在并且可以连通;解析服务启动后,又要确保采集服务已经注册好。
Microduck对启动顺序的处理不是靠脚本里的“先sleep 5秒再启动下一个”这种笨办法,而是在服务启动完成后,会对依赖的下游服务做一次健康检查调用,比如调reporter.ping,如果返回成功再宣告自己启动完成。上层编排脚本只需要按依赖顺序依次启动,并在每个服务启动日志里等待“ready”标记出现。这种健康检查机制既解决了启动顺序问题,也为后续做自动拉起和故障转移打下了基础。
4. 实操记录:从零跑通Microduck守护进程军团的完整流程
4.1 编译与基础进程启动
整个项目在GitHub上有仓库,克隆下来之后就是标准的构建流程。如果跑在普通x86服务器上,直接执行make就能编译出所有守护进程的二进制文件。如果要在ED-330这类嵌入式盒子上跑,则需要交叉编译,或者直接在盒子上装编译工具链再源码编译,我实测下来只要依赖库齐全,过程基本一样。
编译完成之后,目录下会出现一套可执行文件,以及一个名为microduck.conf的配置文件。配置文件的要修改的内容不多,主要是各个socket文件的存放路径、日志路径、以及各服务的开关。我第一次配置时用的是默认路径/var/run/microduck/,需要确保目录存在并且属主正确:
sudo mkdir -p /var/run/microduck sudo chown microduck:microduck /var/run/microduck sudo chmod 750 /var/run/microduck然后启动核心服务。Microduck的启动顺序通常是:先启动依赖最底层的服务,也就是其他服务都要调用的基础服务,然后依次启动上层服务。假设整套军团包含四个守护进程:md-core、md-collector、md-parser、md-reporter,启动命令如下:
sudo -u microduck /usr/local/bin/md-core -c /etc/microduck/md-core.conf & sudo -u microduck /usr/local/bin/md-collector -c /etc/microduck/md-collector.conf & sudo -u microduck /usr/local/bin/md-parser -c /etc/microduck/md-parser.conf & sudo -u microduck /usr/local/bin/md-reporter -c /etc/microduck/md-reporter.conf &每个进程启动后都会生成一个以服务名命名的socket文件。启动完可以确认一下socket文件是否都已就绪:
ls -l /var/run/microduck/*.sock正常情况下,collector.sock、parser.sock、reporter.sock、core.sock这几个文件都会出现。这不代表服务已经可以对外服务了,还需做一次健康检查。
4.2 验证服务状态与JSON-RPC调用是否正常
JSON-RPC的一个巨大好处是调试时完全不需要写专用客户端,curl就够了。curl从7.40版本开始支持--unix-socket参数,把HTTP over Unix socket这个能力直接提供出来,我们在Microduck里复用它来发JSON-RPC请求。比如检查md-core服务的状态:
curl -s --unix-socket /var/run/microduck/core.sock \ -H "Content-Type: application/json" \ -d '{"jsonrpc":"2.0","method":"system.health","params":{},"id":1}' \ http://localhost/这里HTTP方法随便写,因为我们实际使用的只有body里的JSON-RPC内容。返回结果应该类似:
{ "jsonrpc": "2.0", "result": { "status": "ok", "uptime": 30, "version": "0.4.2" }, "id": 1 }如果返回的是error,比如-32601表示方法未注册,再加一个data字段说明具体原因是“method not found”,这就说明路由表没问题但服务端没有暴露这个方法。顺手把collector、parser、reporter三个服务的健康检查也做了,确认四个服务全部在线。
健康检查通过之后,可以模拟一条完整的数据流转。比如手动向md-collector发一个采集指令:
curl -s --unix-socket /var/run/microduck/collector.sock \ -d '{"jsonrpc":"2.0","method":"collector.trigger","params":{"source":"sensor01"},"id":2}' \ http://localhost/如果采集成功,它会通过内部客户端往parser.sock发转换请求,转换完成后parser再调用reporter上报。最终从reporter查询数据是否成功落库,就能验证整条链路是否跑通。这个过程里我只关心最初发出去的请求和最终查询的结果,中间的跳转全部由架构内部处理,这正是微服务化的价值所在。
4.3 守护进程的常规守护化:fork、setsid、umask与pid文件
上面用&启动进程只是简单演示。真正作为“守护进程军团”运行,每个服务还必须完成常规的守护化流程。手动daemon化一般分几步:第一次fork让进程脱离控制终端,调用setsid创建新会话,第二次fork确保不会再获取到控制终端,然后chdir到固定的工作目录,umask设置成022,最后把stdin、stdout、stderr三个标准描述符全部重定向到日志文件或/dev/null。
Microduck里的-d参数就是把这段逻辑封装起来。我给一个直接用C语言实现daemon化的参考片段:
static void daemonize(const char *pidfile) { pid_t pid = fork(); if (pid < 0) exit(EXIT_FAILURE); if (pid > 0) exit(EXIT_SUCCESS); // 父进程退出 if (setsid() < 0) exit(EXIT_FAILURE); umask(022); int nullfd = open("/dev/null", O_RDWR); dup2(nullfd, STDIN_FILENO); dup2(nullfd, STDOUT_FILENO); dup2(nullfd, STDERR_FILENO); close(nullfd); // 写pid文件,便于 systemd 或外部脚本管理 int fd = open(pidfile, O_WRONLY | O_CREAT | O_TRUNC, 0644); dprintf(fd, "%d\n", getpid()); close(fd); }这段逻辑看起来简单,但有不少细节值得注意。第一次fork后,父进程直接退出,让子进程由init进程收养,避免僵尸。setsid是让进程成为新会话的领导,彻底断开与终端的联系。umask设置成022是为了让后续创建的文件默认用户可读写、组可读、其他用户可读,避免出现权限过宽的问题。pid文件建议用O_TRUNC截断重写,确保不残留旧pid。
4.4 用systemd托管守护进程,实现崩溃自动拉起
虽然手动daemonize可行,但生产环境我更推荐用systemd管理这些守护进程。systemd天然支持进程超时退出自动重启、开机自启、标准输出重定向到journal,还能精细控制服务之间的依赖顺序。Microduck的四个服务可以做成四个unit文件,以md-parser.service为例:
[Unit] Description=Microduck Parser Daemon Requires=md-core.service After=md-core.service [Service] User=microduck Group=microduck ExecStart=/usr/local/bin/md-parser -c /etc/microduck/md-parser.conf Restart=on-failure RestartSec=3 PIDFile=/var/run/microduck/parser.pid [Install] WantedBy=multi-user.target注意Requires=md-core.service和After=md-core.service共同保证了依赖顺序:只有md-core启动成功后,md-parser才会被拉起。Restart=on-failure配合RestartSec,可以让进程在崩溃退出后自动重启。这种方式比脚本中的while true循环要优雅可靠得多,systemd还会负责跟踪主进程pid,不需要再额外写pid管理代码。
提示:如果服务里用了
fork做守护化,那systemd里必须设置Type=forking并指定PIDFile,否则systemd会因为无法得知子进程的存活状态而误判服务启动失败。更好的做法是在systemd单元里直接用Type=simple,然后让服务进程不要daemonize,把前台运行交给systemd去管理。Microduck的二进制在检测到环境变量MICRODUCK_FOREGROUND=1时,会跳过daemonize逻辑,直接前台运行。
4.5 网络模拟:启动顺序异常与依赖不可用时的表现
实际操作中最容易遇到的问题是启动顺序乱掉。如果没配依赖关系,直接在md-core还没起来时启动md-parser,md-parser启动时会尝试连接core.sock,connect失败后不会死等,而是会进入重试循环,并输出类似“waiting for core service on /var/run/microduck/core.sock, retry in 2s”的日志。等到md-core起来之后,md-parser会在下一次重试时成功连接,然后初始化内部路由,宣告启动完成。
这个设计避免了一种尴尬场景:编排脚本必须保证绝对的启动顺序,否则所有服务全部启动失败。允许依赖不可用但自动重试的方式,让整套系统具备了一定的容错能力,即使某个依赖服务晚了几秒启动,整个军团也不会直接退化成不可用状态。
5. 常见问题与排查实录
5.1 socket创建失败、连接拒绝与权限不足
我先整理一张速查表,方便大家遇到问题时候对号入座。
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
bind失败,提示Address already in use | 上次进程异常退出,socket残留 | 启动时先执行unlink或rm -f /var/run/microduck/xxx.sock |
connect失败,提示No such file or directory | socket路径不对,或服务未启动 | 用ls -l /var/run/microduck/确认文件存在,检查配置文件路径 |
| connect成功但发送后无响应 | 服务进程阻塞或已僵死 | 查看进程状态,strace -p <pid>跟踪系统调用 |
| 调用报权限错误 | 当前用户没有socket文件访问权限 | 检查socket和目录权限,将调用方加入对应用户组 |
| 路径过长导致创建失败 | Linux的sun_path最大108字节 | 收缩socket目录层级,比如/run/md/代替/var/run/microduck/ |
第一类问题最常见。我自己的机器上出现过一次很诡异的现象:进程明明已经杀了,但新进程就是bind不上,后来发现是一个旧进程变成了D状态(不可中断睡眠),没有真正退出,所以旧socket文件一直被占用。这种情况unlink其实是无效的,需要先确认旧进程确实不存在,再处理文件。
5.2 JSON解析失败和方法不存在
JSON-RPC调用里,格式错误类问题占到了日常故障的一半。常见的有:把jsonrpc写成jsonRpc导致服务端判定为无效请求;请求里少了id字段但调用方期望响应,接收方则把它当成通知处理;还有在JSON里用了单引号或者末尾多逗号,服务端的JSON解析器直接返回-32700。
方法不存在的-32601错误虽然直白,但有时也有误导性。有一次我排查了半天,最后发现请求发到了collector.sock,但collector.trigger方法实际注册在md-core服务上。这个问题本质上是对服务职责边界不清楚。Microduck在实践里会很重视一个原则:调用方代码里显式写明“我要调哪个服务的哪个方法”,不要在调用代码里含糊地只传一个方法名,这样即使发错也可以顺着日志快速定位。
5.3 缓冲区溢出、消息截断与SIGPIPE
字节流的消息边界问题前面讲过了,这里讲一个容易被忽略的陷阱:发送端一次send调用发送了很多字节,接收端缓冲区不够,读到一半消息解析失败,然后连接被服务端关闭。这样的问题表现出来就是“有时候调用成功,有时候失败,失败的时候日志里出现partial message”。
解法其实已经说过,用换行符加动态缓冲区。Microduck在接收端维护了一个可增长缓冲区,初始大小4KB,每次解析失败时不会立即断连,而是先检查是否有可能的换行符被截断了,如果缓冲区填满了且仍然没有换行符,才会主动断开并记录“message too long”。
另一个经典坑是SIGPIPE。客户端在连接关闭后如果继续写socket,进程会收到SIGPIPE信号,默认行为是终止进程。在守护进程场景下,一旦某个客户端异常断开,服务端或中间转发进程就可能因为这个信号挂掉。处理方式是在程序初始化时忽略SIGPIPE:
signal(SIGPIPE, SIG_IGN);忽略之后,写socket返回EPIPE错误,代码里检查返回值并做清理,进程就不会再因为客户端断开而意外退出。这条经验我在多进程项目里反复踩过,每次新成员加入团队,第一周总会有人被SIGPIPE坑一次。
5.4 性能与监控:查看socket连接和进程状态
本地Unix socket的性能远好于TCP loopback,但也不是说什么场景都能随便造。Microduck在生产环境里常驻200个连接,每秒处理大概400到600个JSON-RPC请求,CPU占用在单个核的15%以内。数据量再往上走,就要关注消息积压和背压了。
用几个命令可以实时监控服务状态。ss -x可以查看Unix socket连接:
ss -x | grep microduck能看到每个socket文件下的连接数,如果某个socket的连接数堆积明显,说明这个服务的处理速度跟不上请求速度。pidstat -p <pid> 1可以看进程CPU和内存变化,lsof -p <pid> | wc -l统计文件描述符数量。日志方面,Microduck每条JSON-RPC请求都会按级别打印method、调用耗时和返回码,配合grep就能做很粗粒度的链路追踪。
如果要做更细致的监控,可以在框架层给每个服务加一个system.stats方法,返回当前打开连接数、消息处理速率、平均耗时和错误计数。这些数据不需要额外的监控系统,直接在业务侧轮询即可。我还在Microduck里加过一个简单的“自愈”逻辑:某个服务自己发现错误率超过阈值,会自动向管理服务发一个通知请求,管理服务收到后把对应的下游重启一遍。这套机制虽然粗暴,但在无人值守的嵌入式环境里非常有效。
结尾
做完Microduck这套“守护进程军团”之后,我最大的体感是:本地微服务的最优解往往不是最时髦的方案,而是最合适的那一个。Unix socket和JSON-RPC都是非常“老派”的技术,组合起来却意外地顺手,不需要引入庞大的框架,不需要处理复杂的网络配置,一行curl就能完成大部分的调试,这种访问效率提升带来的开发体验是实实在在的。
最后分享一个小技巧:给每个公开方法都加上版本号前缀,比如v1.collector.trigger,而不是直接叫collector.trigger。这样升级接口时,旧版本方法可以和新版本方法并存一段时间,调用方渐进式迁移,避免“发布当天所有服务全部报错”的惨剧。这套架构后续如果继续演进,我大概率会先做服务间的认证机制和更细粒度的链路日志,这两件事能让“军团”真正在复杂环境里站得更稳。