Skip to content

Commit

Permalink
Introduce Idle Replica Mode
Browse files Browse the repository at this point in the history
Signed-off-by: Zbynek Roubalik <zroubali@redhat.com>
  • Loading branch information
Zbynek Roubalik committed Jul 14, 2021
1 parent cc6a5bb commit 3e949fa
Show file tree
Hide file tree
Showing 10 changed files with 985 additions and 236 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- Support non-public cloud environments in the Azure Storage Queue and Azure Storage Blob scalers ([#1863](https://github.com/kedacore/keda/pull/1863))
- Show HashiCorp Vault Address when using `kubectl get ta` or `kubectl get cta` ([#1862](https://github.com/kedacore/keda/pull/1862))
- Add fallback functionality ([#1872](https://github.com/kedacore/keda/issues/1872))
- Introduce Idle Replica Mode ([#1958](https://github.com/kedacore/keda/pull/1958))

### Improvements

Expand Down
2 changes: 2 additions & 0 deletions api/v1alpha1/scaledobject_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ type ScaledObjectSpec struct {
// +optional
CooldownPeriod *int32 `json:"cooldownPeriod,omitempty"`
// +optional
IdleReplicaCount *int32 `json:"idleReplicaCount,omitempty"`
// +optional
MinReplicaCount *int32 `json:"minReplicaCount,omitempty"`
// +optional
MaxReplicaCount *int32 `json:"maxReplicaCount,omitempty"`
Expand Down
5 changes: 5 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions config/crd/bases/keda.sh_scaledobjects.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ spec:
- failureThreshold
- replicas
type: object
idleReplicaCount:
format: int32
type: integer
maxReplicaCount:
format: int32
type: integer
Expand Down
25 changes: 25 additions & 0 deletions controllers/scaledobject_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,11 @@ func (r *ScaledObjectReconciler) reconcileScaledObject(logger logr.Logger, scale
return "ScaledObject doesn't have correct scaleTargetRef specification", err
}

err = r.checkReplicaCountBoundsAreValid(scaledObject)
if err != nil {
return "ScaledObject doesn't have correct Idle/Min/Max Replica Counts specification", err
}

// Create a new HPA or update existing one according to ScaledObject
newHPACreated, err := r.ensureHPAForScaledObjectExists(logger, scaledObject, &gvkr)
if err != nil {
Expand Down Expand Up @@ -305,6 +310,26 @@ func (r *ScaledObjectReconciler) checkTargetResourceIsScalable(logger logr.Logge
return gvkr, nil
}

// checkReplicaCountBoundsAreValid checks that Idle/Min/Max ReplicaCount defined in ScaledObject are correctly specified
// ie. that Min is not greater then Max or Idle greater or equal to Min
func (r *ScaledObjectReconciler) checkReplicaCountBoundsAreValid(scaledObject *kedav1alpha1.ScaledObject) error {
min := int32(0)
if scaledObject.Spec.MinReplicaCount != nil {
min = *getHPAMinReplicas(scaledObject)
}
max := getHPAMaxReplicas(scaledObject)

if min > max {
return fmt.Errorf("MinReplicaCount=%d must be less than MaxReplicaCount=%d", min, max)
}

if scaledObject.Spec.IdleReplicaCount != nil && *scaledObject.Spec.IdleReplicaCount >= min {
return fmt.Errorf("IdleReplicaCount=%d must be less or equal to MinReplicaCount=%d", *scaledObject.Spec.IdleReplicaCount, min)
}

return nil
}

// ensureHPAForScaledObjectExists ensures that in cluster exist up-to-date HPA for specified ScaledObject, returns true if a new HPA was created
func (r *ScaledObjectReconciler) ensureHPAForScaledObjectExists(logger logr.Logger, scaledObject *kedav1alpha1.ScaledObject, gvkr *kedav1alpha1.GroupVersionKindResource) (bool, error) {
hpaName := getHPAName(scaledObject)
Expand Down
247 changes: 215 additions & 32 deletions controllers/scaledobject_controller_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package controllers
import (
"context"
"fmt"
"time"

"github.com/golang/mock/gomock"
. "github.com/onsi/ginkgo"
Expand Down Expand Up @@ -185,47 +186,17 @@ var _ = Describe("ScaledObjectController", func() {
})

Describe("functional tests", func() {
var deployment *appsv1.Deployment

BeforeEach(func() {
deployment = &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: "myapp", Namespace: "default"},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": "myapp",
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": "myapp",
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: "app",
Image: "app",
},
},
},
},
},
}
})

It("cleans up a deleted trigger from the HPA", func() {
// Create the scaling target.
err := k8sClient.Create(context.Background(), deployment)
err := k8sClient.Create(context.Background(), generateDeployment("clean-up"))
Expect(err).ToNot(HaveOccurred())

// Create the ScaledObject with two triggers.
so := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{Name: "clean-up-test", Namespace: "default"},
Spec: kedav1alpha1.ScaledObjectSpec{
ScaleTargetRef: &kedav1alpha1.ScaleTarget{
Name: "myapp",
Name: "clean-up",
},
Triggers: []kedav1alpha1.ScaleTriggers{
{
Expand Down Expand Up @@ -278,5 +249,217 @@ var _ = Describe("ScaledObjectController", func() {
// And it should only be the first one left.
Expect(hpa.Spec.Metrics[0].External.Metric.Name).To(Equal("cron-UTC-0xxxx-1xxxx"))
})

It("deploys ScaledObject and creates HPA, when IdleReplicaCount, MinReplicaCount and MaxReplicaCount is defined", func() {

deploymentName := "idleminmax"
soName := "so-" + deploymentName

// Create the scaling target.
err := k8sClient.Create(context.Background(), generateDeployment(deploymentName))
Expect(err).ToNot(HaveOccurred())

var one int32 = 1
var five int32 = 5
var ten int32 = 10

// Create the ScaledObject
so := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"},
Spec: kedav1alpha1.ScaledObjectSpec{
ScaleTargetRef: &kedav1alpha1.ScaleTarget{
Name: deploymentName,
},
IdleReplicaCount: &one,
MinReplicaCount: &five,
MaxReplicaCount: &ten,
Triggers: []kedav1alpha1.ScaleTriggers{
{
Type: "cron",
Metadata: map[string]string{
"timezone": "UTC",
"start": "0 * * * *",
"end": "1 * * * *",
"desiredReplicas": "1",
},
},
},
},
}
err = k8sClient.Create(context.Background(), so)
Ω(err).ToNot(HaveOccurred())

// Get and confirm the HPA
hpa := &autoscalingv2beta2.HorizontalPodAutoscaler{}
Eventually(func() error {
return k8sClient.Get(context.Background(), types.NamespacedName{Name: "keda-hpa-" + soName, Namespace: "default"}, hpa)
}).ShouldNot(HaveOccurred())

Ω(*hpa.Spec.MinReplicas).To(Equal(five))
Ω(hpa.Spec.MaxReplicas).To(Equal(ten))

Eventually(func() metav1.ConditionStatus {
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
Ω(err).ToNot(HaveOccurred())
return so.Status.Conditions.GetReadyCondition().Status
}, 5*time.Second).Should(Equal(metav1.ConditionTrue))
})

It("doesn't allow MinReplicaCount > MaxReplicaCount", func() {
deploymentName := "minmax"
soName := "so-" + deploymentName

// Create the scaling target.
err := k8sClient.Create(context.Background(), generateDeployment(deploymentName))
Expect(err).ToNot(HaveOccurred())

var five int32 = 5
var ten int32 = 10

// Create the ScaledObject
so := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"},
Spec: kedav1alpha1.ScaledObjectSpec{
ScaleTargetRef: &kedav1alpha1.ScaleTarget{
Name: deploymentName,
},
MinReplicaCount: &ten,
MaxReplicaCount: &five,
Triggers: []kedav1alpha1.ScaleTriggers{
{
Type: "cron",
Metadata: map[string]string{
"timezone": "UTC",
"start": "0 * * * *",
"end": "1 * * * *",
"desiredReplicas": "1",
},
},
},
},
}
err = k8sClient.Create(context.Background(), so)
Ω(err).ToNot(HaveOccurred())

Eventually(func() metav1.ConditionStatus {
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
Ω(err).ToNot(HaveOccurred())
return so.Status.Conditions.GetReadyCondition().Status
}, 5*time.Second).Should(Equal(metav1.ConditionFalse))
})

It("doesn't allow IdleReplicaCount > MinReplicaCount", func() {
deploymentName := "idlemin"
soName := "so-" + deploymentName

// Create the scaling target.
err := k8sClient.Create(context.Background(), generateDeployment(deploymentName))
Expect(err).ToNot(HaveOccurred())

var five int32 = 5
var ten int32 = 10

// Create the ScaledObject with two triggers
so := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"},
Spec: kedav1alpha1.ScaledObjectSpec{
ScaleTargetRef: &kedav1alpha1.ScaleTarget{
Name: deploymentName,
},
IdleReplicaCount: &ten,
MinReplicaCount: &five,
Triggers: []kedav1alpha1.ScaleTriggers{
{
Type: "cron",
Metadata: map[string]string{
"timezone": "UTC",
"start": "0 * * * *",
"end": "1 * * * *",
"desiredReplicas": "1",
},
},
},
},
}
err = k8sClient.Create(context.Background(), so)
Ω(err).ToNot(HaveOccurred())

Eventually(func() metav1.ConditionStatus {
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
Ω(err).ToNot(HaveOccurred())
return so.Status.Conditions.GetReadyCondition().Status
}, 5*time.Second).Should(Equal(metav1.ConditionFalse))
})

It("doesn't allow IdleReplicaCount > MaxReplicaCount, when MinReplicaCount is not explicitly defined", func() {
deploymentName := "idlemax"
soName := "so-" + deploymentName

// Create the scaling target.
err := k8sClient.Create(context.Background(), generateDeployment(deploymentName))
Expect(err).ToNot(HaveOccurred())

var five int32 = 5
var ten int32 = 10

// Create the ScaledObject with two triggers
so := &kedav1alpha1.ScaledObject{
ObjectMeta: metav1.ObjectMeta{Name: soName, Namespace: "default"},
Spec: kedav1alpha1.ScaledObjectSpec{
ScaleTargetRef: &kedav1alpha1.ScaleTarget{
Name: deploymentName,
},
IdleReplicaCount: &ten,
MaxReplicaCount: &five,
Triggers: []kedav1alpha1.ScaleTriggers{
{
Type: "cron",
Metadata: map[string]string{
"timezone": "UTC",
"start": "0 * * * *",
"end": "1 * * * *",
"desiredReplicas": "1",
},
},
},
},
}
err = k8sClient.Create(context.Background(), so)
Ω(err).ToNot(HaveOccurred())

Eventually(func() metav1.ConditionStatus {
err = k8sClient.Get(context.Background(), types.NamespacedName{Name: soName, Namespace: "default"}, so)
Ω(err).ToNot(HaveOccurred())
return so.Status.Conditions.GetReadyCondition().Status
}, 5*time.Second).Should(Equal(metav1.ConditionFalse))
})
})
})

func generateDeployment(name string) *appsv1.Deployment {
return &appsv1.Deployment{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: "default"},
Spec: appsv1.DeploymentSpec{
Selector: &metav1.LabelSelector{
MatchLabels: map[string]string{
"app": name,
},
},
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{
Labels: map[string]string{
"app": name,
},
},
Spec: corev1.PodSpec{
Containers: []corev1.Container{
{
Name: name,
Image: name,
},
},
},
},
},
}
}
Loading

0 comments on commit 3e949fa

Please sign in to comment.