凌晨两点,服务毫无征兆地卡死,CPU 被打满,接口全部超时。ps确认了 PID,pstack一把抓出线程堆栈,剩下的就是漫长的人肉读栈:一个个帧翻过去,查锁、查系统调用、查业务代码。那一晚我翻了快两个小时,才定位到一个非常隐蔽的锁竞争。
天快亮的时候我一直在想,这种事为什么不交给 Claude?pstack 负责把"现场"拍下来,Claude 负责"看图说话",我只需要做个胶水层,把它们接起来。于是就有了 pstack-claude 这个项目:一个挂在 Claude Code 上的 MCP 工具,让 Claude 能直接执行 pstack(失败了还会自动用 gdb 兜底),然后基于堆栈输出给你做诊断。
这篇文章是完整的落地记录:要解决什么问题、底层怎么设计、代码长什么样、实际排障效果如何,以及一路踩过的坑。适合两类人,一类是天天跟进程卡死、CPU 飙高打交道的开发或运维,另一类是正在研究 Claude Code 工具调用、想给 AI"装手装脚"的折腾党。
1. 这个项目到底解决什么问题
1.1 传统 pstack 工作流的真实痛点
先说一个非常典型的排障场景:线上服务突然不响应,top一看某个进程 CPU 飙到 100%。常规操作是ps -L -p <pid>列出线程,然后pstack <pid>把现场抓出来。问题是,抓出堆栈只是开始,读堆栈才是真正的体力活。
我见过太多堆栈输出的实际内容了。一个多线程服务,pstack打印出来经常是几十个线程的栈,每个栈从内核态到用户态有十几层帧。你得知道哪些线程是空闲等待的,哪些线程真的在干活,哪些帧是锁等待,哪些是死循环。这需要经验,更需要耐心。更麻烦的是,很多情况下你抓完一次堆栈还不够,需要隔几秒再抓一次,对比线程状态是否一直在原地打转,才能判断是不是真的卡死。
而且传统工作流是割裂的:抓堆栈是一套命令,分析是纯人肉劳动,最后写排查结论又是另一件事。工具和思考之间没有任何衔接。pstack-claude 想解决的,就是把"抓取堆栈"和"分析堆栈"这两段用 Claude 串起来。你要做的只是输入一句话,比如"PID 4321 卡住了,帮我看看它在干什么",剩下的由 Claude 自己调用工具、读取输出、组织判断。
1.2 Claude 在流程里的定位:不是替代,是"代读"
我得先说清楚,pstack-claude 不是要替代 pstack,也不打算替代工程师的判断。pstack 是 Linux 下抓取进程线程堆栈的经典命令,它负责把现场完整记录下来;Claude 在这里扮演的是"代读"的角色,把堆栈里那些晦涩的函数名、锁等待、内核帧翻译成人类能快速理解的诊断结论。
实际用下来,Claude 最有价值的地方在于它不会漏看。人读堆栈的时候,扫到第 20 个线程往往已经烦了,注意力会下降。Claude 会把每一个线程的栈都过一遍,主动标出可疑的线程、可疑的调用链,然后告诉你最可能需要关注的点。它不会吐槽你,也不会越读越烦躁,这在我看来就是 AI 替人干活最理想的状态。
当然,前提是你得让它能调用 pstack。这就是 MCP(Model Context Protocol)出现的意义:Claude Code 支持通过 MCP 加载外部工具,pstack-claude 本质上就是这样一个工具服务器。
2. 核心设计:pstack 的封装与 Claude 的工具调用
2.1 pstack 命令的真实面纱
pstack 在多数 Linux 发行版里其实是一个脚本,底层经常是调 gdb 的批处理模式来获取栈。比如在 CentOS 系统上,pstack大致会执行这样一条命令:
gdb -quiet -batch -ex "thread apply all bt" -p <pid>它依赖的是一个核心的系统调用:ptrace。pstack 通过 ptrace 挂到目标进程上,去读取每个线程的寄存器、栈地址和符号信息,再整理成人类可读的调用栈。这决定了它的两个天然限制。
第一个限制是权限。ptrace 不是你想 attach 谁就 attach 谁的。Linux 有个 Yama LSM 模块,/proc/sys/kernel/yama/ptrace_scope这个参数如果为 1,你只能 ptrace 自己的子进程。所以线上抓栈经常要 root,或者用sudo -u <运行用户> pstack <pid>。第二个限制是依赖。如果系统里没有 gdb,pstack 大概率会失败,这时候你需要一个替代方案,比如直接调 gdb,或者安装 gdb 包。
在设计 pstack-claude 的 MCP 工具时,我必须把这两个限制都考虑进去。不能只封装一个裸的pstack命令,否则实际使用中遇到一次权限问题或者 pstack 缺失,整个工具就废了。
2.2 MCP 工具调用的实现逻辑
MCP 全称是 Model Context Protocol,它提供了一套标准协议,让大模型可以调用外部工具。你可以把它理解成给 AI 定义了一组"API":AI 在对话中判断需要用什么工具,按约定的 JSON 格式发出调用请求,工具执行完把结果返回给 AI,AI 再基于结果继续推理。
Claude Code 支持 MCP 服务器,传输方式里最常用的是 stdio,也就是 Claude Code 直接启动一个子进程,通过标准输入输出和 MCP server 通信。对本地命令封装来说很干净,没有网络端口,不需要额外启动服务。
pstack-claude 就是实现一个 MCP server,暴露一个get_thread_stack工具。它的核心逻辑分三步:先校验 PID 是否存在,然后尝试用 pstack 抓栈,如果失败自动换 gdb 兜底,最后把堆栈文本返回给 Claude。Claude 拿到文本后,会自己分析和总结。
这里有个关键设计决策:工具只做"抓取"和"返回原始文本",不替 Claude 做任何总结。为什么不直接在工具里做诊断?因为模型在工具调用循环里,对文本的上下文理解更充分。你把未经修饰的堆栈交给 Claude,它可以根据当前对话目标给出针对性解读,而不是服务器端提前固化一套分析规则。分析规则如果写死在工具里,那这工具就永远不可能分析出你预设之外的诡异问题。
2.3 为什么选择 MCP 而不是直接 Shell 管道
有朋友问我,抓个堆栈而已,直接把 pstack 输出复制粘贴给 Claude 不就行了?为什么要多此一举搞 MCP?
区别在于"被动投喂"和"主动调用"。Shell 管道方式下,是你替 Claude 做决策:你自己决定要抓谁的堆栈、什么时候抓、要不要附加 gdb。MCP 方式下,决策权在 Claude 手里:你跟它说"那个卡死的进程",它自己会先问你要 PID,然后调用工具抓取,发现抓不到还会尝试兜底,整个过程是模型自主驱动的。
还有一个实际原因:MCP 是一次性的能力注册。pstack-claude 挂上之后,后续任何一个会话里 Claude 都可以直接调用,不用每次在对话上下文里粘贴工具说明。整个流程更接近"给 AI 装上一只手,它想用就能用"。
另外,MCP 是可以叠加的。后面把日志查看、strace 分析、健康检查等工具做成更多的 MCP server,Claude 就能在一个会话里自主组合排障链路。这是直接复制粘贴永远做不到的。pstack-claude 只是这个思路的第一块积木。
3. 从零到一:pstack-claude 的完整落地过程
3.1 环境准备:Claude CLI 与 MCP SDK
pstack-claude 依赖两个东西:Claude Code 命令工具,以及 MCP 的 Node.js SDK。Claude Code 的安装走 npm 是最直接的:
npm install -g @anthropic-ai/claude-code装完先确认版本:
claude --version然后登录或者配置密钥。我习惯用环境变量方式,把 Anthropic API Key 配到 shell 配置里:
export ANTHROPIC_API_KEY=你的密钥之后就进入项目目录,初始化 MCP server。MCP SDK 也以 npm 包形式存在,建议在独立目录里做,避免和全局环境搅在一起:
mkdir -p /opt/pstack-claude cd /opt/pstack-claude npm init -y npm install @modelcontextprotocol/sdk这里多一句嘴:Claude Code 对 Node 版本有要求,尽量用 Node 18 以上。如果后续启动 MCP 时报语法错误,八成是 Node 太老了,先检查node -v。
3.2 核心代码实现:一个能自己兜底的 MCP Server
我直接贴核心实现,一个server.js文件就能跑:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { exec } from "node:child_process"; import { promisify } from "node:util"; const execAsync = promisify(exec); const server = new McpServer({ name: "pstack-claude", version: "0.1.0" }); async function runPstack(pid) { const candidates = [ `pstack ${pid}`, `gdb -p ${pid} -batch -ex "thread apply all bt" 2>/dev/null` ]; let lastError = ""; for (const cmd of candidates) { try { const { stdout } = await execAsync(cmd, { timeout: 30000 }); if (stdout && stdout.trim()) { return stdout; } lastError = "命令执行成功但没有输出"; } catch (err) { lastError = err.message; } } throw new Error(`无法获取堆栈: ${lastError}`); } server.tool( "get_thread_stack", "获取Linux系统上指定进程的线程堆栈信息,用于排查进程卡死、死锁、CPU飙高、请求不响应等问题", { pid: { type: "number", description: "目标进程的PID" } }, async ({ pid }) => { try { await execAsync(`kill -0 ${pid}`); } catch { return { content: [{ type: "text", text: `进程 ${pid} 不存在,请确认PID是否正确` }] }; } try { const output = await runPstack(pid); return { content: [{ type: "text", text: output }] }; } catch (err) { return { content: [{ type: "text", text: `抓取堆栈失败: ${err.message}` }] }; } } ); const transport = new StdioServerTransport(); await server.connect(transport);代码不多,但有几个细节值得讲。
第一,kill -0 ${pid}是个很妙的探活方式。kill -0不会真的发信号杀进程,只是检查进程是否存在、当前用户有没有权限操作它。如果这一步就失败,后面的 ptrace 大概率也失败,提前返回能给 Claude 一个更明确的错误信号。
第二,兜底策略很重要。pstack在很多精简容器里根本没有,但 gdb 可能存在;反过来也有系统装了大把诊断工具却没有 gdb 的情况。候选命令按优先级排列,哪个能出结果就用哪个,这是经验里长出来的设计。
第三,超时控制。execAsync里必须加 timeout。pstack 挂到一个大进程上,有时候会慢得出奇,不加超时可能让整个 Claude 会话卡在那里等半天。30 秒是一个平衡值,既能覆盖大部分正常情况,又不至于把对话拖死。
3.3 配置与启动:让 Claude"长出手脚"
server.js 写好后,要让 Claude Code 识别它。Claude Code 的 MCP 配置有两种方式,我更推荐命令行方式:
claude mcp add pstack -- node /opt/pstack-claude/server.js这条命令会把名为pstack的 MCP server 注册为:用 node 执行/opt/pstack-claude/server.js。之后在 Claude Code 会话里,这个工具就会被加载。
也可以直接编辑配置文件。Claude Code 的配置文件一般是~/.claude.json或项目里的.mcp.json,把 server 注册信息写进去,等价于上面命令。配置文件的写法大致是这样:
{ "mcpServers": { "pstack": { "command": "node", "args": ["/opt/pstack-claude/server.js"] } } }改完配置文件后需要重启 Claude Code 会话,MCP 工具才会被重新加载。
3.4 先做一次冒烟测试
配置好别急着上生产排障,先找个无害的进程试一下。比如随便找一个当前 shell 的 PID:
echo $$然后在 Claude Code 里直接说:
"用 get_thread_stack 工具看一下进程 12345 的堆栈"
如果一切正常,Claude 会调用工具,返回堆栈内容,然后向你说明这个进程目前的调用情况。如果用在 bash 这种进程上,你会看到它大概率卡在read相关的系统调用上,这是正常的,说明工具链路通了。
冒烟测试时最容易翻车的点是 Node 环境。如果 Claude Code 启动 MCP server 时静默失败,先手动跑一遍:
node /opt/pstack-claude/server.js看能不能正常启动。如果手动启动有报错,通常错误信息会直接告诉你缺什么依赖、语法哪里不对。还有一个坑:如果你的系统 node 命令不在 Claude Code 子进程能拿到的 PATH 里,配置里的"command": "node"可能找不到。这时候可以把 command 换成 node 的绝对路径,比如/usr/local/bin/node,省去一堆 PATH 的麻烦。
4. 实战:用 Claude 排查一次 CPU 飙升
4.1 现象与前置定位
纸上谈兵没用,直接看真实排障流程。假设有一台线上机器,某个 nginx worker 进程 CPU 占满,请求大量超时。传统流程下,我们通常会这么走:
top -H -p <nginx_worker_pid>top -H能看到进程内每个线程的 CPU 占用。如果发现某个线程 CPU 特别高,基本能断定问题出在它身上,就可以抓堆栈看它到底在跑什么。但这里的问题是你得先看懂现象再决定下一步,而这正是 Claude 能介入的环节。
实际做的时候,我先手动拿到 nginx worker 的 PID(比如 4321),然后用 pstack-claude 把所有线程的堆栈抓了出来,再让 Claude 分析。下面是 pstack-claude 抓回来的堆栈内容,我截取了两段关键线程:
Thread 3 (Thread 0x7f8b2c0d0700 (LWP 4321)): #0 0x00007f8b2c5d69a3 in epoll_wait (epfd=8, events=0x7f8b2c0cfad0, maxevents=512, timeout=5000) #1 0x000000000042e29d in ngx_process_events_and_timers (cycle=0x1b7e0a0) #2 0x0000000000434d32 in ngx_worker_process_cycle (cycle=0x1b7e0a0, data=0x0) #3 0x0000000000434a81 in ngx_worker (cycle=0x1b7e0a0, data=0x0) #4 0x000000000043348a in ngx_spawn_process (cycle=0x1b7e0a0, proc=0x434a20) #5 0x00000000004346ae in ngx_master_process_cycle (cycle=0x1b7e0a0) #6 0x00000000004075d9 in main (argc=3, argv=0x7ffd2c3aff18) Thread 1 (Thread 0x7f8b2c2d0740 (LWP 4320)): #0 0x00007f8b2c5e0a45 in sched_yield () #1 0x0000000000441b12 in ngx_shmtx_lock (mtx=0x7f8b2c6d4040) #2 0x0000000000432f2a in ngx_event_accept (ev=0x7f8b2c6d4000) #3 0x0000000000434d07 in ngx_process_events_and_timers (cycle=0x1b7e0a0) #4 0x0000000000434d32 in ngx_worker_process_cycle (cycle=0x1b7e0a0, data=0x0) #5 0x0000000000434a81 in ngx_worker (cycle=0x1b7e0a0, data=0x0) #6 0x000000000043348a in ngx_spawn_process (cycle=0x1b7e0a0, proc=0x434a20) #7 0x00000000004346ae in ngx_master_process_cycle (cycle=0x1b7e0a0) #8 0x00000000004075d9 in main (argc=3, argv=0x7ffd2c3aff18)这种原始堆栈对不常看的人就是天书。我把这堆文本丢给 Claude,让它分析。
4.2 让 Claude 自己抓栈并分析
在 Claude Code 会话里,我只需要说:
"进程 4321 的 CPU 飙到了 100%,请求大量超时。请用 get_thread_stack 抓一下它的堆栈,帮我看看问题出在哪。"
Claude 会先调用工具拿到堆栈,然后很快给出解读。它的回答风格大概是这样:
从堆栈看,线程 1(LWP 4320)卡在了
ngx_shmtx_lock,这是 nginx 的共享内存锁,调用路径是ngx_event_accept -> ngx_shmtx_lock -> sched_yield。线程 3(LWP 4321)正常阻塞在epoll_wait。关键点:
ngx_shmtx_lock内部在反复调用sched_yield,这不是普通的阻塞等待,而是自旋锁在空转。通常意味着某个持有 accept 锁的线程没有及时释放,其他 worker 拿不到锁,只能在这里死等。CPU 飙高的原因就在这里。建议下一步:
- 用
strace -p 4320 -e futex跟踪系统调用,确认锁等待的行为模式;- 检查 nginx 配置里
accept_mutex是否开启,如果开启且压测场景下出现锁竞争,可以考虑关闭或调优;- 重点排查持有锁之后慢路径上的业务逻辑,比如内置的 Lua 脚本或上游代理是否在长时间阻塞。
说实话,这个分析水平已经超过了相当一部分初级运维。它没有说废话,直接锁定了最可疑的帧和调用链,并且给出了下一步操作建议。对人来说可能要先搜ngx_shmtx_lock是什么、在什么场景下会调用,Claude 不需要这一步,它训练时看过足够多的相关内容,能直接建立帧和语义之间的关联。
4.3 Claude 给出的诊断结论
Claude 除了指出表面现象,还能帮你做一层推理:自旋和阻塞的区别。同样是sched_yield,出现在这个调用路径上和出现在普通业务代码里,含义完全不同。它结合上下文判断这是 nginx accept 锁的竞争,而不是业务代码死循环,这个判断方向很关键。
我又追问了它一句:"如果抓第二次堆栈,这个线程还在这里,是不是能确认是持锁线程卡死?"
Claude 回答:如果两次抓取间隔 2 秒以上,线程 1 依然停留在ngx_shmtx_lock,同时其他 epoll 线程有事件进来却处理不动,基本可以确认是持锁线程长时间占用了 accept 锁。这时候应该去排查持锁线程,而不是再看等待锁的线程。
这个思路是对的。多抓几次堆栈看线程是否"原地不动",本身就是判断卡死的经典方法。Claude 能把工具调用和这个方法论连起来,pstack-claude 实际用起来就不只是个命令封装,而是一个有分析行为的助手。
5. 常见问题与排查记录
5.1 pstack 权限不足、命令不存在
实际用得最多的是坑就是权限和工具缺失。报错通常是Operation not permitted或者command not found。前者基本是 ptrace 权限不够,可以按下面的顺序排查:
cat /proc/sys/kernel/yama/ptrace_scope如果是 1,非 root 用户只能 attach 自己的子进程。想临时放开,可以echo 0 > /proc/sys/kernel/yama/ptrace_scope,但这只是临时生效,重启后恢复。生产环境更靠谱的方式是用目标进程的同款用户来执行,比如 nginx 是 nobody 跑的,那就:
sudo -u nobody pstack <pid>后者command not found说明 pstack 没装。pstack 在 CentOS 上是 gdb 依赖带出来的,但 Ubuntu 这类系统不一定预装。我之前在 Ubuntu 20.04 上就踩过一次,解决办法不是硬装 pstack,而是直接用 gdb 命令抓栈。这也是为什么 pstack-claude 要做 gdb 兜底——你永远猜不到客户的服务器上有什么工具。
pstack-claude 遇到这个问题时,你可以直接问 Claude"为什么抓不到堆栈",它会告诉你工具执行失败的原因。很多情况下,跟着错误信息走就能找到答案。
5.2 Claude Code 升级与 npm 权限问题
Claude Code 自己的问题我遇到的第二大坑。有段时间它启动时一直报:
auto-update failed: no write permission to npm prefix原因是全局 npm 包的目录属于 root,当前用户写不进去,自动更新就失败了。解决办法是给 npm 换一个用户级的前缀目录:
npm config get prefix mkdir -p ~/.npm-global npm config set prefix ~/.npm-global export PATH=~/.npm-global/bin:$PATH然后重新安装 Claude Code 到用户目录:
npm install -g @anthropic-ai/claude-code注意的是,换完前缀之后,要让claude命令指向新路径,也就是~/.npm-global/bin/claude。这个改完所有依赖 npm 全局工具的习惯都会舒服很多。如果你之前用 root 安装过,旧版本可能还残留在系统目录里,必要时which claude检查一下命令实际路径。
还有人在 Windows 上遇到 Claude 相关桌面端提示需要开启"虚拟机平台"功能,这是因为它的沙箱组件依赖 Windows 的虚拟化底层。这类问题通常去"启用或关闭 Windows 功能"里把"虚拟机平台"勾上,重启系统即可。但如果你是直接用 Claude Code(命令行),在 Linux 服务器上基本碰不到这种桌面端限制。
5.3 MCP 工具没有出现在会话里
配置好 MCP server 后在会话里找不到get_thread_stack,这种情况我排查过不少次。最常见的原因是 server.js 启动时报错,但错误被 Claude Code 吞掉了。这时候手动执行一下配置里的命令,看能不能正常冒烟:
node /opt/pstack-claude/server.js如果手动执行没有输出且不退出,说明 server 正常。如果手动执行立刻报错,按报错去修依赖。
还有一个容易忽略的点:MCP server 是独立于 Claude Code 的进程,它启动的时机很重要。如果你是先启动 Claude Code 再做 MCP 配置,需要重启会话才能生效。另外配置文件如果放在项目目录内,只在那个项目下才加载,换个目录就不认识了。这时候claude mcp list会告诉你当前有哪些可用工具,先跑一下这个命令做验证。
5.4 堆栈符号缺失,Claude 也难为无米之炊
这个坑我相信所有用过 pstack 的人都有体会:堆栈打印出来一堆??或者只有十六进制地址,没有函数名。原因很简单,目标进程的符号表被剥离了,或者 debuginfo 没装。
pstack-claude 能做的只是把抓到的原文给 Claude。如果原文全是地址没符号,Claude 能给你的信息就非常有限。它也许会告诉你这些地址段可能属于哪些模块,或者建议你安装 debuginfo 包后重新抓。这里我的体会是:堆栈质量决定了 AI 能发挥的上限,工具层能做的只有保证传递链路干净。
所以如果你的服务有排障需求,打包部署时最好保留符号表文件,至少保证核心服务的 debuginfo 是安装的。否则再强的模型,面对一堆裸地址也只能摊手。
写在最后的一点实践体会
pstack-claude 做出来以后,我最大的感受是:工具本身并不复杂,真正的价值在于它改变了排障的交互方式。以前是我读堆栈、我下命令、我写结论,现在是我描述现象、AI 抓取现场、AI 给方向,我在中间做最后一道验证。"AI 根据堆栈给出判断"这一步的质量,确实超出了我最初预期,而且它能保持耐心的态度,把所有线程全扫一遍再总结。
但我也得说句实在话:AI 的结论不能直接照抄。它在锁竞争这种比较标准的问题上表现很好,但遇到你业务里特有的奇怪状态,它的知识可能跟不上你的代码。每次 Claude 给出诊断后,我都会用后面的实际操作去验证,比如补一次堆栈对比、看一眼日志、跑一下 strace。工具是放大你的效率,不是替代你的判断。
这个项目后续的扩展空间其实很大。我已经在计划给它加一个strace工具,让 Claude 能直接观察进程的系统调用;再加一个日志摘要工具,把最近一段时间的应用日志也纳入分析上下文。当这些工具串起来,一个真正自主的排障助手会慢慢成形。pstack-claude 只是第一步,但这一步走通之后,后面的路就顺了。