Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions pkg/logcounter/log_counter.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ package logcounter

import (
"fmt"
"regexp"
"time"

"k8s.io/utils/clock"
Expand All @@ -43,8 +42,8 @@ const (
type logCounter struct {
logCh <-chan *systemtypes.Log
buffer systemlogmonitor.LogBuffer
pattern *regexp.Regexp
revertPattern *regexp.Regexp
pattern *systemlogmonitor.Pattern
revertPattern *systemlogmonitor.Pattern
clock clock.Clock
}

Expand All @@ -53,7 +52,7 @@ func NewJournaldLogCounter(options *options.LogCounterOptions) (types.LogCounter
if err != nil {
return nil, fmt.Errorf("invalid pattern %q: %w", options.Pattern, err)
}
var revertPattern *regexp.Regexp
var revertPattern *systemlogmonitor.Pattern
if options.RevertPattern != "" {
revertPattern, err = systemlogmonitor.CompilePattern(options.RevertPattern)
if err != nil {
Expand Down
6 changes: 2 additions & 4 deletions pkg/systemlogmonitor/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,6 @@ limitations under the License.
package systemlogmonitor

import (
"regexp"

watchertypes "k8s.io/node-problem-detector/pkg/systemlogmonitor/logwatchers/types"
systemlogtypes "k8s.io/node-problem-detector/pkg/systemlogmonitor/types"
"k8s.io/node-problem-detector/pkg/types"
Expand Down Expand Up @@ -59,8 +57,8 @@ func (mc *MonitorConfig) ApplyDefaultConfiguration() {
}
}

func (mc MonitorConfig) compileRules() ([]*regexp.Regexp, error) {
patterns := make([]*regexp.Regexp, len(mc.Rules))
func (mc MonitorConfig) compileRules() ([]*Pattern, error) {
patterns := make([]*Pattern, len(mc.Rules))
for i, rule := range mc.Rules {
pattern, err := CompilePattern(rule.Pattern)
if err != nil {
Expand Down
112 changes: 98 additions & 14 deletions pkg/systemlogmonitor/log_buffer.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,27 +18,41 @@ package systemlogmonitor

import (
"regexp"
"regexp/syntax"
"slices"
"strings"

"k8s.io/node-problem-detector/pkg/systemlogmonitor/types"
)

// LogBuffer buffers the logs and supports match in the log buffer with regular expression.
// LogBuffer buffers the logs and matches a compiled pattern.
type LogBuffer interface {
// Push pushes log into the log buffer.
Push(*types.Log)
// Match with regular expression in the log buffer.
Match(*regexp.Regexp) []*types.Log
// String returns a concatenated string of the buffered logs.
String() string
// Match with a compiled pattern in the log buffer.
Match(*Pattern) []*types.Log
}

// Pattern is a compiled rule plus the facts that let Match narrow its scan.
type Pattern struct {
// regexp is the rule anchored to the end of the buffered logs.
regexp *regexp.Regexp
// lastLineOnly reports that the rule can match only in the last pushed line.
// Match then skips building the joined buffer.
lastLineOnly bool
}

// logBuffer is not safe for concurrent use.
type logBuffer struct {
// buffer is a simple ring buffer.
buffer []*types.Log
msg []string
max int
current int
// joined caches the result of String. Push clears it.
joined string
// joinedOK reports whether joined is current.
joinedOK bool
}

// NewLogBuffer creates log buffer with max line number limit. Because we only match logs
Expand All @@ -55,22 +69,74 @@ func NewLogBuffer(maxLines int) *logBuffer {

// CompilePattern compiles a log buffer pattern that must match to the end of
// the buffered logs.
func CompilePattern(expr string) (*regexp.Regexp, error) {
func CompilePattern(expr string) (*Pattern, error) {
// Compile expr alone first so an error cites the pattern as written.
if _, err := regexp.Compile(expr); err != nil {
return nil, err
}
return regexp.Compile(expr + `\z`)
anchored := expr + `\z`
reg, err := regexp.Compile(anchored)
if err != nil {
return nil, err
}
p := &Pattern{regexp: reg}
tree, err := syntax.Parse(anchored, syntax.Perl)
if err != nil {
return p, nil
}
// A top-level alternation binds the appended anchor to its last branch only.
// Equal trees prove that the anchor covers every branch.
grouped, err := syntax.Parse(`(?:`+expr+`)\z`, syntax.Perl)
if err != nil {
return p, nil
}
p.lastLineOnly = tree.Equal(grouped) && isLastLineOnly(tree)
return p, nil
}

// isLastLineOnly reports whether the rule accepts no newline and has no start anchor.
func isLastLineOnly(re *syntax.Regexp) bool {
switch re.Op {
case syntax.OpAnyChar:
// `(?s).` accepts a newline.
return false
case syntax.OpBeginText, syntax.OpBeginLine, syntax.OpEndLine:
// A start anchor marks the start of the whole buffer.
return false
case syntax.OpLiteral:
if slices.Contains(re.Rune, '\n') {
return false
}
case syntax.OpCharClass:
// Rune stores the character class as inclusive lo, hi pairs.
for i := 0; i+1 < len(re.Rune); i += 2 {
if re.Rune[i] <= '\n' && '\n' <= re.Rune[i+1] {
return false
}
}
}
for _, sub := range re.Sub {
if !isLastLineOnly(sub) {
return false
}
}
return true
}

func (b *logBuffer) Push(log *types.Log) {
b.buffer[b.current%b.max] = log
b.msg[b.current%b.max] = log.Message
b.current++
b.joinedOK = false
b.joined = ""
}

func (b *logBuffer) Match(reg *regexp.Regexp) []*types.Log {
func (b *logBuffer) Match(p *Pattern) []*types.Log {
if p.lastLineOnly {
return b.matchLastLine(p.regexp)
}
log := b.String()
loc := reg.FindStringIndex(log)
loc := p.regexp.FindStringIndex(log)
if loc == nil {
// No match
return nil
Expand All @@ -86,15 +152,33 @@ func (b *logBuffer) Match(reg *regexp.Regexp) []*types.Log {
break
}
}
for i := 0; i < len(matched)/2; i++ {
matched[i], matched[len(matched)-i-1] = matched[len(matched)-i-1], matched[i]
}
slices.Reverse(matched)
return matched
}

// matchLastLine matches a lastLineOnly rule against the most recently pushed line.
func (b *logBuffer) matchLastLine(reg *regexp.Regexp) []*types.Log {
if b.current == 0 {
return nil
}
last := (b.current - 1) % b.max
if !reg.MatchString(b.msg[last]) {
return nil
}
return []*types.Log{b.buffer[last]}
}

func (b *logBuffer) String() string {
logs := append(b.msg[b.current%b.max:], b.msg[:b.current%b.max]...)
return concatLogs(logs)
if b.joinedOK {
return b.joined
}
head := b.current % b.max
lines := make([]string, 0, b.max)
lines = append(lines, b.msg[head:]...)
lines = append(lines, b.msg[:head]...)
b.joined = concatLogs(lines)
b.joinedOK = true
return b.joined
}

// tail returns current tail index.
Expand Down
74 changes: 74 additions & 0 deletions pkg/systemlogmonitor/log_buffer_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
Copyright 2026 The Kubernetes Authors All rights reserved.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package systemlogmonitor

import (
"fmt"
"testing"

"k8s.io/node-problem-detector/pkg/systemlogmonitor/types"
)

// kernelMonitorPatterns is the rule set of config/kernel-monitor.json.
var kernelMonitorPatterns = []string{
`Killed process \d+ (.+) total-vm:\d+kB, anon-rss:\d+kB, file-rss:\d+kB.*`,
`task [\S ]+:\w+ blocked for more than \w+ seconds\.`,
`unregister_netdevice: waiting for \w+ to become free. Usage count = \d+`,
`BUG: unable to handle kernel NULL pointer dereference at .*`,
`divide error: 0000 \[#\d+\] SMP`,
`EXT4-fs error .*`,
`EXT4-fs warning .*`,
`Buffer I/O error .*`,
`XFS .* Shutting down filesystem.?`,
`CE memory read error .*`,
`.*\[Hardware Error\]: event severity: corrected$`,
`.*\[Hardware Error\]: event severity: recoverable$`,
`.*\[Hardware Error\]: event severity: fatal$`,
`task docker:\w+ blocked for more than \w+ seconds\.`,
}

// benchmarkLine matches none of the rules above, the common case on a healthy node.
const benchmarkLine = "systemd[1]: Started Session 4321 of user core."

// BenchmarkPushAndMatchAll measures the per-line cost of the monitor hot path.
// Each iteration pushes one line and evaluates every rule against the buffer.
func BenchmarkPushAndMatchAll(b *testing.B) {
for _, bufferSize := range []int{10, 100} {
b.Run(fmt.Sprintf("buffer=%d", bufferSize), func(b *testing.B) {
buf := NewLogBuffer(bufferSize)
for range bufferSize {
buf.Push(&types.Log{Message: benchmarkLine})
}
patterns := make([]*Pattern, 0, len(kernelMonitorPatterns))
for _, expr := range kernelMonitorPatterns {
p, err := CompilePattern(expr)
if err != nil {
b.Fatalf("failed to compile %q: %v", expr, err)
}
patterns = append(patterns, p)
}
log := &types.Log{Message: benchmarkLine}
b.ReportAllocs()
for b.Loop() {
buf.Push(log)
for _, p := range patterns {
buf.Match(p)
}
}
})
}
}
Loading