feat: allow setting autoscaling options to deployed KService (#374)

* feat: allow setting autoscaling options to deployed KService

Signed-off-by: Zbynek Roubalik <zroubali@redhat.com>

* incorporate feedback

Signed-off-by: Zbynek Roubalik <zroubali@redhat.com>

* move `target` & `utilization` to `scale` section

Signed-off-by: Zbynek Roubalik <zroubali@redhat.com>

* don't include concurrency.limit

Signed-off-by: Zbynek Roubalik <zroubali@redhat.com>
This commit is contained in:
Zbynek Roubalik 2021-06-15 09:26:36 +02:00 committed by GitHub
parent 8041a25486
commit a937c490b7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 337 additions and 10 deletions

View File

@ -28,6 +28,18 @@ type Env struct {
Value *string `yaml:"value"`
}
type Options struct {
Scale *ScaleOptions `yaml:"scale,omitempty"`
}
type ScaleOptions struct {
Min *int64 `yaml:"min,omitempty"`
Max *int64 `yaml:"max,omitempty"`
Metric *string `yaml:"metric,omitempty"`
Target *float64 `yaml:"target,omitempty"`
Utilization *float64 `yaml:"utilization,omitempty"`
}
// Config represents the serialized state of a Function's metadata.
// See the Function struct for attribute documentation.
type config struct {
@ -41,6 +53,7 @@ type config struct {
Volumes Volumes `yaml:"volumes"`
Envs Envs `yaml:"envs"`
Annotations map[string]string `yaml:"annotations"`
Options Options `yaml:"options"`
// Add new values to the toConfig/fromConfig functions.
}
@ -83,10 +96,11 @@ func newConfig(root string) (c config, err error) {
}
}
// Let's check that all entries in `volumes` and `envs` contain all required fields
// Let's check that all entries in `volumes`, `envs` and `options` contain all required fields
volumesErrors := validateVolumes(c.Volumes)
envsErrors := ValidateEnvs(c.Envs)
if len(volumesErrors) > 0 || len(envsErrors) > 0 {
optionsErrors := validateOptions(c.Options)
if len(volumesErrors) > 0 || len(envsErrors) > 0 || len(optionsErrors) > 0 {
// if there aren't any previously reported errors, we need to set the error message header first
if errMsg == "" {
errMsg = errMsgHeader
@ -102,6 +116,9 @@ func newConfig(root string) (c config, err error) {
for i := range envsErrors {
envsErrors[i] = " " + envsErrors[i]
}
for i := range optionsErrors {
optionsErrors[i] = " " + optionsErrors[i]
}
errMsg = errMsg + strings.Join(volumesErrors, "\n")
// we have errors from both volumes and envs sections -> let's make sure they are both indented
@ -109,6 +126,11 @@ func newConfig(root string) (c config, err error) {
errMsg = errMsg + "\n"
}
errMsg = errMsg + strings.Join(envsErrors, "\n")
// lets indent options related errors if there are already some set
if len(optionsErrors) > 0 && (len(volumesErrors) > 0 || len(envsErrors) > 0) {
errMsg = errMsg + "\n"
}
errMsg = errMsg + strings.Join(optionsErrors, "\n")
}
if errMsg != "" {
@ -132,6 +154,7 @@ func fromConfig(c config) (f Function) {
Volumes: c.Volumes,
Envs: c.Envs,
Annotations: c.Annotations,
Options: c.Options,
}
}
@ -148,6 +171,7 @@ func toConfig(f Function) config {
Volumes: f.Volumes,
Envs: f.Envs,
Annotations: f.Annotations,
Options: f.Options,
}
}
@ -163,7 +187,7 @@ func writeConfig(f Function) (err error) {
}
// validateVolumes checks that input Volumes are correct and contain all necessary fields.
// Returns array of error messages, empty if none
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - secret: example-secret # mount Secret as Volume
@ -193,7 +217,7 @@ func validateVolumes(volumes Volumes) (errors []string) {
}
// ValidateEnvs checks that input Envs are correct and contain all necessary fields.
// Returns array of error messages, empty if none
// Returns array of error messages, empty if no errors are found
//
// Allowed settings:
// - name: EXAMPLE1 # ENV directly from a value
@ -238,7 +262,58 @@ func ValidateEnvs(envs Envs) (errors []string) {
i, *env.Name, *env.Value))
}
}
}
}
return
}
// validateOptions checks that input Options are correctly set.
// Returns array of error messages, empty if no errors are found
func validateOptions(options Options) (errors []string) {
// options.scale
if options.Scale != nil {
if options.Scale.Min != nil {
if *options.Scale.Min < 0 {
errors = append(errors, fmt.Sprintf("options field \"scale.min\" has invalid value set: %d, the value must be greater than \"0\"",
*options.Scale.Min))
}
}
if options.Scale.Max != nil {
if *options.Scale.Max < 0 {
errors = append(errors, fmt.Sprintf("options field \"scale.max\" has invalid value set: %d, the value must be greater than \"0\"",
*options.Scale.Max))
}
}
if options.Scale.Min != nil && options.Scale.Max != nil {
if *options.Scale.Max < *options.Scale.Min {
errors = append(errors, "options field \"scale.max\" value must be greater or equal to \"scale.min\"")
}
}
if options.Scale.Metric != nil {
if *options.Scale.Metric != "concurrency" && *options.Scale.Metric != "rps" {
errors = append(errors, fmt.Sprintf("options field \"scale.metric\" has invalid value set: %s, allowed is only \"concurrency\" or \"rps\"",
*options.Scale.Metric))
}
}
if options.Scale.Target != nil {
if *options.Scale.Target < 0.01 {
errors = append(errors, fmt.Sprintf("options field \"scale.target\" has value set to \"%f\", but it must not be less than 0.01",
*options.Scale.Target))
}
}
if options.Scale.Utilization != nil {
if *options.Scale.Utilization < 1 || *options.Scale.Utilization > 100 {
errors = append(errors,
fmt.Sprintf("options field \"scale.utilization\" has value set to \"%f\", but it must not be less than 1 or greater than 100",
*options.Scale.Utilization))
}
}
}

View File

@ -4,6 +4,8 @@ package function
import (
"testing"
"knative.dev/pkg/ptr"
)
func Test_validateVolumes(t *testing.T) {
@ -505,3 +507,176 @@ func Test_validateEnvs(t *testing.T) {
}
}
func Test_validateOptions(t *testing.T) {
tests := []struct {
name string
options Options
errs int
}{
{
"correct 'scale.metric' - concurrency",
Options{
Scale: &ScaleOptions{
Metric: ptr.String("concurrency"),
},
},
0,
},
{
"correct 'scale.metric' - rps",
Options{
Scale: &ScaleOptions{
Metric: ptr.String("rps"),
},
},
0,
},
{
"incorrect 'scale.metric'",
Options{
Scale: &ScaleOptions{
Metric: ptr.String("foo"),
},
},
1,
},
{
"correct 'scale.min'",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(1),
},
},
0,
},
{
"correct 'scale.max'",
Options{
Scale: &ScaleOptions{
Max: ptr.Int64(10),
},
},
0,
},
{
"correct 'scale.min' & 'scale.max'",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(0),
Max: ptr.Int64(10),
},
},
0,
},
{
"incorrect 'scale.min' & 'scale.max'",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(100),
Max: ptr.Int64(10),
},
},
1,
},
{
"incorrect 'scale.min' - negative value",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(-10),
},
},
1,
},
{
"incorrect 'scale.max' - negative value",
Options{
Scale: &ScaleOptions{
Max: ptr.Int64(-10),
},
},
1,
},
{
"correct 'scale.target'",
Options{
Scale: &ScaleOptions{
Target: ptr.Float64(50),
},
},
0,
},
{
"incorrect 'scale.target'",
Options{
Scale: &ScaleOptions{
Target: ptr.Float64(0),
},
},
1,
},
{
"correct 'scale.utilization'",
Options{
Scale: &ScaleOptions{
Utilization: ptr.Float64(50),
},
},
0,
},
{
"incorrect 'scale.utilization' - < 1",
Options{
Scale: &ScaleOptions{
Utilization: ptr.Float64(0),
},
},
1,
},
{
"incorrect 'scale.utilization' - > 100",
Options{
Scale: &ScaleOptions{
Utilization: ptr.Float64(110),
},
},
1,
},
{
"correct all options",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(0),
Max: ptr.Int64(10),
Metric: ptr.String("concurrency"),
Target: ptr.Float64(40.5),
Utilization: ptr.Float64(35.5),
},
},
0,
},
{
"incorrect all options",
Options{
Scale: &ScaleOptions{
Min: ptr.Int64(-1),
Max: ptr.Int64(-1),
Metric: ptr.String("foo"),
Target: ptr.Float64(-1),
Utilization: ptr.Float64(110),
},
},
5,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := validateOptions(tt.options); len(got) != tt.errs {
t.Errorf("validateOptions() = %v\n got %d errors but want %d", got, len(got), tt.errs)
}
})
}
}

View File

@ -62,6 +62,26 @@ volumes:
path: /workspace/configmap
```
### `options`
Options allows you to set specific configuration for the deployed function, allowing you to tweak Knative Service options related to autoscaling and other properties. If these options are not set, the Knative defaults will be used.
- `scale`
- `min`: Minimum number of replicas. Must me non-negative integer, default is 0. See related [Knative docs](https://knative.dev/docs/serving/autoscaling/scale-bounds/#lower-bound).
- `max`: Maximum number of replicas. Must me non-negative integer, default is 0 - meaning no limit. See related [Knative docs](https://knative.dev/docs/serving/autoscaling/scale-bounds/#upper-bound).
- `metric`: Defines which metric type is watched by the Autoscaler. Could be `concurrency` (default) or `rps`. See related [Knative docs](https://knative.dev/docs/serving/autoscaling/autoscaling-metrics/).
- `target`: Recommendation for when to scale up based on the concurrent number of incoming request. Can be float value greater than 0.01, default is 100. See related [Knative docs](https://knative.dev/docs/serving/autoscaling/concurrency/#soft-limit).
- `utilization`: Percentage of concurrent requests utilization before scaling up. Can be float value between 1 and 100, default is 70. See related [Knative docs](https://knative.dev/docs/serving/autoscaling/concurrency/#target-utilization).
```yaml
options:
scale:
min: 0
max: 10
metric: concurrency
target: 75
utilization: 75
```
### `image`
This is the image name for your function after it has been built. This field

View File

@ -61,6 +61,9 @@ type Function struct {
// Map containing user-supplied annotations
// Example: { "division": "finance" }
Annotations map[string]string
// Options to be set on deployed function (scaling, etc.)
Options Options
}
// NewFunction loads a Function from a path on disk. use .Initialized() to determine if

View File

@ -13,7 +13,9 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/util/sets"
"knative.dev/client/pkg/kn/flags"
servingclientlib "knative.dev/client/pkg/serving"
"knative.dev/client/pkg/wait"
"knative.dev/serving/pkg/apis/autoscaling"
servingv1 "knative.dev/serving/pkg/apis/serving/v1"
v1 "knative.dev/serving/pkg/apis/serving/v1"
@ -52,7 +54,7 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (result fn.Deploym
referencedSecrets := sets.NewString()
referencedConfigMaps := sets.NewString()
service, err := generateNewService(f.Name, f.ImageWithDigest(), f.Runtime, f.Envs, f.Volumes, f.Annotations)
service, err := generateNewService(f.Name, f.ImageWithDigest(), f.Runtime, f.Envs, f.Volumes, f.Annotations, f.Options)
if err != nil {
err = fmt.Errorf("knative deployer failed to generate the Knative Service: %v", err)
return fn.DeploymentResult{}, err
@ -116,7 +118,7 @@ func (d *Deployer) Deploy(ctx context.Context, f fn.Function) (result fn.Deploym
return fn.DeploymentResult{}, err
}
_, err = client.UpdateServiceWithRetry(ctx, f.Name, updateService(f.ImageWithDigest(), newEnv, newEnvFrom, newVolumes, newVolumeMounts, f.Annotations), 3)
_, err = client.UpdateServiceWithRetry(ctx, f.Name, updateService(f.ImageWithDigest(), newEnv, newEnvFrom, newVolumes, newVolumeMounts, f.Annotations, f.Options), 3)
if err != nil {
err = fmt.Errorf("knative deployer failed to update the Knative Service: %v", err)
return fn.DeploymentResult{}, err
@ -145,7 +147,7 @@ func probeFor(url string) *corev1.Probe {
}
}
func generateNewService(name, image, runtime string, envs fn.Envs, volumes fn.Volumes, annotations map[string]string) (*servingv1.Service, error) {
func generateNewService(name, image, runtime string, envs fn.Envs, volumes fn.Volumes, annotations map[string]string, options fn.Options) (*servingv1.Service, error) {
containers := []corev1.Container{
{
Image: image,
@ -196,11 +198,16 @@ func generateNewService(name, image, runtime string, envs fn.Envs, volumes fn.Vo
},
}
err = setServiceOptions(&service.Spec.Template, options)
if err != nil {
return service, err
}
return service, nil
}
func updateService(image string, newEnv []corev1.EnvVar, newEnvFrom []corev1.EnvFromSource, newVolumes []corev1.Volume, newVolumeMounts []corev1.VolumeMount,
annotations map[string]string) func(service *servingv1.Service) (*servingv1.Service, error) {
annotations map[string]string, options fn.Options) func(service *servingv1.Service) (*servingv1.Service, error) {
return func(service *servingv1.Service) (*servingv1.Service, error) {
// Removing the name so the k8s server can fill it in with generated name,
// this prevents conflicts in Revision name when updating the KService from multiple places.
@ -212,7 +219,12 @@ func updateService(image string, newEnv []corev1.EnvVar, newEnvFrom []corev1.Env
service.ObjectMeta.Annotations[k] = v
}
err := flags.UpdateImage(&service.Spec.Template.Spec.PodSpec, image)
err := setServiceOptions(&service.Spec.Template, options)
if err != nil {
return service, err
}
err = flags.UpdateImage(&service.Spec.Template.Spec.PodSpec, image)
if err != nil {
return service, err
}
@ -509,3 +521,45 @@ func checkSecretsConfigMapsArePresent(ctx context.Context, namespace string, ref
return nil
}
// setServiceOptions sets annotations on Service Revision Template or in the Service Spec
// from values specifed in function configuration options
func setServiceOptions(template *servingv1.RevisionTemplateSpec, options fn.Options) error {
toRemove := []string{}
toUpdate := map[string]string{}
if options.Scale != nil {
if options.Scale.Min != nil {
toUpdate[autoscaling.MinScaleAnnotationKey] = fmt.Sprintf("%d", *options.Scale.Min)
} else {
toRemove = append(toRemove, autoscaling.MinScaleAnnotationKey)
}
if options.Scale.Max != nil {
toUpdate[autoscaling.MaxScaleAnnotationKey] = fmt.Sprintf("%d", *options.Scale.Max)
} else {
toRemove = append(toRemove, autoscaling.MaxScaleAnnotationKey)
}
if options.Scale.Metric != nil {
toUpdate[autoscaling.MetricAnnotationKey] = *options.Scale.Metric
} else {
toRemove = append(toRemove, autoscaling.MetricAnnotationKey)
}
if options.Scale.Target != nil {
toUpdate[autoscaling.TargetAnnotationKey] = fmt.Sprintf("%f", *options.Scale.Target)
} else {
toRemove = append(toRemove, autoscaling.TargetAnnotationKey)
}
if options.Scale.Utilization != nil {
toUpdate[autoscaling.TargetUtilizationPercentageKey] = fmt.Sprintf("%f", *options.Scale.Utilization)
} else {
toRemove = append(toRemove, autoscaling.TargetUtilizationPercentageKey)
}
}
return servingclientlib.UpdateRevisionTemplateAnnotations(template, toUpdate, toRemove)
}