Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prepare downscale webhook #47

Merged
merged 64 commits into from
Apr 24, 2023

Conversation

JordanRushing
Copy link
Contributor

@JordanRushing JordanRushing commented Mar 31, 2023

This PR follows #25 and implements a prep_downscale mutating admission webhook that, after receiving a request that scales down a resource, sends a HTTP POST to pods in a StatefulSet/Deployment/ReplicaSet that are labeled with grafana.com/prep-downscale: true.

The endpoint targets for the requests are crafted by extracting and combining the grafana.com/prep-downscale-http-path and grafana.com/prep-downscale-http-port labels along with the diff in the number of replicas.

An example scenario for when this is useful:

  • KEDA evaluates a metric query and suggests to scale down the replicas in a StatefulSet
  • The pods in the StatefulSet implement a prep_shutdown HTTP endpoint that tells them to expect to be terminated on the next SIGTERM and manage their state appropriately
  • The new mutating admission webhook activates, validates the scale down, successfully hits the prep_shutdown HTTP endpoints on relevant pods, and allows the admission request thus resulting in the pods being deleted gracefully

JordanRushing and others added 3 commits March 27, 2023 23:01
Signed-off-by: JordanRushing <rushing.jordan@gmail.com>
…oroutines where the POST errors

Signed-off-by: JordanRushing <rushing.jordan@gmail.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
@colega
Copy link
Contributor

colega commented Apr 3, 2023

👋 Thank you for taking this task.

Did a quick first review, and have some feedback:

  • This has to be a MutatingWebhook, and it should state that it has side-effects.
    • Edit: I was wrong here, it can't be a mutating webhook really, because when the operation is /scale we can't mutate anything, but we still have side-effects.
  • We should add an annotation to the downscaled resource saying when it was downscaled last time, so we can validate that and disallow downscaling other zones when one of the zones was recently downscaled.

Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
@CLAassistant
Copy link

CLAassistant commented Apr 3, 2023

CLA assistant check
All committers have signed the CLA.

Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
DownScalePort string
}

func testPrepDownscaleWebhook(t *testing.T, oldReplicas, newReplicas, httpStatusCode int, allowed bool, podsPrepared bool) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we write an integration test please?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this PR is getting bigger and bigger I propose to do this in a separate PR.

Comment on lines 94 to 96
// Since it's a downscale, check if the resource has the label that indicates it's ready to be prepared to be downscaled.
// Create a slice of endpoint addresses for pods to send HTTP post requests to and to fail if any don't return 200
if lbls[PrepDownscaleLabelKey] == PrepDownscaleLabelValue {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We need to make sure it's not a dry run.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has been added.

Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
pkg/admission/prep_downscale.go Outdated Show resolved Hide resolved
pkg/admission/prep_downscale.go Outdated Show resolved Hide resolved
pkg/admission/prep_downscale.go Outdated Show resolved Hide resolved
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Comment on lines 20 to 32
client := api.AppsV1().StatefulSets(namespace)
sts, err := client.Get(ctx, stsName, v1.GetOptions{})
if err != nil {
return err
}
annotations := sts.GetAnnotations()
if annotations == nil {
annotations = map[string]string{}
}
annotations[LastDownscaleAnnotationKey] = time.Now().UTC().Format(time.RFC3339)
sts.SetAnnotations(annotations)

_, err = client.Update(ctx, sts, v1.UpdateOptions{})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if this can trigger a race condition (something updating sts in between our read & update). Can we do a Patch() operation here instead?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patch does work better here.

Copy link
Contributor

@colega colega left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking very good! Thank you for addressing all my feedback. Left one last comment about updating operation, but otherwise LGTM!

Copy link
Contributor

@DylanGuedes DylanGuedes left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looking good, just a few questions/suggestions

pkg/admission/annotations.go Outdated Show resolved Hide resolved
pkg/tlscert/webhookconfig.go Outdated Show resolved Hide resolved
err := errors.New("HTTP post request returned non-2xx status code")
body, readError := io.ReadAll(resp.Body)
defer resp.Body.Close()
level.Error(logger).Log("msg", err, "status", resp.StatusCode, "response_body", body)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how large can be the response_body? depending on the size we might prefer to not log it

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Loki and Mimir at the moment it will be empty. They will log the error in more detail so you have to look there.

Comment on lines 176 to 187
for i := 0; i < int(diff); i++ {
index := int(*oldReplicas) - i - 1 // nr in statefulset
eps[i].url = fmt.Sprintf("%v-%v.%v.%v.svc.cluster.local:%s/ingester/%s",
ar.Request.Name, // pod name
index,
ar.Request.Name, // svc name
ar.Request.Namespace,
port,
path,
)
eps[i].index = index
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT?

Suggested change
for i := 0; i < int(diff); i++ {
index := int(*oldReplicas) - i - 1 // nr in statefulset
eps[i].url = fmt.Sprintf("%v-%v.%v.%v.svc.cluster.local:%s/ingester/%s",
ar.Request.Name, // pod name
index,
ar.Request.Name, // svc name
ar.Request.Namespace,
port,
path,
)
eps[i].index = index
}
for i := int(*newReplicas); i < int(*oldReplicas); i++ {
eps[i].url = fmt.Sprintf("%v-%v.%v.%v.svc.cluster.local:%s/ingester/%s",
ar.Request.Name, // pod name
index,
ar.Request.Name, // svc name
ar.Request.Namespace,
port,
path,
)
eps[i].index = index
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't define a field called index. Do you mean using i instead of index?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, mostly because iterating from 0 to diff and evaluating index as oldreplicas - i - 1, which is a little confusing, just iterate from newReplicas to oldReplicas.

pkg/admission/prep_downscale.go Outdated Show resolved Hide resolved
err := errors.New("HTTP post request returned non-2xx status code")
body, readError := io.ReadAll(resp.Body)
defer resp.Body.Close()
level.Error(logger).Log("msg", err, "status", resp.StatusCode, "response_body", body)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WDYT of having a dedicated "err" field for the err? Like:
("msg", "non-2xx received", "err", err, ...)

motiavtion is: it would allow us of searching for errors precisely by doing {mystream} |= "err="

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that makes it consistent with the other error log messages as well.

pkg/admission/prep_downscale.go Outdated Show resolved Hide resolved
pkg/admission/annotations.go Outdated Show resolved Hide resolved
Copy link
Collaborator

@andyasp andyasp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is getting close to being merged. Remember to add an entry to the CHANGELOG (and sorry for the previous dependency update conflict!)

MichelHollands and others added 9 commits April 21, 2023 08:34
Co-authored-by: Dylan Guedes <djmgguedes@gmail.com>
Co-authored-by: Dylan Guedes <djmgguedes@gmail.com>
Co-authored-by: Dylan Guedes <djmgguedes@gmail.com>
Co-authored-by: Dylan Guedes <djmgguedes@gmail.com>
Co-authored-by: Dylan Guedes <djmgguedes@gmail.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
@MichelHollands
Copy link
Contributor

MichelHollands commented Apr 21, 2023

Looks like this is getting close to being merged. Remember to add an entry to the CHANGELOG (and sorry for the previous dependency update conflict!)

The CHANGELOG has been updated as well as the README.md. The e2e tests were fixed. However in the CI the last test fails occasionally. I'm not sure how to fix it at the moment.

Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
@MichelHollands
Copy link
Contributor

The TestExpiringCertificate was a bit flaky. After increasing one timeout there it seems a lot more stable.
Can someone merge this? I don't have permission. The same might apply for making a new release.

pkg/admission/prep_downscale.go Outdated Show resolved Hide resolved
CHANGELOG.md Outdated Show resolved Hide resolved
README.md Outdated Show resolved Hide resolved
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
pkg/admission/prep_downscale.go Outdated Show resolved Hide resolved
Comment on lines 112 to 119
reviewResponse := v1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Message: fmt.Sprintf("downscale of %s/%s in %s from %d to %d replicas is not allowed because the %v label is not set or empty.", ar.Request.Resource.Resource, ar.Request.Name, ar.Request.Namespace, *oldReplicas, *newReplicas, PrepDownscalePortKey),
},
}
level.Warn(logger).Log("msg", fmt.Sprintf("downscale not allowed because the %v label is not set or empty", PrepDownscalePortKey))
return &reviewResponse
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: during this last review, I found it hard to follow the code with all the noise of these denied responses. I would consider not introducing a var for them, and defining a method that would format the Allowed: false response, like this:

Suggested change
reviewResponse := v1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Message: fmt.Sprintf("downscale of %s/%s in %s from %d to %d replicas is not allowed because the %v label is not set or empty.", ar.Request.Resource.Resource, ar.Request.Name, ar.Request.Namespace, *oldReplicas, *newReplicas, PrepDownscalePortKey),
},
}
level.Warn(logger).Log("msg", fmt.Sprintf("downscale not allowed because the %v label is not set or empty", PrepDownscalePortKey))
return &reviewResponse
level.Warn(logger).Log("msg", fmt.Sprintf("downscale not allowed because the %v label is not set or empty", PrepDownscalePortKey))
return deny("downscale of %s/%s in %s from %d to %d replicas is not allowed because the %v label is not set or empty.", ar.Request.Resource.Resource, ar.Request.Name, ar.Request.Namespace, *oldReplicas, *newReplicas, PrepDownscalePortKey)
// ...
// deny returns a *v1.AdmissionResponse with Allowed: false and the message provided formatted with as in fmt.Sprintf.
func deny(msg, args ...any) *v1.AdmissionResponse {
return v1.AdmissionResponse{
Allowed: false,
Result: &metav1.Status{
Message: fmt.Sprintf(msg, args...),
},
}
}

IMO this would make this entire method shorter and easier to follow.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea. The function was added manually.

Comment on lines 147 to 148
if foundSts != nil {
reviewResponse := v1.AdmissionResponse{
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we fail if this sts was downscaled few moments ago? Can we downscale again the same statefulset? I'm trying to think of corner cases that would bite us here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Loki we set the stabilizationWindowSeconds in the Keda ScaledObject (which is passed to the HPA). This makes sure there is a certain time between downscales. If this is set big enough the other statefulsets should have time to finish their downscales at which point another scale down should be possible.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking about a human operator having to do something manually. However, a human can just delete the annotation as a break-glass mechanism so it's ok.

MichelHollands and others added 6 commits April 24, 2023 10:26
Co-authored-by: Oleg Zaytsev <mail@olegzaytsev.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
Signed-off-by: Michel Hollands <michel.hollands@grafana.com>
@colega
Copy link
Contributor

colega commented Apr 24, 2023

Thank you!

@colega colega merged commit 716e332 into grafana:main Apr 24, 2023
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants