ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Microduck:基于Unix socket与JSON-RPC的守护进程军团架构解析

Microduck:基于Unix socket与JSON-RPC的守护进程军团架构解析 Microduck这套东西最开始其实压在我手里一堆零散脚本的统称。服务器上跑着各种后台任务——数据同步、日志清洗、指标采集、定时报表每个都是独立进程各自维护出了事谁也不理谁。后来我痛下决心把它们统一收编成一个守护进程军团所有后台服务都以守护进程方式常驻互相之间的通信不走 HTTP 端口全部通过 Unix socket协议统一封装成 JSON-RPC 2.0。这套改造跑通以后整个服务群的稳定性、可观测性和维护成本都有了肉眼可见的改善。这篇就把 Microduck 的整体设计思路、关键实现细节以及我在实操里踩过的坑都摊开聊一聊。想搭轻量级本地微服务通信方案的朋友或者正在纠结进程间通信选型的同学可以重点看中间几节。哪怕你不打算用这套架构里面关于 Unix socket 权限管理、JSON-RPC 帧格式设计、守护进程优雅退出这些东西也都是能直接抄走用的。1. 为什么是 Unix socket JSON-RPC1.1 本地通信的乱局Microduck 出现之前这台机器上的产物大致是这样的A 服务要调用 B 服务的功能B 就开一个 HTTP 端口监听在 127.0.0.1 上A 用 requests 发请求。表面上看挺顺但一旦服务数量超过五个麻烦就来了。端口是不受控的。每个人起服务时随手挑一个端口今天 8899明天 8890过一阵子你根本记不住哪个端口是哪个服务。更头疼的是有些服务还带依赖比如 A 要连 Redis、MySQL端口列表越来越长防火墙规则、nginx 反代配置全都要跟着改。排查一个端口被占用问题我经常要 lsof 半天。另一个痛点是安全暴露面。HTTP 服务哪怕监听在 loopback 上也免不了要处理完整的 HTTP 语义——各种 header、cookie、MIME 类型这些对进程间通信来说真的都是多余负担。而且一旦有人不小心把 bind 地址写成了 0.0.0.0端口就直接暴露到外网了等于给整个内网开了扇门。1.2 Unix socket 与 HTTP 的取舍Unix socket 是操作系统自带的一种 IPC 机制它的地址不是一个 IP:Port而是一个文件路径。进程 A 和进程 B 通过读写同一个 socket 文件来交换数据整个通信过程不经过 TCP/IP 协议栈没有路由、没有握手、没有拥塞控制数据在同一个内核空间里直接搬移性能上比 TCP loopback 要快不少。我在实测中感受到的最大优势其实是权限控制。socket 文件是一个普通文件你可以用 chmod、chown 精细控制哪些用户或组能访问。同一台机器上跑多个服务时这个特性非常香比如 microduck 的运维管理服务只允许 root 访问业务服务只允许 microduck 用户访问不需要堆防火墙规则直接在文件权限层面就隔离了。还有一点Unix socket 天然没有端口冲突问题。每个 socket 文件就是一个名字只要路径不冲突就能共存。而且服务停止后 socket 文件可以清理删除不会像 TCP 那样还有 TIME_WAIT 状态残留一台机器上起几百个服务也不怕。不过它也有短板。传统 Unix socket 只能用于同一台机器上的进程通信跨机器就无能为力。所以 Microduck 的定位很明确它解决的是单机内多进程协作的问题分布式跨节点通信该用 HTTP 或消息队列还是得用。架构上不要把两者混为一谈不然后面会越来越拧巴。1.3 JSON-RPC 2.0 这个老协议的妙处通信通道定下来之后剩下的问题就是序列化协议。我当时在 REST、gRPC、JSON-RPC 之间纠结了很久。REST 的问题是无状态资源但很多后台操作本质是动作而不是资源比如触发一次数据重建重载配置检查健康状态套 REST 会非常别扭——你到底是 POST 一个 job还是 PUT 一个 stategRPC 又太重了。它需要 .proto 文件、需要生成代码、需要额外维护一套 IDL对于十几个内部脚本来说这个学习成本和工程成本都不划算。而且 gRPC 默认走 HTTP/2和 Unix socket 结合还需要自定义 transport复杂度直接拉满。JSON-RPC 2.0 是我最后的选择。它是基于 JSON 格式的协议规则极其简单一个请求就是一个 JSON 对象包含jsonrpc、method、params、id四个字段响应包含jsonrpc、result或error、id还有一种不带 id 的通知形式适合只触发不管结果的场景。因为协议足够简单自己实现解析逻辑也就几十行不需要引入任何框架。REST 还有一个问题它约定了大量 HTTP 状态码的语义200、201、204、400、404、500……但进程间通信根本用不到那么多。JSON-RPC 把错误封装在error字段里带code和message语义更贴近函数调用。对微服务内部协作来说我调用你一个函数你给我返回值或异常这个模型比我对你的资源做了一次操作直观得多。2. Microduck 的架构设计2.1 守护进程军团如何排兵布阵Microduck 的核心思想可以概括成一句话每个功能单元都是一个独立的守护进程统称守护进程军团。这里面有三类角色第一类是常驻服务对外提供能力比如数据查询服务、配置下发服务、指标采集服务。它们长期运行在后台监听自己的 Unix socket等待请求。第二类是任务型进程执行完一个任务就退出比如定时清理、批量导入通常由调度器拉起。第三类是主控进程也是军团的大脑负责启动、停止、监控其他所有进程收集状态统一处理日志。整个布局用文字描述是这样主控进程在/run/microduck/目录下维护一堆.sock文件每个.sock对应一个常驻服务任务型进程被主控进程 fork 出来跑完就交回状态所有进程通过心跳定期向主控进程汇报健康状况。为什么叫军团因为单体进程被切分成独立战斗单元之后它们互相配合、各司其职一个单元挂了不会拖垮全部。比如日志清洗服务挂了数据查询服务依然在跑这跟全在同一个进程里一个 panic 全崩是有本质区别的。微服务架构的核心价值就在这里只不过 Microduck 把粒度收敛到了单机的守护进程层面。2.2 通信帧与消息规则Unix socket 只是字节流传输通道它不关心你传的是什么格式。我们在 JSON-RPC 之上还加了一层简单的帧封装解决一个请求在哪里结束、下一条从哪里开始的问题。方案很简单每条消息以换行符\n结尾也就是 JSON line 格式。发送方把 JSON 对象压缩成一行末尾补一个\n接收方按行读取。为什么选换行分隔而不选 Content-Length 前缀因为对大多数内部消息来说单条 JSON 体积不大用换行分隔最直观也能直接用nc -U或socat做调试。如果未来消息体真的超大了再演进成 Content-Length 前缀也容易解析器两侧都在自己手里改造成本可控。消息规则上我们严格遵守 JSON-RPC 2.0 规范。特别强调两点一是 id 必须对得上。客户端发出的每个请求带一个自增 id响应里必须原样带回 id这样异步场景下才能区分结果对应哪次调用。二是错误处理必须统一。服务端内部无论发生什么异常都要封装成error对象返回不要直接把堆栈打在 socket 里。规范里约定了几种标准错误码-32700表示解析错误、-32600表示无效请求、-32601表示方法不存在、-32602表示无效参数、-32603表示内部错误。我们在此基础上给业务错误留了从-32000到-32099的私有段方便区分协议层问题和业务逻辑问题。2.3 目录、配置和启动流程Microduck 的运行时目录是标准化的直接列一下/run/microduck/放各服务的 socket 文件和 pid 文件。这是 tmpfs系统重启后自动清空适合放运行时状态。/var/log/microduck/统一日志目录每个服务一个子目录按天轮转。/etc/microduck/配置文件主控进程和各服务的配置都放在这里。/opt/microduck/程序本体按服务分子目录。启动流程也很有讲究。主控进程本身由 systemd 启动但它不是直接用 systemd 管理所有服务而是自己拉起子进程这样可以在内部实现一套更灵活的重启和依赖调度逻辑。主控进程启动后会扫描配置文件确定要拉起哪些服务、顺序怎么排、哪些服务之间要等待依赖关系成立然后逐个启动。每个子进程启动后创建自己的 pid 文件主控进程通过 pid 文件判断进程是否还活着再配合waitpid回收退出状态。3. 核心实现解析3.1 Unix socket 服务的核心代码这里直接贴核心实现。我用 Python 做示例因为 Microduck 本身主要是 Python 写的也方便读者快速理解。完整代码在 GitHub 仓库里这里只贴关键片段。import json import socket import os import threading def handle_message(message: str) - str: 处理单条 JSON 消息返回 JSON 响应。 try: req json.loads(message) method req.get(method) params req.get(params, []) if method ping: result pong else: raise Exception(fmethod not found: {method}) return json.dumps({jsonrpc: 2.0, result: result, id: req.get(id)}) except Exception as e: return json.dumps({ jsonrpc: 2.0, error: {code: -32603, message: str(e)}, id: id }) def serve(sock_path: str) - None: if os.path.exists(sock_path): os.unlink(sock_path) # 清理上次残留的 socket 文件 server socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) server.bind(sock_path) os.chmod(sock_path, 0o600) # 只允许本用户访问 server.listen(16) while True: conn, _ server.accept() t threading.Thread(targethandle_conn, args(conn,)) t.start() def handle_conn(conn) - None: buf b while True: data conn.recv(65536) if not data: break buf data while b\n in buf: line, buf buf.split(b\n, 1) if line.strip(): resp handle_message(line.decode(utf-8)) conn.sendall((resp \n).encode(utf-8)) conn.close()这段代码有几个重点。第一socket 文件的预热清理。server.bind()前必须检查路径是否已存在如果上次进程异常退出socket 文件会残留不清理会 bind 失败。但清理前一定要确认没有别的实例在监听这个文件否则就把别人家的门牌拆了。稳妥做法是先尝试connect连接失败再unlink。第二权限设置。os.chmod(sock_path, 0o600)这行非常关键它限制只有运行 microduck 的用户能访问这个 socket。如果图省事不设置创建结果会受umask影响很有可能会留下过宽的权限其他用户就能往里面塞请求了。第三并发模型。示例里用多线程每来一个连接开一个线程。对内部服务来说连接量很小这个模型完全够用。如果未来吞吐量上来可以改成 asyncio 事件循环Microduck 生产代码里就是 asyncio 版本后面会单独讲。3.2 守护进程的自我修养一个合格的守护进程光会绑定 socket 还不够还要处理几件事。第一是脱离控制终端。真实场景里我们不希望主控进程因为终端关闭就带着所有子进程一起退出所以子进程在启动时要做setsid()创建新的会话。Python 里可以在进程内部调用os.setsid()也可以在fork时通过start_new_sessionTrue实现。有的项目偷懒不做结果 ssh 一断开服务就没了这个坑我踩过。第二是信号处理。守护进程要能优雅退出收到SIGTERM时先把 socket 文件删掉再关闭所有活动连接最后落盘当前状态。如果直接SIGKILLsocket 文件就会残留下次启动就得靠清理逻辑兜底。Microduck 里统一处理SIGTERM和SIGINT保证退得干净。第三是日志。守护进程没有标准输出所有print默认写到哪去了答案是没人知道。所以一定要把 stdout/stderr 重定向到日志文件或者直接用 logging 模块的 FileHandler。我还习惯在每个服务启动时打印一条固定的启动日志包含版本号和 pid排查问题的第一步就是看这行日志有没有出现。3.3 主控进程的监管模型主控进程的核心职责一句话概括fork 出子进程然后盯着它们。关键机制是waitpid()和SIGCHLD信号。子进程自然退出时内核会给父进程发SIGCHLD父进程在信号处理器里调用waitpid()回收退出状态。如果子进程是被信号杀死的比如段错误waitpid会返回非正常退出状态主控进程据此判断是正常结束还是崩了。崩了以后怎么办Microduck 默认的复活策略是前 3 次立即重启之后如果 10 秒内连续崩溃就进入 backoff 模式等 30 秒再拉起防止进程陷入启动即崩溃的死循环。这里有个细节waitpid一定要用非阻塞方式或者放在单独的线程里处理不要在信号处理器里做太多事情否则容易出现信号重入和竞态。Microduck 的做法是主循环统一用select或poll监听子进程退出事件信号处理器只做标记、不做业务。主控进程还管心跳。每个常驻服务需要每 10 秒汇报一次心跳如果 3 个周期没收到主控进程就认为这个服务已经假死了会把它的进程强制杀掉再拉起。这个机制防的就是进程还在但内部死锁的诡异场景。4. 从零跑通 Microduck 最小集群4.1 初始化环境假设你已经从 GitHub 拉下来了 microduck 仓库代码在/opt/microduck。第一步是准备运行目录sudo mkdir -p /run/microduck sudo chown microduck:microduck /run/microduck sudo chmod 755 /run/microduck sudo mkdir -p /var/log/microduck sudo chown microduck:microduck /var/log/microduck sudo mkdir -p /etc/microduck sudo cp /opt/microduck/etc/*.yaml /etc/microduck/然后可以用 systemd 启动主控进程unit 文件里只需要一行ExecStart/opt/microduck/bin/microduckd。注意这一步不要直接用 root 跑最好以专用用户身份运行这样所有日志和 socket 文件的归属权才统一。启动之前先看一眼配置确认服务注册表没有问题# /etc/microduck/services.yaml services: - name: my_service command: python /opt/microduck/services/my_service/service.py socket: /run/microduck/my_service.sock restart: always这里restart: always表示进程退出后自动拉起我一般调成 always因为如果某个服务连启动都不能成功主控的 backoff 机制会兜底不会疯狂重启。4.2 编写第一个守护进程在/opt/microduck/services下新建my_service目录里面放一个service.py内容就用前面那段serve函数在入口调用if __name__ __main__: serve(/run/microduck/my_service.sock)启动之后它会自己完成这几件事检测 socket 文件是否存在并清理旧文件、创建新 socket、设置 0600 权限、进入监听循环。基本就是一个标准的 Unix socket JSON-RPC 服务端。如果你用的是 Microduck 自带的服务脚手架它还会自动做setsid()脱离终端、把 stdout/stderr 重定向到/var/log/microduck/my_service/底下的日志文件这些都不需要你重复写。4.3 调通一次完整调用链配置好后启动主控进程然后用 microduck-cli 做一次端到端验证microduckctl start my_service microduckctl status my_service microduckctl call my_service ping正常会看到类似{jsonrpc: 2.0, result: pong, id: 1}的输出。如果这一步通了说明 Unix socket、JSON-RPC 封装、主控监管、socket 权限这套链路全部打通了。如果不用命令行也可以直接用 Python 手写客户端验证加深理解import json import socket def call(sock_path: str, method: str, paramsNone, timeout: int 5): payload { jsonrpc: 2.0, method: method, params: params or [], id: 1, } s socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) s.settimeout(timeout) s.connect(sock_path) s.sendall((json.dumps(payload) \n).encode(utf-8)) resp b while not resp.endswith(b\n): resp s.recv(4096) s.close() return json.loads(resp.decode(utf-8)) print(call(/run/microduck/my_service.sock, ping))这里有个小坑要提醒call()函数里的超时时间一定要设置。如果没有超时一旦服务端假死或者 socket 文件是坏的客户端就会无限阻塞在那里看起来就像是整个程序卡住了。内部工具通常都会有无数个地方调用这种函数超时是防止连锁雪崩的第一道防线。5. 实战踩坑与优化记录5.1 典型问题速查表Microduck 跑起来之后遇到的很多问题其实是同类问题反复出现。我把最常见的几个列成了一张速查表按症状、可能原因、解决方案三列来对照降低排查成本。症状可能原因解决办法socket bind 报 Address already in use上一次进程异常退出socket 文件残留先检查是否有进程占用确认无事后删除文件客户端 connect 被拒绝socket 文件权限不对或对应服务未启动用stat看权限ls -l确认 socket 文件存在调用一直卡住客户端没设置超时服务端假死给所有调用加超时同时用心跳检测服务健康服务端收到请求但无响应客户端消息没带换行符\n发送时统一用sendall(payload \n)子进程一直重启启动即崩溃日志又没落盘重定向 stdout/stderr 到日志文件先看真实报错主控进程明明拉起服务又立刻显示挂了pid 文件路径或权限不对检查服务是否有 pid 文件写入权限以及主控读的 pid 路径是否一致这里特别说一下第一行的 socket bind 失败。我之前有个习惯服务启动时发现 socket 文件存在就os.unlink结果有一次线上服务还在正常运行部署脚本重启时把旧的 socket 文件删了然后新进程 bind 上去结果旧进程和新进程指向同一个 socket 文件路径消息就乱了。后来我改成先尝试connect如果连接成功说明有服务在监听绝不能删只有连接失败才说明是残留文件可以清理。5.2 线程模型与并发控制Microduck 第一版全部用多线程一个连接一个线程简单粗暴。但后来有个服务频繁出现响应延迟查了半天发现是许多长耗时操作把线程池占满了后续请求排队排到天荒地老。后来我把高频服务改成了 asyncio 版本。asyncio 是单线程事件循环CPU 密集任务依然会阻塞但对我们这种以 IO 为主的内部服务效果非常显著。核心模式是在事件循环里监听 socket每个连接注册一个on_readable回调消息读完以后交给run_in_executor丢到线程池里执行真正的业务方法业务方法跑完后再把结果写入连接。import asyncio import json async def handle_client(reader, writer): try: while True: line await reader.readline() if not line: break req json.loads(line) loop asyncio.get_event_loop() result await loop.run_in_executor(None, dispatch, req) writer.write((json.dumps(result) \n).encode()) await writer.drain() except Exception: pass finally: writer.close()这里有个经验不要把 CPU 密集型逻辑直接丢到 asyncio 回调里跑。有一个服务在回调里做数据压缩结果一个耗时操作把整个事件循环卡住了几秒其他所有请求全部卡顿。后来把压缩操作也丢进run_in_executor问题就消失了。5.3 平滑重启与滚动升级Microduck 服务的升级不能像普通脚本那样粗暴 kill 再起因为客户端可能正在持有 socket 连接。平滑重启的思路是先让旧进程停止接受新请求处理完当前请求后退出新进程在相同路径上重新创建 socket 监听。但注意Unix socket 有个特性客户端连接成功后持有的是文件描述符服务端重启、socket 文件重新创建之后旧连接仍然有效直到数据交互中断。这意味着升级期间已经连上的客户端请求可以被旧进程继续处理完新请求会进入新进程的 listen 队列两边不会互踩。我用的升级流程是这样主控进程给目标服务进程发SIGTERM触发优雅退出。旧进程先把 socket 文件标记为不在接受新连接状态然后处理完所有已连接的请求再真正退出。主控进程确认旧进程退出后在新代码目录下拉起新进程。新进程 bind 同一个 socket 路径开始接受新连接。这个流程做下来客户端几乎没有感知。但如果旧进程有大量长连接等它慢慢退完会拖很久所以 Microduck 里加了一个超时参数默认 30 秒超时未退完的强制 kill。5.4 心跳与超时策略守护进程军团能不能协同工作很大程度上取决于心跳和超时策略。这里分享几个实践下来比较合理的参数心跳周期 10 秒太频繁会增加不必要的进程唤醒太慢会导致故障发现延迟。连续 3 次未收到心跳判为死亡也就是说 30 秒内没有心跳就认为服务假死强制重启。客户端调用超时 5 秒内部服务之间一般响应都很快超过 5 秒大概率有问题。主控进程重启服务的 backoff 指数第一次立即重启第二次等 5 秒第三次等 15 秒超过三次等 60 秒。这些参数不是拍脑袋定的。心跳周期乘以判断次数决定了故障恢复的响应时间对于内部任务型服务来说30 秒的容错窗口完全够用而 backoff 指数是为了在服务启动就崩溃的情况下防止无脑重启把 CPU 打满。Microduck 这套架构还远谈不上完美但对需要在一台机器上管理几十个后台服务的人来说它已经让我少掉了大量头发。从最初各种乱起端口的 HTTP 服务到现在统一走 Unix socket 和 JSON-RPC 的守护进程军团整个系统的行为变得非常可预期一个进程一个 socket一个服务一段日志出了问题不用再到处猜。如果你也想在自己机器上试试建议先不要一上来就改造所有服务。挑一个边缘的、不重要的定时任务把它按照文档里的步骤接进 Microduck跑通一次完整的 start、call、stop 流程感受一下这套机制。等熟悉了再逐步迁移其他服务。最后再说一个小技巧给 Microduck 写一个 shell 自动补全脚本敲microduckctl call wincmdTab的时候能自动补全服务名和方法名体验会好非常多值得为它多花十分钟。
返回列表