很多人都遇到过这种场景:一个 JVM 进程因为 -Xmx 写错了一个字符,启动起来就死掉,控制台一行日志都没有;一个 Node.js CLI 比如 opencode 在前台 hang 住,光标不闪,也不退出;一个 Spring Boot 服务被 systemd 拉起来,systemctl status 显示 running,但端口永远不开。stderr 看不到、源码也没有,只能反复重启。

这类问题的共同点不是程序"出 bug",而是进程在"出第一行日志"之前就已经失败或卡住。常规的"看日志找异常"那一套这时候没东西可看。需要的是另一类方法论:把进程当黑盒,从外部撬开它的状态。

下面把这套方法论拆成四步,先给通用框架,再给 JVM 与 Node.js 两个语言专题,最后用 opencode hang 这个具体例子推演一遍。

一、先把"看不到日志"分成四种情况

把"启动期黑盒"展开来看,至少是四种性质完全不同的故障。混在一起调,方向就错了。

类别 特征 进程状态 主要工具方向
A. crash 静默 进程已经退出,但没看到任何 stderr 不存在 找输出去向;看 OS / runtime 留下的尸检文件
B. 系统调用阻塞 进程还在,CPU 接近 0,卡在某个 syscall sleeping 内核态栈 / strace / dtruss
C. 用户态死循环 进程还在,CPU 接近 100%,没产出 running profiler / thread dump / inspector
D. 到不了 main 进程在,但还在 pre-main 阶段(agent / classloader / native init) 视语言而定 runtime 自带的 verbose 启动开关

A 和 D 最容易被搞混。两者外在表现都是"启动起来什么都没说",但 A 已经退出而 D 还在跑。看一眼 PID 是否还在就能分清。B 和 C 也容易搞混,看 CPU 占用即可——top -p PID 上接近 0 就是阻塞,接近 100% 就是死循环。

这四类对应不同的下一步。所有调试动作的第一件事,就是先确定自己面对的是哪一类。

模式提炼:先做分类,再选工具

症状 → 分类 → 工具

这条原则之所以重要,是因为常见的踩坑是"看见控制台没输出,就开始猜测代码 bug"。猜代码是工具栈最末端的事。在确定进程到底是 crash 了还是 hang 住、CPU 是 0 还是 100% 之前,做的所有假设都可能踩空。

二、第一步:找输出

“我没看到日志"在工程现实里通常不是"程序没产生输出”,而是"输出被某层吞掉了"。可能的去向至少有这几个:

  1. stderr 被重定向nohup cmd > out.log 这种写法会把 stderr 也吞进 nohup.outcmd 2>/dev/null 直接丢弃;docker 容器只会显示 PID 1 的 stdout/stderr,子进程要靠自己把日志写出来。
  2. stdout 全缓冲。当 stdout 不连接到 tty 时,glibc 默认是块缓冲(一般 4KB 或 8KB),程序 crash 时缓冲区里的内容会随进程一起消失。这就是为什么"在终端里能看到日志,重定向到管道就看不到"。
  3. 日志框架自身没起来。log4j2 配置错误时,业务日志不会出,但 log4j 自己有一个 status logger 用来报告"我配错了"——默认级别是 ERROR,且独立写到 stderr。如果连 stderr 也被吞了,连"配错了"这件事都看不到。
  4. runtime 的尸检文件没在你看的目录。HotSpot crash 会写 hs_err_pid<PID>.log,默认放在当前工作目录或 /tmp,不在控制台,不在 stdout。

对应的反制手段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 强制行缓冲
stdbuf -oL -eL ./your-program

# 在 systemd 下查输出
journalctl -u your.service -f

# 看进程的标准输出/错误真实指向哪里
ls -l /proc/$(pidof your-prog)/fd/{1,2}

# 用 script(1) 把整个会话录下来(含 tty 行为)
script -f /tmp/session.log -c "./your-program"

# 把已经在跑的进程的 stderr 拉过来看
strace -p PID -e trace=write -e write=2 -s 4096

最后一条 strace -e write=2 -s 4096 是个被低估的招——即使一个进程已经在跑、stderr 早就被挂到 /dev/null,这条命令仍然能把它正在写 fd 2 的内容拉出来打印到当前终端。前提是 Linux,且有调试权限(ptrace_scope 或 root)。

模式提炼:永远先验证 fd 0/1/2 指向哪里

/proc/PID/fd/{0,1,2} 是进程对外说话的喉舌。一切"找输出"问题都从这里看起。

很多看起来诡异的"日志去哪了"的问题,本质都是 fd 1 或 fd 2 被 wrapper 脚本、systemd unit、容器 entrypoint 重定向到了一个"调试时谁也想不到的地方"。

三、第二步:定位卡点

如果是 B(系统调用阻塞)或 C(用户态死循环),下一步是搞清楚"进程现在在哪一行"。

OS 层的工具按平台分:

用途 Linux macOS
跟踪 syscall strace -fp PID dtruss -p PID(需关 SIP)
当前内核栈 cat /proc/PID/stack sample PID 5(用户态栈)
当前 syscall cat /proc/PID/syscall + wchan sample 或 lldb attach
抓现场(CPU profiling) perf top -p PID / async-profiler Instruments / sample
通用 attach gdb -p PID lldb -p PID

判断卡点的几个常见线索:

  • 卡在 futex → 锁等待,下一步抓 thread dump 看是谁拿着锁。
  • 卡在 read / recvfrom / epoll_wait → 在等 IO 或事件,下一步看是哪条连接、对端是谁。
  • 卡在 connect / poll 配合一个外部地址 → 网络等待,常见是 DNS 反解、TLS 握手、对端不通。
  • 卡在 nanosleep / clock_nanosleep → 程序自己在 sleep,是逻辑问题不是阻塞。

对 Linux 来说,/proc/PID/wchan 直接给出一个 kernel symbol,比如 futex_wait_queue_meinet_csk_accept,哪怕不读 strace 也能粗略判断。

模式提炼:syscall 是黑盒进程的"自白"

用户态代码看不到没关系,syscall 是必经之路。卡在哪个 syscall,等价于告诉你"它在等什么"。

当一个 Java 或 Node 进程闭源、没日志、还 hang 住时,往往不需要懂业务逻辑,只看 syscall 序列就能猜出大致原因。比如 strace 只输出 recvfrom(7, ...) = ? 一直不返回,PID 7 用 lsof -p PID 查到对应一个远端 IP,而那个远端正好是公司被墙的服务,问题就清楚了。

四、第三步:到不了 main 的情况

A 和 D 这两类问题都属于"在程序员能写日志的代码之前就已经失败"。这里就要换语言专题。

JVM 启动期诊断

JVM 的启动流程比看上去复杂得多:加载 libjvm.so → 解析参数 → 加载基础类 → 启动 JVMTI agent → 调用 main。任何一步失败都可能导致"我什么都没看到,但 java 进程没了"。

几个最有用的入口:

hs_err_pid 文件。HotSpot 在遇到 fatal error(含参数解析错误、内部 assert、SIGSEGV)时会写一份 dump,文件名形如 hs_err_pid<PID>.log。默认目录是当前工作目录,写不进去时会退到 /tmp。可用 -XX:ErrorFile=/var/log/jvm/hs_err_%p.log 强制指定。这个文件几乎是 JVM crash 后唯一可靠的现场。

JAVA_TOOL_OPTIONS 环境变量。JVMTI 规范定义的环境变量,每个 HotSpot JVM 启动都会读它,把里面的内容拼到命令行参数前面。这个机制特别适合"我改不了启动命令"的场景:CI 脚本、容器 ENTRYPOINT、systemd unit、IDE Run 配置。设一个 JAVA_TOOL_OPTIONS=-Dlog4j2.debug -verbose:class -Xlog:all=info,下次启动就能看到全部参数解析、类加载、log4j 状态。

统一 JVM 日志(JDK 9+)。-Xlog:all=info 一次性把 GC、类加载、JIT 编译、JNI 等子系统的诊断输出都打开。早期版本要分别开 -verbose:class -verbose:gc -verbose:jni -XX:+TraceClassLoading

诊断选项解锁。有些参数(如 -XX:+PrintFlagsFinal-XX:+PrintConcurrentLocks)需要先 -XX:+UnlockDiagnosticVMOptions 才能用。PrintFlagsFinal 把当前 JVM 实际使用的所有 flag 值打出来,包括默认值,比 GC 调优指南有用得多——它告诉你"实际生效的是什么"。

log4j2 自身的诊断开关。当 log4j2 配置错误时,它的默认行为是退化到 fallback configuration(通常只在 console 输出 ERROR),并把"我退化了"通过 status logger 报告出来。-Dlog4j2.debug 把 status logger 拉到 DEBUG 级别,能看到它读了哪个配置文件、为什么解析失败。配置文件根元素的 <Configuration status="WARN"> 属性也是同一个 status logger 的级别。

已经在跑的 JVM 怎么戳

1
2
3
4
5
6
7
8
9
10
11
# 触发 thread dump(输出到进程的 stderr)
kill -3 PID # 等价于 jstack PID

# jcmd 是 JVM 通用诊断入口,比 jstack/jmap 更现代
jcmd PID Thread.print
jcmd PID VM.flags
jcmd PID VM.system_properties
jcmd PID GC.heap_info

# 已经 hang 住的 JVM,可以用 jhsdb attach 走 SA 通道
jhsdb jstack --pid PID

kill -3 PID 这条值得单独提:HotSpot 把 SIGQUIT 当作 thread dump 触发器,输出落到进程的 stderr。这意味着如果进程 stderr 已经被重定向到一个文件,你 kill -3 之后就要去那个文件里捞 dump,而不是当前终端。

Node.js 启动期诊断

Node 的启动期也并不简单:加载 V8 → 加载 node 自己的 builtin 模块 → 执行入口脚本(其中包含一棵 require 树)→ 进入事件循环。

几个常用入口:

NODE_OPTIONS 环境变量。和 JAVA_TOOL_OPTIONS 同样的思路,可以在不改启动命令的情况下注入 node 命令行参数。NODE_OPTIONS="--inspect=0.0.0.0:9229 --trace-warnings --trace-uncaught" 就把 inspector 端口、警告栈、未捕获异常栈都打开了。

NODE_DEBUG 与 DEBUG。两个不同的机制,经常被搞混:

  • NODE_DEBUG=* 控制 node core 模块的内置 debug 输出(比如 httpnettlsfs),底层是 util.debuglog
  • DEBUG=* 是 npm 上 debug 包的约定,几乎所有第三方库(Express、Mongoose、puppeteer…)都遵守它。

两个一起开通常是必要的:NODE_DEBUG=* DEBUG=* node app.js

–inspect-brk。让 node 在执行第一行前暂停,等待调试器连接。比 --inspect 严格,避免"我还没 attach 上业务逻辑就跑过去了"。

SIGUSR1 信号。这是 node 的一个隐藏特性:给一个已经在跑的 node 进程发 SIGUSR1,它会原地启动 inspector 端口(默认 9229)。对前面那个 hang 住的 opencode 来说尤其有用——不需要重启,直接 kill -SIGUSR1 PID,然后 chrome://inspect 附加上去看 stack trace。

未捕获异常--abort-on-uncaught-exception 让 node 在抛 uncaught exception 时直接 abort 并产生 core dump,可以用 lldbgdb 离线分析。--trace-uncaught 输出栈而不退出。

深度跟踪--prof 写 V8 tick log(isolate-*.log),用 node --prof-process 解析,能看出启动期热点函数。--cpu-prof 直接生成 .cpuprofile,可以扔进 Chrome DevTools。

模式提炼:runtime 都有"无侵入注入"的环境变量

JVM 是 JAVA_TOOL_OPTIONS,Node 是 NODE_OPTIONS,Python 是 PYTHONSTARTUP / PYTHONFAULTHANDLER。在改不了启动命令的场景下,这些都是一类语义。

这条模式的价值在调试容器、CI、桌面应用时极高。docker run 不能改,但可以加 -e JAVA_TOOL_OPTIONS=...;systemd unit 不能改,但可以加一行 Environment=NODE_OPTIONS=...;IDE 不让传参,但 OS 全局环境变量它躲不开。

五、把不能修改启动命令的进程也盯住

很多调试场景的根本困难不是"工具不够",而是"我没有命令行控制权"。比如:

  • 容器 ENTRYPOINT 写死,要重新打镜像才能改。
  • systemd unit 在 /etc/systemd/system/ 下,重启需要权限。
  • 客户机器上的桌面应用,源码在远端,本地只有二进制。
  • IDE Run/Debug 配置里 stderr 是被吞的。

四种通用注入点:

注入点 JVM Node.js 通用
命令行参数 改不了 改不了 改不了
环境变量 JAVA_TOOL_OPTIONS NODE_OPTIONS LD_PRELOAD(Linux)/ DYLD_INSERT_LIBRARIES(macOS)
配置文件 log4j2.xmlstatus 属性、logging.properties NODE_DEBUG 配合 package.json ~/.bashrc、shell wrapper
信号 SIGQUIT(thread dump) SIGUSR1(inspector) SIGINFO(部分 OS)

实战里这四种经常组合用。比如要观察一个 docker 化的 Spring Boot,最常见的是在 Compose 里加一个 JAVA_TOOL_OPTIONS=-Dlog4j2.debug -XX:+UnlockDiagnosticVMOptions -Xlog:all=info,gc*=debug:file=/tmp/jvm.log,然后用 docker exec 进容器看 /tmp/jvm.log

六、用 opencode hang 这个例子推演一遍

opencode 是基于 Node.js 的终端 CLI(TUI 模式)。即使没有源码,按上面这套方法也能盯。

第一步:分类。先用 top -p PID 看 CPU 占用,区分 B 和 C;再 ps PID 看进程是否还在,区分 A。如果 CPU ≈ 0 且进程还在,是典型的 B(在等什么 syscall)。

第二步:让它说话。下次启动加全套环境变量:

1
DEBUG=* NODE_DEBUG=* NODE_OPTIONS="--inspect=127.0.0.1:9229 --trace-warnings --trace-uncaught" opencode

如果 opencode 自己有 verbose flag(CLI 一般会有 -v--debug),优先用它的,因为业务层日志比 node core 日志更具体。

第三步:附加到当前 hang 住的进程。如果不想重启:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 找到 PID
pgrep -f opencode

# 触发 inspector
kill -SIGUSR1 <PID>

# 同时看它卡在哪个 syscall
# Linux:
sudo strace -fp <PID>
sudo cat /proc/<PID>/stack
# macOS:
sample <PID> 5
sudo dtruss -p <PID>

然后浏览器打开 chrome://inspect,点 “Open dedicated DevTools for Node”,应该能看到 V8 的 call stack。Node TUI 类应用 hang 住的常见根因有几类:

  • 网络请求没设超时(fetch / axios 默认就是无限等)。
  • TLS 握手慢(公司网络代理、自签证书校验)。
  • DNS 阻塞(/etc/resolv.conf 配错或 IPv6 走通了 IPv4 没回来)。
  • TUI 渲染锁住事件循环(同步阻塞 stdin 读,或者一个无限的 setImmediate 链)。
  • 某个 plugin / extension 在初始化时 await 了一个永远不会 resolve 的 Promise。

通过 strace 看到 connect / recvfrom 卡在某个 IP,多半是网络类;通过 inspector 看到栈停在 tls.jsdns.js,多半是 TLS / DNS 类;如果完全空闲、栈停在 epoll_wait,又没新事件,那就是 Promise 永远不 resolve。

第四步:缩小重现。一旦定位了大致方向,把环境变量限定到最少的开关上重启,确认每一步推理。这一步看起来累赘,但能避免"我以为是网络其实是 plugin"的方向性错误。

七、模式速查表

横轴是故障类型,纵轴是 runtime。每格给出"第一时间该敲什么"。

A. 静默 crash B. syscall 阻塞 C. 用户态死循环 D. 到不了 main
JVM hs_err_pid*.logJAVA_TOOL_OPTIONS=-verbose:class -Xlog:all=info 重跑 kill -3 PID 看 thread dump;jstack / jhsdb jstack async-profiler -d 30 -f flame.html PIDjcmd PID Thread.print 多次采样 JAVA_TOOL_OPTIONS=-Dlog4j2.debug + -Xlog:class+load=debug;查 javaagent 加载顺序
Node.js --abort-on-uncaught-exception 产 core dump + lldb;查 wrapper 是否吞 stderr kill -SIGUSR1 PID 起 inspector;strace 看 syscall --cpu-prof 或 inspector 的 Profiler NODE_OPTIONS="--inspect-brk --trace-warnings" 重跑;NODE_DEBUG=module 看 require 链
通用 OS /proc/PID/fd/{1,2} 之前的最后写入;dmesg 看 OOM kill;core file cat /proc/PID/stack + cat /proc/PID/wchan perf top -p PID;按 CPU 时间排序 strace 整个 exec 链;查 LD_PRELOAD / DYLD_* 是否有人埋了 hook

八、几个反复踩到的反模式

  • nohup 把 stderr 也搞丢了nohup cmd > out.log 不会重定向 stderr,但 nohup cmd > out.log 2>&1 才是想要的。最常见的是写 nohup cmd & 然后困惑为什么没日志——其实它默认写到 nohup.out
  • IDE Run 配置吞启动错。IntelliJ / VS Code 的 Run 面板对 stderr 的处理因 runner 不同而不同;同样的程序在 Terminal 里跑能看到错,在 Run 面板里看不到。出怪问题时用 Terminal 重跑一次。
  • PID 1 不传信号。容器里如果 entrypoint 是 shell 脚本而不是 exec 替换,子进程不是 PID 1,外部 docker kill -s SIGUSR1 信号可能根本到不了 node。dockerfile 里的 CMD ["node", "app.js"] 是 PID 1,CMD node app.js 不是。
  • 启动期 DNS 反解阻塞/etc/resolv.conf 里第一个 nameserver 不通时,glibc 默认会试到超时。一台新机器、VPN 切换、容器 networkmode 错了,都会触发。表现就是"程序 hang 住几十秒到几分钟,然后突然继续"。strace 上是反复 connect 到 53/UDP。
  • macOS 上的工具受 SIP 限制dtrussdtrace 默认不能 attach 到 Apple 签名的二进制上;lldb 也需要 codesign 或关 SIP。如果命令直接说 “operation not permitted”,多半是这个原因。
  • log4j2 自己配错,连"配错"都不打印。这是最阴险的一类。log4j2.xml 没在 classpath、命名不对(必须是 log4j2.xml 不是 log4j.xml)、或 XML 语法错,log4j2 会退化到默认 console + ERROR 级别,业务日志全没了,但 status logger 不出错就不会主动报告。记住打 -Dlog4j2.debug 是诊断这一类的钥匙
  • 把 JIT 编译当成 hang。冷启动的 JVM 在第一次跑热点路径时,可能在 C2 编译器里花几秒到几十秒,看起来像 hang。-XX:+PrintCompilation-Xlog:jit+compilation 可以确认是不是这个。

九、收尾

这套方法论的核心其实只有一句话:没有看见输出 ≠ 没有产生输出。剩下的工作是按 OS / runtime / 框架 三层倒推,把"输出去哪了"、“卡在哪一步”、"什么时候开始失败的"分别定位清楚。

工具的具体名字会随版本变(jstack 被 jcmd 替代,strace 在 macOS 不存在),但分类和原则不会变:

  1. 先按 A/B/C/D 把症状归类,避免方向错。
  2. 找输出永远是第一步——/proc/PID/fd 或等价物先看一眼。
  3. 卡点定位用 syscall,因为它是用户态代码绕不开的接口。
  4. pre-main 失败用 runtime 自带的 verbose 开关,靠不到代码就靠不到日志。
  5. 改不了启动命令时,环境变量 / 信号 / 配置文件三选一注入。

最后一个建议:把这五步沉淀成自己的 cheat sheet,下次遇到 opencode 这类"启动黑盒",第一反应就不会是"重装一遍试试"。