守护进程军团:基于Unix socket与JSON-RPC的轻量微服务架构实践
发布时间:2026/9/7 12:40:39来源:尧图网络
Microduck 这个名字是我们团队在 GitHub 上起的一个内部项目代号。它对外不是什么大厂框架而是跑在单机小集群上的一套轻量服务架构。这套架构的核心是一群常驻后台的守护进程——我们内部习惯叫它们“守护进程军团”——进程之间通过 Unix socket 通信上层协议走的是 JSON-RPC整体上就是一种很朴素的微服务架构。这篇文章想聊的不是高深理论而是我从零把 Microduck 这套架构跑通的全过程。如果你正面临类似的处境几个职责不同的程序必须同时挂在一台机器上各自维护自己的状态互相之间又需要频繁交换数据你不想一上来就上 Kubernetes、Docker Compose 全家桶你希望每个模块可以独立升级、独立重启、独立崩溃——那这篇文章应该能帮你省下不少试错时间。我会把模块拆分、守护进程化、IPC 通信协议、权限管理、连接管理、故障排查这几个环节逐一拆开讲每一步都附上能直接抄的代码和踩坑经验。适合正在做边缘设备、本地工具链、模型训练任务调度的开发者也适合单纯想搞懂“多进程之间怎么优雅通信”的同学。1. 先拆问题什么场景逼出了这套“守护进程军团”1.1 Microduck 跑起来之后我才发现服务间通信没那么简单Microduck 要解决的业务是一整套“数据采集 模型训练 结果上报”的流水线。项目刚开始的那会儿我们图省事把数据采集器、任务调度器、训练 Worker、结果上报器全塞进了同一个进程用线程去并行调度。当时想着“反正就一台机器进程内线程通信多简单共享变量一把锁就完事了”。结果跑到后期问题一个接一个冒出来。第一个问题某个模块依赖的第三方库一崩溃整个进程跟着陪葬。采集器在半夜 OOM 了训练任务全部中断结果上报也没了第二天早上才发现整条链路都停了。第二个问题想升级其中一个功能模块必须重启整个进程所有任务统统打断。第三个问题更头疼——多个线程共享全局状态竞态条件排查起来就像大海捞针你在日志里看到一个诡异的状态根本不知道是哪个线程什么时候改的。后来痛下决心把每个功能模块拆成独立守护进程。采集器一个进程调度中枢一个进程训练 Worker 可以跑多个实例上报器一个进程互相之间通过 IPC 沟通。这就是“守护进程军团”的由来每个成员独立运行、独立崩溃、独立重启调度中枢负责协调节奏。虽然拆完之后要写不少通信代码但换来的却是每个模块的独立性和可维护性这笔账怎么算都值。1.2 为什么偏偏是 Unix socket JSON-RPC而不是 REST 或 gRPC确定拆成多进程之后第一个要拍板的问题就是进程之间到底用什么通信。我们当时对比了好几个方案列表如下。方案优势在我们场景里的劣势HTTP REST通用、生态好、易调试走 TCP 协议栈多一次握手方法定义太重还得带一堆 HTTP 头gRPC跨语言、性能好、支持流式需要编译生成 stub构建链路重调试时看二进制抓包很不直观消息队列解耦彻底、天然削峰多一个中间件要维护我们只是进程间方法调用不需要存储Unix socket JSON-RPC极轻、极快、直接、跨语言只适用于本机通信出不了机器先说说为什么放弃 HTTP REST。REST 设计的核心是“资源”所以你得把每个操作映射成 GET/POST/PUT/DELETE还要设计 URL 层级、状态码语义。但我们这种进程间调用本质上是“让对面帮我执行一个动作”不是“操作一个资源”。硬套 REST 反而别扭。打个比方你在办公室里喊一声“帮我把那份文件拿过来”和你在工位上填一张复杂的取件单前者明显更直接。JSON-RPC 就是这样直接——它就是“调一个函数传参数拿结果”。gRPC 我们也考虑过毕竟跨语言能力很强。但它的依赖链条太长要装 protoc、要定义 proto 文件、要生成各种语言的 stub 代码。我们那时候有两个守护进程还是用 Python 写的快速原型C 写的采集器Go 写的小工具每次都生成一套 stub构建过程直接翻倍。而且 gRPC 默认走 HTTP/2二进制帧调试起来非常不友好。我承认 gRPC 是好东西但对我们这种一台机器上几个小进程来说属于“杀鸡用了牛刀”。再来说传输层为什么选 Unix socket。Unix domain socketUDS最大的特点是它不经过网络协议栈不需要环回网卡不需要三次握手、四次挥手数据直接在内核里走文件系统路径完成传递。这意味着同一台机器上两个进程通信时UDS 的延迟和 CPU 开销比 TCP loopback 低一个量级。你在本机做性能压测就能看到明显差异UDS 可以轻松做到几十万 QPSTCP loopback 往往卡在协议栈处理上。UDS 还有个安全好处——它没有端口号外部网络根本扫不到。服务监听在/var/run/microduck/xxx.sock这种文件路径上你可以用文件权限精确控制谁能连、谁不能连这比防火墙规则要直接得多。所以最终定下来的组合就是传输层用 Unix socket协议层用 JSON-RPC简单、快、安全、跨语言。2. 守护进程的正确姿势从手动 daemonize 到 systemd 托管2.1 守护进程是什么为什么我们的服务必须守护化守护进程daemon就是脱离终端、在后台长期运行的进程。普通前台进程的宿命是“跟着终端走”——你 SSH 登录上去跑一个程序终端一关、网络一断它就被 SIGHUP 挂断了。Microduck 的服务必须 24 小时常驻不能依赖某个终端窗口还要能开机自启、崩溃自动拉起。所以每个服务都必须是一个真正的守护进程。我一直喜欢把守护进程比作“军团的士兵”平时安静地待在营地里后台运行不听终端指挥只听调度中枢manager的信号信号来了就行动没信号就待命。如果它自己挂了外部监管系统要把它的“番号”重新拉起来。这也是后面我们用 systemd 托管所有守护进程的核心理由。2.2 手动 daemonize五个关键动作一个都不能少系统提供的daemon()函数不是所有平台都有而且可控性不强。我是更倾向于自己动手把流程写明白。标准的 daemonize 需要做五件事fork、setsid、二次 fork、重置 umask 和 chdir、重定向标准 I/O。下面这段 Python 代码是我在实际项目里用的模板直接用os模块实现。import os import sys def daemonize(log_file/var/log/microduck/daemon.log): # 第一次 fork让父进程退出 if os.fork() 0: os._exit(0) # 创建新会话彻底脱离控制终端 os.setsid() # 第二次 fork确保进程不会重新获取控制终端 if os.fork() 0: os._exit(0) # 重置文件权限掩码避免继承的 umask 影响后续创建文件 os.umask(0) # 切换工作目录到根目录避免占用某个挂载点 os.chdir(/) # 重定向标准输入/输出/错误 sys.stdout.flush() sys.stderr.flush() stdin_fd os.open(/dev/null, os.O_RDONLY) os.dup2(stdin_fd, sys.stdin.fileno()) stdout_fd os.open(log_file, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o644) os.dup2(stdout_fd, sys.stdout.fileno()) os.dup2(stdout_fd, sys.stderr.fileno())解释一下这里面的几个关键设计。第一次 fork 的目的是让父进程退出这样子进程就不会是进程组组长为后续 setsid 创造条件。setsid()会让当前进程成为新会话的会话首进程彻底脱离原来的控制终端。但会话首进程如果之后打开了一个终端设备理论上还有可能重新获取控制终端所以再来一次 fork让最终运行的进程不再是会话首进程从根上杜绝这个隐患。umask(0)很多人会忽略。如果 daemon 继承的 umask 是 022那之后你创建 socket 文件、PID 文件时权限会被强行减掉组写权限和其他写权限有时候会出现“明明代码里写了 0660结果创建出来是 0640”这种怪问题。先把 umask 清零后面再精确控制文件权限。chdir(/)则是为了防止进程占用某个工作目录导致那个目录所在的文件系统无法卸载。真正写守护进程时还要注意一点fork之后父进程退出子进程会变成孤儿进程由 init/systemd 收养。如果你在 Python 里用了多线程os.fork()之后一定要谨慎子进程里尽量不要调用可能依赖父进程锁状态的库。所以更稳妥的做法是先在主线程完成所有初始化再执行 daemonize。2.3 PID 文件与优雅退出别让军团突然“失联”守护化只是第一步。要让外部工具能够查找并管理这个进程必须写 PID 文件。Microduck 每个守护进程都维护自己的 PID 文件路径统一放在/var/run/microduck/下。写入代码很简单但有一个经验写入之前先os.makedirs(os.path.dirname(path), exist_okTrue)避免目录不存在时报错。def write_pidfile(path/var/run/microduck/scheduler.pid): os.makedirs(os.path.dirname(path), exist_okTrue) with open(path, w) as f: f.write(str(os.getpid()))比 PID 文件更重要的是优雅退出。守护进程收到 SIGTERM 或 SIGINT 时不应该直接os._exit(0)拍屁股走人这样会留下很多烂摊子正在处理的请求被截断、临时文件没清理、socket 文件残留。我们定义的优雅退出流程是停止接收新任务 → 等待已在途的请求处理完成设一个最长等待时间比如 30 秒→ 关闭监听 socket 和连接 → 删除 PID 文件和 socket 文件 → 最后才退出。import signal import sys running True def handle_exit(signum, frame): global running running False # 通知业务逻辑停止接收新任务 server.shutdown() # 清理 socket 文件、PID 文件 cleanup() sys.exit(0) signal.signal(signal.SIGTERM, handle_exit) signal.signal(signal.SIGINT, handle_exit)优雅退出这套机制在“军团”模式里极其重要。如果调度中枢正在给训练 Worker 下发任务你突然把它杀掉任务状态就丢了。有了优雅退出调度中枢会先把当前的任务状态持久化再关停自己训练 Worker 虽然暂时联系不上调度中枢但它手里的任务还能继续跑完之后上报结果时会重新连上。2.4 systemd 托管这才是“军团稳定运行”的关键手工 daemonize 有一个很明显的问题进程如果莫名其妙挂了谁来把它拉起来总不能每天靠人肉看着。Microduck 后来全面转向 systemd 托管每个守护进程一个 unit 文件。systemd 提供了进程生命周期管理、崩溃自动重启、日志统一采集journalctl这些能力比自己写 watchdog 脚本要靠谱得多。下面这个 service 文件是调度中枢的配置[Unit] DescriptionMicroduck Scheduler Daemon Afternetwork.target [Service] Typesimple Usermicroduck Groupmicroduck RuntimeDirectorymicroduck RuntimeDirectoryMode0750 ExecStart/usr/local/bin/microduck-scheduler --config /etc/microduck/scheduler.yaml Restartalways RestartSec5 NoNewPrivilegestrue PrivateTmptrue [Install] WantedBymulti-user.target几个关键配置项值得多说一句。Typesimple表示 systemd 认为 ExecStart 启动的进程本身就是主进程不需要再 fork所以你不用在代码里 daemonizesystemd 会自行把进程放到后台管理。RuntimeDirectorymicroduck会在/run下创建一个目录服务停止时自动清理这个目录非常适合放 UDS socket 文件。Restartalways配合RestartSec5让崩溃的进程在 5 秒后自动拉起来不至于疯狂重启。NoNewPrivileges和PrivateTmp是简单的安全加固建议保留。进程从手工 daemonize 迁移到 systemd 后一个额外收获就是日志统一了。以前每个 daemon 自己写文件排错时要挨个目录翻现在journalctl -u microduck-scheduler -f直接看实时输出配上-x还能看到附带的结构化字段排查效率高了几倍。3. 通信层实现怎么把 JSON-RPC 跑在 Unix socket 上3.1 JSON-RPC 2.0 协议骨架请求、响应、通知和错误码JSON-RPC 2.0 是一个非常精简的远程调用协议核心就几个 JSON 对象。一个典型的请求长这样{jsonrpc: 2.0, method: trainer.start, params: {epochs: 10}, id: 1}服务端处理完成后的响应{jsonrpc: 2.0, result: {job_id: abc123}, id: 1}如果出错了响应里的result换成error{jsonrpc: 2.0, error: {code: -32601, message: method not found}, id: 1}还有一个特殊类型叫通知notification请求里不带id服务端处理完后不回任何包。这个用于主动心跳、日志上报这类“我说完就行了你不用回我”的场景非常合适。协议里有两个容易踩的细节。一个是id必须唯一客户端靠它匹配请求和响应。如果同一个客户端发起两个并发请求A 请求的回包乱序到达只有靠id才能分辨哪个对应哪个。另一个是params可以是数组也可以是对象但建议统一用对象这样扩展字段时不用改调用顺序。Microduck 里每个守护进程暴露的方法我们都按“模块.动作”的语义命名比如collector.pause、scheduler.submit、trainer.report。好处是看日志时一眼就能分辨调用方向而且在之后做权限控制时可以直接按方法名前缀粒度去判定。3.2 服务端实现用 Python 写一个 Unix Stream Server核心服务端我用了 Python 标准库的socketserver来做代码量不大但能覆盖大部分场景。下面是一个完整的可运行骨架包含粘包处理、JSON 解析、方法分发、权限设置。import json import os import socketserver SOCKET_PATH /var/run/microduck/scheduler.sock class RPCDispatcher: def __init__(self): self.methods {} def register(self, name, func): self.methods[name] func def dispatch(self, raw_line): try: request json.loads(raw_line) except json.JSONDecodeError: return json.dumps({ jsonrpc: 2.0, error: {code: -32700, message: parse error}, id: None }) request_id request.get(id) method request.get(method, ) params request.get(params, {}) if method not in self.methods: return json.dumps({ jsonrpc: 2.0, error: {code: -32601, message: method not found}, id: request_id }) try: result self.methods[method](params) except Exception as exc: return json.dumps({ jsonrpc: 2.0, error: {code: -32603, message: str(exc)}, id: request_id }) # 通知类请求不带 id不返回响应 if request_id is None: return None return json.dumps({ jsonrpc: 2.0, result: result, id: request_id }) class RPCHandler(socketserver.BaseRequestHandler): def handle(self): while True: try: data self.request.recv(65536) if not data: break # 约定每条消息以换行结尾这里逐条处理 for line in data.split(b\n): line line.strip() if not line: continue response self.server.dispatcher.dispatch(line) if response is not None: self.request.sendall(response.encode() b\n) except (ConnectionResetError, BrokenPipeError): break except Exception: # 单条消息异常不能拖垮整个 daemon break class UnixRPCServer(socketserver.UnixStreamServer): allow_reuse_address True def start_server(socket_pathSOCKET_PATH): os.makedirs(os.path.dirname(socket_path), exist_okTrue) # 如果 socket 文件残留但已经没有进程在服务先清理 if os.path.exists(socket_path): os.unlink(socket_path) server UnixRPCServer(socket_path, RPCHandler) server.dispatcher RPCDispatcher() # 注册业务方法 server.dispatcher.register(scheduler.submit, submit_job) server.dispatcher.register(scheduler.status, get_job_status) # 收紧 socket 文件权限只允许同组用户访问 os.chmod(socket_path, 0o660) server.serve_forever()粘包问题在 UDS 上和 TCP 一样存在。recv(65536)一次可能读到客户端发来的多条 JSON 消息所以我按换行符\n把数据切分成多条逐条处理。这是个简单有效的“帧分隔”方案只要约定所有客户端发来的消息都严格以换行结尾就行。另外一个重要的编码习惯服务端里每个方法入口都应该 try/except 兜底因为调用方可能传过来任何奇奇怪怪的参数。某个方法内部抛异常只应该影响这条请求不应该让整个守护进程退出。上面示例中dispatch里已经做了这个保护实践下来非常有用。3.3 客户端实现调用远程函数就像调用本地函数客户端相对简单封装一个RPCClient类把 JSON 序列化、发送、接收、匹配响应这些细节全部藏起来。业务代码里只需要client.call(scheduler.submit, {task: xxx})这种感觉。import json import socket import uuid class RPCError(Exception): pass class RPCClient: def __init__(self, socket_path, timeout3): self.socket_path socket_path self.timeout timeout self._id str(uuid.uuid4()) def call(self, method, paramsNone): payload json.dumps({ jsonrpc: 2.0, method: method, params: params or {}, id: self._id }) \n sock socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(self.timeout) sock.connect(self.socket_path) sock.sendall(payload.encode()) buffer b while True: chunk sock.recv(4096) if not chunk: break buffer chunk if b\n in buffer: line, _, _ buffer.partition(b\n) response json.loads(line) if response.get(id) self._id: sock.close() if error in response: raise RPCError(response[error]) return response[result] sock.close() raise RPCError({code: -32000, message: connection closed})客户端这里有几个细节是实际踩过坑之后才补上的。第一socket.settimeout(timeout)一定要设否则对端进程假死客户端会一直卡在recv上把调用线程拖死。第二收到响应后要校验id防止把别的请求的回包当成自己的。第三sendall必须用全量发送不能用send——send在底层可能只发送一部分字节而sendall会循环处理直到全部发送完成。3.4 并发模型多线程、asyncio 还是多进程JSON-RPC 通信层做好了紧接着要决定服务端怎么处理并发请求。我们按守护进程的业务类型分了三类处理方式。多线程适合采集器、上报器这种“IO 密集、方法内部不做重计算”的服务。Python 里ThreadingMixIn几行就搞定理解成本最低只要有锁的地方少基本不会出问题。asyncio 适合调度中枢这种要同时维护大量连接、状态要频繁刷新的服务。我们后来用 asyncio 重写了调度中枢的通信层单线程单进程就扛住了原先需要十几个线程的压力。监听 socket、每个连接的读写、超时控制全部挂在一个事件循环上状态管理清晰很多。多进程适合训练 Worker 这种 CPU 密集服务。进程级并行可以直接利用多核Python 的 GIL 在进程模式下不是问题。多个 Worker 各自监听不同的 socket 文件调度中枢只要知道“哪个 Worker 空闲就把任务给谁”就行。如果调用量不大也就是每秒几千次以内我的建议是直接用多线程不要一上来就上 asyncio。asyncio 的调试心智负担明显更高日志里全是回调或者协程栈新手很容易绕晕。等真的出现单线程阻塞瓶颈了再把通信层切换到 asyncioJSON-RPC 协议和 API 都不用动只是底层 I/O 模型变了。4. 细节是魔鬼Unix socket 连接管理的几个大坑4.1 socket 文件路径别踩 108 字节这个坑Linux 内核里sockaddr_un结构的sun_path字段长度是 108 字节不同发行版略有差异也就是说 Unix socket 的完整路径不能超过这个长度。一旦超了bind()直接返回[Errno 22] Invalid argument而且报错信息非常难懂不熟悉的人根本猜不到是路径太长。Microduck 早期踩过这个坑我们把 socket 文件放在/var/run/microduck/testing/long-service-name/...这种层级很深的路径下启动时直接 bind 失败。后来统一约定所有 socket 文件放在/var/run/microduck/或/tmp/microduck/这类短路径下文件名也尽量简短比如scheduler.sock、collector.sock、trainer-1.sock。如果用的是 systemd 的RuntimeDirectory目录路径默认是/run/microduck也足够短。4.2 权限控制socket 文件的“门禁”给每个军团成员发工牌UDS 文件权限这件事很多资料里都是一笔带过但实际生产环境中它非常重要。默认情况下UnixStreamServer绑定 socket 文件后权限是srwxr-xr-x也就是说机器上任意用户都能连进来。如果你的守护进程暴露了关键操作——比如scheduler.submit这种能提交任务的接口——那就是门户大开。我们后来统一约定了一套权限门禁策略socket 目录权限设为0750只有microduck用户和microduck组能进入socket 文件本身在 bind 之后立刻chmod 0660只允许组内读写。所有需要访问其他守护进程的模块都以microduck组的身份运行。这样即使别的用户知道 socket 文件路径也连不进来。这里有一个容易忽略的点如果你用了os.umask(0)那么不显式设置权限时创建出来的文件权限会比默认环境更宽松所以chmod必须紧跟bind并且要在同一个初始化函数里完成避免中间窗口期。我们曾在代码里看到过“bind 之后、chmod 之前”被人调用的情况虽然罕见但权限收紧这种事最好一步到位。4.3 连接失效与半关闭守护进程必须对“断连”有免疫力UDS 连接和 TCP 一样随时可能因为对端崩溃、重启、资源耗尽而失效。服务端recv()返回空字节流表示对端正常关闭抛出ConnectionResetError或BrokenPipeError表示对端异常退出。这两种情况都应该及时关闭连接、回收文件描述符。这里最容易犯的错误是不做任何处理任由连接挂在队列里。连接本质上占用一个 fd而单进程的 fd 上限通常只有 1024可以调高但没必要。如果有大量断开的连接不及时清理很快就把 fd 消耗光新连接全被拒绝。Microduck 的调度中枢曾经因为一个客户端进程反复崩溃重启导致几百个半开连接堆积最后整个服务无响应。后来在服务端handle()里加了严格的异常捕获和连接关闭逻辑这个问题才彻底解决。另外客户端和服务端都要注意“半封闭”场景。所谓半封闭就是一方关闭了写端但还想读对方的数据。如果客户端在发送完请求后马上调用shutdown(SHUT_WR)服务端可以正常读到请求并回包但某些实现里客户端如果没有正确读取就可能丢响应。我建议普通场景不要主动 shutdown而是用“发送完整消息 等待完整响应 最后关闭”的标准顺序。4.4 超时与心跳防止“活连接”变成“死守候”socket默认是阻塞的connect 一个不存在的 socket 很快会报错但连接一旦建立长时间没有数据任何一端都无法主动感知对端是不是挂了。所以我在客户端所有调用里都设置了超时服务端的serve_forever里也加了超时机制防止单个连接长时间占用线程。更稳妥的做法是加心跳每个守护进程都会定时向它依赖的服务发一个不带id的 JSON-RPC 通知例如{jsonrpc: 2.0, method: ping, params: {}}。对端收到后只需要简单返回或者不返回notification 不需要回包。如果连续几次心跳没有按时到达就认为连接断了主动关闭重建。心跳间隔根据场景从 5 秒到 30 秒都有太频繁会浪费资源太慢则故障感知延迟高需要权衡。5. 常见问题速查与一次真实排查实录5.1 问题速查表Microduck 跑起来之后我们积累了一份常见问题速查表。遇到故障先查表能省下大量时间。报错原因解决[Errno 111] Connection refusedsocket 文件存在但对端没有监听或路径写错用ls -l检查 socket 文件用 socat 探活[Errno 98] Address already in usesocket 文件残留旧进程已死但没有清理确认没有旧进程后unlinksocket 文件[Errno 13] Permission deniedsocket 文件或目录权限不够chmod 0660socket把用户加入对应组BrokenPipeError/ConnectionResetError对端进程崩溃、重启或半关闭捕获异常按退避策略重建连接JSON-RPC 返回-32601方法名拼错或服务端未注册该方法检查服务端方法注册表用调试接口列出方法收到乱码或者半包粘包处理没做或者消息分隔符不统一统一用换行分隔接收端按\n切分5.2 一次“connection refused”的连环坑那次故障我印象很深。现象是调度中枢的日志里不断刷[Errno 111] Connection refused但ls看 socket 文件确实存在ps看训练 Worker 的进程也确实在跑。诡异的是用 socat 去手动连接同一个 socket 文件也被拒绝。排查过程是这样的。第一步先确认进程在跑ps -ef | grep trainer输出正常。第二步再看 socket 文件发现文件的 inode 一直在变——这说明每过几秒就有一个新进程重新创建 socket 文件。第三步才想到去翻 systemd 日志发现训练 Worker 其实一直在崩溃重启每次重启的时间间隔正好是 5 秒对应RestartSec5。问题根因是启动脚本里的一段“先 unlink 再 bind”逻辑。旧进程非正常退出后socket 文件残留新进程启动时执行了 unlink再 bind 创建了新文件。但新进程 bind 成功后马上又因为另一个配置错误崩溃退出于是又留下一个新 socket 文件。systemd 每 5 秒拉起新进程每次都在“删掉旧文件、创建新文件、崩溃、留下文件”这个循环里打转。外部客户端看到 socket 文件存在但对应的进程已经在崩溃边缘连接自然全被拒绝。这个坑的关键教训是启动时清理残留 socket 文件之前必须先确认“旧进程确实不在了”。标准做法是在 unlink 之前尝试用一个短连接探测 socket 是否可用如果连接成功说明有另一个实例正在服务此时应该直接报错退出避免双实例冲突如果连接失败才能安全 unlink 并创建新文件。这套“先探测再清理”的逻辑后来成了所有 Microduck 守护进程启动流程的标配。5.3 调试技巧socat、curl 与日志串链路UDS 调试最常用的工具是 socat。一行命令就能直连 socket 文件手动发送 JSON-RPC 请求socat - UNIX-CONNECT:/var/run/microduck/scheduler.sock连上之后直接输入{jsonrpc: 2.0, method: scheduler.status, params: {}, id: 1}按回车就能看到服务端的响应。这个方法适合快速验证服务端是否正常也适合在写客户端之前先把协议调试清楚。curl 也能派上用场前提是服务端做了一点兼容处理。curl 的--unix-socket参数会通过 UDS 发 HTTP 请求如果服务端能在dispatch之前先跳过 HTTP 头只把后面的 JSON 内容当协议体处理那么调试体验会非常直接。我们在调度中枢里加了一个小处理如果收到的第一行不是{开头就持续读行直到出现{。这样既能接受原始 JSON-RPC 请求也能接受 curl 发来的 HTTP 包装请求。curl --unix-socket /var/run/microduck/collector.sock \ -d {jsonrpc:2.0,method:collector.status,params:{},id:1} \ http://localhost/最后再补充一点日志心得。UDS 不走网络协议栈抓包工具没法直接用所以排查跨进程问题基本靠日志。我们在每个守护进程的每条请求日志里都带上[pid] [request_id] [method]三个字段比如[12345] [req_8f3a2c] scheduler.submit start [12345] [req_8f3a2c] scheduler.submit done in 12ms这样当一个问题跨越多个进程时只要在调度中枢日志里搜到某个request_id再去训练 Worker 日志里一查就能把整条调用链串起来几分钟定位到是哪个“兵”掉了链子。Microduck 这套“守护进程军团”跑了快一年我最深的体会是架构本身并不复杂复杂的是把所有边界情况都想到。守护进程、Unix socket、JSON-RPC每个零件单拿出来都是老技术但把它们组合在一起确实解决了一个实际的大问题。如果你也想做类似的事情我建议不要一上来就照搬全篇先从两个守护进程开始一个 server 一个 client用 socat 把协议验证清楚再逐步扩成“军团”。最后再分享一个我们保留的实用小技巧所有客户端调用都带超时所有服务端方法入口都打日志所有 socket 文件都严格控制权限。这三条看着不起眼但跑久了你会发现它们才是这套架构稳定运行的地基。Microduck 后面如果要跨机器扩展我们只需要把传输层从 UnixStreamServer 换成 TCPStreamServerJSON-RPC 的协议层和业务代码基本不用动这也是当初选这个组合时没想到的额外收益。
网站建设高端定制企业官网