Skip to content

Commit 5a2dcba

Browse files
authored
Merge pull request #166 from pznamensky/metrics
add "evicted_pods_total" metric
2 parents bb36e70 + babbc24 commit 5a2dcba

9 files changed

Lines changed: 306 additions & 1 deletion

File tree

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,26 @@ GLOBAL OPTIONS:
4343
--memory-usage-threshold value Memory usage eviction threshold (0-100) (default: 95)
4444
--channel-queue-size value Size of the queue for pod eviction (default: 100)
4545
--ignore-namespace value [ --ignore-namespace value ] Do not evict Pods from this namespace. Can be used multiple times
46+
--enable-metrics value Expose Prometheus metrics endpoint (default: false)
47+
--metrics-bind-address value Bind address for the Prometheus exporter (default: :9288)
4648
--loglevel value, -v value Log Level (default: 0)
4749
--help, -h show help
4850
```
4951

52+
## Metrics
53+
54+
Enable the exporter with `--enable-metrics` and set `--metrics-bind-address` (for example, `:9288`). The endpoint is served at `/metrics` and currently publishes a single counter:
55+
56+
- `soft_pod_memory_evicter_evicted_pods_total{affected_namespace="<ns>",affected_app_kubernetes_io_name="<name>",affected_app_kubernetes_io_instance="<instance>"}` — counts evictions per workload (namespace + app.kubernetes.io/name + app.kubernetes.io/instance).
57+
58+
You might also want to add the following annotations into Helm values to let Prometheus know about the exporter:
59+
60+
```yaml
61+
podAnnotations:
62+
"prometheus.io/scrape": "true"
63+
"prometheus.io/port": "9288"
64+
```
65+
5066
## Pod-specific configurations
5167
5268
Pods can be configured individually using annotations to override the default behavior:

go.mod

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ module github.com/maxlaverse/soft-pod-memory-evicter
33
go 1.25.0
44

55
require (
6+
github.com/prometheus/client_golang v1.20.4
67
github.com/stretchr/testify v1.11.1
78
github.com/urfave/cli/v2 v2.27.7
89
k8s.io/api v0.35.0
@@ -13,6 +14,8 @@ require (
1314
)
1415

1516
require (
17+
github.com/beorn7/perks v1.0.1 // indirect
18+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
1619
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
1720
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
1821
github.com/emicklei/go-restful/v3 v3.12.2 // indirect
@@ -26,11 +29,15 @@ require (
2629
github.com/google/uuid v1.6.0 // indirect
2730
github.com/josharian/intern v1.0.0 // indirect
2831
github.com/json-iterator/go v1.1.12 // indirect
32+
github.com/klauspost/compress v1.17.9 // indirect
2933
github.com/mailru/easyjson v0.9.0 // indirect
3034
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
3135
github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect
3236
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
3337
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
38+
github.com/prometheus/client_model v0.6.1 // indirect
39+
github.com/prometheus/common v0.55.0 // indirect
40+
github.com/prometheus/procfs v0.15.1 // indirect
3441
github.com/russross/blackfriday/v2 v2.1.0 // indirect
3542
github.com/spf13/pflag v1.0.9 // indirect
3643
github.com/x448/float16 v0.8.4 // indirect

go.sum

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
4+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
15
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
26
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
37
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
@@ -33,10 +37,16 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
3337
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
3438
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
3539
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
40+
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
41+
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
42+
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
43+
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
3644
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
3745
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
3846
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
3947
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
48+
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
49+
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
4050
github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
4151
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
4252
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -54,6 +64,16 @@ github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6u
5464
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5565
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
5666
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
67+
github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI=
68+
github.com/prometheus/client_golang v1.20.4/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
69+
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
70+
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
71+
github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc=
72+
github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8=
73+
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
74+
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
75+
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
76+
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
5777
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
5878
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
5979
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=

main.go

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@ package main
33
import (
44
"flag"
55
"fmt"
6+
"net"
67
"os"
78
"os/signal"
89
"strconv"
10+
"strings"
911
"time"
1012

1113
"github.com/maxlaverse/soft-pod-memory-evicter/pkg"
@@ -23,6 +25,8 @@ func main() {
2325
MemoryUsageThreshold: 95,
2426
ChannelQueueSize: 100,
2527
IgnoredNamespaces: *cli.NewStringSlice(),
28+
EnableMetrics: false,
29+
MetricsBindAddress: ":9288",
2630
}
2731

2832
app := &cli.App{
@@ -31,7 +35,22 @@ func main() {
3135
Before: func(c *cli.Context) error {
3236
fs := flag.NewFlagSet("", flag.PanicOnError)
3337
klog.InitFlags(fs)
34-
return fs.Set("v", strconv.Itoa(c.Int("loglevel")))
38+
if err := fs.Set("v", strconv.Itoa(c.Int("loglevel"))); err != nil {
39+
return err
40+
}
41+
42+
if opts.EnableMetrics {
43+
bindAddr := strings.TrimSpace(opts.MetricsBindAddress)
44+
if bindAddr == "" {
45+
return fmt.Errorf("--metrics-bind-address cannot be empty when --enable-metrics is true")
46+
}
47+
48+
if _, _, err := net.SplitHostPort(bindAddr); err != nil {
49+
return fmt.Errorf("invalid --metrics-bind-address: %w", err)
50+
}
51+
}
52+
53+
return nil
3554
},
3655
Flags: []cli.Flag{
3756
&cli.BoolFlag{
@@ -69,6 +88,16 @@ func main() {
6988
Usage: "Do not evict Pods from this namespace. Can be used multiple times",
7089
Value: &opts.IgnoredNamespaces,
7190
Destination: &opts.IgnoredNamespaces,
91+
}, &cli.BoolFlag{
92+
Name: "enable-metrics",
93+
Usage: "Expose Prometheus metrics endpoint",
94+
Value: opts.EnableMetrics,
95+
Destination: &opts.EnableMetrics,
96+
}, &cli.StringFlag{
97+
Name: "metrics-bind-address",
98+
Usage: "Bind address for the Prometheus exporter",
99+
Value: opts.MetricsBindAddress,
100+
Destination: &opts.MetricsBindAddress,
72101
},
73102
&cli.IntFlag{
74103
Name: "loglevel",

pkg/controller.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ const (
3030

3131
// memoryUsageThresholdAnnotation is the annotation key for specifying memory usage threshold
3232
memoryUsageThresholdAnnotation = "soft-pod-memory-evicter.laverse.net/memory-usage-threshold"
33+
34+
appKubernetesNameLabel = "app.kubernetes.io/name"
35+
appKubernetesInstanceLabel = "app.kubernetes.io/instance"
3336
)
3437

3538
type Controller interface {
@@ -47,6 +50,7 @@ type controller struct {
4750
factory informers.SharedInformerFactory
4851
clientset kubernetes.Interface
4952
recorder record.EventRecorder
53+
metrics metricsRecorder
5054
opts Options
5155
pauseChan chan *corev1.Pod // eviction channel for Pods without PodDisruptionBudget
5256
pdbChan chan *corev1.Pod // eviction channel for Pods with PodDisruptionBudget
@@ -85,11 +89,13 @@ func NewController(opts Options) Controller {
8589
scheme.Scheme,
8690
corev1.EventSource{Component: componentName},
8791
),
92+
metrics: newMetricsRecorder(opts),
8893
}
8994
}
9095

9196
func (c *controller) Run(ctx context.Context) error {
9297
stopCh := make(chan struct{})
98+
c.metrics.Start(ctx)
9399

94100
klog.V(1).Info("1/3 Starting Factory")
95101
c.factory.Start(stopCh)
@@ -142,6 +148,7 @@ func (c *controller) evictWithPauseChanLoop(ctx context.Context) {
142148
time.Sleep(c.opts.EvictionPause)
143149
}
144150

151+
c.metrics.RecordPodEviction(pod)
145152
klog.V(2).Infof("Pod '%s/%s' evicted", pod.Namespace, pod.Name)
146153
}
147154
}
@@ -162,6 +169,7 @@ func (c *controller) evictWithPDBChanLoop(ctx context.Context) {
162169
continue
163170
}
164171

172+
c.metrics.RecordPodEviction(pod)
165173
klog.V(2).Infof("Pod '%s/%s' evicted", pod.Namespace, pod.Name)
166174
}
167175
}
@@ -224,6 +232,8 @@ func (c *controller) evictPodsCloseToMemoryLimit(ctx context.Context) error {
224232
continue
225233
}
226234

235+
c.metrics.RecordObservedNamespace(pod)
236+
227237
containers, err := identifyContainersCloseToMemoryLimit(podMetric, *pod, c.getPodMemoryUsageThreshold(pod))
228238
if err != nil {
229239
klog.Errorf("could not find Pod Container over-user '%s/%s'", podMetric.Namespace, podMetric.Name)

pkg/controller_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ func TestGetPodMemoryUsageThreshold(t *testing.T) {
495495
for _, tt := range tests {
496496
t.Run(tt.name, func(t *testing.T) {
497497
c := &controller{
498+
metrics: noopMetricsRecorder{},
498499
opts: Options{
499500
MemoryUsageThreshold: int(tt.defaultValue),
500501
},
@@ -533,6 +534,7 @@ func fakeController(podConfigs ...testPod) *controller {
533534
lister: lister,
534535
pdbLister: pdbLister,
535536
recorder: record.NewFakeRecorder(10),
537+
metrics: noopMetricsRecorder{},
536538
pauseChan: make(chan *corev1.Pod, 10),
537539
pdbChan: make(chan *corev1.Pod, 10),
538540

pkg/metrics_exporter.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
package pkg
2+
3+
import (
4+
"context"
5+
"errors"
6+
"net/http"
7+
"strings"
8+
"sync"
9+
"time"
10+
11+
corev1 "k8s.io/api/core/v1"
12+
13+
"github.com/prometheus/client_golang/prometheus"
14+
"github.com/prometheus/client_golang/prometheus/promhttp"
15+
"k8s.io/klog/v2"
16+
)
17+
18+
const (
19+
metricsPath = "/metrics"
20+
evictionMetricName = "soft_pod_memory_evicter_evicted_pods_total"
21+
evictionMetricHelp = "Total number of Pods evicted by soft-pod-memory-evicter."
22+
metricsShutdownGrace = 5 * time.Second
23+
metricsReadHeaderTimeout = 4 * time.Second
24+
25+
metricLabelNamespace = "affected_namespace"
26+
metricLabelAppName = "affected_app_kubernetes_io_name"
27+
metricLabelAppInstance = "affected_app_kubernetes_io_instance"
28+
)
29+
30+
type metricsRecorder interface {
31+
Start(ctx context.Context)
32+
RecordPodEviction(pod *corev1.Pod)
33+
RecordObservedNamespace(pod *corev1.Pod)
34+
}
35+
36+
func newMetricsRecorder(opts Options) metricsRecorder {
37+
if !opts.EnableMetrics {
38+
return noopMetricsRecorder{}
39+
}
40+
41+
bindAddr := strings.TrimSpace(opts.MetricsBindAddress)
42+
if bindAddr == "" {
43+
klog.Warning("Metrics exporter enabled but no bind address provided; disabling exporter")
44+
return noopMetricsRecorder{}
45+
}
46+
47+
counter := prometheus.NewCounterVec(
48+
prometheus.CounterOpts{
49+
Name: evictionMetricName,
50+
Help: evictionMetricHelp,
51+
},
52+
[]string{
53+
metricLabelNamespace,
54+
metricLabelAppName,
55+
metricLabelAppInstance,
56+
},
57+
)
58+
59+
registry := prometheus.NewRegistry()
60+
if err := registry.Register(counter); err != nil {
61+
klog.Errorf("unable to register metrics exporter: %v", err)
62+
return noopMetricsRecorder{}
63+
}
64+
65+
mux := http.NewServeMux()
66+
mux.Handle(metricsPath, promhttp.HandlerFor(registry, promhttp.HandlerOpts{}))
67+
68+
return &prometheusMetricsRecorder{
69+
server: &http.Server{
70+
Addr: bindAddr,
71+
Handler: mux,
72+
ReadHeaderTimeout: metricsReadHeaderTimeout,
73+
},
74+
counter: counter,
75+
}
76+
}
77+
78+
type noopMetricsRecorder struct{}
79+
80+
func (noopMetricsRecorder) Start(context.Context) {}
81+
82+
func (noopMetricsRecorder) RecordPodEviction(*corev1.Pod) {}
83+
84+
func (noopMetricsRecorder) RecordObservedNamespace(*corev1.Pod) {}
85+
86+
type prometheusMetricsRecorder struct {
87+
server *http.Server
88+
counter *prometheus.CounterVec
89+
once sync.Once
90+
}
91+
92+
func (r *prometheusMetricsRecorder) Start(ctx context.Context) {
93+
r.once.Do(func() {
94+
go func() {
95+
<-ctx.Done()
96+
97+
shutdownCtx, cancel := context.WithTimeout(context.Background(), metricsShutdownGrace)
98+
defer cancel()
99+
100+
if err := r.server.Shutdown(shutdownCtx); err != nil {
101+
klog.Errorf("unable to shutdown metrics exporter: %v", err)
102+
}
103+
}()
104+
105+
go func() {
106+
klog.Infof("Prometheus exporter listening on %s%s", r.server.Addr, metricsPath)
107+
if err := r.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
108+
klog.Errorf("Prometheus exporter failed: %v", err)
109+
}
110+
}()
111+
})
112+
}
113+
114+
func (r *prometheusMetricsRecorder) RecordPodEviction(pod *corev1.Pod) {
115+
r.counter.WithLabelValues(
116+
pod.Namespace,
117+
pod.Labels[appKubernetesNameLabel],
118+
pod.Labels[appKubernetesInstanceLabel],
119+
).Inc()
120+
}
121+
122+
func (r *prometheusMetricsRecorder) RecordObservedNamespace(pod *corev1.Pod) {
123+
r.counter.WithLabelValues(
124+
pod.Namespace,
125+
pod.Labels[appKubernetesNameLabel],
126+
pod.Labels[appKubernetesInstanceLabel],
127+
)
128+
}

0 commit comments

Comments
 (0)