cdpc
v6.2.0
Published
child process management
Maintainers
Readme
cdpc:坚强的进程管理模块
Node.js环境的进程管理模块。用于针对不同进程的管理工作。可管理任何需要托管的程序。它利用child_process模块的spawn接口完成子进程的创建工作。
自 v6.1 起支持 cluster 模式(cluster: true / workers: N),给 nodejs 服务起
N 个 worker 共享同一端口,应用零改动 —— 但 cdpc 自身不做 cluster 管理,
它只多管一个 launcher.js 进程,worker 的 fork / 补员 / 收尾都在 launcher 内完成。
详见「cluster 模式」一节。
非 nodejs 服务、或需要更复杂负载策略时,仍可以:
自行实现,最简单的示例,使用cluster也就几行代码。
使用其他框架再配合相关扩展。
需要明确的是:
此扩展是为了开发工作而设计,不是提供一个命令去管理程序。
基于此扩展设计进程管理的命令也很容易,并且有一个基于此实现的cdpc命令和服务,具体参考cdpcmd。
同一个命令,同样的参数不能重复。
切忌不要把多个服务监听同一个端口。
示例配置中涉及到的Web服务文件,需要自行编写测试程序,使用任何你熟悉的方式都可以。
开发此扩展的原因
其主要原因是因为我在titbit中已经实现了cluster模式自动管理子进程。而基于worker_threads也可以实现多线程管理的模型。但是还需要一个既能和它们配合使用还可以单独使用的进程管理模块,可以整合多个Web应用,还可以管理脚本、编译的二进制程序等。
另一个原因是,cluster模式由于其内部实现机制比较复杂,考虑到不同用户权限导致的问题,在Linux/Unix上,子进程和父进程必须是相同的uid和gid,如果master进程是root身份,则worker进程也是root身份,所以对worker更改uid和gid会失败。
若要综合实现各种需求,那么这个扩展和web框架再结合cluster是一个利器。
特点
它很简单并强大,而且还很稳定,提供了简单的接口控制子进程的终止和启动,可以在运行时删除和添加子进程服务。
你可以进行嵌套式管理:调用此模块去管理另一个文件,另一个文件中还使用了此模块。
如此反复,可以实现任意复杂的多进程多线程模型。当然我不建议你做的太复杂,尽可能扁平化最好管理。
应用内直接调用接口即可完成全部管理;若需要跨进程管理(命令行工具、运维脚本), 可以开启基于 unix socket 的控制通道,它带请求-响应语义、入参白名单校验与自愈能力, 详见「控制通道(unix socket)」一节。
示例
通过调用runChilds或run,传递一个配置数组即可,每个元素就是一个要启动的子进程配置说明。
'use strict'
const CDPC = require('cdpc')
//开启strong模式,监听'uncaughtException' 和 'unhandledRejection'事件不退出。
//还可以传递参数设置自定义监听函数,两个监听函数对应的事件顺序就是:
// 'uncaughtException' 'unhandledRejection'
let cm = new CDPC({
debug: true,
//收到SIGTERM、SIGABRT、SIGINT信号不退出。
notExit: true
})
cm.strong()
cm.runChilds([
{
name : 'api',
file : 'app.js',
args : ['--port', 2021],
options : {
stdio: ['ignore', 1, 2]
}
},
{
name : 'test',
command : 'date',
restart : 'count',
restartLimit: 10,
restartDelay: 1000,
options : {
stdio: ['ignore', 1, 2]
}
}
])
子进程配置选项详细说明
| 配置项 | 说明 | 必须 | 可选值 | |----|----|----|----| | name | 子进程应用的名称 | 否 | 自定义,建议名称必写,方便管理。 | | command | 要运行的命令 | 否 | 若是运行js文件,默认会使用当前node版本。 | | file | 要运行的js文件路径 | 否 | 快捷选项,最终会把此选项指定的文件放在args中作为参数。 | | args | 运行命令要传递的参数 | 否 | 默认为空,具体传递参数自行定义。 | | options | spawn接口的options选项 | 否 | 参考child_process.spawn文档。 | | callback | 创建子进程后的回调函数 | 否 | 回调函数传递的第一个参数是spawn的返回值,就是ChildProcess实例,第二个参数是cdpc实例。 | | onError | error事件的回调函数 | 否 | 方便错误处理提供的选项,所有事件回调都可以在callback中自行定义。 | | restart | 重启模式 | 否 | 默认为always,可选值:always,count,none,fail,fail-count。 | | user | 指定以某个用户身份运行 | 否 | 只针对Linux、类Unix有效,指定的用户必须在/etc/passwd中有记录。 | | group | 指定以某个用户组身份运行 | 否 | 只针对Linux、类Unix有效,指定的用户组必须在/etc/group中有记录。 | | cgroup | 指定Linux cgroups控制组,默认为空表示不做资源控制。 | | monitor | 是否开启监控,开启后会监控此进程的CPU、内存 | 否 | true或false。 | | stopTimeout | stop应用之后的定时器毫秒数值 | 否 | 取值范围:5 ~ 600000。包括边界值。 | | restartDelay | 重启延迟 | 否 | 毫秒数,默认为延迟1000毫秒重启。 | | restartLimit | 重启上限 | 否 | 当restart模式为count,则会通过计数和此值比较。 | | autoRemove | 自动移除 | 否 | 只有在restart为count、fail、fail-count时有效,表示当应用运行完成后,自动移除。 | | onceMode | 作为命令执行一次 | 否 | true或false。相当于设定了autoRemove为true、restart为count、restartLimit为0。 | | after | 声明关系依赖 | 否 | 用于声明此应用要在哪些服务运行之后再启动。可以传递一个字符串,或字符串数组,参数是所依赖服务的名字。 | | monitorNetData | 是否监控网络数据 | 否 | 默认是false,当开启后,会在loadinfo.net上看到网络收发数据以及一段时间内的速率。 | | env | 环境变量,object类型 | 否 | 此配置表示扩展添加的环境,如果是options.env配置是直接覆盖。 | | only | 是否唯一,默认为false, | 否 | 表示此进程是否需要唯一运行,比如某个服务监听某个端口必须是唯一的。 | | onlyArgs | only为true的时候,哪些参数作为唯一标识,默认是空数组 | 否 | 默认是空数组,表示命令本身就是唯一标识。 | | lockReload | reload 时是否豁免移除 | 否 | 默认false。设为true时,loadConfig 的 reload 差集同步不会移除此服务,用于程序化添加、不随配置目录同步的服务。 | | cluster | 启用 cluster 模式(多 worker 共享端口) | 否 | true 或 false。只支持 nodejs 服务,详见「cluster 模式」一节。 | | workers | worker 数量 | 否 | 数字。0 表示 CPU 核数,上限 64;只写它也会启用 cluster。 | | real_command | 实际执行的命令(包装用) | 否 | 字符串。存在时 spawn 用它代替 command,而 command 保持用户原值。 | | real_args | 实际执行的参数(包装用) | 否 | 数组。cluster 模式会自动生成,与手写互斥。 |
command 与 file 至少要指定一个。 表里每一项单独看都是可选的,但两个都不写
就没有可执行的命令,配置会被拒绝并报 没有可执行的命令,command 与 file 至少要指定一个。
file 是快捷写法,会按扩展名推导 command(.js/.cjs/.mjs → node,
.sh → bash,.py → python,其余扩展名必须自己写 command)。
包装型配置只写 real_command 也算数——spawn 实际取的是 real_command || command。
如果配置项monitor设置为true,需要调用monitorStart开启监控。
CDPC.prototype.run
run是runChilds的别名。
file和所在路径
当你指定file的时候,会自动在创建子进程的时候让子进程的工作目录在file文件所在目录。
restart模式
- always 表示总是重启。
- count 是表示重启次数有上限。
- none 表示不重启。
- fail 表示失败后重启,通过检测退出状态码(code)是不是为0。
- fail-count 表示失败重启计数,只有在检测退出状态码不是0,并且计数不超过限制的情况下才会重启。
关于user和group
如果直接设定options的uid和gid会比较麻烦,需要去查看文件,而不同发行版或者同一发行版的不同版本其用户的uid和gid也可能不同。
Linux上默认就有很多为服务提供的系统用户,比如在各个不同发行版中基本都会有nobody、www、www-data等经常用于Web服务的系统用户。所以通过名称来指定用户和用户组是更好的选择。
当指定了user和group,则会自动去对应的配置文件解析查找出对应的uid和gid,如果不指定group,则使用user默认的uid和gid。
对用户和组进行缓存
指定了用户和组,会读取文件进行解析,但是这是一个同步处理的过程。所以提供了一个缓存机制。
首次解析用户会把解析后的结果加入到this.linuxUsers和this.linuxGroups进行缓存,格式如下:
//this.linuxUsers
{
www: {uid: 123, gid: 126}
}
//this.linuxGroups
{
www: {gid: 126}
}
如果想避免首次执行进程在指定用户和组的情况下去读取文件进行解析,可以自定义处理函数,预先解析好数据,并按照对应的格式,设置两个缓存变量的值。
Linux发行版默认的user和group文件路径:
/etc/passwd
/etc/group
如果你使用的Linux发行版对目录结构做了调整,可以通过配置来指定:
//假设你使用的系统把默认的配置文件放在了/usr/etc。
let cm = new CDPC({
userFile: '/usr/etc/passwd',
groupFile: '/usr/etc/group'
})
自定义事件
若要针对创建的子进程做事件处理,则可以在callback中完成,示例:
'use strict'
const CDPC = require('cdpc')
let cm = new CDPC({
debug: true
})
cm.run({
name : 'testapp',
command : 'date',
restart: 'count',
restartLimit: 10,
restartDelay: 1000,
callback: (ch) => {
ch.stdout.on('data', data => {
console.log(data.toString())
})
}
})
指定用户和用户组
'use strict'
const CDPC = require('cdpc')
let cm = new CDPC({
debug: true
})
cm.runChilds([
{
name: 'web-service',
file: '/home/xx/api/app.js',
user: 'www-data',
group: 'www-data',
options: {
stdio: ['ignore', 1, 2]
}
}
])
其中的app.js是你使用web框架编写的服务程序。无论是直接通过options指定uid和gid还是使用user和group指定用户名,只有root用户有权限这样做,所以这个程序必须以root身份运行才可以成功,你需要用sudo。
假设以上代码的文件名是chld.js:
sudo node chld.jsuser、group选项可以使用数组传递多个用户,这种方式是为了防止对应的用户身份不存在,比如web服务系统经常会运行在www或www-data用户身份,但是有的系统没有www-data,有的没有www,为了避免程序经常的更改,可以这样指定用户,用来兼容多个不同的系统。
指定多个用户和用户组
这种方式,会在查到用户之后,直接返回,不会继续查找用户身份。
'use strict'
const CDPC = require('cdpc')
let cm = new CDPC({
debug: true
})
cm.runChilds([
{
name: 'web-service',
file: '/home/xx/api/app.js',
user: ['www-data', 'www', 'nobody'],
group: ['www-data', 'www', 'nobody'],
options: {
stdio: ['ignore', 1, 2]
}
}
])name选项和应用管理
以下演示的pause、resume、stop、start、remove、restart都是基于name的,也就是说你要给应用命名。
name 命名规则
- 以字母、数字或下划线开头。
- 仅包含字母、数字、下划线、减号(-)、@。
- 长度不超过 50。
- 配置未指定 name 时,从配置文件名(去扩展名)派生,此时文件名须符合以上规则。
允许 @ 是为了支持 user@xxx 这类便于区分来源的命名。
暂停和恢复、停止和启动
'use strict'
const CDPC = require('cdpc')
let cm = CDPC({
debug: true
})
cm.runChilds([
{
name : 'tofile',
file : 'tofile.js',
user : 'www',
options : {
stdio: ['ignore', 1, 2]
}
}
])
//5秒之后暂停tofile应用,此时应用程序不销毁,还在内存里。
setTimeout(() => {
cm.pause('tofile')
}, 5000)
//15秒后恢复tofile应用,resume用于恢复pause暂停的应用。
setTimeout(() => {
cm.resume('tofile')
}, 15000)
//25秒后停止tofile应用,此时应用销毁,子进程停止。
setTimeout(() => {
cm.stop('tofile')
}, 25000)
//35秒后启动tofile应用,start用于启动stop停止的应用。
setTimeout(() => {
cm.start('tofile')
}, 35000)
//45秒后重启tofile应用。
setTimeout(() => {
cm.restart('tofile')
}, 45000)
stop和清理工作
stop接口会向指定的应用发送SIGTERM信号。5秒后检测是否还在运行,仍然运行则发送SIGKILL信号。
一个细节问题是,如果我使用了stop停止服务,但是如果服务子进程有一些资源需要清理,或者还需要向它自己的子进程发送通知,该如何处理?
子进程监听SIGTERM信号,当收到此信号,表示要退出,可用于后续任务安排后再选择退出。
stop支持第二个参数用于指定多少毫秒后检测是否运行并发送SIGKILL信号,默认为5000毫秒。若需要灵活的配置,可以在子进程配置项中通过stopTimeout指定。
移除和添加应用
remove通过name指定的名字来移除应用,移除应用是一个暴力操作,如果要安全移除,可以使用safeRemove,safeRemove会先进行stop,然后默认在5秒后进行remove操作。
safeRemove仍然支持第二个参数作为定时器超时检测的毫秒数值,safeRemove内部调用了stop,子进程配置项的stopTimout仍然会对此起作用。
add用于在运行时动态添加应用。
示例:
'use strict'
const CDPC = require('cdpc')
let cm = new CDPC({
debug: true
})
cm.runChilds([
{
name : 'tofile',
file : 'tofile.js',
user : 'wy',
options : {
stdio: ['ignore', 1, 2]
}
},
{
name : 'tofile2',
file : 'tofile.js',
user : 'wy',
args : ['--port', 1235, '--https', '--session'],
options : {
stdio: ['ignore', 1, 2]
}
},
])
//25秒后安全移除tofile2应用,并添加subchld应用。
//subchld应用同样是一个使用cdpc模块管理子进程的应用。
//其内部应用也是几个Web服务程序。
setTimeout(() => {
cm.safeRemove('tofile2')
cm.add({
name : 'subchld',
file : 'mchld.js',
user: 'www-data',
group: 'www-data',
options : {
stdio: ['ignore', 1, 2]
}
})
}, 25000)
使用cgroup进行资源控制
在Linux上可以使用cgroups进行资源控制,cdpc在检测到是Linux平台会自动初始化cgroup功能模块,使用cgroup属性即可访问。
const CDPC = require('cdpc')
let cm = new CDPC()
//创建一个名为cf-test的控制组,设定模式为domain,这是默认值,type可以不传。
cm.cgroup.create('cf-test', {
type: 'domain',
//在10000时间片上,占有5000,就是50%占有率
cpu: [5000, 10000],
//内存上限,单位字节:50MB
memory: 50 * 1024 * 1024
})
cm.runChilds([
{
name: 'xxx',
file: './a.js',
cgroup: 'cf-test'
}
])
单位对照(务必分清)
cgroup 的配置项单位跟 cgroup v2 的接口文件完全一致,跟 limit.maxrss 不是一套:
| 配置项 | 单位 | 谁来执行 | 超限行为 |
|---|---|---|---|
| cgroup.memory / setMem() | 字节(或 'max') | 内核 | 直接 OOM kill,进程收 SIGKILL |
| cgroup.swap / setSwap() | 字节(或 'max') | 内核 | — |
| cgroup.cpu / setCPU() | 数字 = 百分比;数组 = [quota, period] 微秒;0 或不写 = 不限制 | 内核 | 限流,不杀进程 |
| cgroup.pids | 进程数(或 'max') | 内核 | fork 失败 |
| cgroup.rbps/wbps | 字节每秒;riops/wiops 次每秒 | 内核 | 限流 |
| limit.maxrss | KB | cdpc 自己轮询 | 按 maxRestart 重启或停止 |
写 memory: 100 想表达 100MB 是最常见的误配 —— 小于 1000 字节的限额没有现实意义,
cdpc 会退回不限制并打印告警,不会静默放开。
cpu 的内核硬约束(实测 5.15):quota >= 1000µs,period ∈ [1000, 1000000]µs。
百分比写法换算成 [pct * 1000, 100000],所以 1%~99% 全部可用;数组写法不满足
约束时提前拦下并告警,不留给内核去 EINVAL。
cpu: 0以及完全不写 cpu 都表示不限制,与 memory / pids / setCPU() 的
0 语义一致。cdpc 是库,没被要求限制就跟系统默认走;为稳定性做兜底收敛是上层的事
(cdpcmd 的预设组全部显式写了 cpu)。
关于内核这几个"内存限制"接口的单位,实测结论:
RLIMIT_RSS(setrlimit/ulimit -m)单位是字节,但在 Linux 上根本不生效 (man 2 setrlimit:只在 2.4.x, x < 30 有效,且只影响madvise(MADV_WILLNEED))。 另外 shell 的ulimit -m按 KB 收发,底层 rlimit 结构里是字节,两层单位不同。getrusage()的ru_maxrss(Node 的process.resourceUsage().maxRSS)在 Linux 上是 KB;macOS / *BSD 上是字节,跨平台代码要注意。/proc/<pid>/stat第 24 字段rss是页;/proc/<pid>/status的VmRSS是 kB。- cgroup v2 的
memory.max是字节,而且是唯一真正被强制执行的那个。
cpu 配额的单位:一个 CPU,不是整台机器
cpu.max 的 quota / period 是多少个 CPU,与机器核数无关。三份原始文档一致:
- 内核
Documentation/admin-guide/cgroup-v2.rst:$MAX $PERIODwhich indicates that the group may consume up to$MAXin each$PERIODduration. - systemd
systemd.resource-control(5)的CPUQuota=(它控制的就是cpu.max):The percentage specifies how much CPU time the unit shall get at maximum, relative to the total CPU time available on one CPU. Use values > 100% for allotting CPU time on more than one CPU.
- Docker:
--cpus="1.5"在双核主机上是「at most one and a half of the CPUs」, 等价于--cpu-period=100000 --cpu-quota=150000。
实测(本机 2 核,判据只需要一个核的余量,与机器多忙无关):
| cpu.max | 单线程实际拿到 | 被限流 |
|---|---|---|
| max 100000 | 1.00 个 CPU | 0 次 ← 证明有整整一个核可用 |
| 50000 100000 | 0.51 个 CPU | 41 次 |
| 25000 100000 | 0.26 个 CPU | 41 次 |
| 10000 100000 | 0.11 个 CPU | 42 次 |
如果 50000/100000 是「全机 50%」,2 核机器上就是 1.0 个 CPU,而单线程最多也只能
用 1.0 个 CPU —— 那就恰好在上限之下、一次都不该被限流。另外 150000 100000
实测能跑到 1.51 个 CPU,超过 1.0,百分比读法本身就不成立。
所以百分比写法 cpu: 50 表示「半个 CPU」,不是「半台机器」。 想按整机比例限制,
自己乘核数:cpu: [Math.round(0.5 * os.cpus().length * 100000), 100000]。
库不替调用方猜机器规模 —— 这类策略判断属于上层(cdpcmd 的预设组就是这么做的)。
cpuset.cpus 才是「能用哪些核」,两者是不同维度,可以叠加使用。
加入 cgroup 失败会告警
服务配了 cgroup 但实际没能入组(非 root 运行、cgroup 未创建、写 cgroup.procs
失败)时,会通过 errorHandle 抛出 --WARN-CGROUP-ATTACH--,并说明该服务当前
没有资源限制——配了限额却毫无提示地在无限制状态下运行,是最难查的那类问题。
注意入组发生在 spawn 之后:子进程有一个极短的窗口运行在 cgroup 之外,在这个窗口里 fork 出来的孙子进程也不会被纳入。对常规服务无影响,对启动瞬间就大量分配内存的程序 需要留意。
cgroup 创建位置与 cgroupBaseDir
默认情况下,cgroup 子组创建在 cgroup v2 根 /sys/fs/cgroup 下。当 cdpc 运行于
systemd 服务中时,更合理的做法是把子组建在本服务自己的 cgroup 子树内,这样服务停止时
systemd 能连同子组里的进程一并回收,进程不会逃逸出服务 cgroup。
用初始化选项 cgroupBaseDir 指定基准目录:
let cm = new CDPC({
// 本进程所在的 cgroup 目录,cgroup 子组将建在它下面
cgroupBaseDir: '/sys/fs/cgroup/system.slice/myservice.service'
})注意 cgroup v2 的"无内部进程"规则:作为基准目录的 cgroup 一旦创建了子组,就不能再 直接驻留进程,调用方需先把自身进程移入一个叶子子组。
控制器必须被上级委托,否则限额一个都建不起来。 子组里只会出现上级
cgroup.subtree_control 已启用的控制器对应的接口文件,而上级能启用的又受它自己的
cgroup.controllers 限制。往 subtree_control 写一个不可用的控制器,内核返回的是
ENOENT 而不是 EINVAL —— 老版本这里抛出的就是一个既不说文件也不说原因的
ENOENT: no such file or directory, write。现在 create() 会先比对
cgroup.controllers,缺哪个控制器、上级可用的是哪些、应该怎么办都写在错误信息里。
实测两种环境的差别:
| 运行方式 | 上级 cgroup.controllers | 结果 |
|---|---|---|
| systemd 服务(unit 带 Delegate=yes) | cpuset cpu io memory pids | 正常,限额全部生效 |
| 手工在登录会话里直接跑 | 往往只有 memory pids,且 scope 里还有别的进程 | 建组失败(EBUSY / 控制器缺失),并给出明确原因 |
所以生产环境请通过 systemd 运行(makesystemd.js 生成的 unit 已经带
Delegate=yes),或显式把 cgroupBaseDir 指到一个已委托对应控制器的 cgroup。
cdpc初始化选项
| 配置项 | 说明 | 可选值 |
|----|----|----|
| debug | 是否启用调试模式。 | true或false |
| signalHandle | 信号处理函数,不设置采用默认处理,参考process.on的信号事件。 | 函数,接收参数signal |
| onExit | process的exit事件回调函数,不设置则采用默认处理。 | 函数 |
| errorHandle | 统一的错误处理函数,接收参数第一个是error,第二个是错误描述的辅助标记名称。 | 函数,示例:(err, errname) => {} |
| notExit | 不退出应用,默认为false,设置为true则会监听信号不退出。 | 若自定义signalHandle,则需要自行处理。 |
| notExitButSpread | 不退出应用,但是收到信号会扩散到子进程,默认为false。 | 若自定义signalHandle,则需要自行处理。 |
| config | 配置文件路径,配置格式和runChilds接收参数一致。 | json或js类型,若是js则必须用module.exports导出模块。 |
| loadInfoType | 负载信息的格式,json格式主要用于程序解析。 | text或json。 |
| loadInfoFile | 负载信息的写入文件路径。 | 若是不设置则输出到终端。 |
| showColor | 在终端输出是否显示颜色。 | true或false。 |
| userFile | Linux用户信息文件路径,默认为/etc/passwd。 | 若非特殊发行版或更改了配置路径不要修改此值。 |
| groupFile | Linux用户组信息文件路径,默认为/etc/group。 | 若非特殊发行版或更改了配置路径不要修改此值。 |
| allowDetached | 是否允许 detached 子进程,默认为false。为false时把配置里的 options.detached 强制归正为 false。 | true或false。 |
| cgroupBaseDir | cgroup 子组的创建基准目录。默认空,在 /sys/fs/cgroup 根下创建。 | 绝对路径字符串。 |
| beforeStartCallback | 运行子进程之前的回调函数,接收参数为一个配置对象,是格式化后的配置对象。 | 函数。 |
| onLoadConfig | loadConfig 完成后的回调,接收加载结果对象。 | 函数。 |
| sockFile | 控制通道的 unix socket 路径。不设置则不开启控制通道(仅可由宿主代码直接调用 API)。 | 绝对路径字符串。详见「控制通道」一节。 |
| sockMode | socket 文件权限,默认 0o600(属主专用)。 | 0 ~ 0o777 |
| sockIdleTimeout | 连接空闲超时(毫秒),按活动重置,默认 30000。 | >= 1000 |
| sockMaxLine | 单行请求上限(字节),默认 1MB,超限断开该连接。 | >= 1024 |
| sockMaxConnections | 并发连接上限,默认 32,超出的连接立即断开。 | >= 1 |
| sockMaxWriteBuffer | 单连接出向缓冲上限(字节),默认 8MB。防止"只发请求不读应答"的客户端把响应缓冲撑爆。 | >= 65536 |
| sockGuardInterval | 自愈守护周期(毫秒),默认 60000。用于 socket 存活的慢路径检查与 pid 文件保活。 | >= 1000 |
| stateDir | 持久状态目录,只存 detached 子进程的 pid 文件(pids/<name>.pid)。不设置时按 sockFile 的父目录推导,再退到 <tmpdir>/cdpc-state-<euid>。 | 绝对路径字符串。 |
| eventDir | stateDir 的路径别名,仅为兼容旧配置而保留:显式给了 stateDir 时它被忽略,未给时才回落到它。采纳与否与键的书写顺序无关。 | 绝对路径字符串。会报一次弃用告警,请改用 stateDir。 |
传入不认识的选项会出声
传入已经不存在的选项(早期文件事件机制的 notWatch / watchGuardInterval /
stateKeepaliveInterval 等)不会被静默忽略,而是通过 errorHandle 报
--WARN-DEPRECATED-OPTION--。静默忽略会让人以为配置生效了,实际什么也没发生。
env 是追加语义,不是覆盖
env 与 options.env 是两个不同层次的选项,位置与语义都不同:
| 选项 | 层级 | 语义 | 说明 |
|----|----|----|----|
| env | 服务配置项,与 options 同级 | 追加 | 以 process.env 为底再叠加,最终写入 options.env。用于"再加几个变量"这种最常见的需求 |
| options.env | options 内,对应 Node 核心模块(child_process.spawn)的原生选项 | 覆盖 | 子进程只能看到这里给的变量 |
三种组合的实测结果:
| 配置 | 子进程看到的环境 |
|----|----|
| 只写 env: {A:'1'} | process.env 全部 + A(38 条,含 PATH) |
| 只写 options.env: {B:'2'} | 只有 B(1 条,无 PATH) |
| 两个都写 | 以 options.env 为底叠加 env → A + B(2 条,无 PATH) |
值会被规范化(不做的话就是静默错误):spawn 对 env 值不校验类型而是静默
String(),实测 null → "null"、{a:1} → "[object Object]"、[1,2] → "1,2"。
所以:
- 字符串原样;数字 / 布尔 / bigint 转字符串
null/undefined/ 对象 / 数组 / 函数:丢弃并告警(--WARN-ENV-VALUE--)- 用户传入的
options.env对象不会被就地修改——同一个对象被多个服务复用时 否则会互相污染
cluster 模式下 worker 的序号(CDPC_WORKER_ID)由 launcher 用
cluster.fork(env) 注入,Node 的语义同样是合并进 worker 环境(实测 38 → 39 条,
PATH / HOME 等全部保留),不会覆盖继承来的变量。
关于 detached 与 allowDetached
子进程是否 detached(脱离父进程、独立进程组)由子进程配置的 options.detached
决定(child_process.spawn 的原生选项)。allowDetached 是实例级的策略开关:
allowDetached: false(默认)—— 检测配置阶段会把options.detached: true强制归正为false,所有子进程都非 detached、与父进程同生死。allowDetached: true—— 放行options.detached: true,并启用 detached 进程的 接管恢复(父进程重启后重新接管上次遗留的 detached 子进程)。
detached 能让子进程跨父进程重启存活,但接管机制较复杂;无此需求时建议保持默认。
以配置文件的方式加载。
const CDPC = require('cdpc')
const cm = new CDPC({
debug: true,
config: './config.js'
})
cm.loadConfig()
控制通道(unix socket)
cdpc 有两种被调用方式,职责分明:
| 方式 | 用途 | 说明 |
|----|----|----|
| 应用内直接调用 API | 同进程管理子进程 | cm.stop('app1')、cm.fmtLoadInfo('json') 等,零通道开销,是首选方式 |
| 控制通道(sock) | 跨进程管理(如 cdpcmd 那样的命令行工具) | 设置 sockFile 才启用;不设置则完全不开 |
如果你只是在自己的程序里用 cdpc 管理子进程,不需要设置 sockFile,直接调 API 即可。
只有当另一个进程(命令行工具、运维脚本、监控程序)需要查询状态或下发控制命令时,
才需要开启控制通道。
为什么不用文件事件
早期版本用「写文件 + fs.watch」实现跨进程控制(eventDir 系列选项)。
这种方式有五个固有缺陷,实践中都出过问题:
- 单向无应答——写进去了不知道有没有被执行;
- 临时文件生命周期不受控——放在 /tmp 会被 systemd-tmpfiles 按年龄清理;
- 半写文件可见——读侧会读到写了一半的内容;
fs.watch会静默失效——目录被删后句柄不报错也不再触发;- 无时效性——旧命令文件可能被重放。
因此 v6 起文件事件机制已整体移除,控制通道只有 unix socket。
文件只保留一处用途:pids/<name>.pid 是 detached 子进程的持久状态
(进程重启后重新接管被 PID 1 收养的子进程,唯一线索就是落盘的 pid,
socket 天生给不了这个信息),它与通信无关,存放在独立的 stateDir。
启用
const CDPC = require('cdpc')
let cm = new CDPC({
config: '/etc/myapp/services',
sockFile: '/run/myapp/control.sock', // 设置即自动启动
sockMode: 0o600,
errorHandle: (err, name) => console.error(name, err.message)
})方法:
await cm.sockStart()—— 手动启动(设置sockFile时构造函数已自动调用)。 返回false表示未启动,原因通过errorHandle给出,不抛异常、不退出进程 (是否因此终止进程属于策略,由宿主代码决定)。cm.sockStop()—— 停止服务、断开所有连接、清理 socket 文件。
协议 v1(NDJSON)
一行一个请求、一行一个应答,nc -U 可直接调试。
应答不保证顺序,客户端按 id 配对,因此允许流水线(一次写多个请求)。
→ {"v":1,"id":1,"op":"status"}
← {"v":1,"id":1,"ok":true,"data":{"pid":1234,"sys":{...},"childs":[...]}}
→ {"v":1,"id":2,"op":"stop","name":"app1"}
← {"v":1,"id":2,"ok":true,"accepted":true,"count":1}
← {"v":1,"id":3,"ok":false,"error":"not-found","name":"appX"}字段约定:
v:协议版本,缺省视为 1;显式给出其他值返回unsupported-version(避免将来的 v2 客户端被当成 v1 静默误解释);id:number/string/null(null 表示不需要配对)。给了却是对象或数组 返回bad-request,不会静默降级;ok:true表示成功;控制类 op 额外带accepted: true。
op 列表
| 类别 | op | 参数 | 说明 |
|----|----|----|----|
| 探活 | ping | — | 返回 {pid},最廉价的在线判定 |
| 查询 | status | — | 复用 fmtLoadInfo('json'),含 pid / sys / childs[] |
| 查询 | has | name(可选) | 返回 [{user, uid, name, pid, state}];不带 name 返回全部 |
| 查询 | inspect | name | 单个服务的运行时快照(白名单浅序列化),含退出码 code 与终止信号 signal——被 cgroup OOM kill 或外部 kill -9 打死时,只有它俩能区分 |
| 控制 | start stop restart pause resume remove safeRemove disable enable restartCount resetCount | name | 语义是已受理,完成确认由客户端轮询 status / has 判断目标状态 |
| 配置 | load | path | 加载指定配置文件(绝对路径、无 NUL 字节、长度 <= 4096、可读) |
| 配置 | reload | — | 重新加载 config 指定的配置 |
控制类 op 的 name 可以是 __all__,表示对当前全部服务广播。
错误码:bad-json(保持连接)、bad-request、unsupported-version、
unknown-op、invalid-name、not-found、invalid-path、path-not-readable、
line-too-long(断开该连接)、internal-error。
入参校验
控制通道的输入是任意字符串(不像文件名受字符集天然约束),因此:
op是精确字符串白名单,逐个分发,不存在this[op](...)这类动态方法名透传;name必须匹配应用名规则,且显式拒绝__proto__/constructor/prototype——应用名正则本身是接受这几个词的(下划线在首字符集内), 只靠正则会命中原型链;查表统一用hasOwnProperty;inspect返回的是白名单字段的浅序列化。子进程记录上挂着 ChildProcess 与 Stream 对象,深序列化会因循环引用崩溃或产生巨型输出,因此禁止。
安全模型
v1 的鉴权就是文件系统权限,由内核判定,协议层不做身份判断 (Node 不提供 SO_PEERCRED,拿不到对端身份):
- listen 前检查父目录:必须存在、属主为当前 euid、且组和其他人都不可写
(
mode & 0o022 == 0)。每次 listen 与每次重新 listen 都重跑, 这条明确封死了把 socket 放进 /tmp 这类全局可写目录的可能; - socket 文件权限
sockMode,默认 0600,在listening回调里同步 chmod 落地; - 属主即身份:unix socket 的
connect()需要该文件的 write 权限, 所以 0600 的 socket 只有属主(和 root)能连。
注意 root 绕过 DAC,能连接任何用户的 socket——这是隔离模型的预期行为 (每个实例一个 socket,各管自己的服务;root 需要聚合时逐个连接)。
明确不支持 abstract socket(\0name):它免疫文件删除,但没有任何访问控制,
任何进程都能连,与上面的安全模型冲突。
自愈
控制通道是长期运行进程的对外入口,必须能从外部破坏中恢复。三条路径:
| 路径 | 触发 | 恢复速度 |
|----|----|----|
| 快路径 | fs.watch 监听父目录,socket 文件被删即触发重建 | 实测 ~50ms |
| 慢路径 | sockGuardInterval 周期检查 socket 存活与监听状态 | 默认 60s 兜底 |
| 父目录重建 | 父目录被整体删除时尝试 mkdir,重建后重跑上面的属主/权限检查 | 随慢路径 |
重新 listen 连续失败按 500/1000/2000ms 指数退避,3 次后告警放弃
(--WARN-SOCK-ABANDON--),避免无限重试风暴。
启动时还会做陈旧 socket 探测:路径上已有 socket 文件时先尝试连接,
连得上说明另一个实例在跑(报 --ERR-SOCK-INUSE-- 并放弃启动,防止双实例抢路径),
连不上则复核属主后删除再 listen。进程正常退出会清理 socket 文件;
被 SIGKILL 的残留由这条探测兜底。
连接治理
- 并发连接上限
sockMaxConnections,超出的连接立即断开; - 单行上限
sockMaxLine,超限断开该连接(畸形 JSON 只回错误、保持连接); - 空闲超时
sockIdleTimeout,按活动重置; - 出向缓冲上限
sockMaxWriteBuffer:空闲超时管不住"一直发请求但从不读应答"的 客户端,这种连接的响应缓冲会无界增长,超限即断开并告警。
慢客户端的内存上界因此是 sockMaxConnections × (sockMaxLine + sockMaxWriteBuffer)。
调试与客户端示例
nc -U 直接可用:
echo '{"v":1,"id":1,"op":"status"}' | nc -U /run/myapp/control.sock
# 流水线:一次三个请求,按 id 认领应答
printf '{"v":1,"id":1,"op":"ping"}\n{"v":1,"id":2,"op":"has"}\n{"v":1,"id":3,"op":"inspect","name":"app1"}\n' \
| nc -U /run/myapp/control.sockNode 客户端(单连接 + id 配对 + 受理后轮询确认):
const net = require('net')
let conn = net.createConnection('/run/myapp/control.sock')
conn.setEncoding('utf8')
let seq = 0, buf = '', pending = new Map()
conn.on('data', chunk => {
buf += chunk
let i
while ((i = buf.indexOf('\n')) >= 0) {
let msg = JSON.parse(buf.substring(0, i))
buf = buf.substring(i + 1)
let p = pending.get(String(msg.id))
if (p) { pending.delete(String(msg.id)); p(msg) }
}
})
function request(op, extra = {}) {
let id = ++seq
return new Promise(rv => {
pending.set(String(id), rv)
conn.write(JSON.stringify(Object.assign({v: 1, id, op}, extra)) + '\n')
})
}
await new Promise(rv => conn.on('connect', rv))
// 控制类是"已受理",完成确认靠轮询
await request('stop', {name: 'app1'})
while (true) {
let r = await request('has', {name: 'app1'})
if (r.data[0] && r.data[0].state === 'EXIT') break
await new Promise(rv => setTimeout(rv, 50))
}客户端要注意三点:
- 失败要分类,不能一律当"没运行":
ENOENT/ECONNREFUSED是未运行;EACCES/EPERM是权限或属主不符;连接超时是进程在但无响应; - 长连接要自动重连:重新 listen 会断开已有连接;
- 并发查询复用一条连接(靠 id 配对),不要为每个请求新建连接。
cluster 模式(多 worker 共享端口)
给 nodejs 服务起 N 个 worker 共享同一端口,目标应用不需要改任何代码:
{
name: 'web',
file: '/opt/app/server.js',
args: ['--port', '8080'],
cluster: true, // 启用;workers 不写则等于 CPU 核数
workers: 4 // 只写 workers 也会启用 cluster;0 = CPU 核数;上限 64
}cdpc 自身不做 cluster 管理
一个 cluster 服务在 cdpc 眼里仍然只是一个子进程——即 launcher.js。
N 个 worker 由 launcher 负责 fork、补员与收尾:
cdpcd
└── launcher.js(cdpc 管的就是它,chk 模型不变)
├── worker 0
├── worker 1
└── worker 2 ← 补员在 launcher 内完成,cdpc 不参与这样 cdpc 的状态机不必承担 M 个服务 × N 个 worker 的组合复杂度, 多个服务同时开 cluster 也互不影响;两层重启职责分明:
- worker 崩 → launcher 补员
- launcher 崩 → cdpc 按
restart策略整组重启
包装机制:real_command / real_args
real_command / real_args 存在时,spawn 用它们代替 command / args,
而 command / args 保持用户配置的原值——所以 status、inspect、
协议与既有逻辑都不用改,用户看到的仍是自己的应用。
cluster 模式只生成 real_args,不设 real_command:目标是 js,command
本来就是 node,而 launcher 也是 node 脚本,执行者不变。更要紧的是
cluster.fork() 的 worker 必然继承 primary 的 execPath
(cluster.settings 只有 args/exec/execArgv/silent,没有 execPath),
所以 launcher 必须由用户配置里那个 node 启动,否则用户为老应用指定的
node 版本会被悄悄换成 cdpcd 自己的 node。
real_command / real_args 也可以由用户直接配置,作为通用包装能力
(numactl 绑核、strace 诊断等),此时与 cluster 互斥。规范化规则:
| 输入 | 结果 |
|----|----|
| real_command 为 falsy / 非字符串 / trim 后为空 | 删除该属性 |
| real_command 两端有空白 | trim 后使用(带空格会 ENOENT) |
| real_args 是字符串 | 包成单元素数组,不按空格切分(想传多个参数请写数组) |
| real_args 是其他非数组类型 | 变成空数组 |
| real_args 元素是数字 | 转成字符串 |
| real_args 元素是 null / 对象 | 丢弃并告警(spawn 会把对象变成字面量 [object Object]) |
| real_args 规范化后为空且没有 real_command | 删除该属性(否则会用"用户命令 + 空参数"静默启动) |
限制与校验
cluster 目前只支持 nodejs 服务(command 是 node 且参数里有 js 入口),
以下组合会被拒绝加载并写入配置加载报告:
- 非 node 目标(python / bash / 二进制)
node -e '...'这类没有入口文件的形式cluster: false同时写了workers(矛盾表达)- 与
only(唯一运行)、onceMode互斥 - 与手写的
real_command/real_args叠加
维持平衡
worker 数以"期望槽位集合"为准,两条路径保证它不漂移:
cluster.on('exit')→ 立即补上同一序号的槽位(主路径)- 每 3 秒 reconcile 巡检 → 补齐缺失的槽位
第 2 条不是冗余:cluster.fork() 本身抛错(内存不足、fd 耗尽)时不会产生
exit 事件,只靠第 1 条会让那个槽位永久空缺。
熔断:滑动窗口(默认 60s)内补员次数超过 max(10, workers×5) 时,
launcher 停止补员、杀掉现有 worker 并以退出码 1 退出,交给 cdpc 按 restart
策略整组重启。这避免了"worker 起来就崩"时 launcher 高速 fork 刷进程。
停机顺序与强制终止
三层超时必须由外到内递减,否则内层没机会执行:
cdpc 的 stop 兜底 SIGKILL(chk.stopTimeout,cluster 服务默认 5000ms)
> launcher 的强制终止阈值(--stop-timeout,默认 3000ms)
> worker 自己的优雅收尾stop 的全局默认等待时间是 2500ms;cluster 服务会把 stopTimeout 提到
5000ms(用户显式配置的值优先),给多进程收尾留出窗口。
实测的信号时序(cdpc stop 一个 3 worker 的 cluster 服务):
[w0] 收到SIGTERM 28:35.821 worker 先收到(cdpc 先遍历子树发信号)
[w1] 收到SIGTERM 28:35.821
[launcher] 收到 SIGTERM,停止补员… master 约 1ms 后收到
[w0] 收到SIGTERM 28:35.822 第二次:launcher 的转发
[w0] 收尾完成退出 28:36.622 800ms 优雅收尾未被打断
[launcher] 全部 worker 已退出,master 退出 master 最后退出要点:
- worker 会收到两次 SIGTERM(cdpc 一次、launcher 转发一次)。
转发不能省:daemon 自身退出走的
killChilds只杀直接子进程, 那条路径下只有 launcher 能通知 worker。 因此应用的 SIGTERM handler 必须幂等。 - master 一定最后退出。launcher 在所有 worker 退出后才退, 否则残余 worker 会被 PID 1 收养成孤儿。
- 忽略 SIGTERM 的 worker 会被强制终止:到达阈值后 SIGKILL。 实测 3 个显式忽略 SIGTERM 的 worker 在 3018ms 被杀,master 3119ms 退出,零残留。
- 孤儿自检:父进程(cdpcd)被 SIGKILL 时它没机会通知任何人, launcher 会检测到"ppid 从非 1 变成 1"并自行带走 worker 收尾。
信号与退出的可靠性矩阵(逐条实测)
| 场景 | 机制 | 实测结果 |
|----|----|----|
| stop | cdpc 向整棵子树发 SIGTERM,再给 launcher;launcher 关补员→转发→等待→超时 SIGKILL | worker 先收到(约 1ms 后 master 收到),worker 优雅收尾不被打断,master 最后退出,零残留 |
| worker 忽略 SIGTERM | launcher 到阈值后 SIGKILL | 3 个顽固 worker 在 3018ms 被杀,master 3119ms 退出,零残留 |
| restart | stop 的回调里 start | launcher 与全部 worker 都换新,旧进程无残留,端口重新绑定成功 |
| remove / safeRemove | SIGKILL 整棵子树 | 零残留(5 轮 × 8 worker 压测无泄漏;两次 kill 在同一 tick 内完成,launcher 没有机会补员) |
| 暂停状态下 remove | 同上(SIGKILL 对 stopped 进程有效) | 零残留。清理守卫是"不是 EXIT 就杀",PAUSE 状态同样会被清干净 |
| pause / resume | SIGSTOP / SIGCONT 整棵子树(pause 先父后子、resume 先子后父) | 整组进程进入 stopped,实测暂停期间 worker 的 CPU 时间不再增长,端口不响应;resume 后完全恢复 |
| 暂停状态下 stop | stop 先整树 SIGCONT 再走正常停机 | 736ms 完成优雅收尾(不 CONT 的话 SIGTERM 只会 pending,只能等强杀) |
| launcher 崩溃(SIGKILL) | node cluster 的 IPC 通道断开 | 旧 worker 全部自行退出,不留孤儿;cdpc 重启整组后 worker 数不翻倍、端口无冲突 |
| daemon 优雅退出(SIGTERM) | killChilds 只杀直接子进程(launcher),worker 靠 launcher 转发 | 全部收干净 |
| daemon 被 SIGKILL | daemon 没机会通知任何人;launcher 检测到 ppid 从非 1 变成 1 | launcher 自行带走 worker 收尾,全部收干净 |
| worker 主动退出(code 0) | 视为需要补员 | 立即补上同一序号的槽位 |
| worker 起来就崩 | 滑动窗口熔断 | 652ms 内熔断退出,交给 cdpc 整组重启,不刷进程 |
设计上的取舍:严格与自由并存。
- 严格的部分:一个服务的全部进程必须被一致地控制。
pause/stop/remove都作用于整棵子树——用户看到的是"一个服务",只控制父进程会留下"还在烧 CPU、 还在处理请求"的半暂停状态,那不叫暂停。分层强制终止、master 最后退出、 不留孤儿也属于这一类。 - 自由的部分:何时退出由进程自己决定,只要在阈值内。SIGTERM 给足优雅收尾窗口 (worker 3s、cdpc 兜底 5s),worker 可以处理完手里的请求、落盘、注销服务发现 再退出;超出阈值才强制终止。
pause 的实现顺序也是这个原则的体现:先停父再停子(父不再分发/补员,
避免它在子被冻结的窗口里误判失联),恢复时先子再父(父恢复后立刻要与子通信,
子必须已经在运行)。
已知代价
- 每个 cluster 服务多一个常驻 node 进程,实测 RSS ≈ 44MB
limit.maxrss对 cluster 服务失效:它测的是 launcher 自身的 RSS (几十 MB 且恒定),worker 吃多少内存都不会触发。cluster 服务的内存限制 要用 cgroup(内核按整个组限制,正好覆盖 launcher + workers)- worker 的输出经 launcher 汇聚,每行带
[wN]前缀,便于定位到具体实例。 若上游(cdpcd 或调用方)不读这根管道,Node 会把写入无界缓冲在内存里—— 高日志量的 worker 能把 launcher 撑到 OOM 并带走整组服务。 因此出向缓冲超过 4MB 时丢弃输出并计数告警:日志可以丢,进程管理不能倒 - 修改
workers数量后reload会整组重启(ck 含 workers 数,配置变更被正确检测到); 在线扩缩容尚未支持
暂停与恢复(作用于整棵进程树)
pause / resume 作用于服务的整棵进程子树,不只是被 cdpc 直接 spawn 的那个进程。
理由很直接:用户看到的是"一个服务"。只停父进程会留下"看着暂停了,但子进程还在
占 CPU、还在处理请求"的半暂停状态,那不叫暂停。stop / remove 早已是整树语义,
pause 不整树本身就是不一致。
信号顺序(不对称,有讲究)
| 操作 | 顺序 | 原因 |
|----|----|----|
| pause | 先父后子(SIGSTOP) | 父先停就不再分发连接/补员,避免它在子进程被冻结的窗口里误判"子进程失联"而做出动作 |
| resume | 先子后父(SIGCONT) | 父恢复后立刻要与子通信(如 cluster 的 IPC),子必须已经在运行,否则写入积压或误判 |
与 stop / remove 的组合
- 暂停状态下
stop:先整树 SIGCONT 再走正常停机。不 CONT 的话,被 SIGSTOP 的 进程收到 SIGTERM 只会 pending,优雅收尾完全丢失、只能等超时被强杀。 实测 261ms 完成优雅收尾。 - 暂停状态下
remove:直接 SIGKILL 整树(SIGKILL 对 stopped 进程同样有效, 不需要先 CONT)。
实测(bash → node ×2 → 孙子 ×2,共 5 进程、深度 3)
pause 后 全部 5 个进程状态 = T,暂停 2.5 秒期间各进程 CPU 时间增量 = 0
resume 后 全部回到 R/S,CPU 时间重新增长幂等:重复 pause / resume 无副作用;resume 一个未暂停的服务不改变状态;
pause 不存在的服务返回 false。
对 cluster 服务同理:launcher 与全部 worker 一起停/一起恢复, 暂停期间端口不再响应。
进程树负载
/proc/<pid>/stat 的 cutime/cstime 只累计已被 wait 回收的子进程时间,
活着的子进程不在其中;rss 也只算自己。所以凡是 fork 出常驻子进程的服务:
- 包装脚本拉起一组服务
- master / worker(nginx、php-fpm 等形态)
- cluster 主进程
- 先 fork 再 exec 的启动器
只看自身就是接近 0,定位不到任何问题。因此监控在自身占用之外, 还按进程树聚合出合计。
口径
| 字段 | 含义 |
|----|----|
| cpu / mem | 永远是该进程/服务自身的占用,语义与历史一致 |
| cpuTotal / memTotal | 自身 + 全部后代的合计 |
| procCount | 树内进程数(含自身) |
| procs[] | 树内每个进程的明细(默认上限 20 条,按自身 CPU 降序,见 CDPC.setMaxTree()),每项的 cpu/mem 同样是该进程自身的占用 |
| procsOmitted | 明细被截断掉的进程数 |
| omittedCpu / omittedMem | 被截断部分的合计,便于上层如实展示"其他 N 个进程" |
procs[] 每项还带 memTotal 与 procCount——树是递归的,
每一层都能回答"这一层往下总共占了多少"。
{
name: 'web', cpu: '0.00', mem: '3.38', // 自身
cpuTotal: '135.65', memTotal: '103.83', procCount: 3,
procs: [
{pid: 3645984, ppid: 3645983, comm: 'MainThread',
cmd: 'node worker.js', cpu: '95.90', mem: '50.44',
memTotal: '50.44', procCount: 1},
...
]
}实现
nps.npstat()建进程树:每进程只读/proc/<pid>/stat一个文件, 同时拿到ppid(建树)、utime/stime(CPU)、rss(内存); 实测约 7ms / 280 进程;treeData.sumTree(['cputime','rss'])自底向上聚合,给每个节点写出cputimeTotal/rssTotal/procTotal——自身属性不被覆盖;- CPU 百分比按每个 pid 各自的时间差求和。直接对"树合计时间"做差 在子进程退出时会得到负值(合计突然变小),这是必须按 pid 记账的原因;
comm对某些运行时没有辨识度(node 把主线程名改成MainThread), 所以只对要展示的这几个节点补读cmdline得到cmd,全量扫描时不读。
健壮性与成本
| 点 | 处理 |
|----|----|
| 进程表非原子快照 | 逐个读 /proc 拼出的表,扫描期间 PID 复用或重定父理论上能拼出环。getAllChilds() 与 sumTree() 都带 visited 去重——否则前者无限循环(killAllChilds 也用它),后者漏算整个环 |
| 环内 / 不可达节点 | 合计属性兜底为"只有自己",不留 undefined(否则参与算术就变 NaN) |
| 明细列表规模 | 默认上限 20 条,取 CPU 最高的那些。合计不受它影响(合计按 pid 时间差求和),被截断的部分用 procsOmitted / omittedCpu / omittedMem 如实上报 |
| cmdline 读取次数 | 只对列出来的那些节点读,不随子树规模增长 |
| 采样频率 | 与监控 tick 解耦:最小间隔 800ms。tick 周期是可配的(stepSlice × maxStep,默认 1000ms),配得很短时不能每 tick 都全量扫 |
| 跨 tick 的百分比 | 分母用上次树采样时的 CPU 总时间,不是单个 tick 的。否则跳过若干 tick 会让"分子跨多个 tick、分母只有一个 tick",把百分比算高 |
| PID 复用 | 某 pid 本轮的 cputime 比上一轮小,说明是新进程占用了这个 pid,该轮按 0 计(不产生尖峰) |
实测:62 个进程的子树,单次聚合约 6ms,status 应答约 5.3KB。
CDPC.setMaxTree(n):调整明细列表上限
明细列表默认只列 20 条。想看全一点就调大它:
const CDPC = require('cdpc')
CDPC.setMaxTree(100) // 立即生效,下一次采集进程树即按新值
const cm = new CDPC({ /* ... */ })- 静态方法,不在原型上:它改的是模块级变量,对本进程内所有实例一起生效, 跟某个实例无关,也不需要为它重建实例。
- 参数必须是 [10, 10000] 的整数,返回生效后的值;非法值抛
TypeError/RangeError,不会静默退回默认值——这是启动期的配置调用, 写错了就该当场知道。 - 它只约束"逐进程列出多少个"以及随之而来的 cmdline 读取次数。
整棵子树一直是完整遍历的,
cpuTotal/memTotal/procCount按 pid 逐个求和,不受此值影响。 - 调大的代价是每次采集多读同样数量的
/proc/<pid>/cmdline;采集本身有 800ms 节流。取多少属于上层策略,库不替调用方决定。
关于资源限制
limit.maxrss 的判定仍按自身 rss,没有改成按树判定——包装型服务的
子进程内存波动大,按树触发重启很容易误杀。配了 cgroup 的服务不受影响:
内核本来就按整个组限制。
limit 各键的单位与实现状态:
| 键 | 单位 | 是否生效 | 说明 |
|---|---|---|---|
| maxrss | KB | 生效 | 自身 rss 超过 maxrss + rssOffset 触发处置 |
| rssOffset | KB | 生效 | 判定时叠加在 maxrss 上的宽容量 |
| maxRestart | 次数 | 生效 | 因超内存重启的次数上限,超过后转为停止而不再重启;不设或为 0 表示一直重启 |
| rssRestartCount | 次数 | 运行时状态 | 已因超内存重启的次数,由 cdpc 自己维护 |
| maxtime | — | 未实现 | 只被规范化,代码里没有任何地方读取 |
| frequency | — | 未实现 | 同上 |
| maxdaylimit | — | 未实现 | 同上 |
未实现的三个键保留是为了兼容老配置,写了不会报错但也不会有任何效果。
cdpc inspect 会把它们标注为「当前版本未实现」,概览详情不再显示它们。
处置动作会写进 cause,形如 maxrss|restart|... 或 maxrss|stop|...。
注意 cause 在每次 startChild 时会被清空,需要连续采样才能观察到重启那几次。
开启IPC
若要使用IPC通信,需要使用options.stdio选项:
const CDPC = require('cdpc')
const cm = new CDPC({
debug: true,
})
cm.runChilds([
{
name: 'app',
file: 'query-load.js',
options: {
//如果不需要输出信息,也可以是 ['ignore', 'ignore', 'ignore', 'ipc']
stdio: ['ignore', 1, 2, 'ipc']
},
//cdp是就是cdpc实例,若是在配置文件中,独立出去的模块,这个参数可以让你操作cdpc实例上的接口。
callback: (child, cdp) => {
child.on('message', msg => {
if (msg.type === 'query-load') {
//把json格式的负载监控信息发送给子进程。
child.send(cdp.fmtLoadInfo('json'))
}
})
}
}
])
对应的query-load.js文件的代码是:
'use strict'
const fs = require('fs')
process.on('message', msg => {
fs.writeFile('/tmp/query-load.json', JSON.stringify(msg), err => {
err && console.error(err)
})
})
setInterval(() => {
process.send && process.send({
type: 'query-load'
})
}, 1000)
启用监控
负载监控的定时器采用了时间片和步进式的策略,并且支持波动策略。
let cm = new CDPC()
//...
/*
定时器每10毫秒执行一次,每执行一次,步进计数器加1。
步进从50到105开始波动,采取的方案是:计数到50开始读取监控信息,然后是计数到101开始读取监控信息,
直到步进计数达到105,然后开始计数到104进行负载信息的获取,直到步进达到50的状态。
如此反复。
dynamicStep = 5 设定动态步进为5,默认是1。这表示每次增长是5,就是50、55、60...
*/
cm.dynamicStep = 5
cm.setStepSlice(10)
cm.setMaxStep(50, 105)
cm.monitorStart()
cdpc.prototype.setStepSlice
设定定时器时间片
cdpc.prototype.setMaxStep
设定步进最大计数
步进数和定时器时间片相乘就是获取负载信息的时间间隔。
Net部分
若要获取进程的网络数据统计,请传递选项:monitorNetData,设置为true。
重置网络数据统计
CDPC.prototype.resetNetData(name)
注意: 使用resetNetData重置网络统计数据,会把统计项的几个属性设置为0,但是loadinfo.net.devData直接一个新的空对象替换。
测试
test/ 下是可直接运行的测试台,全部自带断言与通过/失败统计,退出码非零表示有失败项。
npm test # 串起下面前三个
node test/test-app.js # 基础能力:启停、依赖、限额、pause/resume、负载字段
node test/test-cluster.js # cluster 模式 + sock 控制通道
node test/test-robust.js # 健壮性压力:崩溃循环熔断、高频 churn、并发连接
sudo npm run test:cgroup # cgroup 资源控制(需 root,不在 npm test 里)test-cgroup.js 不进 npm test:它需要 root 并且会在 cgroup v2 根下临时创建控制组。
前提不满足(非 root、cgroup v1、根组未启用 memory 控制器)时整体 skip 而非失败。
| 脚本 | 断言数 | 覆盖 |
|---|---|---|
| test-app.js | 30 | 进程启停、after 依赖顺序、autoRemove、disable/enable、IPC、limit.maxrss 的重启与停止两条分支、pause/resume 的整树语义、负载信息的自身与进程树两组字段、吞掉 SIGTERM 的服务被 SIGKILL 兜底 |
| test-cluster.js | 53 | cluster 配置规范化与互斥校验、real_args 包装可追溯、连接分发、CDPC_WORKER_ID 注入、worker 异常退出后自动补员、launcher 崩溃后整组恢复不留孤儿、pause/resume 覆盖整组、三层停机超时、sock 的 ping/has/inspect/status/load/控制类操作 |
| test-robust.js | 17 | worker 崩溃循环的熔断、10 轮 restart churn 无进程堆积、重复 restart 调用去重、sock 并发 80 连接与单连接管线化 200 请求、同时管理 25 个服务、调整 workers 数量 |
| test-cgroup.js | 27 | create() 写入各接口文件的实际内容、setMem/setSwap/setCPU/setPids 改限额后文件里的值真的变了、memory.max 的强制执行(超限 OOM kill)、cpu.max 的百分比换算、服务自动入组、入组失败必须告警、memory 小于 1000 字节的误配保护 |
端口与退出码
三个脚本都会先做端口预检,被占用时直接退出并打印占用者,不会刷一屏
EADDRINUSE 堆栈。
| 脚本 | 默认端口 | 专属变量 |
|---|---|---|
| test-app.js | 3456 | TEST_PORT |
| test-cluster.js | 3457 | CLUSTER_PORT(未设时取 TEST_PORT + 1) |
| test-robust.js | 34700 | ROBUST_PORT(未设时取 TEST_PORT + 2) |
各自有专属变量是必要的:npm test 把三个脚本串起来跑,如果都读同一个
TEST_PORT,用户为了避开冲突设一次,三个脚本反而会去抢同一个端口。
退出码:
| 码 | 含义 | |---|---| | 0 | 全部通过 | | 1 | 有断言失败(真的回归) | | 2 | 测试台自身异常 | | 78 | 预检失败(端口被占之类的环境问题,不是代码回归) |
区分 78 和 1 是有意的:npm test 里一个遗留的端口占用,如果也返回 1,
看起来跟真的回归一模一样。
关于 test/app1.js 的 --strong
app1.js 带 --strong 时会故意吞掉 SIGINT / SIGTERM / SIGABRT,用来验证
「优雅停机超时后 SIGKILL 兜底」是否真的兜得住。副作用是:如果测试台自己被
SIGKILL(或被容器整组砍掉),这个子进程会成为 ppid=1 的孤儿继续占着端口,
下一次运行就会撞上端口占用。清理方式:
pkill -f 'cdpc/test/app1\.js'单位提醒
limit.maxrss 的单位是 KB(比较对象是 /proc 里 rss 换算出的 rssKB)。
写成 60000000 意味着 60GB,限额永远触发不了 —— 看着在测限额,实际这个分支
从没被跑到过。60MB 应该写 60000。
