NVSentinel syslog-health-monitor模块分析
分析对象:health-monitors/syslog-health-monitor/ 与 distros/kubernetes/nvsentinel/charts/syslog-health-monitor/。引用指向 NVIDIA/NVSentinel
main分支。
1. 模块定位
syslog-health-monitor 是节点侧 DaemonSet,唯一数据源是 systemd journal。它把内核日志里的错误行解析成 HealthEvent,经 Unix socket gRPC 交给 platform-connector。
它与其它 health-monitor 的根本差别在于数据源是一条只能顺序前进的流 ,而不是可以随时重新采样的状态量。这一点决定了模块里绝大部分复杂度:游标持久化、重启后重扫、书签重读修正、handleSingleLine 失败时是否推进游标,全部围绕「日志读过一次就不会再来」这个约束展开。
当前支持四个检查项,由 --checks 逗号列表启用:
| checkName | 处理器包 | 匹配对象 | componentClass |
|---|---|---|---|
SysLogsXIDError |
pkg/xid | NVRM Xid 行、NVRM GPU↔PCI 映射行、GPU reset 回执行 | GPU |
SysLogsSXIDError |
pkg/sxid | nvidia-nvswitch SXid 行 | GPU |
SysLogsGPUFallenOff |
pkg/gpufallen | NVRM fallen off the bus | GPU |
SysLogsNICDriverError |
pkg/nicdriver | mlx5_core 驱动/固件错误、内核软锁死 | NIC |
前三个是 Helm 默认启用,SysLogsNICDriverError 需要手动加进 enabledChecks。
2. 代码结构
仓库路径:health-monitors/syslog-health-monitor/
bash
health-monitors/syslog-health-monitor/
├── main.go # flag 解析、检查项装配、gRPC 连接、轮询循环
├── pkg/syslog-monitor/
│ ├── syslogmonitor.go # 核心:游标状态机、journal 遍历、事件下发
│ ├── types.go # SyslogMonitor 结构体、state 文件结构、字段常量
│ ├── journal_iface.go # Journal / JournalFactory 抽象
│ ├── journal_real.go # //go:build systemd,sdjournal 实现
│ ├── journal_stub.go # //go:build !systemd,数组模拟 + HTTP 注入
│ └── fake_journal.go # 单测用假实现,支持 match 语义
├── pkg/xid/ # XID 处理器 + parser 子包 + metrics 子包
├── pkg/sxid/ # SXID 处理器
├── pkg/gpufallen/ # GPU 掉总线处理器
├── pkg/nicdriver/ # NIC 驱动处理器 + TOML 配置 + sysfs 解析 + 软锁死状态机
├── pkg/cancellation/ # 跨错误码取消规则
├── pkg/common/ # XID 目录表加载、动作字符串映射、驱动版本判定
├── pkg/metadata/ # gpu_metadata.json 读取器
├── pkg/patterns/ # XID 正则单一来源
└── pkg/types/ # Handler 接口、ErrorResolution
三层职责很清晰:
main.go负责装配,不含解析逻辑;pkg/syslog-monitor负责「怎么读、读到哪、读失败怎么办」,不关心日志内容语义;- 四个 handler 包各自负责「一行文本变成什么事件」,通过
types.Handler接口接入,互不感知。
源码:health-monitors/syslog-health-monitor/pkg/types/types.go
go
// health-monitors/syslog-health-monitor/pkg/types/types.go
type Handler interface {
ProcessLine(message string) (*pb.HealthEvents, error)
}
接口只有一个方法,返回 nil, nil 表示这行与我无关。这个约定是整个遍历循环能保持简单的前提。
3. 启动链路
3.1 检查项装配与内核传输过滤
buildChecksFromFlag 把 --checks 拆成 CheckDefinition 列表,并给内核来源的检查项打上 -k 标签:
源码:health-monitors/syslog-health-monitor/main.go
go
// health-monitors/syslog-health-monitor/main.go
var kernelOriginChecks = map[string]bool{
fd.XIDErrorCheck: true,
fd.SXIDErrorCheck: true,
fd.GPUFallenOffCheck: true,
fd.NICDriverErrorCheck: true,
}
func buildChecksFromFlag() ([]fd.CheckDefinition, error) {
list := make([]fd.CheckDefinition, 0)
for c := range strings.SplitSeq((*checksList), ",") {
name := strings.TrimSpace(c)
if name == "" {
continue
}
check := fd.CheckDefinition{
Name: name,
JournalPath: "/nvsentinel/var/log/journal/",
}
if kernelOriginChecks[name] {
check.Tags = []string{"-k"}
}
list = append(list, check)
}
...
}
这个 -k 不是性能优化,是正确性修复。四个检查项关心的日志全部来自内核 printk,如果不加过滤,遍历必须逐条走完 audit、容器运行时等高频用户态日志。journald 有保留窗口,读取速度跟不上写入速度时,含 XID 的那个 segment 会在被处理前先被 vacuum 掉,表现为「XID 明明在 journal 里出现过,但监控没报」。加了 _TRANSPORT=kernel 之后,遍历只在内核条目上推进,游标不会被无关日志拖住。
JournalPath 写死为容器内的 /nvsentinel/var/log/journal/,不可配置,由挂载决定它对应宿主机的哪个目录。
3.2 Kata 变体的过滤覆写
源码:health-monitors/syslog-health-monitor/main.go
go
// health-monitors/syslog-health-monitor/main.go
func applyKataConfig(list []fd.CheckDefinition) []fd.CheckDefinition {
if !stringutil.IsTruthyValue(*kataEnabled) {
return list
}
for i := range list {
list[i].Tags = []string{"-u containerd.service"}
}
filtered := make([]fd.CheckDefinition, 0, len(list))
for _, check := range list {
if check.Name != "SysLogsSXIDError" {
filtered = append(filtered, check)
}
}
return filtered
}
两点值得注意。第一,这里是覆写 而不是追加:Kata 场景下 GPU 在 guest VM 内,XID 由 containerd 转发到宿主 journal,条目的 _TRANSPORT 不是 kernel。如果两个条件都留着,journald 的 match 语义是同字段 OR、跨字段 AND,_TRANSPORT=kernel 与 _SYSTEMD_UNIT=containerd.service 会 AND 成空集,一条都匹配不到。第二,Kata 变体直接把 SXID 检查项过滤掉,因为 NVSwitch 日志不会经由 guest 转发出来。
3.3 依赖初始化顺序
run() 的顺序是有讲究的:
- 校验
NODE_NAME,缺失直接退出,不做降级; dialPlatformConnector带重试连接 platform-connector,最多 10 次,每次递增 sleep,unix scheme 会先os.Stat检查 socket 文件存在再拨号,随后waitUntilReady阻塞到连接进入connectivity.Ready;- 装配检查项、注册 feature flag、预初始化指标。
run()里会用--processing-strategy调SetStoreOnlyMode:值为STORE_ONLY时 gaugestore_only_mode=1,否则为 0。非法枚举名在后面createSyslogMonitor解析 proto 时启动失败; createSyslogMonitor构造监控器,构造过程内部会读 state 文件并可能立即发出重启健康事件;- 起 metrics server;
- 若配置了 sidecar,
waitForSidecarIfEnabled用 TCP 拨号探测最多 30 次; - 起轮询循环。
第 4 步在第 5、7 步之前,意味着重启检测发生在轮询循环启动之前,这是后面 3.6 节那套 pending 重试机制存在的原因。
3.4 轮询循环与存活探针的耦合
源码:health-monitors/syslog-health-monitor/main.go
go
// health-monitors/syslog-health-monitor/main.go
case <-ticker.C:
for {
healthChecker.MarkAlive()
if err := monitor.Run(); err != nil {
if backoff == 0 {
backoff = 2 * time.Second
} else {
backoff *= 2
}
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
...
continue
}
backoff = 0
break
}
外层 ticker 按 --polling-interval 触发,内层是失败重试循环,退避上限 30 秒。关键细节是 MarkAlive 放在每次尝试的开头而不是成功之后:PollingHealthChecker 的判定阈值是 3 倍轮询间隔,如果只在成功时打点,platform-connector 长时间不可用会让存活探针失败并重启 Pod,而重启解决不了下游依赖的问题。放在开头之后,探针只反映「循环是否卡死」,不反映「依赖是否健康」。退避上限 30 秒远小于阈值,所以持续失败不会误判为卡死。
另一个后果是:一旦进入内层重试循环,在成功之前不会回到外层 ticker,所以失败期间的实际检查频率是退避节奏而非轮询间隔。
4. journal 读取引擎
4.1 Journal 抽象与三种实现
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/journal_iface.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/journal_iface.go
type Journal interface {
AddMatch(match string) error
AddDisjunction() error
Close() error
GetBootID() (string, error)
GetCursor() (string, error)
GetData(field string) (string, error)
Next() (uint64, error)
Previous() (uint64, error)
SeekCursor(cursor string) error
SeekHead() error
SeekRealtimeUsec(usec uint64) error
SeekTail() error
}
这是对 sdjournal 的最小切面,只暴露游标遍历需要的 12 个方法。三种实现通过 build tag 和工厂切换:
- journal_real.go 带
//go:build systemd,薄封装sdjournal,RequiresFileSystemCheck()返回 true,打开前会校验 journal 目录存在且是目录; - journal_stub.go 带
//go:build !systemd,用一个包级[]string模拟日志,并起一个 9091 端口的 HTTP server,POST /add往数组追加一行。这是本地无 systemd 环境下注入日志做端到端验证的入口; - fake_journal.go 供单测使用,实现了 match 过滤语义,能验证
_TRANSPORT与SYSLOG_IDENTIFIER的 OR 组合是否按预期生效。
镜像构建时 BUILD_TAGS="systemd"、CGO_ENABLED=1,并安装 libsystemd-dev,运行镜像里带 libsystemd0、liblz4-1、libzstd1。这是本模块唯一需要 CGO 的组件。
4.2 过滤器装配
configureTagFilters 把标签翻译成 journald match。核心分支是 -k:
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
case "-k", "--dmesg":
matchExpr := FieldTransport + "=" + TransportKernel
if err := journal.AddMatch(matchExpr); err != nil { ... }
if check.Name == XIDErrorCheck {
if err := journal.AddDisjunction(); err != nil { ... }
resetMatchExpr := FieldSyslogID + "=" + GPUResetSyslogID
if err := journal.AddMatch(resetMatchExpr); err != nil { ... }
}
最终 XID 检查项的过滤条件是 _TRANSPORT=kernel OR SYSLOG_IDENTIFIER=nvsentinel-gpu-reset。第二个分支是给 GPU reset 回执留的口子:回执由 gpu-reset/gpu_reset.sh 用 logger -t nvsentinel-gpu-reset 写进 journal,走的是用户态传输,如果只有 -k,回执永远读不到,COMPONENT_RESET 之后的恢复事件就发不出来,节点会一直挂着隔离状态。
-u 的处理支持两种写法:标签数组里 ["-u", "containerd.service"] 分开两个元素,以及 ["-u containerd.service"] 合成一个元素。前者走 case "-u" 分支并前进下标取下一个元素,后者落到 default 分支做前缀裁剪。applyKataConfig 生成的是后一种。
4.3 游标状态与持久化
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/types.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/types.go
type syslogMonitorState struct {
Version int `json:"version"`
BootID string `json:"boot_id"`
CheckLastCursors map[string]string `json:"check_last_cursors"`
BootStartScanDone bool `json:"boot_start_scan_done"`
}
每个检查项一个独立游标,因为每个检查项的 match 集合不同,journald 游标只在相同 match 下才有确定语义。
loadState 对异常输入一律降级为默认状态而不是报错退出:文件不存在、内容为空、JSON 损坏,都返回一个空游标表的新状态。版本号不一致时走 migrateStateVersion,只要 CheckLastCursors 非 nil 就认为兼容,改写版本号后回存。这个策略下,state 文件损坏的后果是「丢一次历史位置,从尾部重新开始」,不会导致 Pod 起不来。
写入是 json.Marshal 后 os.WriteFile,权限 0600,不是原子写,没有临时文件加 rename。写入过程中断电会留下截断的 JSON,靠上面的损坏降级路径兜底。
4.4 单次检查的执行路径
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
func (sm *SyslogMonitor) executeCheck(check CheckDefinition) error {
journal, err := sm.openJournal(check)
...
defer journal.Close()
if err := sm.configureTagFilters(journal, check); err != nil { ... }
err = sm.processJournalEntries(journal, check)
if err != nil { ... }
if err := sm.saveCurrentState(); err != nil {
slog.Warn("Failed to save state after processing check", ...)
}
return nil
}
journal 句柄每轮打开、每轮关闭,不复用。match 也随之每轮重建,所以不存在 match 累积的问题。
processJournalEntries 是三岔路口:
go
if !hasLastCursor {
if sm.postRebootInit {
return sm.initializeJournalFromBootStart(journal, check)
}
return sm.initializeJournalFromTail(journal, check)
}
ready, err := sm.resumeFromLastCursor(journal, check, lastKnownCursor)
if err != nil {
return err
}
if !ready {
return nil
}
return sm.processAllEntries(journal, check)
- 无游标且非重启后:
initializeJournalFromTail,只记录尾部位置,本轮不处理任何日志。首次安装时历史日志一概不追溯; - 无游标且重启后:
initializeJournalFromBootStart,见 4.6; - 有游标:从游标恢复继续读。
4.5 书签重读修正
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
func (sm *SyslogMonitor) skipBookmarkedEntryIfPresent(
journal Journal, check CheckDefinition, lastKnownCursor string,
) (bool, error) {
cur, err := journal.GetCursor()
...
if cur != lastKnownCursor {
return true, nil
}
advanced, nextErr := journal.Next()
...
}
SeekCursor 后调一次 Next() 本应落到下一条,但在有 match 过滤的情况下 systemd 可能把游标所在的那条本身再返回一次。不修正的话,最后处理过的那条内核 XID 会在每一轮轮询里被重复解析并重复上报。这段代码显式比对当前游标与书签游标,相同就再前进一次。
4.6 重启处理
这是整个模块最复杂的部分,由四个状态位协同:磁盘上的 BootID 与 BootStartScanDone,内存里的 pendingPostRebootBootID 与 postRebootInit。
第一步,构造期检测。 NewSyslogMonitorWithFactory 读 /proc/sys/kernel/random/boot_id,与 state 文件里的 BootID 比对,不同就调 handleBootIDChange:
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
func (sm *SyslogMonitor) handleBootIDChange(oldBootID, newBootID string) error {
if oldBootID == newBootID {
return nil
}
for checkName := range sm.checkLastCursors {
delete(sm.checkLastCursors, checkName)
}
if oldBootID != "" {
sm.postRebootInit = true
}
sm.pendingPostRebootBootID = newBootID
return sm.tryFlushPostRebootBootIDClear()
}
重启后旧游标全部失效,清空;oldBootID != "" 区分「真重启」与「首次安装」,只有真重启才置 postRebootInit。
第二步,健康事件下发。 tryFlushPostRebootBootIDClear 给每个检查项发一条 IsHealthy=true、RecommendedAction=NONE、不带 impacted entities 的事件,用来清掉 fault-quarantine 里因为重启前的故障而卡住的隔离状态。这条事件同样带模块级 processingStrategy(prepareHealthEventWithAction)。模块跑在 STORE_ONLY 时,下游不会据此改节点 Condition / 隔离;与「观察模式本来就不会隔离」一致。若节点上还留着更早 EXECUTE_REMEDIATION 时代的隔离,单靠这次重启健康事件清不掉。
第三步,条件持久化。 只有全部事件都发成功才写盘:
go
if !allDelivered {
slog.Warn("Post-reboot healthy events deferred; will retry on next poll cycle.")
return nil
}
state := syslogMonitorState{
Version: stateFileVersion,
BootID: sm.pendingPostRebootBootID,
CheckLastCursors: sm.checkLastCursors,
BootStartScanDone: false,
}
if err := saveState(sm.stateFilePath, state); err != nil { ... }
sm.pendingPostRebootBootID = ""
区分两类失败:ErrPlatformConnectorUnavailable 视为「暂时发不出去」,保留 pending 标记,下轮重试;其它错误直接返回,交给外层退避重试。
第四步,Run 入口的拦截。 只要 pending 还在,Run() 会跳过所有检查项:
go
if sm.pendingPostRebootBootID != "" {
slog.Warn("Skipping check execution: post-reboot bootID flush still pending. ...")
return jointError
}
原因是 executeCheck 结尾会调 saveCurrentState,它写的是 sm.currentBootID,即新 bootID。如果不拦截,磁盘上的旧 bootID 会被覆盖,下次进程重启时 handleBootIDChange 判定为「没有重启」,那批健康事件就永久丢失,节点的隔离状态无人清理。
第五步,从本次启动开头重扫。 postRebootInit 为真时走:
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
func (sm *SyslogMonitor) initializeJournalFromBootStart(journal Journal, check CheckDefinition) error {
lookback := sm.bootLookbackWindow
if lookback == 0 {
if err := journal.SeekHead(); err != nil { ... }
} else {
seekTarget := time.Now().Add(-lookback)
seekUsec := seekTarget.UnixMicro()
if seekUsec < 0 {
seekUsec = 0
}
if err := journal.SeekRealtimeUsec(uint64(seekUsec)); err != nil { ... }
}
advanced, err := journal.Next()
...
if errors.Is(err, io.EOF) || advanced == 0 {
return sm.initializeJournalFromTail(journal, check)
}
return sm.processBootFilteredEntries(journal, check)
}
不用 SeekTail 的理由是:机器重启到监控进程起来之间有几十秒到几分钟窗口,这段时间里的 XID 如果直接跳到尾部就永久漏掉。--boot-lookback-window 默认 2 小时,限制回扫深度,避免把很久以前、可能已经人工处理过的错误重新翻出来;设成 0 表示无限回扫到 journal 头部。
boot 过滤是在应用层而不是 journald 层做的:
go
func (sm *SyslogMonitor) isStaleBootEntry(journal Journal) bool {
entryBootID, err := journal.GetData(FieldBootID)
if err != nil || entryBootID == "" || sm.currentBootID == "" {
return false
}
return entryBootID != sm.currentBootID
}
原因写在注释里:现有 match 用了 AddDisjunction,再 AddMatch(_BOOT_ID=...) 只会与最后一个 OR 分支做 AND,前面那组 _TRANSPORT=kernel 仍会放行上一次启动的条目。所以只能逐条读 _BOOT_ID 自己判。
第六步,扫描完成的持久化。 全部检查项成功后清标记并回写 BootStartScanDone: true。构造函数里还有一段崩溃恢复:
go
if !sm.postRebootInit &&
state.BootID == currentBootID &&
currentBootID != "" &&
!state.BootStartScanDone {
sm.postRebootInit = true
}
对应的场景是:上一次运行已经发完健康事件并写下新 bootID,但在完成 boot-start 扫描前崩溃了。此时 bootID 已经相同,handleBootIDChange 不会触发,如果没有这段恢复就会退化成 SeekTail,静默跳过启动初期的 XID。
4.7 单条日志的处理与游标推进规则
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
func (sm *SyslogMonitor) processOneEntryAndAdvance(
journal Journal, check CheckDefinition, currentEntryCursor string, message string,
) (bool, error) {
if message == "" {
sm.checkLastCursors[check.Name] = currentEntryCursor
...
} else {
err := sm.handleSingleLine(check, message)
if err != nil {
return false, nil //nolint:nilerr // intentional: do not stop the loop
}
sm.checkLastCursors[check.Name] = currentEntryCursor
...
}
advancedNext, advErr := journal.Next()
...
}
这里有一条容易看漏但非常关键的语义:handleSingleLine 失败时不推进游标就直接返回 ,而且返回的是 nil 错误让循环继续。函数注释写的是 Skip this entry,实际代码没有调 Next(),下一次迭代的 GetCursor 和 GetData 拿到的还是同一条,效果是原地重投而不是跳过。
这条路径对两类错误一视同仁:ProcessLine 返回错误,以及事件构造成功但 sendHealthEventWithRetry 失败。常见的「本行不是目标错误」并不走这里------各 handler 对无关行返回 nil, nil,只有真错误才会卡住。卡住期间本次检查项不会前进,后面的检查项也拿不到执行机会。好处是不丢事件,代价是下游长时间异常或 metadata 暂时缺失时整个遍历停滞。
这个「同一条会被重复投递」的语义反过来约束了 handler:任何带状态的 handler 必须保证同一条消息重复处理时给出相同结论。pkg/nicdriver 的软锁死检测器正是为此专门做了 pending 缓存,见 6.4。
三个错误恢复分支的策略是分层的:
GetCursor失败 →recoverFromGetCursorError,尝试Next()跳过;GetData失败 →recoverFromMessageError,同样跳过这一条;Next()失败 → 返回错误,终止本次检查项。
getJournalMessage 自带三次重试,间隔 100 毫秒,只对可重试错误生效:
go
func isRetryableJournalError(err error) bool {
...
return strings.Contains(errStr, "cannot assign requested address") ||
strings.Contains(errStr, "connection reset by peer") ||
strings.Contains(errStr, "broken pipe") ||
strings.Contains(errStr, "resource temporarily unavailable") ||
strings.Contains(errStr, "no such file or directory") ||
strings.Contains(errStr, "permission denied")
}
用字符串包含判断错误类型,这是 CGO 边界上拿不到结构化错误的妥协,属于比较脆的实现。
5. 事件下发
源码:health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
go
// health-monitors/syslog-health-monitor/pkg/syslog-monitor/syslogmonitor.go
func (sm *SyslogMonitor) handleSingleLine(check CheckDefinition, lineToEvaluate string) error {
if handler, ok := sm.checkToHandlerMap[check.Name]; ok {
healthEvents, err := handler.ProcessLine(lineToEvaluate)
if err != nil {
return fmt.Errorf("error processing line %s: %w", lineToEvaluate, err)
}
if healthEvents != nil {
if err := sm.sendHealthEventWithRetry(healthEvents, 5, 2*time.Second); err != nil {
return fmt.Errorf("failed to send health event: %w", err)
}
}
}
return nil
}
发送走 commons 的共享发布器:
go
pub := healthpub.New(sm.pcClient, sm.platformConnectorTarget, sm.defaultAgentName,
healthpub.WithRetryPolicy(maxRetries, retryDelay, 1.5, 0.1))
healthpub.Publisher 有两个对本模块行为有直接影响的设计。
其一是 socket 存在性闸门 。每次 Publish 以及每次重试尝试前都会 os.Stat unix socket 路径,不存在就立刻返回 ErrPlatformConnectorUnavailable,不进重试。platform-connector 在退出时和启动绑定前都会删除 socket,所以文件存在性是「对端是否在线」的可靠代理。这样做是为了避免事件卡在重试队列里,最后带着一个陈旧的 GeneratedTimestamp 发出去。
其二是发布器每次调用现构造 。注释说明这是为了让测试在构造之后替换 sm.pcClient 仍能生效。代价是每条事件都要重新解析一次 target 字符串,量级可以忽略。
重试策略是 wait.ExponentialBackoff,5 步、初始 2 秒、因子 1.5、抖动 0.1。可重试错误只有 gRPC 的 Unavailable、DeadlineExceeded,以及 io.EOF、连接重置、broken pipe。
5.1 processingStrategy 与 STORE_ONLY
HealthEvent.processingStrategy 是发给 platform-connectors 的字段,本模块负责填值,不在本进程里拦截发送 。STORE_ONLY 的事件仍然走 sendHealthEventWithRetry,照样入库、照样能被 event-exporter 导出;真正「不改集群」发生在下游。
字段定义见 data-models/protobufs/health_event.proto,设计说明见 docs/designs/025-processing-strategy-for-health-checks.md。
| 取值 | 本模块是否发出 | 入库 / 导出 | health-events-analyzer 当输入 | 改集群(Condition、Kubernetes Event、隔离、排空、修复) |
|---|---|---|---|---|
EXECUTE_REMEDIATION |
是(chart 默认) | 会 | 会 | 会 |
STORE_ONLY |
是(模块或 NIC 规则可配) | 会 | 不会 | 不会 |
STORE_AND_ANALYSE |
本模块不主动发 | 会 | 会 | 不会 |
UNSPECIFIED |
缺省字段 | 按 EXECUTE_REMEDIATION 归一 |
会 | 会 |
STORE_AND_ANALYSE 由 platform-connectors 的去重把「已经见过的重复不健康观测」从 EXECUTE_REMEDIATION 降级而来,syslog-health-monitor 的 flag 合法值只有 EXECUTE_REMEDIATION 和 STORE_ONLY。
模块级。 --processing-strategy 默认 EXECUTE_REMEDIATION,Helm processingStrategy 原样传入 DaemonSet args。createSyslogMonitor 用 pb.ProcessingStrategy_value 解析后传给所有 handler,以及重启清账用的 prepareHealthEventWithAction。XID / SXID / GPU 掉总线事件都直接写这个模块默认值。
规则级。 只有 SysLogsNICDriverError 能在 TOML 里按模式覆盖:processingStrategy 非空则用该值,空则回落到 handler 上的模块默认。非法枚举名启动失败。见 6.4。
下游跳过路径(本模块发出之后)。 platform-connectors 的 filterProcessableEvents 对 STORE_ONLY(以及 STORE_AND_ANALYSE)不写 Node Condition、不发 Kubernetes Event,但仍写入数据库。fault-quarantine 的 change-stream 排除 STORE_ONLY,因此不打污点、标签、注解。隔离未发生则 nodeQuarantined 仍为空,node-drainer 与 fault-remediation 收不到这条事件。health-events-analyzer 的输入管道也排除 STORE_ONLY,避免观察规则的噪声再聚合成可执行事件。
不要把 STORE_ONLY 理解成本模块「不报」。journal 命中、游标推进、重试闸门与 STORE_ONLY 无关;只是事件上多了一个「请观察、别动手」的标签。
6. 四个处理器
6.1 XID 处理器
ProcessLine 是一个三段优先级的分发:
源码:health-monitors/syslog-health-monitor/pkg/xid/xid_handler.go
go
// health-monitors/syslog-health-monitor/pkg/xid/xid_handler.go
func (xidHandler *XIDHandler) ProcessLine(message string) (*pb.HealthEvents, error) {
start := time.Now()
defer func() {
metrics.XidProcessingLatency.Observe(time.Since(start).Seconds())
}()
if pciID, gpuUUID := xidHandler.parseNVRMGPUMapLine(message); pciID != "" && gpuUUID != "" {
normPCI := xidHandler.normalizePCI(pciID)
xidHandler.pciToGPUUUID[normPCI] = gpuUUID
return nil, nil
}
if uuid, success := xidHandler.parseGPUResetLine(message); len(uuid) != 0 {
return xidHandler.createHealthEventGPUResetEvent(uuid, success)
}
xidResp, err := xidHandler.parser.Parse(message)
if err != nil {
return nil, nil
}
if xidResp == nil || !xidResp.Success {
return nil, nil
}
return xidHandler.createHealthEventFromResponse(xidResp, message), nil
}
第一段消费驱动打印的 NVRM: GPU at PCI:xxxx: GPU-uuid 映射行,只更新内存表不产生事件。第二段消费 reset 回执,产生正向或负向事件。第三段才是 XID 解析。注意解析失败一律吞掉返回 nil, nil,绝大多数内核日志都不是 XID,报错会淹没日志。
解析器双实现
parser.CreateParser 按 --xid-analyser-endpoint 是否为空二选一:
- 空 →
CSVParser,用go:embed进二进制的 Xid-Catalog.xlsx。文件来自 NVIDIA 官方文档,代码注释里记了 SHA256 用于完整性核对; - 非空 →
SidecarParser,POST 到<endpoint>/decode-xid,用retryablehttp客户端。
两者都通过 DriverVersionFn 每次调用现取驱动版本,而不是启动时快照。理由写得很明确:metadata-collector 可能还没写出 gpu_metadata.json,启动时快照到空字符串会导致 NVL5 解码表长期选错。metadata.Reader.GetDriverVersion 配合这个语义做了特殊处理:
源码:health-monitors/syslog-health-monitor/pkg/metadata/reader.go
go
// health-monitors/syslog-health-monitor/pkg/metadata/reader.go
func (r *Reader) GetDriverVersion() string {
r.mu.Lock()
defer r.mu.Unlock()
if !r.loaded || r.metadata == nil || r.metadata.DriverVersion == "" {
if err := r.load(); err != nil { ... return "" }
r.loaded = true
}
return r.metadata.DriverVersion
}
其它字段是标准的一次加载永久缓存,唯独驱动版本在缓存值为空时每次都重新读文件。sidecar 路径还额外埋了 XidEmptyDriverVersion 计数器,用来发现「一直拿不到驱动版本」的状态。
两条 XID 正则
标准格式的正则是单一来源,放在独立的 pkg/patterns 包里被多处引用:
源码:health-monitors/syslog-health-monitor/pkg/patterns/xid.go
go
// health-monitors/syslog-health-monitor/pkg/patterns/xid.go
var XIDPattern = regexp.MustCompile(
`NVRM: Xid \(PCI:([0-9a-fA-F:.]+)\): (\d+)(?:, pid=(\d+))?(?:, name=([^,]+))?(?:, Ch ([0-9a-fA-F]+))?`,
)
NVL5 格式的正则在 pkg/xid/parser/csv.go,多抓 subcode、severity、link、intrInfo、errorStatus:
go
reXidNVL5Pattern = regexp.MustCompile(
`NVRM: Xid \(PCI:([^)]+)\): (\d+)(?:, pid=[^,]*)?(?:, name=[^,]*)?, ` +
`(\w+)\s+(\w+)\s+(\w+)\s+(\w+)\s+Link\s+(-?\d+)\s+\((0x[0-9a-fA-F]+)\s+(0x[0-9a-fA-F]+)`,
)
Parse 先试 NVL5 再退回标准格式。NVL5 命中后,用 Excel 表里的规则做二次匹配:
go
func (p *CSVParser) doesXIDIntrInfoMatchRule(intrinfoBinaryPattern string, intrInfoInMessage int64) bool {
messageBinary := fmt.Sprintf("%032b", intrInfoInMessage)
patternLen := len(intrinfoBinaryPattern)
messageLen := len(messageBinary)
if patternLen < messageLen {
messageBinary = messageBinary[messageLen-patternLen:]
} else if patternLen > messageLen {
messageBinary = strings.Repeat("0", patternLen-messageLen) + messageBinary
}
for i, patternChar := range intrinfoBinaryPattern {
if patternChar == '-' {
continue
}
if patternChar != rune(messageBinary[i]) {
return false
}
}
return true
}
把 intrInfo 展成 32 位二进制串,与表里带通配符 - 的模板逐位比。表里同一个 XID 号有 V1、V2 两列模板,按 IsDriverVersionR575OrNewer 选,判定就是取版本号第一段与 575 比大小。NVL5 事件的错误码形如 145.RLW_SRC_TRACK,带 subcode 后缀。
各 XID 编号如何变成 recommendedAction
fault-quarantine / node-drainer / fault-remediation 不按 XID 号写分支 。本模块解析出编号后查目录,填进 HealthEvent 的 recommendedAction 和 errorCode,下游只认动作(以及 PCI / GPU_UUID)。要改「79 复位、13 忽略」,改目录或 MapActionStringToProto,不是改隔离/驱逐代码。
标准编号的对照表在 embed 的 Xid-Catalog.xlsx,来源是 NVIDIA 官方 XID catalog。LoadErrorResolutionMap 读 Xids 表:第 2 列 Code、第 9 列 Resolution Bucket(Immediate Action),得到 map[编号]RecommendedAction。NVL5(约 144--150)另读 Xid 144-150 Decode,同一编号还要配 IntrInfo / Error Status,见上一小节。
查表过程在 pkg/xid/parser/csv.go 的 getRecommendedActionForXid:表里有该编号就用 Bucket 映射结果;没有该编号默认 CONTACT_SUPPORT。XID 154 在查表之后还会被日志正文覆盖,见下一小节。
事件发出之后,FQ 规则盯的是 checkName=SysLogsXIDError 且不健康,一般不再判断「这是 48 还是 79」。仍跟编号/模式有关的例外:
- health-events-analyzer 的
RepeatedXidError:短时间多次 XID 再升一档(Mongo pipeline 或 PostgreSQL burst detector); - docs/designs/014-workflow-XID-13-and-31.md:13/31 要用历史(同 GPC/TPC、是否 burst),目录里的 WORKFLOW 当时还没完全做成代码。
日志从哪来、格式是什么,见同目录 XID 日志说明。
动作映射与 XID 154 特判
源码:health-monitors/syslog-health-monitor/pkg/common/common.go
go
// health-monitors/syslog-health-monitor/pkg/common/common.go
func MapActionStringToProto(s string) pb.RecommendedAction {
s = strings.ToUpper(strings.TrimSpace(s))
if value, exists := pb.RecommendedAction_value[s]; exists {
return pb.RecommendedAction(value)
}
switch s {
case "RESTART_APP", "IGNORE", "XID_154_EVAL", "WORKFLOW_XID_45":
return pb.RecommendedAction_NONE
case "WORKFLOW_XID_48", "RESET_GPU", "RECOVER_FEATURE_RESET_GPU":
return pb.RecommendedAction_COMPONENT_RESET
case "WORKFLOW_XID_168", "RESET_FABRIC":
return pb.RecommendedAction_RESTART_VM
default:
slog.Warn("Unknown action string, defaulting to CONTACT_SUPPORT", "action", s)
return pb.RecommendedAction_CONTACT_SUPPORT
}
}
先尝试直接命中 proto 枚举名,再走人工映射表,兜底 CONTACT_SUPPORT。表里没有的 XID 号同样兜底 CONTACT_SUPPORT。
XID 154 不查表,从消息里最后一对括号中取推荐动作字符串:
go
if xidCode == 154 {
lastOpenParan := strings.LastIndex(message, "(")
lastCloseParan := strings.LastIndex(message, ")")
if lastOpenParan != -1 && lastCloseParan != -1 && lastOpenParan < lastCloseParan {
recommendation := message[lastOpenParan+1 : lastCloseParan]
switch recommendation {
case "GPU Reset Required", "Drain and Reset":
recommendedAction = pb.RecommendedAction_COMPONENT_RESET
case "Node Reboot Required":
recommendedAction = pb.RecommendedAction_RESTART_BM
case "None":
recommendedAction = pb.RecommendedAction_NONE
default:
recommendedAction = pb.RecommendedAction_CONTACT_SUPPORT
}
}
}
XID 154 的日志本身就携带驱动给出的恢复动作,比静态表更准。sidecar 路径在 SidecarParser.Parse 尾部做了等价的字符串归一。
GPU UUID 双来源与动作降级
这是 XID 处理器里业务含义最重的一段:
源码:health-monitors/syslog-health-monitor/pkg/xid/xid_handler.go
go
// health-monitors/syslog-health-monitor/pkg/xid/xid_handler.go
func (xidHandler *XIDHandler) getGPUUUID(normPCI string) (uuid string, fromMetadata bool) {
gpuInfo, err := xidHandler.metadataReader.GetGPUByPCI(normPCI)
if err == nil && gpuInfo != nil {
return gpuInfo.UUID, true
}
if err != nil {
slog.Error("Error getting GPU UUID from metadata", "pci", normPCI, "error", err)
}
if uuid, ok := xidHandler.pciToGPUUUID[normPCI]; ok {
return uuid, false
}
return "", false
}
UUID 有两个来源:metadata-collector 产出的 gpu_metadata.json,以及从 dmesg 映射行现攒的内存表。第二个返回值区分来源,直接决定动作是否降级:
go
recommendedAction := common.MapActionStringToProto(xidResp.Result.Resolution)
if !fromMetadata && recommendedAction == pb.RecommendedAction_COMPONENT_RESET {
slog.Info("Overriding recommended action from COMPONENT_RESET to RESTART_VM", "pci", normPCI, "gpuUUID", uuid)
recommendedAction = pb.RecommendedAction_RESTART_VM
}
降级的逻辑链条是这样的:COMPONENT_RESET 走完之后,需要一条 impacted entities 完全一致的健康事件才能让 fault-quarantine 解除隔离;而恢复事件是由 reset 回执触发的,回执里只有 UUID,必须反查 PCI;反查依赖 metadata。如果 UUID 本身就不是从 metadata 来的,反查大概率也会失败,恢复事件发不出来,节点会永久卡在隔离态。与其冒这个风险,不如退一步做整机重启。
对应的正向恢复路径:
go
func (xidHandler *XIDHandler) createHealthEventGPUResetEvent(uuid string, success bool) (*pb.HealthEvents, error) {
gpuInfo, err := xidHandler.metadataReader.GetInfoByUUID(uuid)
if err != nil {
return nil, fmt.Errorf("failed to look up GPU info using UUID %s: %w", uuid, err)
}
if len(gpuInfo.PCIAddress) == 0 {
return nil, fmt.Errorf("failed to look up PCI info using UUID %s", uuid)
}
...
}
这里宁可返回错误也不发只带 UUID 的半截事件,因为实体集合对不上的健康事件不会清除隔离,反而会污染事件流。按 4.7 的语义,返回错误意味着这一条回执会被原地重投,metadata 就绪后自然成功;但在就绪之前遍历会卡在这一条上。
reset 失败时发的是新的不健康事件,错误码 GPU_RESET_FAILURE、动作 RESTART_VM,作为「reset 修不好就重启整机」的兜底。
实体与元数据
getDefaultImpactedEntities 固定带 PCI,UUID 非空时追加 GPU_UUID。XID 13 额外带 GPC/TPC/SM,XID 74 额外带 NVLINK 和 REG0~REG6,寄存器值转成 32 位二进制串。事件的 Metadata 里带 chassis_serial,来自 metadata 读取器。
IsFatal 的判定很简单:只要动作不是 NONE 就是致命。
取消规则
pkg/cancellation 实现「看到 A 错误码就为 B 错误码补发健康事件」:
源码:health-monitors/syslog-health-monitor/pkg/cancellation/resolver.go
go
// health-monitors/syslog-health-monitor/pkg/cancellation/resolver.go
type Resolver map[string][]string
源码:health-monitors/syslog-health-monitor/pkg/xid/xid_handler.go
go
// health-monitors/syslog-health-monitor/pkg/xid/xid_handler.go
func (xidHandler *XIDHandler) buildCancellationEvents(
sourceErrorCode string, entities []*pb.Entity, source *pb.HealthEvent,
) []*pb.HealthEvent {
targets := xidHandler.cancellations[sourceErrorCode]
if len(targets) == 0 {
return nil
}
synthetic := make([]*pb.HealthEvent, 0, len(targets))
for _, target := range targets {
synthetic = append(synthetic, xidHandler.buildCancellationEvent(target, entities, source))
metrics.CancellationsEmittedMetric.WithLabelValues(
xidHandler.checkName, sourceErrorCode, target,
).Inc()
}
return synthetic
}
合成事件与源事件共享实体集合,但实体是深拷贝的,避免下游修改互相影响。processingStrategy 从源事件拷贝,模块跑 STORE_ONLY 时取消事件同样不会驱动隔离清除。事件里带 nvsentinel.nvidia.com/cancel-source-error-code 元数据,便于追溯是谁触发的。
Helm 默认只配了一条:观察到 XID 162 时取消 XID 163,语义是电源平滑功能重新接入意味着之前的断开已恢复。
配置校验有三层,全部在启动时做,任何一层不过就直接启动失败:Validate 检查结构合法性,包括空值、首尾空格、自我取消、重复项;ValidateSupportedChecks 拒绝为没有接入 Resolver 的检查项配规则,当前白名单只有 SysLogsXIDError;ValidateAgainstEnabledChecks 拒绝为没在 --checks 里启用的检查项配规则。这三层是为了防止规则写了却静默不生效。配置文件不存在视为无规则,不报错。
6.2 SXID 处理器
最简单的一个。正则一条:
源码:health-monitors/syslog-health-monitor/pkg/sxid/types.go
go
// health-monitors/syslog-health-monitor/pkg/sxid/types.go
reSXIDPattern = regexp.MustCompile(
`nvidia-nvswitch(\d+): SXid \(PCI:([0-9a-fA-F:.]+)\): (\d+), (Fatal|Non-fatal), Link (\d+) (.+)`)
关键在于它必须做一次拓扑反查,把「NVSwitch 的某条 link 报错」翻译成「哪块 GPU 受影响」:
源码:health-monitors/syslog-health-monitor/pkg/metadata/reader.go
go
// health-monitors/syslog-health-monitor/pkg/metadata/reader.go
func (r *Reader) buildMaps() {
...
for _, link := range gpu.NVLinks {
remotePCI := normalizePCI(link.RemotePCIAddress)
if r.nvswitchLinks[remotePCI] == nil {
r.nvswitchLinks[remotePCI] = make(map[int]*gpuLinkInfo)
}
r.nvswitchLinks[remotePCI][link.RemoteLinkID] = &gpuLinkInfo{
GPU: gpu,
LocalLinkID: link.LinkID,
}
}
}
从 GPU 侧的 NVLink 列表反向建 NVSwitch PCI + 远端 link ID → GPU 的索引。反查失败会返回错误,这一行不产生事件并触发重投。
实体带五个:NVSWITCH、PCI、NVLINK、GPU、GPU_UUID。动作只有两档,Fatal 给 CONTACT_SUPPORT,Non-fatal 给 NONE。
normalizePCI 值得单独看一眼,PCI 地址在不同来源里格式不一致,需要归一:
go
func normalizePCI(pci string) string {
parts := strings.Split(pci, ":")
if len(parts) != 3 {
return strings.ToLower(pci)
}
domain := parts[0]
if len(domain) > 4 {
domain = domain[len(domain)-4:]
}
busDeviceFunc := parts[2]
if idx := strings.Index(busDeviceFunc, "."); idx != -1 {
busDeviceFunc = busDeviceFunc[:idx]
}
return fmt.Sprintf("%s:%s:%s", strings.ToLower(domain), strings.ToLower(parts[1]), strings.ToLower(busDeviceFunc))
}
domain 截尾四位、去掉 function 号、统一小写。XID 处理器里另有一个更简单的 normalizePCI,只做截断 function 号。
6.3 GPU 掉总线处理器
正则用了 (?s) 跨行标志,因为这个错误在 journal 里是一条包含多个换行的条目:
源码:health-monitors/syslog-health-monitor/pkg/gpufallen/types.go
go
// health-monitors/syslog-health-monitor/pkg/gpufallen/types.go
reGPUFallenPattern = regexp.MustCompile(
`(?s)NVRM: The NVIDIA GPU ([0-9a-fA-F:.]+).*?fallen off the bus and is not responding to commands`)
这个处理器的主要工作其实是去重。GPU 掉总线通常伴随 XID,两个检查项会看到相关联的日志,如果都报事件,下游会看到同一次故障的两条不同错误码记录。去重分两层:
源码:health-monitors/syslog-health-monitor/pkg/gpufallen/gpufallen_handler.go
go
// health-monitors/syslog-health-monitor/pkg/gpufallen/gpufallen_handler.go
func (h *GPUFallenHandler) parseGPUFallenError(message string) *gpuFallenErrorEvent {
if common.XIDPattern.MatchString(message) {
return nil
}
m := reGPUFallenPattern.FindStringSubmatch(message)
if len(m) < 2 {
return nil
}
pciAddr := m[1]
if h.hasRecentXID(pciAddr) {
return nil
}
...
}
第一层是同条消息内含 XID 就让给 XID 处理器。第二层是跨消息的时间窗:ProcessLine 每次都先调 trackXIDIfPresent 记录「这个 PCI 刚出过 XID」,窗口默认 5 分钟,窗口内的掉总线消息不再单独报。
窗口表的清理有两条路径。一条是 hasRecentXID 里的顺手清理,发现过期就删;另一条是构造时起的后台 goroutine,周期是窗口的五分之一:
go
func (h *GPUFallenHandler) cleanupExpiredXIDs(ctx context.Context) {
h.mu.RLock()
cleanupInterval := h.xidWindow / 5
h.mu.RUnlock()
ticker := time.NewTicker(cleanupInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
...
}
}
}
注意 ticker 间隔在 goroutine 启动时读一次就固定了,之后 SetXIDWindow 改窗口不会改变清理频率。SetXIDWindow 只在测试里用。这个 handler 是唯一带互斥锁的,因为清理 goroutine 与主循环并发访问 recentXIDs。
事件固定 IsFatal=true、动作 RESTART_BM、错误码 GPU_FALLEN_OFF_BUS,实体带 PCI,能解析出来时追加 PCI_ID。
6.4 NIC 驱动处理器
这是四个里配置面最完整的一个。设计上把正则、严重性、推荐动作、描述全部放在代码里,TOML 只能按名字开关和覆盖 processingStrategy:
源码:health-monitors/syslog-health-monitor/pkg/nicdriver/config.go
go
// health-monitors/syslog-health-monitor/pkg/nicdriver/config.go
type PatternConfig struct {
Name string `toml:"name"`
Enabled bool `toml:"enabled"`
ProcessingStrategy string `toml:"processingStrategy"`
}
var patternDefinitions = map[string]patternDefinition{
"cmd_exec_timeout": {
re: regexp.MustCompile(`mlx5_core.*timeout\. Will cause a leak of a command resource`),
isFatal: true,
recommendedAction: pb.RecommendedAction_REPLACE_VM,
description: "Firmware/driver command timeout - control plane broken",
},
...
}
compilePatterns 对未知名字、重复名字、非法 processingStrategy 一律报错,启动失败。这样运维改不出一个能匹配任意内容的正则,也不会因为拼错名字而静默少一个检测项。handler 组事件时:模式带了 HasProcessingStrategy 就用模式上的值,否则用 --processing-strategy 的模块默认。因此可以把整模块留在 EXECUTE_REMEDIATION,只把某几条 NIC 规则设成 STORE_ONLY 做观察。
11 个内置模式,Helm 默认全部 enabled: false。致命的四个是 cmd_exec_timeout、health_poll_failed、unrecoverable_err、mlx5_napi_soft_lockup,动作都是 REPLACE_VM。其余是非致命、动作 NONE,因为 mlx5 驱动自身有自恢复,报出来只做观测。
netdev_watchdog 的正则兼容了两种内核输出顺序,注释里指明了引入变化的内核提交:
go
re: regexp.MustCompile(
`(NETDEV WATCHDOG.*mlx5_core|mlx5_core.*NETDEV WATCHDOG).*transmit queue.*timed out`),
软锁死状态机
mlx5_napi_soft_lockup 不是单行模式,被单独路由出主匹配循环:
源码:health-monitors/syslog-health-monitor/pkg/nicdriver/handler.go
go
// health-monitors/syslog-health-monitor/pkg/nicdriver/handler.go
for _, p := range patterns {
if p.Name == softLockupPatternName {
h.lockup = newSoftLockupDetector(p)
continue
}
singleLine = append(singleLine, p)
}
原因是内核软锁死报告横跨多条 journal 条目:头一行给 CPU 号和卡住秒数但不说是谁的锅,后面的 RIP 与调用栈才带 mlx5 符号。检测器因此设计成「头行武装、栈帧确认」:
源码:health-monitors/syslog-health-monitor/pkg/nicdriver/softlockup.go
go
// health-monitors/syslog-health-monitor/pkg/nicdriver/softlockup.go
func (d *softLockupDetector) observe(message string) *softLockupMatch {
if d.pending != nil {
if message == d.pendingMessage {
return d.pending
}
d.pending = nil
d.pendingMessage = ""
}
if m := softLockupHeaderRe.FindStringSubmatch(message); m != nil {
d.armed = true
d.cpu = m[1]
d.duration = m[2]
d.linesLeft = softLockupWindowLines
return nil
}
if !d.armed {
return nil
}
d.linesLeft--
if d.linesLeft <= 0 {
d.armed = false
}
if !d.pattern.Re.MatchString(message) || softLockupSpeculativeRe.MatchString(message) {
return nil
}
d.armed = false
if !d.lastEmit.IsZero() && d.now().Sub(d.lastEmit) < softLockupEmitCooldown {
return nil
}
d.lastEmit = d.now()
match := &softLockupMatch{cpu: d.cpu, duration: d.duration, evidence: message}
d.pending = match
d.pendingMessage = message
return match
}
四个设计点:
- 窗口按行数而不是墙钟计,150 行。监控重启后会全速回放 journal 积压,墙钟距离与日志时间距离完全脱节,行数才是稳定的。
- 排除推测性栈帧 。
softLockupSpeculativeRe匹配带?前缀的帧,那是内核回溯器自己都不确定的残留,会出现在无关的 dump 里,不能作为归因证据。 - 发射冷却 30 分钟。内核 watchdog 对持续锁死会每几十秒重报一次,不限流会刷屏。
- pending 缓存 。这一条直接对应 4.7 节的重投语义:
handleSingleLine失败时同一条日志会被再次observe,如果不缓存,第二次进来armed已经被清、冷却也已生效,会返回nil,这条致命事件就被静默吞掉。缓存的判据是「消息内容与上次确认的相同就认为是重投」,因为流水线只有在成功投递后才会前进到不同的条目。
软锁死事件的消息体是重新拼装的,并把 CPU 与秒数放进 Metadata:
go
event.Message = fmt.Sprintf(
"kernel soft lockup: CPU#%s stuck for %ss in mlx5 NAPI poll loop (evidence: %s)",
m.cpu, m.duration, m.evidence)
event.Metadata = map[string]string{
"cpu": m.cpu,
"durationSeconds": m.duration,
}
实体富化
单行模式命中后尝试从日志里抓 PCI BDF,再经 sysfs 反查设备名:
源码:health-monitors/syslog-health-monitor/pkg/nicdriver/pcilookup.go
go
// health-monitors/syslog-health-monitor/pkg/nicdriver/pcilookup.go
func (r *sysfsResolver) Resolve(bdf string) (string, string, bool) {
deviceDir := filepath.Join(r.root, "bus", "pci", "devices", bdf)
driverName, ok := readDriverName(filepath.Join(deviceDir, "driver"))
if !ok {
return "", "", false
}
device := resolveDeviceName(deviceDir)
return driverName, device, true
}
driver 是个符号链接,读 link 目标取 basename 就是驱动名。设备名优先取 infiniband/ 下第一个条目,没有再取 net/ 下第一个。
富化是尽力而为,且带了一道防御性校验:
go
if hasBDF {
if driver, device, ok := h.resolver.Resolve(bdf); ok && driver == mlx5CoreDriver && device != "" {
entities = append(entities, &pb.Entity{EntityType: "NIC", EntityValue: device})
}
}
即使 BDF 抓不到或解析不出 mlx5_core,事件照样发,只是没有 NIC 实体、退化成节点级事件。这是因为有些 mlx5 日志行本身就不带 PCI 地址。软锁死事件走的就是这条无实体路径。
7. 部署关键点
只列会影响行为判断的几条。
两个 DaemonSet 变体。 同一个 _helpers.tpl 模板用 kataMode 参数渲染两份,靠 nvsentinel.dgxc.nvidia.com/kata 标签和 nodeSelector 区分。节点还必须带 nvsentinel.dgxc.nvidia.com/driver.installed: "true"。
权限。 runAsUser: 0,追加 SYSLOG、SYS_ADMIN capability。读 journal 二进制文件需要。
journal 挂载路径不同。 常规变体把 journalHostPath 默认 /var/log 挂到 /nvsentinel/var/log,Kata 变体直接把 /var/log/journal 挂到 /nvsentinel/var/log/journal,并额外挂 /run/systemd/journal 与 /etc/machine-id。代码里 JournalPath 写死 /nvsentinel/var/log/journal/,两种挂法最终都落到这个路径。
state 文件的实际落点。 --state-file 没有在 DaemonSet args 里覆盖,用的是代码默认值 /var/run/syslog_monitor/state.json。而 var-run-vol 把宿主 /var/run/nvsentinel 挂到容器 /var/run/,所以 state 实际持久化在宿主的 /var/run/nvsentinel/syslog_monitor/state.json。另一个挂载 syslog-state-vol 指向 /var/run/syslog_health_monitor,从当前代码看没有任何东西会写到那里,属于残留。
轮询间隔的实际值。 flag 默认 30 分钟,但 chart 固定传 --polling-interval 15s,所以生产行为是 15 秒一轮,存活探针阈值 45 秒。
探针。 liveness 走 /healthz,由 PollingHealthChecker 支撑;readiness 走 /metrics,只反映 HTTP server 起没起来。
sidecar 的两种挂法。 Kubernetes 1.29 及以上用 restartPolicy: Always 的 initContainer 保证启动顺序,低版本退回普通 sidecar 容器,此时靠代码里的 WaitUntilReady 做应用层等待。
ConfigMap。 nic-driver.toml 与 cancellations.toml 同在一个 ConfigMap,挂到 /etc/syslog-health-monitor/。ConfigMap 更新不会被进程感知,两个配置都只在启动时读一次。
processingStrategy。 chart 默认 EXECUTE_REMEDIATION,模板固定传 --processing-strategy。改成 STORE_ONLY 后本模块仍上报全部命中事件,但下游不隔离、不排空、不修复。NIC 各模式还可在 values 的 nicDriverDetection.patterns[].processingStrategy 上单独覆盖,渲染进同一份 nic-driver.toml。
8. 指标关键点
| 指标 | 类型 | 标签 | 用途 |
|---|---|---|---|
syslog_health_monitor_xid_errors |
Counter | node, err_code | XID 计数,启动时按内置目录表预置 0 |
syslog_health_monitor_sxid_errors |
Counter | node, err_code, link, nvswitch | SXID 计数,故意不预置 |
syslog_health_monitor_gpu_fallen_errors |
Counter | node | 掉总线计数,预置 0 |
syslog_health_monitor_nic_driver_errors |
Counter | node, event, severity | NIC 事件计数,按已加载模式预置 0 |
syslog_health_monitor_xid_processing_errors |
Counter | error_type, node | 解析与 sidecar 调用失败 |
syslog_health_monitor_xid_processing_latency_seconds |
Histogram | 无 | 单行 XID 处理耗时 |
syslog_health_monitor_xid_empty_driver_version_total |
Counter | node | 驱动版本为空的解码请求数 |
syslog_health_monitor_cancellations_emitted_total |
Counter | check, source_error_code, target_error_code | 合成取消事件数 |
预置为 0 这件事在代码里反复出现,理由统一:部分 Prometheus 后端把首个采样点当基线,不预置的话某个错误码在本节点的第一次出现会被吞掉。SXID 是唯一例外,注释解释得很清楚------它的 link 与 nvswitch 标签组合无法在启动时枚举,全笛卡尔积会造出几百条永远为 0 的时间序列,比丢首样本更糟。
preInitializeMetrics 按本 Pod 实际启用的检查项决定预置哪些,只跑部分检查项的变体不会为未启用的子系统凭空导出计数器。
9. 代码层面的边界与风险
以下是读代码时能直接看到的约束,不涉及运行验证。
- 配置全部是启动时一次性读取。 NIC 模式、取消规则、检查项列表都不支持热更新,改 ConfigMap 需要重启 DaemonSet。
- state 文件非原子写。 靠加载侧的损坏降级兜底,代价是丢一次游标位置并从尾部重来,这意味着丢一段未处理的日志。
- journal 错误分类靠字符串匹配。
isRetryableJournalError用strings.Contains判断,错误文案变化会让重试策略失效。 handleSingleLine失败会在processAllEntries里原地无限重投同一条。 发送失败和ProcessLine硬错误都走这条路径;代码注释写的 Skip 与实现不符。好处是不丢事件,代价是 platform-connector 长时间异常或 metadata 暂时缺失时遍历完全停滞、后续检查项得不到执行,且 journald 保留窗口仍在流逝,最终可能读到游标失效。游标失效时resumeFromLastCursor会退回SeekTail,中间那段日志静默丢弃,只有一条 Warn 日志。- 无限回扫是个危险配置。
--boot-lookback-window 0会从 journal 头部开始扫,日志量大的机器上首轮会非常慢,而且会把很久以前的错误重新报一遍。 - XID 处理器的
pciToGPUUUID内存表只增不减。 条目数上限是节点 GPU 数,不构成泄漏,但进程重启后清空,在 metadata 缺失时会短暂退化。 - 掉总线的清理 goroutine 周期在启动时固定。 后续调整窗口不会同步调整清理频率。
- 软锁死的 pending 缓存依赖「消息内容相同即重投」。 如果内核连续输出两条完全相同的确认帧,第二条会被当成重投而复用同一个 match,不会重复发事件。在冷却期内这个行为与预期一致。
10. 测试覆盖速览
单测约 120 个,分布很能说明各部分的复杂度:
- pkg/syslog-monitor/syslogmonitor_test.go 24 个,全部围绕状态机:bootID 变化、发送被跳过时不落盘、从启动点扫描、第二轮从游标恢复、
BootStartScanDone的三种恢复场景、书签重读修正、过滤器组合; - pkg/nicdriver/softlockup_test.go 15 个,专测状态机边界:窗口边界、重新武装、推测帧、冷却、重投返回同一 match;
- pkg/xid/ 下分三块,handler 行为、CSV 解析与驱动版本选择、sidecar 就绪与解析、取消规则五例;
- pkg/metadata/reader_test.go 覆盖懒加载、并发访问、加载失败后重试、PCI 归一;
- pkg/cancellation/config_test.go 集中在校验拒绝路径。
fake_journal.go 是关键测试基建,它实现了 match 语义,所以「_TRANSPORT=kernel OR SYSLOG_IDENTIFIER=nvsentinel-gpu-reset」这类过滤组合能在单测里被真正验证,而不是只验证 AddMatch 被调用过。
11. 参考
- 上游文档:docs/syslog-health-monitor.md
- 处理策略(含
STORE_ONLY):docs/designs/025-processing-strategy-for-health-checks.md - XID 日志来源与格式:XID 日志说明
- 官方目录表:pkg/common/Xid-Catalog.xlsx
- XID 13/31 工作流设计:docs/designs/014-workflow-XID-13-and-31.md
- 代码:health-monitors/syslog-health-monitor/
- Chart:distros/kubernetes/nvsentinel/charts/syslog-health-monitor/
- 共享发布器:commons/pkg/healthpub/publisher.go