mirror of https://github.com/knative/func.git
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:
parent
8041a25486
commit
a937c490b7
85
config.go
85
config.go
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
175
config_test.go
175
config_test.go
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue