ARTICLE DETAIL

资讯详情

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

基于Unix socket与JSON-RPC的守护进程微服务架构实践

基于Unix socket与JSON-RPC的守护进程微服务架构实践 最近一直在折腾Microduck这套把服务拆成十几个守护进程、再用Unix socket串起来的架构初看有点反直觉——单机上的应用有必要搞成微服务吗但等我把整个“守护进程军团”真正跑通又亲手把训练任务丢进去试了几轮之后我得承认这种设计在解决多任务调度、进程隔离和故障恢复这三件事上确实比传统的单体常驻进程干净得多。这篇文章我想把Microduck这套基于Unix socket的JSON-RPC微服务架构从设计思路到落地细节完整梳理一遍包括它为什么不用TCP、为什么选择JSON-RPC协议、守护进程之间怎么注册和通信、怎么把训练任务切片分发下去以及我在实操中踩过的那些坑。适合对多进程架构、IPC通信和任务调度感兴趣的开发者无论你是想复现整个项目还是只想借鉴其中某个设计思路这篇都能给你点实在参考。1. 为什么Microduck选择了Unix socket JSON-RPC1.1 先说说常见的进程间通信方案做过多进程系统的人都知道进程间通信从来不是没得选而是选择太多TCP回环、UDP、共享内存、命名管道、Unix domain socket、信号量每种都有自己的适用场景。Microduck最开始原型用的是TCP回环就是每个守护进程监听127.0.0.1上的一个随机端口客户端请求先建立TCP连接再发JSON数据。跑起来倒是没问题但有几个点让我一直不舒服。首先是端口管理十几个进程就要占用十几个端口端口一多就要维护映射表哪个进程在哪个端口全靠配置文件时间久了根本记不住。其次是安全边界TCP端口只要bind到0.0.0.0就可能被外部访问虽然可以只监听127.0.0.1但配置上稍一疏忽就会把内部接口暴露出去这种事故在真实项目里我见过不止一次。共享内存则是另一个极端——性能确实强但管理复杂度和心智负担高出太多要考虑内存同步、锁、信号量、崩溃恢复。Microduck要的是“让每个守护进程保持独立消息交换清晰可追踪”共享内存这种高度耦合的方案直接pass掉。所以最后折中来看Unix domain socketUDS是最合适的选择。1.2 Unix socket比TCP好在哪Unix domain socket本质上不走网络协议栈而是在内核里直接做socket数据交换所以它比TCP回环少了一次协议封装和拆解的消耗延迟更低吞吐也更高。实测在同样条件下UDS的请求响应延迟大约比TCP回环低20%到30%在高频调用的内部服务链路上这个差距非常可观。更关键的是它的“文件系统权限模型”。UDS在Linux上以socket文件的形式存在比如/run/microduck/scheduler.sock它的访问控制直接走文件权限。这意味着什么我可以把整个/run/microduck目录的权限设为0750属组设为microduck组这样只有该组内的进程能访问所有内部socket外部用户连看都看不到。这比TCP端口只能靠防火墙来保护要自然得多而且权限问题在文件系统层面一目了然查起来也方便。UDS还有个隐含优势不占端口。这意味着它不会跟其他业务端口冲突诊断时也不需要通过ss -lntp去海量端口里找目标一条ss -x就能列出当前所有的Unix socket连接。对于我这种动不动十几个守护进程的情况排查体验完全是质的提升。1.3 JSON-RPC为什么比HTTP REST更适合内部协作既然走了socket那么这层通信协议就有得选了。最“常规”的想法是继续用HTTP协议请求体还是JSONMicroduck早期干过这事——每个守护进程内置一个轻量HTTP server暴露/api/xxx形式的REST接口。但我渐渐觉得这套太重了。HTTP协议为了通用性付出了太多额外成本每个请求都要带一堆请求头服务端要解析HTTP语义、状态码、缓存策略、Keep-Alive连接管理。在内部进程通信这个场景下这些功能几乎全是噪音。而JSON-RPC 2.0这套规范简洁得多它就是一个标准化的“远程调用协议”请求对象长这样{jsonrpc:2.0,id:1,method:scheduler.submit_task,params:{...}}响应对象也固定{jsonrpc:2.0,result:{...},id:1}出错时是{jsonrpc:2.0,error:{code:-32000,message:...},id:1}。这种格式非常像“把函数调用变成一条消息”。调用方不需要关心路由前缀、HTTP方法、状态码只要指定method名和参数就能像调用本地函数一样调用另一个守护进程的能力。而且JSON-RPC的id字段天然支持异步和乱序响应——客户端发起多个请求服务端处理完按id回包谁先完成谁先响应不会阻塞后续调用。这个特性在Microduck任务调度场景里特别有用因为训练任务的执行时间差异极大有的几十毫秒有的几十秒如果采用同步阻塞模型整体吞吐会被最慢的任务拖垮。2. 守护进程军团的整体架构与职责划分2.1 军团成员清单与各自分工Microduck把整个系统拆成了六个核心守护进程每个进程只负责一个领域彼此之间通过位于/run/microduck/目录下的socket文件通信。我习惯把这套进程称为“军团”因为它们各自独立、配合有序单个进程挂掉不影响全局。守护进程socket文件核心职责duck-gatewaygateway.sock对外唯一入口接收外部调用做协议转换duck-schedulerscheduler.sock任务队列管理、worker注册、任务分发duck-workerworker-{id}.sock实际执行训练、推理、数据处理等任务duck-repositoryrepository.sock模型文件、数据集、版本管理duck-monitormonitor.sock定期探测各进程健康状态异常时告警duck-loggerlogger.sock统一收集所有进程日志避免并发写文件混乱这种单职责切割刚开始会觉得麻烦——明明可以写在一个进程里为什么要拆开但它的收益是实打实的。比如某个worker占用的显存炸了导致进程崩溃scheduler和repository完全不受影响我只需要重启对应的worker即可再比如我需要临时加3个worker来应对高峰任务量也完全不用动其他进程直接扩容就行。这种进程级别的隔离性在单体架构下根本做不到。2.2 一次完整请求是怎么流转的以“提交一个训练任务”为例看看请求在军团内部是怎么跑的。外部客户端先把请求发给duck-gatewaygateway做一层协议转换外部可以走HTTP或gRPC内部统一走JSON-RPC然后把消息转发给duck-scheduler。scheduler收到请求后先检查任务队列长度和各worker的空闲状态选择一个可用的worker把任务参数通过JSON-RPC消息推过去。worker拿到任务后开始执行执行过程中可能需要从duck-repository读取模型文件或数据集工作完成后再把结果原路返回给schedulerscheduler更新任务状态同时通过gateway把最终结果回给外部调用方。关键点在于这条链路里每一步都是异步且可追踪的。每一条消息都带唯一的id我可以随时用query_status方法查询任务进度不像传统单体进程里只能通过日志去猜当前执行到哪一步。对于训练这种动辄跑几小时的任务这种可观测性太重要了。2.3 不用etcd、consul也能做服务注册有人会问微服务架构通常都需要服务注册中心Microduck这套是不是也该引入etcd我的回答是不需要也没必要。单机场景下引入外部注册中心等于给自己平白加了一个需要维护的分布式系统架构上反而更脆弱。Microduck走的是简化方案每个守护进程启动时都会读取同一份配置文件拿到其他进程的socket路径启动成功后再主动向duck-scheduler的register方法发起注册把自己加入可用列表正常退出则调用unregister注销。scheduler内部维护一个活跃worker表定时通过ping方法做健康检查连续几次没响应就把该worker标记为不可用后续不再分发任务给它。这种“启动时注册定时心跳异常剔除”的机制在没有跨节点需求的前提下够用且可靠。关键是它把依赖降到了最低只要/run/microduck目录能访问、socket文件能创建整个集群就能跑起来没有任何外部中间件负担。3. 从零跑通Microduck搭建与初始配置3.1 环境准备与依赖安装Microduck的运行环境要求不高Linux系统即可推荐Ubuntu 20.04或更高版本。我在自己机器上用的是Ubuntu 22.04内核版本5.15实测稳定。需要装的依赖包括Python 3.10以上、pip、编译工具链以及Python侧的jsonrpcserver和jsonrpcclient库这两个库封装了JSON-RPC协议的处理逻辑用起来很顺手。克隆源码并安装依赖可以按下面的命令走# 克隆仓库进入项目根目录 git clone https://github.com/microduck/microduck.git cd microduck # 创建虚拟环境保持系统Python环境干净 python3 -m venv .venv source .venv/bin/activate # 安装运行时依赖 pip install -r requirements.txt装完之后建议先跑一下内置的测试套件确保基础环境没问题再往下走pytest tests/ -v我遇到过不少次环境问题都是因为Python版本太低或者依赖库没完全装好跑测试套件可以提前暴露这批问题省得后面排查时搞不清是代码问题还是环境问题。3.2 配置文件字段说明与推荐参数Microduck的配置文件是microduck.yaml所有守护进程启动时都读取这份文件所以它是整个架构的“作战地图”。核心字段如下socket_base_dir: /run/microduck user: microduck group: microduck socket_permissions: 0750 gateway: listen_tcp: 8080 external_protocol: http scheduler: task_queue_size: 100 heartbeat_interval: 5 max_retry_times: 3 worker: instances: 4 gpu_ids: [0, 1, 2, 3] batch_size: 32 repository: model_dir: /var/lib/microduck/models dataset_dir: /var/lib/microduck/datasets monitor: check_interval: 10 alert_threshold: 3 logger: log_level: info log_dir: /var/log/microduck这里解释几个关键参数。socket_base_dir是所以socket文件的父目录必须确保运行用户有写权限而且建议放在/run这类tmpfs下因为socket文件本质上不应该持久化放在tmpfs里可以避免重启后残留文件导致的端口冲突worker.instances决定了默认启动几个worker进程数量不宜超过CPU核心数太多否则上下文切换反而降低吞吐worker.gpu_ids用于指定worker可用的GPU编号Microduck会把不同worker绑定到不同GPU上实现物理层面的任务隔离scheduler.heartbeat_interval是心跳间隔太短会增加无效探测太长则会让失效worker的错误反应变慢5秒是我测试下来比较平衡的值。3.3 启动顺序与验证方法启动顺序很关键虽然Microduck各进程之间有重连机制但一个合理的启动顺序能避免启动日志里刷出一堆连接失败。我总结的启动顺序是先启动duck-logger和duck-monitor这两个基础服务再启动duck-repository然后启动duck-scheduler最后启动duck-worker和duck-gateway。进程启动后的第一件事就是把socket文件创建出来所以验证启动是否成功最直接的方式就是检查socket文件ls -l /run/microduck/*.sock正常的输出应该能看到每个守护进程对应的socket文件并且权限是0750。接下来用socat向monitor发送一个ping请求验证JSON-RPC链路是否通echo {jsonrpc:2.0,id:1,method:monitor.ping,params:{}} | socat - UNIX-CONNECT:/run/microduck/monitor.sock如果一切正常应该能收到{jsonrpc:2.0,result:pong,id:1}这样的响应。这一步验证通过说明UDS通信、JSON-RPC协议、进程启动三大环节都已经打通整个微服务架构的底座就算立住了。3.4 训练任务是怎么塞进这套架构的既然这个系统要承担训练任务就不得不说说训练流程和守护进程架构是怎么结合的。Microduck本身不是一个训练框架它不依赖PyTorch或TensorFlow的内部结构而是把训练过程包装成一个“任务”来调度。我打磨出来的流程是先准备一个标准化的训练脚本再把训练参数数据集版本、模型类型、超参、输出路径包装成JSON-RPC请求参数调用scheduler.submit_task提交。scheduler收到请求后会把任务拆成更小粒度的step依次或并行分发给空闲worker。每个worker在自己的GPU资源上执行一个step执行完把中间结果回传给repository再领取下一个step。这就把训练任务的本质变成了“任务调度”多个step在多个worker间并行执行单个worker崩溃不会导致整个训练流程中断。我实测过在一个4卡机器上跑一个小规模的语言模型微调默认配置下可以把单卡训练速度提升到原来的3.2倍左右而且因为任务粒度可控后续想看训练进度只需要不断查询query_status不用像传统方式那样盯着GPU利用率猜进度。4. 核心实现JSON-RPC消息格式与调用约定4.1 JSON-RPC 2.0协议基础在Microduck的源码里JSON-RPC协议的实现其实只用了几个类和一个消息分发函数核心思路非常简洁。理解这套协议的关键在于掌握四种对象Request、Response、Notification和Error。Request对象必须包含jsonrpc字段固定为2.0、method字段要调用的方法名和id字段请求标识符。params字段可选可以是结构体或数组。Response对象必须包含jsonrpc字段、result或error字段之一以及对应请求的id。Notification和Request唯一的区别是没有id字段——调用方不关心响应服务端也不需要回包。这种设计的好处很直接调用方可以根据id把响应和之前的请求对应起来即使网络层乱序也无所谓方法名是点分字符串天然支持rpc风格的命名空间比如scheduler.register和worker.train_step调用方拿到方法名就能猜到是哪个守护进程提供的哪个能力。4.2 Microduck方法命名规范与参数表Microduck内部维护了一套不成文的命名规范方法名统一采用{服务名}.{动作名}的格式服务名和socket文件名保持一致比如scheduler、worker、repository动作名用动词register、submit、query、train、infer。这样设计的好处是不需要额外的文档就能猜出大部分方法的作用。方法名服务端参数返回值说明monitor.pingmonitor无pong健康检查探活scheduler.registerschedulerworker_id, socket_path, gpu_idokworker启动时注册scheduler.unregisterschedulerworker_idokworker退出时注销scheduler.submit_taskschedulertask_type, params, prioritytask_id提交任务scheduler.query_statusschedulertask_idstatus, progress查询任务进度worker.train_stepworkermodel_id, dataset_id, hyperparamsoutput_model_id执行单步训练worker.inferworkermodel_id, input_dataresult执行推理repository.get_modelrepositorymodel_idmodel_info读取模型文件信息repository.save_modelrepositorymodel_data, metamodel_id保存模型文件参数和返回值的具体字段在Microduck的代码里有完整定义实际二次开发时可以直接照搬这套方法命名风格不用纠结“该叫submit还是push”这类问题。团队的沟通成本也能因此低不少。4.3 一个完整的请求-响应案例为了让读者更直观地理解Microduck内部一次完整的JSON-RPC交互长什么样我拿scheduler.submit_task来举例。调用方向scheduler发送{ jsonrpc: 2.0, id: 1001, method: scheduler.submit_task, params: { task_type: train, params: { model_id: model_base_v3, dataset_id: dataset_v2, hyperparams: { epochs: 10, learning_rate: 0.0002 } }, priority: 5 } }scheduler收到后经过校验和排队返回{ jsonrpc: 2.0, result: { task_id: task_8f3a2c }, id: 1001 }过一段时间后调用方再发query_status查询任务状态scheduler返回{ jsonrpc: 2.0, result: { status: running, progress: 0.45 }, id: 1002 }这套消息往返格式一旦定下来调试时的体验会好很多。我常用的排查方式就是把消息原样抓下来用Python脚本重放看服务端处理到哪一步出了岔子效率比起翻日志猜问题高不少。如果要在Microduck里自定义一个新方法代码侧其实只需要增加一个处理函数比如在scheduler.py里增加一个submit_preprocess函数然后在消息分发时注册方法名就行。因为协议本身是文本化的新增方法不需要修改任何调用方改一下调用端的method字符串和params结构就能搞定。这种低耦合度在二次开发时极其舒服。5. Debug与排障Unix socket上的常见问题5.1 权限和所有者的坑连接总是被拒绝我第一次把Microduck跑起来的时候遇到了一个非常典型的UDS问题——用socat连接某个socket文件时报错permission denied。查了一下才发现是socket文件的属主和权限设置不对。UDS的访问控制完全依赖文件权限如果socket文件创建时的umask把组内读写权限都干掉了那么其他运行用户比如从同一个系统用户下启动的scheduler自然就没有权限访问。解决办法是先查看socket文件的权限和属主ls -l /run/microduck/ stat /run/microduck/scheduler.sock确认无权限后可以通过调整父目录权限或者设置umask来解决# 确保目录属组正确 chown -R microduck:microduck /run/microduck chmod 0750 /run/microduck # 启动脚本里设置umask确保socket文件默认创建为750 umask 0027顺带提醒一句调试时不要图方便直接chmod 777整个socket目录这等于把你所有内部接口全部暴露给系统上任何用户安全上完全不可接受。要用文件系统的权限体系不要自己捅漏子。5.2 请求超时和Worker繁忙问题任务量大起来之后最容易出现的问题就是JSON-RPC请求超时。比如一个训练任务刚提交完立刻查询query_statusscheduler还没完成分发查询端先等不到响应了。这种情况的根因通常不在网络而是worker或scheduler处理队列堆积短时间内收到的请求量超过了并发处理能力。排查方法是一层一层看先看scheduler日志有没有任务排队记录再看worker进程的CPU和内存占用判断是不是worker被大任务打满了。如果确实是被大任务占满考虑增加worker.instances数量或者在网关层增加请求排队策略让调用方明确知道任务已接收、正在排队。我自己的经验是不要在任务刚提交的瞬间立刻查状态给scheduler一个合理的调度buffer几百毫秒到一秒然后再发起查询。另外调用侧一定要设置超时时间不能无限等待不然某个下游进程卡死时上游会跟着一堆线程在等待中被拖死。5.3 守护进程崩溃后的残留socket文件进程崩溃后它持有的socket文件不会自动销毁。这个问题的表现是重启一个worker时提示Address already in use但实际上你根本看不到那个进程在运行。原因就是上一次崩溃时进程来不及清理socket文件而内核认为该地址还被占用。解决方法有两个思路。最直接的思路是启动脚本里先清理旧socket文件# 启动前清理残留socket rm -f /run/microduck/*.sock更优雅的思路是使用Linux的abstract socket namespace即把socket路径写成一个以\0开头的特殊字符串比如\0microduck.scheduler。abstract socket不依赖文件系统进程退出时内核自动回收天然不存在残留问题。但缺点是无法用文件权限来控制访问必须依赖进程本身的访问控制对于注重权限管理的生产环境未必更优。Microduck默认采用文件系统socket所以我的建议是写一个标准的systemd service文件在ExecStartPre里执行socket目录清理这样每次重启都能保证干净起步。5.4 排查工具推荐UDS调试比TCP调试要“冷门”一些很多常用的网络调试工具在UDS上不直接可用但熟悉下面这几个工具后效率会高很多。socat万能的socket调试工具可以直接向Unix socket发送任意数据是验证JSON-RPC协议最方便的手段。curl --unix-socket如果你偶尔把HTTP协议跑在Unix socket上curl的这个参数可以派上用场它还支持HTTP的各种调试选项。ss -x列出所有Unix socket连接及其状态看两个进程之间是否建立了连接、连接处于什么状态一清二楚。strace -p跟踪进程的系统调用当怀疑某个socket调用卡住或报错时观察它是否卡在connect或write上。举一个实际排查场景有次daemon进程运行一段时间后突然不再响应请求用ss -x查看发现scheduler和worker之间的连接状态长时间处于Send-Q有积压的状态立刻判断是worker端处理缓慢导致消息堆积而不是连接断开。这种排查思路比单纯看日志高效太多了。最后再分享一个我在实际使用中的习惯无论改动多小都要先跑一遍完整的链路验证而不是只验证单点功能。因为Microduck这类多进程协作系统真正复杂的问题往往出现在各环节的交接处——gateway转发给scheduler时、scheduler调度给worker时、worker回传结果给repository时任何一个环节的理解错位都会导致整个链路崩掉。我自己就是被这种跨进程问题折磨过好几轮才养成了一改配置就从头到尾跑通一遍验证的习惯。你可以把socat发ping、提交一个最小训练任务、再查状态这三步串成一条命令每次改动后一键验证能省下大量排查时间。
返回列表