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

VPA: prune stale container aggregates, split recommendations over true number of containers #6745

Open
wants to merge 7 commits into
base: master
Choose a base branch
from

Conversation

jkyros
Copy link

@jkyros jkyros commented Apr 22, 2024

What type of PR is this?

/kind bug

What this PR does / why we need it:

Previously we weren't cleaning up "stale" aggregates when container names changed (because of renames, removals) and that was resulting in:

  • VPAs showing recommendations for containers which no longer exist
  • Resources being split across containers which no longer exist (resulting in some containers ending up with resource limits too small for them to effectively live)
  • There was also a corner case where during a rollout after a container was renamed/removed from a deployment, we were counting the number of unique container names and not the actual number of containers in each pod, so we were splitting resources that shouldn't have been split.

This PR is an attempt to clean up those stale aggregates without incurring too much overhead, and make sure that the resources get spread across the correct number of containers during a rollout.

Which issue(s) this PR fixes:

Fixes #6744

Special notes for your reviewer:

There are probably a lot of different ways we can do the pruning of stale aggregates for missing containers:

  • I went with explicitly marking and sweeping them because it saved us an additional loop through all the pods and containers
  • We could also just as easily just have a PruneAggregates() that runs after LoadPods() that goes through everything and removes them (or do this work as part of LoadPods() but that seems...deceptive?)
  • We could probably also tweak the existing garbageCollectAggregateCollectionStates and run it immediately after LoadPods() every time but that might be expensive.

I'm not super-attached to any particular approach, I'd just like to fix this, so I can retool it if necessary.

  • If I am being ignorant, and there are corner cases I'm missing, absolutely let me know
  • it probably need some tests/cleanup and I'll change the names of things to...whatever you want them to be. 😄

Does this PR introduce a user-facing change?

Added pruning of container aggregates and changed container math so resources will no longer be split across the wrong number of containers

Additional documentation e.g., KEPs (Kubernetes Enhancement Proposals), usage docs, etc.:


@k8s-ci-robot k8s-ci-robot added do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. kind/bug Categorizes issue or PR as related to a bug. labels Apr 22, 2024
@k8s-ci-robot k8s-ci-robot added area/vertical-pod-autoscaler cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. labels Apr 22, 2024
@k8s-ci-robot
Copy link
Contributor

Hi @jkyros. Thanks for your PR.

I'm waiting for a kubernetes member to verify that this patch is reasonable to test. If it is, they should reply with /ok-to-test on its own line. Until that is done, I will not automatically test new commits in this PR, but the usual testing commands by org members will still work. Regular contributors should join the org to skip this step.

Once the patch is verified, the new status will be reflected by the ok-to-test label.

I understand the commands that are listed here.

Instructions for interacting with me using PR comments are available here. If you have questions or suggestions related to my behavior, please file an issue against the kubernetes/test-infra repository.

@k8s-ci-robot k8s-ci-robot added the size/L Denotes a PR that changes 100-499 lines, ignoring generated files. label Apr 22, 2024
@jkyros jkyros marked this pull request as ready for review April 22, 2024 17:56
@k8s-ci-robot k8s-ci-robot removed the do-not-merge/work-in-progress Indicates that a PR should not merge because it is a work in progress. label Apr 22, 2024
@k8s-ci-robot k8s-ci-robot requested a review from kgolab April 22, 2024 17:57
// TODO(jkyros): This only removes the container state from the VPA's aggregate states, there
// is still a reference to them in feeder.clusterState.aggregateStateMap, and those get
// garbage collected eventually by the rate limited aggregate garbage collector later.
// Maybe we should clean those up here too since we know which ones are stale?
Copy link
Member

Choose a reason for hiding this comment

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

Is it a lot of extra work to do that? Do you see any risks doing it here?

Copy link
Author

@jkyros jkyros May 2, 2024

Choose a reason for hiding this comment

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

No, I don't think it's a lot of extra work, it should be reasonably cheap to clean them up here since it's just deletions from the other maps if the keys exist, I just didn't know all the history.

It seemed possible at least that we were intentionally waiting to clean up the aggregates so if there was an unexpected hiccup we didn't just immediately blow away all that aggregate history we worked so hard to get? (Like maybe someone oopses, deletes their deployment, then puts it back? Right now we don't have to start over -- the pods come back in, find their container aggregates, and resume ? But if I clean them up here, we have to start over...)

// the correct number and not just the number of aggregates that have *ever* been present. (We don't want minimum resources
// to erroneously shrink, either)
func (cluster *ClusterState) setVPAContainersPerPod(pod *PodState) {
for _, vpa := range cluster.Vpas {
Copy link
Member

Choose a reason for hiding this comment

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

I'm wondering if there is already a place where this logic could go so we don't have to loop over all VPAs for every pod again here.
In large clusters with a VPA to Pod ratio that's closer to 1 this could be a little wasteful.

Copy link
Author

Choose a reason for hiding this comment

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

Hmm, yeah, I struggled with finding a less expensive way without making too much of a mess. Unless I'm missing something (and I might be) we don't seem to have a VPA <--> Pod map -- probably because we didn't need one until now? At the very least I think I should gate this to only run if the number of containers in the pod is > 1.

Like, I think our options are:

  1. update the VPA as the pods roll through (which requires me to find the VPA for each pod like I did here) or
  2. count the containers as we load the VPAs (but we load the VPAs before we load the pods, so we'd have to go through the pods again, so that doesn't help us)
  3. have the VPA actually track the pods it's managing, something like this: jkyros@6ddc208 (could also just be an array of PodIDs and we could look up the state so we could save the memory cost of the PodState pointer, but you know what I mean)

I put it where I did (option 1) because at least LoadPods() was already looping through all the pods so we could freeload off the "outer" pod loop and I figured we didn't want to spend the memory on option 3. If we'd entertain option 3 and are okay with the memory usage, I can totally do that?

@k8s-triage-robot
Copy link

The Kubernetes project currently lacks enough contributors to adequately respond to all PRs.

This bot triages PRs according to the following rules:

  • After 90d of inactivity, lifecycle/stale is applied
  • After 30d of inactivity since lifecycle/stale was applied, lifecycle/rotten is applied
  • After 30d of inactivity since lifecycle/rotten was applied, the PR is closed

You can:

  • Mark this PR as fresh with /remove-lifecycle stale
  • Close this PR with /close
  • Offer to help out with Issue Triage

Please send feedback to sig-contributor-experience at kubernetes/community.

/lifecycle stale

@k8s-ci-robot k8s-ci-robot added the lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale. label Jul 31, 2024
@k8s-triage-robot
Copy link

The Kubernetes project currently lacks enough active contributors to adequately respond to all PRs.

This bot triages PRs according to the following rules:

  • After 90d of inactivity, lifecycle/stale is applied
  • After 30d of inactivity since lifecycle/stale was applied, lifecycle/rotten is applied
  • After 30d of inactivity since lifecycle/rotten was applied, the PR is closed

You can:

  • Mark this PR as fresh with /remove-lifecycle rotten
  • Close this PR with /close
  • Offer to help out with Issue Triage

Please send feedback to sig-contributor-experience at kubernetes/community.

/lifecycle rotten

@k8s-ci-robot k8s-ci-robot added lifecycle/rotten Denotes an issue or PR that has aged beyond stale and will be auto-closed. and removed lifecycle/stale Denotes an issue or PR has remained open with no activity and has become stale. labels Aug 30, 2024
@sreber84
Copy link

/remove-lifecycle rotten

@k8s-ci-robot k8s-ci-robot removed the lifecycle/rotten Denotes an issue or PR that has aged beyond stale and will be auto-closed. label Sep 20, 2024
@adrianmoisey
Copy link
Member

/ok-to-test
/assign

I want to see if I can help get this merged

@k8s-ci-robot k8s-ci-robot added ok-to-test Indicates a non-member PR verified by an org member that is safe to test. and removed needs-ok-to-test Indicates a PR that requires an org member to verify it is safe to test. labels Dec 3, 2024
@maxcao13
Copy link

maxcao13 commented Dec 3, 2024

Hi everyone, so John has take a hiatus and has left me with this PR, so after catching up, I guess we are still waiting for those conversations to resolve on which way do we want to go with those design decisions. The two commits I just put up are just a improvement on the existing implementation (assuming we will go with that, we don't have to), and some e2e tests to prove this works.

@maxcao13 maxcao13 force-pushed the vpa-aggregates-fix-mark-sweep branch 2 times, most recently from c3a1f0e to c84075b Compare December 3, 2024 22:29
@maxcao13
Copy link

maxcao13 commented Dec 9, 2024

/remove-area cluster-autoscaler

@adrianmoisey
Copy link
Member

I thought of a scenario, which may be something that needs to be catered for.

if someone has a VPA on a CronJob with successfulJobsHistoryLimit set to 0, then the VPA could cleanup any recommendation that it has of that CronJob, since there are moments in time when no Pods exist.

Sorry for the noise, and forgive me for not understanding, but what would be the problem here in terms of removing stale recommendations? Is it because the VPA is supposed to use identical recommendations for every container in a Job that gets created by a CronJob and we want to reuse the recommendations? If so, then apparently #6745 (comment) should solve this issue since the CronJob still exists. But I'm probably misunderstanding the question...

I think this is a possible scenario prior to this PR merging:

  1. A CronJob and VPA are configured
  2. On first run of the CronJob, VPA's recommendation is calculated and written to the VPA object
  3. On second run of the CronJob, the admission-controller has a recommendation that it can apply to the new Pod
  4. Step 3 repeats, and as the workload changes over time, so does the recommendation

But, I think this PR would change that scenario to the following:

  1. A CronJob and VPA are configured
  2. On first run of the CronJob, VPA's recommendation is calculated and written to the VPA object
  3. VPA recommender cleans up the recommendation, as no Pods currently exist
  4. On second run of the CronJob, no recommendation is applied, since no recommendation exists. But the resulting Pod will cause a recommendation to be created
  5. VPA recommender cleans up the recommendation, as no Pods currently exist
  6. On third run of the CronJob, no recommendation is applied, since no recommendation exists. But the resulting Pod will cause a recommendation to be created
  7. Steps 5-6 repeat, effectively causing the CronJob to not have a VPA

This scenario is assuming that the CronJob is configured with successfulJobsHistoryLimit set to 0, and that the frequency of the recommendation cleanup is greater than the frequency of the CronJob.

@k8s-ci-robot k8s-ci-robot added the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Dec 10, 2024
@maxcao13
Copy link

maxcao13 commented Dec 10, 2024

This scenario is assuming that the CronJob is configured with successfulJobsHistoryLimit set to 0, and that the frequency of the recommendation cleanup is greater than the frequency of the CronJob.

Got it, thanks for the detailed explanation!

This does happen and the recommendations are cleaned up before the next job gets scheduled, assuming the recommendation happens in time and the job schedule is long enough for the recommendation to prune the stale recommendations.

Thinking about your solution, I think it makes sense to add some sort of timeout feature for recommendations that refresh when they are used again -- pretty much what you mean by your cleanup feature. Maybe as a per VPA configuration in the VPA CR spec? Currently this is the current implemenation of whether recommendations will get recorded in the object:

So instead, the "matching pods" filtered pods should still include pods that have removed aggregates for a certain period of time based on a per VPA configuration, and maybe a global timeout as well (similar to how minReplicas works).

Although thinking about it, there is a small window between the VPA deciding "we should remove this container recommendation, it doesn't exist anymore and we should stop splitting recommendations over it because it was a rename" vs "we shouldn't remove this container recommendation because its targetRef's top most controller is a cronjob so we should not remove the recommendation as jobs will quickly use it again." I think there would need to be a lot of care in setting this staleness timeout feature, and users would have to know how exactly how long they want the recommendation to linger for depending on if it's a cronjob, or a regular workload, for them to scale as they intended.

@maxcao13
Copy link

So maybe it is best to just look at if the topmost controller is CronJob and decide based on that to remove this extra complexity?

@adrianmoisey
Copy link
Member

So maybe it is best to just look at if the topmost controller is CronJob and decide based on that to remove this extra complexity?

I was actually wondering about this option too.
It would also be done for all well known types. Rather than looking at the Pods to determine if a VPA recommendation, needs cleaning, look at the top most well known type.

@maxcao13
Copy link

maxcao13 commented Dec 11, 2024

It would also be done for all well known types. Rather than looking at the Pods to determine if a VPA recommendation, needs cleaning, look at the top most well known type.

In this case if we do this, if we rename a container, and the deployment exists, wouldn't there still be stale recommendations for the old container? Maybe the aggregate would get deleted and we wouldn't split resources anymore, but there just might be a stale recommendation in the vpa object that will never get deleted (but maybe that's okay?), if I am understanding this right.

That's why I am wondering if we have to specially handle CronJobs since those containers that get created are supposed to be deleted.

EDIT: I think the grace period idea might be the way to go, because the renaming situation vs the CronJob situation seems to me like the VPA would not be able to tell the difference, and I'm not sure we want to implement the top most controller checking in the recommender since the caches and informers don't exist there (exists for updater and admission I think?).

jkyros and others added 5 commits December 11, 2024 16:32
Previously we were dividing the resources per pod by the number of
container aggregates, but in a situation where we're doing a rollout and
the container names are changing (either a rename, or a removal) we're
splitting resources across the wrong number of containers, resulting in
smaller values than we should actually have.

This collects a count of containers in the model when the pods are
loaded, and uses the "high water mark value", so in the event we are
doing something like adding a container during a rollout, we favor the
pod that has the additional container.

There are probably better ways to do this plumbing, but this was my
initial attempt, and it does fix the issue.
Previously we were only cleaning checkpoints after something happened to
the VPA or the targetRef, and so when a container got renamed the
checkpoint would stick around forever.

Since we're trying to clean up the aggregates immediately now, we need
to force the checkpoint garbage collection to clean up any checkpoints
that don't have matching aggregates.

If the checkpoints did get loaded back in after a restart,
PruneContainers() would take the aggregates back out, but we probably
shouldn't leave the checkpoints out there.

Signed-off-by: Max Cao <[email protected]>
Previously we were letting the rate limited garbage collector clean up
the aggregate states, and that works really well in most cases, but when
the list of containers in a pod changes, either due to the removal or
rename of a container, the aggregates for the old containers stick
around forever and cause problems.

To get around this, this marks all existing aggregates/initial
aggregates in the list for each VPA as "not under a VPA" every time
before we LoadPods(), and then LoadPods() will re-mark the aggregates as
"under a VPA" for all the ones that are still there, which lets us
easily prune the stale container aggregates that are still marked as
"not under a VPA" but are still wrongly in the VPA's list.

This does leave the ultimate garbage collection to the rate limited
garbage collector, which should be fine, we just needed the stale
entries to get removed from the per-VPA lists so they didn't affect VPA
behavior.
@maxcao13 maxcao13 force-pushed the vpa-aggregates-fix-mark-sweep branch from 4c48eda to 5742d15 Compare December 12, 2024 01:10
@k8s-ci-robot k8s-ci-robot removed the needs-rebase Indicates a PR cannot be merged because it has merge conflicts with HEAD. label Dec 12, 2024
…pa prunes recommendations from non-existente containers

Signed-off-by: Max Cao <[email protected]>
@maxcao13 maxcao13 force-pushed the vpa-aggregates-fix-mark-sweep branch from 5742d15 to b6e435b Compare December 12, 2024 02:56
@maxcao13
Copy link

maxcao13 commented Dec 12, 2024

Okay, in b6e435b, I added pruningGracePeriod to per VPA containerPolicies so that people can specify graces periods for stale recommendations. Here's an example:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: hello
spec:
  schedule: "*/3 * * * *"
  successfulJobsHistoryLimit: 0
  jobTemplate:
    spec:
      template:
        metadata:
          labels:
            app: hello
        spec:
          containers:
          - name: hello
            image: busybox:1.28
            imagePullPolicy: IfNotPresent
            command:
            - /bin/sh
            - -c
            - |
              tries=0
              while [ $tries -lt 30 ]; do
                echo "Hello, World $tries!"
                tries=$((tries+1))
                sleep 5
              done
          restartPolicy: OnFailure
---
apiVersion: autoscaling.k8s.io/v1
kind: VerticalPodAutoscaler
metadata:
  name: hello
spec:
  targetRef:
      apiVersion: "apps/v1"
      kind:       "CronJob"
      name:       "hello"
  updatePolicy:
    updateMode: "Initial"
    minReplicas: 1
  resourcePolicy:
    containerPolicies:
    - containerName: "*"
      pruningGracePeriod: 3m

The grace period is long enough so that aggregates don't get pruned before the next job gets created and refreshes the last update time of the container, and so the recommendations still get preserved. If you specify a long grace period or the targetRef is CronJob, it will be pruned in 24 hours. If you don't specify the field and the targetRef is not CronJob, then the pruningGracePeriod is 0 and aggregates will be pruned immediately as soon as the next recommendation loop occurs, which will then remove the recommendations from the api object.

IDK how people feel about this, so I didn't write any tests. But using it locally, it seems to work as intended.

@adrianmoisey
Copy link
Member

It would also be done for all well known types. Rather than looking at the Pods to determine if a VPA recommendation, needs cleaning, look at the top most well known type.

In this case if we do this, if we rename a container, and the deployment exists, wouldn't there still be stale recommendations for the old container? Maybe the aggregate would get deleted and we wouldn't split resources anymore, but there just might be a stale recommendation in the vpa object that will never get deleted (but maybe that's okay?), if I am understanding this right.

My idea was to check the targetRef to see what it contains, in order to determine if a VPA's recommendation needs cleaning.
Ie: for a Deployment targetRef, specifically looking at the list of Deployment.spec.template.spec.containers[*].name to figure out which containers recommendations to keep.

This will only work on well-known types

@adrianmoisey
Copy link
Member

Okay, in b6e435b, I added pruningGracePeriod to per VPA containerPolicies so that people can specify graces periods for stale recommendations.

My opinion is that this is an adequate solution.

@voelzmo @raywainman @kwiesmueller @omerap12 thoughts on this one?

@omerap12
Copy link
Member

Okay, in b6e435b, I added pruningGracePeriod to per VPA containerPolicies so that people can specify graces periods for stale recommendations.

My opinion is that this is an adequate solution.

@voelzmo @raywainman @kwiesmueller @omerap12 thoughts on this one?

I’m not sure I understand the use case for allowing users to specify grace periods for stale recommendations.
For deployments where container names have changed, couldn’t we use a default value or immediately discard stale recommendations?
For CronJobs, why not calculate the value dynamically based on the schedule? For example, if the schedule is 1 hour, we could set it to 1 hour + 10 minutes as a safeguard.

What do you think?

if targetRef != nil && targetRef.Kind == "CronJob" {
// CronJob is a special case, because they create containers they are usually supposed to be deleted after the job is done.
// So we set a higher default grace period so that future recommendations for the same workload are not pruned too early.
// TODO(maxcao13): maybe it makes sense to set the default based on the cron schedule?
Copy link
Member

Choose a reason for hiding this comment

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

IMHO It does make sense.

@adrianmoisey
Copy link
Member

If you don't specify the field and the targetRef is not CronJob, then the pruningGracePeriod is 0 and aggregates will be pruned immediately as soon as the next recommendation loop occurs, which will then remove the recommendations from the api object.

I think I figured out another edge-case that needs to be handled.

Sometimes in my day-job, when we're firefighting issues, we may scale a deployment down to zero to alleviate pressure on other parts of the system. I think this use-case is possibly common, tools such as https://keda.sh are built to allow users to scale workloads down to zero, to save costs.

During these times the recommendation would be removed. I think the grace period needs to apply to all resource types.

@maxcao13
Copy link

maxcao13 commented Dec 16, 2024

I’m not sure I understand the use case for allowing users to specify grace periods for stale recommendations. For deployments where container names have changed, couldn’t we use a default value or immediately discard stale recommendations? For CronJobs, why not calculate the value dynamically based on the schedule? For example, if the schedule is 1 hour, we could set it to 1 hour + 10 minutes as a safeguard.

What do you think?

I think allowing the users to set it will be good in a case like @adrianmoisey said, where you may scale a deployment down to 0 to alleviate pressure on the system but don't want the recommendations to get lost. Then you can specifically set the pruningGracePeriod on the deployment's containers to be something high like 10d, so that the recommendations don't get removed while you are troubleshooting your system. But maybe you don't want that for some other workload, so you can set the gracePeriod to something like 10 minutes, so we don't have a memory leak in the recommender.

I think I figured out another edge-case that needs to be handled.

Sometimes in my day-job, when we're firefighting issues, we may scale a deployment down to zero to alleviate pressure on other parts of the system. I think this use-case is possibly common, tools such as https://keda.sh/ are built to allow users to scale workloads down to zero, to save costs.

During these times the recommendation would be removed. I think the grace period needs to apply to all resource types.

This should be able to work on all types as long as you set the field (unless I am misunderstanding you). Do you mean that we should default the gracePeriod to be very high (never expire) instead of 0? Maybe we also allow a global default gracePeriod set with a command line flag?

@adrianmoisey
Copy link
Member

I think I figured out another edge-case that needs to be handled.

Sometimes in my day-job, when we're firefighting issues, we may scale a deployment down to zero to alleviate pressure on other parts of the system. I think this use-case is possibly common, tools such as https://keda.sh/ are built to allow users to scale workloads down to zero, to save costs.

During these times the recommendation would be removed. I think the grace period needs to apply to all resource types.

This should be able to work on all types as long as you set the field (unless I am misunderstanding you). Do you mean that we should default the gracePeriod to be very high (never expire) instead of 0? Maybe we also allow a global default gracePeriod set with a command line flag?

Yeah, I guess it makes sense to have a long default or keep the feature disabled and allow it to be opt-in.

@raywainman
Copy link
Contributor

Could the default be "don't clean them up" to match behavior today? I worry that this is verging on a breaking change.

Then a user can override the pruning grace period for the problematic VPAs that are churning containers?

@omerap12
Copy link
Member

I think I figured out another edge-case that needs to be handled.

Sometimes in my day-job, when we're firefighting issues, we may scale a deployment down to zero to alleviate pressure on other parts of the system. I think this use-case is possibly common, tools such as https://keda.sh/ are built to allow users to scale workloads down to zero, to save costs.

During these times the recommendation would be removed. I think the grace period needs to apply to all resource types.

This should be able to work on all types as long as you set the field (unless I am misunderstanding you). Do you mean that we should default the gracePeriod to be very high (never expire) instead of 0? Maybe we also allow a global default gracePeriod set with a command line flag?

Yeah, I guess it makes sense to have a long default or keep the feature disabled and allow it to be opt-in.

I agree

@maxcao13 maxcao13 force-pushed the vpa-aggregates-fix-mark-sweep branch from 098c138 to 808c62b Compare December 17, 2024 21:52
@maxcao13
Copy link

maxcao13 commented Dec 17, 2024

Okay, I've added a --pruning-grace-period-duration flag which defaults to 100 years meaning that aggregates will not expire which is the previous implementation before this PR (hopefully someone is not keeping a vpa-recommender container up for 100 years).

I removed any special handling of CronJob since the default pruning functionality should now opt-in and not be a breaking change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/vertical-pod-autoscaler cncf-cla: yes Indicates the PR's author has signed the CNCF CLA. kind/bug Categorizes issue or PR as related to a bug. ok-to-test Indicates a non-member PR verified by an org member that is safe to test. size/L Denotes a PR that changes 100-499 lines, ignoring generated files.
Projects
None yet
9 participants