Jelajahi Sumber

Merge pull request #1036 from kubecost/kaelan-pvcshare

Allow accurate even sharing of PV costs for Pods that share PVCs
Kaelan Patel 4 tahun lalu
induk
melakukan
d5a0730b15
3 mengubah file dengan 563 tambahan dan 12 penghapusan
  1. 79 12
      pkg/costmodel/allocation.go
  2. 148 0
      pkg/costmodel/intervals.go
  3. 336 0
      pkg/costmodel/intervals_test.go

+ 79 - 12
pkg/costmodel/allocation.go

@@ -15,7 +15,6 @@ import (
 	"github.com/kubecost/cost-model/pkg/log"
 	"github.com/kubecost/cost-model/pkg/prom"
 	"k8s.io/apimachinery/pkg/labels"
-	"k8s.io/klog"
 )
 
 const (
@@ -375,6 +374,67 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 	podPVCMap := map[podKey][]*PVC{}
 	buildPodPVCMap(podPVCMap, pvMap, pvcMap, podMap, resPodPVCAllocation)
 
+	// Because PVCs can be shared among pods, the respective PV cost
+	// needs to be evenly distributed to those pods based on time
+	// running, as well as the amount of time the PVC was shared.
+
+	// Build a relation between every PVC to the pods that mount it
+	// and a window representing the interval during which they
+	// were associated.
+	pvcPodIntervalMap := make(map[pvcKey]map[podKey]kubecost.Window)
+
+	for _, pod := range podMap {
+
+		for _, alloc := range pod.Allocations {
+
+			cluster := alloc.Properties.Cluster
+			namespace := alloc.Properties.Namespace
+			pod := alloc.Properties.Pod
+			thisPodKey := newPodKey(cluster, namespace, pod)
+
+			if pvcs, ok := podPVCMap[thisPodKey]; ok {
+				for _, pvc := range pvcs {
+
+					// Determine the (start, end) of the relationship between the
+					// given PVC and the associated Allocation so that a precise
+					// number of hours can be used to compute cumulative cost.
+					s, e := alloc.Start, alloc.End
+					if pvc.Start.After(alloc.Start) {
+						s = pvc.Start
+					}
+					if pvc.End.Before(alloc.End) {
+						e = pvc.End
+					}
+
+					thisPVCKey := newPVCKey(cluster, namespace, pvc.Name)
+					if pvcPodIntervalMap[thisPVCKey] == nil {
+						pvcPodIntervalMap[thisPVCKey] = make(map[podKey]kubecost.Window)
+					}
+
+					pvcPodIntervalMap[thisPVCKey][thisPodKey] = kubecost.NewWindow(&s, &e)
+				}
+			}
+
+			// We only need to look at one alloc per pod
+			break
+		}
+
+	}
+
+	// Build out a PV price coefficient for each pod with a PVC. Each
+	// PVC-pod relation needs a coefficient which modifies the PV cost
+	// such that PV costs can be shared between all pods using that PVC.
+	sharedPVCCostCoefficientMap := make(map[pvcKey]map[podKey][]CoefficientComponent)
+	for pvcKey, podIntervalMap := range pvcPodIntervalMap {
+
+		// Get single-point intervals from alloc-PVC relation windows.
+		intervals := getIntervalPointsFromWindows(podIntervalMap)
+
+		// Determine coefficients for each PVC-pod relation.
+		sharedPVCCostCoefficientMap[pvcKey] = getPVCCostCoefficients(intervals, podIntervalMap)
+
+	}
+
 	// Identify unmounted PVs (PVs without PVCs) and add one Allocation per
 	// cluster representing each cluster's unmounted PVs (if necessary).
 	applyUnmountedPVs(window, podMap, pvMap, pvcMap)
@@ -402,16 +462,16 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 			alloc.GPUCost = alloc.GPUHours * node.CostPerGPUHr
 			if pvcs, ok := podPVCMap[podKey]; ok {
 				for _, pvc := range pvcs {
-					// Determine the (start, end) of the relationship between the
-					// given PVC and the associated Allocation so that a precise
-					// number of hours can be used to compute cumulative cost.
+
+					pvcKey := newPVCKey(cluster, namespace, pvc.Name)
+
 					s, e := alloc.Start, alloc.End
-					if pvc.Start.After(alloc.Start) {
-						s = pvc.Start
-					}
-					if pvc.End.Before(alloc.End) {
-						e = pvc.End
+					if pvcInterval, ok := pvcPodIntervalMap[pvcKey][podKey]; ok {
+						s, e = *pvcInterval.Start(), *pvcInterval.End()
+					} else {
+						log.Warningf("CostModel.ComputeAllocation: allocation %s and PVC %s have no associated active window", alloc.Name, pvc.Name)
 					}
+
 					minutes := e.Sub(s).Minutes()
 					hrs := minutes / 60.0
 
@@ -423,6 +483,13 @@ func (cm *CostModel) ComputeAllocation(start, end time.Time, resolution time.Dur
 					gib := pvc.Bytes / 1024 / 1024 / 1024
 					cost := pvc.Volume.CostPerGiBHour * gib * hrs
 
+					// Scale PV cost by PVC sharing coefficient.
+					if coeffComponents, ok := sharedPVCCostCoefficientMap[pvcKey][podKey]; ok {
+						cost *= getCoefficientFromComponents(coeffComponents)
+					} else {
+						log.Warningf("CostModel.ComputeAllocation: allocation %s and PVC %s have relation but no coeff", alloc.Name, pvc.Name)
+					}
+
 					// Apply the size and cost of the PV to the allocation, each
 					// weighted by count (i.e. the number of containers in the pod)
 					// record the amount of total PVBytes Hours attributable to a given PV
@@ -678,7 +745,7 @@ func applyCPUCoresAllocated(podMap map[podKey]*Pod, resCPUCoresAllocated []*prom
 
 		cpuCores := res.Values[0].Value
 		if cpuCores > MAX_CPU_CAP {
-			klog.Infof("[WARNING] Very large cpu allocation, clamping to %f", res.Values[0].Value*(pod.Allocations[container].Minutes()/60.0))
+			log.Infof("[WARNING] Very large cpu allocation, clamping to %f", res.Values[0].Value*(pod.Allocations[container].Minutes()/60.0))
 			cpuCores = 0.0
 		}
 		hours := pod.Allocations[container].Minutes() / 60.0
@@ -724,7 +791,7 @@ func applyCPUCoresRequested(podMap map[podKey]*Pod, resCPUCoresRequested []*prom
 			pod.Allocations[container].CPUCoreHours = res.Values[0].Value * (pod.Allocations[container].Minutes() / 60.0)
 		}
 		if pod.Allocations[container].CPUCores() > MAX_CPU_CAP {
-			klog.Infof("[WARNING] Very large cpu allocation, clamping! to %f", res.Values[0].Value*(pod.Allocations[container].Minutes()/60.0))
+			log.Infof("[WARNING] Very large cpu allocation, clamping! to %f", res.Values[0].Value*(pod.Allocations[container].Minutes()/60.0))
 			pod.Allocations[container].CPUCoreHours = res.Values[0].Value * (pod.Allocations[container].Minutes() / 60.0)
 		}
 
@@ -764,7 +831,7 @@ func applyCPUCoresUsedAvg(podMap map[podKey]*Pod, resCPUCoresUsedAvg []*prom.Que
 
 		pod.Allocations[container].CPUCoreUsageAverage = res.Values[0].Value
 		if res.Values[0].Value > MAX_CPU_CAP {
-			klog.Infof("[WARNING] Very large cpu USAGE, dropping outlier")
+			log.Infof("[WARNING] Very large cpu USAGE, dropping outlier")
 			pod.Allocations[container].CPUCoreUsageAverage = 0.0
 		}
 	}

+ 148 - 0
pkg/costmodel/intervals.go

@@ -0,0 +1,148 @@
+package costmodel
+
+import (
+	"sort"
+	"time"
+
+	"github.com/kubecost/cost-model/pkg/kubecost"
+)
+
+// IntervalPoint describes a start or end of a window of time
+// Currently, this used in PVC-pod relations to detect/calculate
+// coefficients for PV cost when a PVC is shared between pods.
+type IntervalPoint struct {
+	Time      time.Time
+	PointType string
+	Key       podKey
+}
+
+// IntervalPoints describes a slice of IntervalPoint structs
+type IntervalPoints []IntervalPoint
+
+// Requisite functions for implementing sort.Sort for
+// IntervalPointList
+func (ips IntervalPoints) Len() int {
+	return len(ips)
+}
+
+func (ips IntervalPoints) Less(i, j int) bool {
+	if ips[i].Time.Equal(ips[j].Time) {
+		return ips[i].PointType == "start" && ips[j].PointType == "end"
+	}
+	return ips[i].Time.Before(ips[j].Time)
+}
+
+func (ips IntervalPoints) Swap(i, j int) {
+	ips[i], ips[j] = ips[j], ips[i]
+}
+
+// NewIntervalPoint creates and returns a new IntervalPoint instance with given parameters.
+func NewIntervalPoint(time time.Time, pointType string, key podKey) IntervalPoint {
+	return IntervalPoint{
+		Time:      time,
+		PointType: pointType,
+		Key:       key,
+	}
+}
+
+// CoefficientComponent is a representitive struct holding two fields which describe an interval
+// as part of a single number cost coefficient calculation:
+// 1. Proportion: The division of cost based on how many pods were running between those points
+// 2. Time: The ratio of the time between those points to the total time that pod was running
+type CoefficientComponent struct {
+	Proportion float64
+	Time       float64
+}
+
+// getIntervalPointFromWindows takes a map of podKeys to windows
+// and returns a sorted list of IntervalPoints representing the
+// starts and ends of all those windows.
+func getIntervalPointsFromWindows(windows map[podKey]kubecost.Window) IntervalPoints {
+
+	var intervals IntervalPoints
+
+	for podKey, podInterval := range windows {
+
+		start := NewIntervalPoint(*podInterval.Start(), "start", podKey)
+		end := NewIntervalPoint(*podInterval.End(), "end", podKey)
+
+		intervals = append(intervals, []IntervalPoint{start, end}...)
+
+	}
+
+	sort.Sort(intervals)
+
+	return intervals
+
+}
+
+// getPVCCostCoefficients gets a coefficient which represents the scale
+// factor that each PVC in a pvcIntervalMap and corresponding slice of
+// IntervalPoints intervals uses to calculate a cost for that PVC's PV.
+func getPVCCostCoefficients(intervals IntervalPoints, pvcIntervalMap map[podKey]kubecost.Window) map[podKey][]CoefficientComponent {
+
+	pvcCostCoefficientMap := make(map[podKey][]CoefficientComponent)
+
+	// pvcCostCoefficientMap is mutated in this function. The format is
+	// such that the individual coefficient components are preserved for
+	// testing purposes.
+
+	activeKeys := map[podKey]struct{}{
+		intervals[0].Key: struct{}{},
+	}
+
+	// For each interval i.e. for any time a pod-PVC relation ends or starts...
+	for i := 1; i < len(intervals); i++ {
+
+		// intervals will always have at least two IntervalPoints (one start/end)
+		point := intervals[i]
+		prevPoint := intervals[i-1]
+
+		// If the current point happens at a later time than the previous point
+		if !point.Time.Equal(prevPoint.Time) {
+			for key := range activeKeys {
+				if pvcIntervalMap[key].Duration().Minutes() != 0 {
+					pvcCostCoefficientMap[key] = append(
+						pvcCostCoefficientMap[key],
+						CoefficientComponent{
+							Time:       point.Time.Sub(prevPoint.Time).Minutes() / pvcIntervalMap[key].Duration().Minutes(),
+							Proportion: 1.0 / float64(len(activeKeys)),
+						},
+					)
+				}
+			}
+		}
+
+		// If the point was a start, increment and track
+		if point.PointType == "start" {
+			activeKeys[point.Key] = struct{}{}
+		}
+
+		// If the point was an end, decrement and stop tracking
+		if point.PointType == "end" {
+			delete(activeKeys, point.Key)
+		}
+
+	}
+
+	return pvcCostCoefficientMap
+}
+
+// getCoefficientFromComponents takes the components of a PVC-pod PV cost coefficient
+// determined by getPVCCostCoefficient and gets the resulting single
+// floating point coefficient.
+func getCoefficientFromComponents(coefficientComponents []CoefficientComponent) float64 {
+
+	coefficient := 0.0
+
+	for i := range coefficientComponents {
+
+		proportion := coefficientComponents[i].Proportion
+		time := coefficientComponents[i].Time
+
+		coefficient += proportion * time
+
+	}
+
+	return coefficient
+}

+ 336 - 0
pkg/costmodel/intervals_test.go

@@ -0,0 +1,336 @@
+package costmodel
+
+import (
+	"reflect"
+	"testing"
+	"time"
+
+	"github.com/kubecost/cost-model/pkg/kubecost"
+)
+
+func TestGetIntervalPointsFromWindows(t *testing.T) {
+	cases := []struct {
+		name           string
+		pvcIntervalMap map[podKey]kubecost.Window
+		expected       []IntervalPoint
+	}{
+		{
+			name: "four pods w/ various overlaps",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8 am to 9 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod2",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:45 am to 9 am
+				podKey{
+					Pod: "Pod3",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 45, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8 am to 8:15 am
+				podKey{
+					Pod: "Pod4",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 8, 15, 0, 0, time.UTC),
+				)),
+			},
+			expected: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod4"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 15, 0, 0, time.UTC), "end", podKey{Pod: "Pod4"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 45, 0, 0, time.UTC), "start", podKey{Pod: "Pod3"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod3"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+			},
+		},
+		{
+			name: "two pods no overlap",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8 am to 8:30 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod2",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+			},
+			expected: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod2"}),
+			},
+		},
+		{
+			name: "two pods total overlap",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod2",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+			},
+			expected: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod2"}),
+			},
+		},
+		{
+			name: "one pod",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8 am to 9 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+			},
+			expected: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+			},
+		},
+	}
+
+	for _, testCase := range cases {
+		t.Run(testCase.name, func(t *testing.T) {
+
+			result := getIntervalPointsFromWindows(testCase.pvcIntervalMap)
+
+			if len(result) != len(testCase.expected) {
+				t.Errorf("getIntervalPointsFromWindows test failed: %s: Got %+v but expected %+v", testCase.name, result, testCase.expected)
+			}
+
+			for i := range testCase.expected {
+
+				// For correctness in terms of individual position of IntervalPoints, we only need to check the time/type.
+				// Key is used in other associated calculations, so it must exist, but order does not matter if other sorting
+				// logic is obeyed.
+				if !testCase.expected[i].Time.Equal(result[i].Time) || testCase.expected[i].PointType != result[i].PointType {
+					t.Errorf("getIntervalPointsFromWindows test failed: %s: Got point %s:%s but expected %s:%s", testCase.name, testCase.expected[i].PointType, testCase.expected[i].Time, result[i].PointType, result[i].Time)
+				}
+
+			}
+
+		})
+	}
+}
+
+func TestGetPVCCostCoefficients(t *testing.T) {
+	cases := []struct {
+		name           string
+		pvcIntervalMap map[podKey]kubecost.Window
+		intervals      []IntervalPoint
+		expected       map[podKey][]CoefficientComponent
+	}{
+		{
+			name: "four pods w/ various overlaps",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8 am to 9 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod2",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:45 am to 9 am
+				podKey{
+					Pod: "Pod3",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 45, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8 am to 8:15 am
+				podKey{
+					Pod: "Pod4",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 8, 15, 0, 0, time.UTC),
+				)),
+			},
+			intervals: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod4"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 15, 0, 0, time.UTC), "end", podKey{Pod: "Pod4"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 45, 0, 0, time.UTC), "start", podKey{Pod: "Pod3"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod3"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+			},
+			expected: map[podKey][]CoefficientComponent{
+				podKey{
+					Pod: "Pod1",
+				}: []CoefficientComponent{
+					CoefficientComponent{0.5, 0.25},
+					CoefficientComponent{1, 0.25},
+					CoefficientComponent{0.5, 0.25},
+					CoefficientComponent{1.0 / 3.0, 0.25},
+				},
+				podKey{
+					Pod: "Pod2",
+				}: []CoefficientComponent{
+					CoefficientComponent{0.5, 0.50},
+					CoefficientComponent{1.0 / 3.0, 0.50},
+				},
+				podKey{
+					Pod: "Pod3",
+				}: []CoefficientComponent{
+					CoefficientComponent{1.0 / 3.0, 1.0},
+				},
+				podKey{
+					Pod: "Pod4",
+				}: []CoefficientComponent{
+					CoefficientComponent{0.5, 1.0},
+				},
+			},
+		},
+		{
+			name: "two pods no overlap",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8 am to 8:30 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod2",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+			},
+			intervals: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod2"}),
+			},
+			expected: map[podKey][]CoefficientComponent{
+				podKey{
+					Pod: "Pod1",
+				}: []CoefficientComponent{
+					CoefficientComponent{1.0, 1.0},
+				},
+				podKey{
+					Pod: "Pod2",
+				}: []CoefficientComponent{
+					CoefficientComponent{1.0, 1.0},
+				},
+			},
+		},
+		{
+			name: "two pods total overlap",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+				// Pod running from 8:30 am to 9 am
+				podKey{
+					Pod: "Pod2",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+			},
+			intervals: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 30, 0, 0, time.UTC), "start", podKey{Pod: "Pod2"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod2"}),
+			},
+			expected: map[podKey][]CoefficientComponent{
+				podKey{
+					Pod: "Pod1",
+				}: []CoefficientComponent{
+					CoefficientComponent{0.5, 1.0},
+				},
+				podKey{
+					Pod: "Pod2",
+				}: []CoefficientComponent{
+					CoefficientComponent{0.5, 1.0},
+				},
+			},
+		},
+		{
+			name: "one pod",
+			pvcIntervalMap: map[podKey]kubecost.Window{
+				// Pod running from 8 am to 9 am
+				podKey{
+					Pod: "Pod1",
+				}: kubecost.Window(kubecost.NewClosedWindow(
+					time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC),
+					time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC),
+				)),
+			},
+			intervals: []IntervalPoint{
+				NewIntervalPoint(time.Date(2021, 2, 19, 8, 0, 0, 0, time.UTC), "start", podKey{Pod: "Pod1"}),
+				NewIntervalPoint(time.Date(2021, 2, 19, 9, 0, 0, 0, time.UTC), "end", podKey{Pod: "Pod1"}),
+			},
+			expected: map[podKey][]CoefficientComponent{
+				podKey{
+					Pod: "Pod1",
+				}: []CoefficientComponent{
+					CoefficientComponent{1.0, 1.0},
+				},
+			},
+		},
+	}
+
+	for _, testCase := range cases {
+		t.Run(testCase.name, func(t *testing.T) {
+			result := getPVCCostCoefficients(testCase.intervals, testCase.pvcIntervalMap)
+
+			if !reflect.DeepEqual(result, testCase.expected) {
+				t.Errorf("getPVCCostCoefficients test failed: %s: Got %+v but expected %+v", testCase.name, result, testCase.expected)
+			}
+		})
+	}
+}