Visitar URL original
feat: Horizontal scaling support to the Feast operator by ntkathole · Pull Request #6000 · feast-dev/feast · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
fix: Use autoscalingv2.HorizontalPodAutoscaler
Signed-off-by: ntkathole <nikhilkathole2683@gmail.com>
  • Loading branch information
ntkathole committed Feb 27, 2026
commit befdf231d609b0fb4a14ad766092a4e05d8a8cd8
1 change: 1 addition & 0 deletions infra/feast-operator/config/rbac/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ rules:
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
Expand Down
1 change: 1 addition & 0 deletions infra/feast-operator/dist/install.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -18585,6 +18585,7 @@ rules:
- delete
- get
- list
- patch
- update
- watch
- apiGroups:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ type FeatureStoreReconciler struct {
// +kubebuilder:rbac:groups=authentication.k8s.io,resources=tokenreviews,verbs=create
// +kubebuilder:rbac:groups=route.openshift.io,resources=routes,verbs=get;list;create;update;watch;delete
// +kubebuilder:rbac:groups=batch,resources=cronjobs,verbs=get;list;watch;create;update;patch;delete
// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;delete
// +kubebuilder:rbac:groups=autoscaling,resources=horizontalpodautoscalers,verbs=get;list;watch;create;update;patch;delete

// Reconcile is part of the main kubernetes reconciliation loop which aims to
// move the current state of the cluster closer to the desired state.
Expand Down
147 changes: 91 additions & 56 deletions infra/feast-operator/internal/controller/services/scaling.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,16 @@ limitations under the License.
package services

import (
"encoding/json"

feastdevv1 "github.com/feast-dev/feast/infra/feast-operator/api/v1"
appsv1 "k8s.io/api/apps/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
hpaac "k8s.io/client-go/applyconfigurations/autoscaling/v2"
metaac "k8s.io/client-go/applyconfigurations/meta/v1"
"sigs.k8s.io/controller-runtime/pkg/client"
"sigs.k8s.io/controller-runtime/pkg/log"
)
Expand Down Expand Up @@ -50,8 +55,8 @@ func (feast *FeastServices) getDesiredReplicas() *int32 {
}

// createOrDeleteHPA reconciles the HorizontalPodAutoscaler for the FeatureStore
// deployment using Server-Side Apply. If autoscaling is not configured, any
// existing HPA is deleted.
// deployment using Server-Side Apply with typed apply configurations. If
// autoscaling is not configured, any existing HPA is deleted.
func (feast *FeastServices) createOrDeleteHPA() error {
cr := feast.Handler.FeatureStore

Expand All @@ -64,84 +69,114 @@ func (feast *FeastServices) createOrDeleteHPA() error {
return feast.Handler.DeleteOwnedFeastObj(hpa)
}

hpa := feast.buildHPA()
hpaAC := feast.buildHPAApplyConfig()
data, err := json.Marshal(hpaAC)
if err != nil {
return err
}

hpa := &autoscalingv2.HorizontalPodAutoscaler{ObjectMeta: feast.GetObjectMeta()}
logger := log.FromContext(feast.Handler.Context)
if err := feast.Handler.Client.Patch(feast.Handler.Context, hpa,
client.Apply, client.FieldOwner(fieldManager), client.ForceOwnership); err != nil {
client.RawPatch(types.ApplyPatchType, data),
client.FieldOwner(fieldManager), client.ForceOwnership); err != nil {
return err
}
logger.Info("Successfully applied", "HorizontalPodAutoscaler", hpa.Name)

return nil
}

// buildHPA constructs the fully desired HPA state for Server-Side Apply.
func (feast *FeastServices) buildHPA() *autoscalingv2.HorizontalPodAutoscaler {
// buildHPAApplyConfig constructs the fully desired HPA state as a typed apply
// configuration for Server-Side Apply.
func (feast *FeastServices) buildHPAApplyConfig() *hpaac.HorizontalPodAutoscalerApplyConfiguration {
cr := feast.Handler.FeatureStore
autoscaling := cr.Status.Applied.Services.Scaling.Autoscaling

objMeta := feast.GetObjectMeta()
deploy := feast.initFeastDeploy()

minReplicas := defaultHPAMinReplicas
if autoscaling.MinReplicas != nil {
minReplicas = *autoscaling.MinReplicas
}

metrics := defaultHPAMetrics()
hpa := hpaac.HorizontalPodAutoscaler(objMeta.Name, objMeta.Namespace).
WithLabels(feast.getLabels()).
WithOwnerReferences(
metaac.OwnerReference().
WithAPIVersion(feastdevv1.GroupVersion.String()).
WithKind("FeatureStore").
WithName(cr.Name).
WithUID(cr.UID).
WithController(true).
WithBlockOwnerDeletion(true),
).
WithSpec(hpaac.HorizontalPodAutoscalerSpec().
WithScaleTargetRef(
hpaac.CrossVersionObjectReference().
WithAPIVersion(appsv1.SchemeGroupVersion.String()).
WithKind("Deployment").
WithName(deploy.Name),
).
WithMinReplicas(minReplicas).
WithMaxReplicas(autoscaling.MaxReplicas),
)

if len(autoscaling.Metrics) > 0 {
metrics = autoscaling.Metrics
}

isController := true
hpa := &autoscalingv2.HorizontalPodAutoscaler{
TypeMeta: metav1.TypeMeta{
APIVersion: autoscalingv2.SchemeGroupVersion.String(),
Kind: "HorizontalPodAutoscaler",
},
ObjectMeta: metav1.ObjectMeta{
Name: feast.GetObjectMeta().Name,
Namespace: feast.GetObjectMeta().Namespace,
Labels: feast.getLabels(),
OwnerReferences: []metav1.OwnerReference{
{
APIVersion: feastdevv1.GroupVersion.String(),
Kind: "FeatureStore",
Name: cr.Name,
UID: cr.UID,
Controller: &isController,
BlockOwnerDeletion: &isController,
},
},
},
Spec: autoscalingv2.HorizontalPodAutoscalerSpec{
ScaleTargetRef: autoscalingv2.CrossVersionObjectReference{
APIVersion: appsv1.SchemeGroupVersion.String(),
Kind: "Deployment",
Name: deploy.Name,
},
MinReplicas: &minReplicas,
MaxReplicas: autoscaling.MaxReplicas,
Metrics: metrics,
Behavior: autoscaling.Behavior,
},
hpa.Spec.Metrics = convertMetrics(autoscaling.Metrics)
} else {
hpa.Spec.Metrics = defaultHPAMetrics()
}

if autoscaling.Behavior != nil {
hpa.Spec.Behavior = convertBehavior(autoscaling.Behavior)
}

return hpa
}

func defaultHPAMetrics() []autoscalingv2.MetricSpec {
utilization := defaultHPACPUUtilization
return []autoscalingv2.MetricSpec{
{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
Name: corev1.ResourceCPU,
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.UtilizationMetricType,
AverageUtilization: &utilization,
},
},
},
func defaultHPAMetrics() []hpaac.MetricSpecApplyConfiguration {
return []hpaac.MetricSpecApplyConfiguration{
*hpaac.MetricSpec().
WithType(autoscalingv2.ResourceMetricSourceType).
WithResource(
hpaac.ResourceMetricSource().
WithName(corev1.ResourceCPU).
WithTarget(
hpaac.MetricTarget().
WithType(autoscalingv2.UtilizationMetricType).
WithAverageUtilization(defaultHPACPUUtilization),
),
),
}
}

// convertMetrics converts standard API metric specs to their apply configuration
// equivalents via JSON round-trip (the types share identical JSON schemas).
func convertMetrics(metrics []autoscalingv2.MetricSpec) []hpaac.MetricSpecApplyConfiguration {
data, err := json.Marshal(metrics)
if err != nil {
return nil
}
var result []hpaac.MetricSpecApplyConfiguration
if err := json.Unmarshal(data, &result); err != nil {
return nil
}
return result
}

// convertBehavior converts a standard API behavior spec to its apply configuration
// equivalent via JSON round-trip.
func convertBehavior(behavior *autoscalingv2.HorizontalPodAutoscalerBehavior) *hpaac.HorizontalPodAutoscalerBehaviorApplyConfiguration {
data, err := json.Marshal(behavior)
if err != nil {
return nil
}
result := &hpaac.HorizontalPodAutoscalerBehaviorApplyConfiguration{}
if err := json.Unmarshal(data, result); err != nil {
return nil
}
return result
}

// updateScalingStatus updates the scaling status fields using the deployment
Expand Down
57 changes: 42 additions & 15 deletions infra/feast-operator/internal/controller/services/scaling_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
. "github.com/onsi/gomega"
appsv1 "k8s.io/api/apps/v1"
autoscalingv1 "k8s.io/api/autoscaling/v1"
autoscalingv2 "k8s.io/api/autoscaling/v2"
corev1 "k8s.io/api/core/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/types"
Expand Down Expand Up @@ -550,31 +551,31 @@ var _ = Describe("Horizontal Scaling", func() {
})

Describe("HPA Configuration", func() {
It("should build an HPA with default CPU metrics", func() {
It("should build an HPA apply config with default CPU metrics", func() {
featureStore.Status.Applied.Services.Scaling = &feastdevv1.ScalingConfig{
Autoscaling: &feastdevv1.AutoscalingConfig{
MaxReplicas: 10,
},
}

hpa := feast.buildHPA()
Expect(hpa.Spec.MaxReplicas).To(Equal(int32(10)))
hpa := feast.buildHPAApplyConfig()
Expect(*hpa.Spec.MaxReplicas).To(Equal(int32(10)))
Expect(*hpa.Spec.MinReplicas).To(Equal(int32(1)))
Expect(hpa.Spec.Metrics).To(HaveLen(1))
Expect(hpa.Spec.Metrics[0].Resource.Name).To(Equal(corev1.ResourceCPU))
Expect(*hpa.Spec.Metrics[0].Resource.Name).To(Equal(corev1.ResourceCPU))
})

It("should build an HPA with custom min replicas", func() {
It("should build an HPA apply config with custom min replicas", func() {
featureStore.Status.Applied.Services.Scaling = &feastdevv1.ScalingConfig{
Autoscaling: &feastdevv1.AutoscalingConfig{
MinReplicas: ptr(int32(2)),
MaxReplicas: 10,
},
}

hpa := feast.buildHPA()
hpa := feast.buildHPAApplyConfig()
Expect(*hpa.Spec.MinReplicas).To(Equal(int32(2)))
Expect(hpa.Spec.MaxReplicas).To(Equal(int32(10)))
Expect(*hpa.Spec.MaxReplicas).To(Equal(int32(10)))
})

It("should set correct scale target reference", func() {
Expand All @@ -584,10 +585,10 @@ var _ = Describe("Horizontal Scaling", func() {
},
}

hpa := feast.buildHPA()
Expect(hpa.Spec.ScaleTargetRef.APIVersion).To(Equal("apps/v1"))
Expect(hpa.Spec.ScaleTargetRef.Kind).To(Equal("Deployment"))
Expect(hpa.Spec.ScaleTargetRef.Name).To(Equal(GetFeastName(featureStore)))
hpa := feast.buildHPAApplyConfig()
Expect(*hpa.Spec.ScaleTargetRef.APIVersion).To(Equal("apps/v1"))
Expect(*hpa.Spec.ScaleTargetRef.Kind).To(Equal("Deployment"))
Expect(*hpa.Spec.ScaleTargetRef.Name).To(Equal(GetFeastName(featureStore)))
})

It("should set TypeMeta and owner reference for SSA", func() {
Expand All @@ -597,13 +598,39 @@ var _ = Describe("Horizontal Scaling", func() {
},
}

hpa := feast.buildHPA()
Expect(hpa.TypeMeta.APIVersion).To(Equal("autoscaling/v2"))
Expect(hpa.TypeMeta.Kind).To(Equal("HorizontalPodAutoscaler"))
hpa := feast.buildHPAApplyConfig()
Expect(*hpa.Kind).To(Equal("HorizontalPodAutoscaler"))
Expect(*hpa.APIVersion).To(Equal("autoscaling/v2"))
Expect(hpa.OwnerReferences).To(HaveLen(1))
Expect(hpa.OwnerReferences[0].Name).To(Equal(featureStore.Name))
Expect(*hpa.OwnerReferences[0].Name).To(Equal(featureStore.Name))
Expect(*hpa.OwnerReferences[0].Controller).To(BeTrue())
})

It("should convert custom metrics via JSON round-trip", func() {
customMetrics := []autoscalingv2.MetricSpec{
{
Type: autoscalingv2.ResourceMetricSourceType,
Resource: &autoscalingv2.ResourceMetricSource{
Name: corev1.ResourceMemory,
Target: autoscalingv2.MetricTarget{
Type: autoscalingv2.UtilizationMetricType,
AverageUtilization: ptr(int32(75)),
},
},
},
}
featureStore.Status.Applied.Services.Scaling = &feastdevv1.ScalingConfig{
Autoscaling: &feastdevv1.AutoscalingConfig{
MaxReplicas: 10,
Metrics: customMetrics,
},
}

hpa := feast.buildHPAApplyConfig()
Expect(hpa.Spec.Metrics).To(HaveLen(1))
Expect(*hpa.Spec.Metrics[0].Resource.Name).To(Equal(corev1.ResourceMemory))
Expect(*hpa.Spec.Metrics[0].Resource.Target.AverageUtilization).To(Equal(int32(75)))
})
})

Describe("Scale sub-resource", func() {
Expand Down
Loading