崩溃日志落盘(INV-CRASH-STDERR-01)

wesgine 使用 dup2 把 fd 2 指向文件,确保 Go runtime.throw 的崩溃栈被完整捕获。


为什么需要 dup2

Go 的 runtime.throw 绕过 os.Stderr 直写 fd 2。

runtime.throw → write(2, stackBytes) → exit(2)

如果 fd 2 指向管道(如 systemd journal),runtime.throw 打印前会 freezetheworld(),转发 goroutine 此后永不被调度——栈留在管道缓冲区里随 exit(2) 一起消失。

症状:崩溃了但没有现场。


teeStderrToFile

func teeStderrToFile(dataDir string) {
    logPath := filepath.Join(dataDir, "logs", "wesgine-stderr.log")
    f, _ := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
    
    // dup2:把 fd 2 指向文件
    syscall.Dup2(int(f.Fd()), 2)
    // 从此 runtime.throw 的输出落盘
}

业务日志另行镜像

journal 一个字都收不到,而 unit 里 StandardError=journal 会让运维把空 journal 读成"引擎没启动"。

解决:bootLogger 把 slog 同时写文件与 dup 出来的原始 stderr。

slog → 文件(Debug 级)
slog → journal(Info 级,systemd 有速率限制)

Debug 不灌 journal:systemd 对单元有速率限制且丢弃超额,Debug 灌进去会以"引擎突然安静了"的形状复现同一个症状。


Bootstrap Admin Token

Bootstrap admin token 不走 logger,直接写 os.Stderr(即文件)。

理由:journal 常被转发出机器,admin token 不应出现在远端日志。


每次启动的第一行

version=v1.0.3 commit=abc1234 built=2026-09-01T00:00:00Z
wesgine: boot: hypervisor starting...
wesgine: boot: cell registry loaded (3 cells)

卡在哪一步、跑的是哪个 build,看这几行就够——不需要 strace。


排障起手式

  1. 看 {data-dir}/logs/wesgine-stderr.log
  2. 不是 journal(panic 栈不在那里)
  3. 不是 nohup.out(同理)
  4. 每次启动有版本指纹和阶段里程碑

禁止的替代方案

方案问题
管道 + goroutine 转发freezetheworld() 后转发 goroutine 不被调度
只用 os.Stderrruntime.throw 绕过 os.Stderr
只用 journaljournal 常被转发、有速率限制
不做镜像运维报"无日志输出"