Skip to content
Merged
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

## Unreleased

### bugfix
- Use actual applied container resources (`Pod.Status.ContainerStatuses[i].Resources`) over desired spec resources (`Pod.Spec.Containers[i].Resources`) where present to correctly report `cpuRequestedCores`, `memoryRequestedBytes`, and related metrics during in-place pod vertical scaling (K8s 1.33+ beta, 1.35+ GA) @kondracek-nr [#1433](https://github.com/newrelic/nri-kubernetes/pull/1433)

## v4.3.6 - 2026-07-01

### 🐞 Bug fixes
Expand Down
48 changes: 34 additions & 14 deletions src/kubelet/metric/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,11 @@ import (
)

// KubeletPodsPath is the path where kubelet serves information about pods.
const KubeletPodsPath = "/pods"
const KubeServiceKubeletPodsPath = "/api/v1/pods"
const nodeSelectorQuery = "fieldSelector=spec.nodeName=%s"
const (
KubeletPodsPath = "/pods"
KubeServiceKubeletPodsPath = "/api/v1/pods"
nodeSelectorQuery = "fieldSelector=spec.nodeName=%s"
)

// maxPodsResponseBytes caps the kubelet /pods response read at 100MB to prevent OOM.
// Normal responses are typically 1-10MB even on large nodes.
Expand Down Expand Up @@ -192,12 +194,13 @@ func getKubeServiceHost() string {

func (podsFetcher *PodsFetcher) fetchContainersData(pod *v1.Pod) map[string]definition.RawMetrics {
statuses := make(map[string]definition.RawMetrics)
fillContainerStatuses(pod, statuses)
containerStatusByName := getContainerStatusesByName(pod)
fillContainerStatuses(pod, containerStatusByName, statuses)
Comment thread
dbudziwojskiNR marked this conversation as resolved.

metrics := make(map[string]definition.RawMetrics)
containers := pod.Spec.Containers

// Add sidecar containers
// Add sidecar containers (init containers with RestartPolicy Always)
for _, initContainer := range pod.Spec.InitContainers {
if initContainer.RestartPolicy != nil && *initContainer.RestartPolicy == v1.ContainerRestartPolicyAlways {
containers = append(containers, initContainer)
Expand All @@ -218,19 +221,29 @@ func (podsFetcher *PodsFetcher) fetchContainersData(pod *v1.Pod) map[string]defi
metrics[id]["nodeIP"] = v
}

if v, ok := c.Resources.Requests[v1.ResourceCPU]; ok {
// As of K8s 1.33 (beta, on by default) / 1.35 (GA), in-place pod vertical scaling means
// Pod.Spec.Containers[i].Resources is the desired state, not the actual state.
// Pod.Status.ContainerStatuses[i].Resources holds the actual applied resources.

// Prefer status resources (actual applied) over spec resources (desired state).
resources := c.Resources
if cs, ok := containerStatusByName[c.Name]; ok && cs.Resources != nil {
resources = *cs.Resources
}

if v, ok := resources.Requests[v1.ResourceCPU]; ok {
metrics[id]["cpuRequestedCores"] = v.MilliValue()
}

if v, ok := c.Resources.Limits[v1.ResourceCPU]; ok {
if v, ok := resources.Limits[v1.ResourceCPU]; ok {
metrics[id]["cpuLimitCores"] = v.MilliValue()
}

if v, ok := c.Resources.Requests[v1.ResourceMemory]; ok {
if v, ok := resources.Requests[v1.ResourceMemory]; ok {
metrics[id]["memoryRequestedBytes"] = v.Value()
}

if v, ok := c.Resources.Limits[v1.ResourceMemory]; ok {
if v, ok := resources.Limits[v1.ResourceMemory]; ok {
metrics[id]["memoryLimitBytes"] = v.Value()
}

Expand All @@ -254,23 +267,30 @@ func (podsFetcher *PodsFetcher) fetchContainersData(pod *v1.Pod) map[string]defi
return metrics
}

func fillContainerStatuses(pod *v1.Pod, dest map[string]definition.RawMetrics) {
containerStatuses := pod.Status.ContainerStatuses
func getContainerStatusesByName(pod *v1.Pod) map[string]*v1.ContainerStatus {
containerStatusByName := make(map[string]*v1.ContainerStatus)
for i := range pod.Status.ContainerStatuses {
containerStatus := &pod.Status.ContainerStatuses[i]
containerStatusByName[containerStatus.Name] = containerStatus
}

// Add sidecar containers (init containers with RestartPolicy Always)
for _, initContainer := range pod.Spec.InitContainers {
if initContainer.RestartPolicy != nil && *initContainer.RestartPolicy == v1.ContainerRestartPolicyAlways {
for _, initContainerStatus := range pod.Status.InitContainerStatuses {
if initContainerStatus.Name == initContainer.Name {
containerStatuses = append(containerStatuses, initContainerStatus)
containerStatusByName[initContainerStatus.Name] = &initContainerStatus
break
}
}
}
}

for _, c := range containerStatuses {
name := c.Name
return containerStatusByName
}

func fillContainerStatuses(pod *v1.Pod, containerStatuses map[string]*v1.ContainerStatus, dest map[string]definition.RawMetrics) {
for name, c := range containerStatuses {
id := containerID(pod, name)

// Set the ExitCode. Zero if no terminated Exit Code.
Expand Down
102 changes: 98 additions & 4 deletions src/kubelet/metric/pods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/stretchr/testify/assert"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

"github.com/newrelic/nri-kubernetes/v3/internal/config"
Expand Down Expand Up @@ -573,7 +574,8 @@ func TestFillContainerStatuses_IsReadyForAllStates(t *testing.T) {
{Name: testContainerName, Ready: true, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}},
}
dest := make(map[string]definition.RawMetrics)
fillContainerStatuses(pod, dest)
containerStatusByName := getContainerStatusesByName(pod)
fillContainerStatuses(pod, containerStatusByName, dest)
id := fmt.Sprintf("%s_%s_%s", namespace, podName, testContainerName)
assert.Equal(t, true, dest[id]["isReady"])
})
Expand All @@ -585,7 +587,8 @@ func TestFillContainerStatuses_IsReadyForAllStates(t *testing.T) {
{Name: testContainerName, Ready: false, State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "CrashLoopBackOff"}}},
}
dest := make(map[string]definition.RawMetrics)
fillContainerStatuses(pod, dest)
containerStatusByName := getContainerStatusesByName(pod)
fillContainerStatuses(pod, containerStatusByName, dest)
id := fmt.Sprintf("%s_%s_%s", namespace, podName, testContainerName)
assert.Equal(t, false, dest[id]["isReady"])
})
Expand All @@ -597,7 +600,8 @@ func TestFillContainerStatuses_IsReadyForAllStates(t *testing.T) {
{Name: testContainerName, Ready: false, State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{ExitCode: 0}}},
}
dest := make(map[string]definition.RawMetrics)
fillContainerStatuses(pod, dest)
containerStatusByName := getContainerStatusesByName(pod)
fillContainerStatuses(pod, containerStatusByName, dest)
id := fmt.Sprintf("%s_%s_%s", namespace, podName, testContainerName)
assert.Equal(t, false, dest[id]["isReady"])
})
Expand All @@ -609,7 +613,8 @@ func TestFillContainerStatuses_IsReadyForAllStates(t *testing.T) {
{Name: testContainerName, Ready: false, State: corev1.ContainerState{}}, // all nil → Unknown
}
dest := make(map[string]definition.RawMetrics)
fillContainerStatuses(pod, dest)
containerStatusByName := getContainerStatusesByName(pod)
fillContainerStatuses(pod, containerStatusByName, dest)
id := fmt.Sprintf("%s_%s_%s", namespace, podName, testContainerName)
assert.Equal(t, false, dest[id]["isReady"])
})
Expand Down Expand Up @@ -706,3 +711,92 @@ func TestFetchPodData_WithPriorityClassNameOnly(t *testing.T) {
assert.False(t, hasPriority, "priority should not be present when nil")
assert.Equal(t, "system-cluster-critical", result["priorityClassName"])
}

// TestFetchContainersData_InPlaceVerticalScaling verifies that when
// ContainerStatus.Resources is populated (K8s 1.33+ in-place pod vertical scaling),
// the actual applied resources are used instead of Spec resources (desired state).
func TestFetchContainersData_InPlaceVerticalScaling(t *testing.T) {
t.Parallel()

specCPU := resource.MustParse("100m")
specMemory := resource.MustParse("128Mi")
statusCPU := resource.MustParse("200m") // resized up
statusMemory := resource.MustParse("256Mi")

pod := &corev1.Pod{
ObjectMeta: metav1.ObjectMeta{
Name: "test-pod",
Namespace: "default",
},
Spec: corev1.PodSpec{
NodeName: "test-node",
Containers: []corev1.Container{
{
Name: "app",
Image: "app:latest",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: specCPU,
corev1.ResourceMemory: specMemory,
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: specCPU,
corev1.ResourceMemory: specMemory,
},
},
},
{
Name: "sidecar",
Image: "sidecar:latest",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: specCPU,
corev1.ResourceMemory: specMemory,
},
},
},
},
},
Status: corev1.PodStatus{
HostIP: "192.168.0.1",
ContainerStatuses: []corev1.ContainerStatus{
{
Name: "app",
// Resources populated: actual applied state after resize
Resources: &corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: statusCPU,
corev1.ResourceMemory: statusMemory,
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: statusCPU,
corev1.ResourceMemory: statusMemory,
},
},
},
{
Name: "sidecar",
Resources: nil, // no status resources; fall back to spec
},
},
},
}

podFetcher := &PodsFetcher{}
result := podFetcher.fetchContainersData(pod)

appID := "default_test-pod_app"
sidecarID := "default_test-pod_sidecar"

// "app" container: status resources should take precedence
assert.Equal(t, statusCPU.MilliValue(), result[appID]["cpuRequestedCores"], "app: should use status CPU request")
assert.Equal(t, statusCPU.MilliValue(), result[appID]["cpuLimitCores"], "app: should use status CPU limit")
assert.Equal(t, statusMemory.Value(), result[appID]["memoryRequestedBytes"], "app: should use status memory request")
assert.Equal(t, statusMemory.Value(), result[appID]["memoryLimitBytes"], "app: should use status memory limit")

// "sidecar" container: status resources nil, should fall back to spec
assert.Equal(t, specCPU.MilliValue(), result[sidecarID]["cpuRequestedCores"], "sidecar: should fall back to spec CPU request")
assert.Equal(t, specMemory.Value(), result[sidecarID]["memoryRequestedBytes"], "sidecar: should fall back to spec memory request")
_, hasLimit := result[sidecarID]["cpuLimitCores"]
assert.False(t, hasLimit, "sidecar: no CPU limit in spec, should not be set")
}
Loading