Skip to content
This repository was archived by the owner on Dec 4, 2025. It is now read-only.

Conversation

@mpelekh
Copy link
Contributor

@mpelekh mpelekh commented Oct 4, 2024

Problem statement is in argoproj/argo-cd#8172 (comment)

The IterateHierrchyV2 significantly improved performance, getting us ~90% of the way there. But on huge clusters, we still have significant lock contention.

The fix in this pull request approaches the problem differently - it avoids lock contention by utilizing a channel to process events from the cluster.

More details are in the comments.

@mpelekh
Copy link
Contributor Author

mpelekh commented Oct 4, 2024

The issue

In large clusters where Argo CD monitors numerous resources, the processing of watches becomes significantly slow—in our case (total k8s resources in cluster: ~400k, Pods: ~76k, ReplicaSets: ~52k), taking around 10 minutes. As a result, the Argo CD UI displays outdated information, impacting several features reliant on sync waves, like PruneLast. Eventually, the sheer volume of events from the cluster overwhelmed the system, causing Argo CD to stall completely.

To address this, we disabled the tracking of Pods and ReplicaSets, although this compromises one of the main benefits of the Argo CD UI. We also filtered out irrelevant events and tried to optimize various settings in the application controller. However, vertical scaling of the application controller had no effect, and horizontal scaling is not an option for a single cluster due to sharding limitations.

Issue causes

During the issue investigation, it was found that the problem lies in the following:

  • resource lock contention
  • slow performance of IterateHierarchy

Patched v2.10.9

v2.10.9 was patched with the following commits.

Though patches significantly improve performance, Argo CD still can not handle the load from large clusters.

In the screenshot, you can see one of the largest clusters. Here, the patched with the above commits v2.10.9 build is running.

  • till 12:50, pods and replica sets are disabled from tracking
  • from 12:50 to 13:34, pods and replica sets are enabled to be tracked
  • after 13:34, pods and replica sets are disabled from tracking

As can be seen, once pods and rs are enabled to be tracked, the cluster event count falls close to zero, and reconciliation time increases drastically.

Screenshot 2024-08-09 at 20 40 44

Screenshot 2024-08-09 at 20 51 00

Number of pods in cluster: ~76k
Number of rs in cluster: ~52k

A more detailed comparison of different patched versions is added to this comment - argoproj/argo-cd#8172 (comment)

The potential reason is lock contention.

Here, a few more metrics were added, and it was found that when the number of events is significant, sometimes it takes ~5 minutes to acquire a lock, which leads to a delay in reconciliation.
mpelekh@560ef00#diff-9c9e197d543705f08c9b1bc2dc404a55506cfc2935a988e6007d248257aadb1aR1372

Screenshot 2024-08-09 at 21 11 33

The suggested fix #602 to optimize the lock usage has not improved the issue in large clusters.

Avoid resources lock contention utilizing channel

Since we still have significant lock contention in massive clusters, and the approaches above didn’t resolve the issue, another approach has been considered. It is a part of this PR.

When we must acquire a write lock in each goroutine, we can’t handle more than one event at a time. What if we introduce the channel where all the received events are sent, and one goroutine is responsible for processing events in batch from the channel? In such a way, the locks from each goroutine are moved to the goroutine, which processes events from the channel. This means we would have only one place where the write lock is acquired; in such a way, we would get rid of the lock contention.

The fix results

2a758ca7-9f86-4c98-b0c7-4bf81e6b91e7

As can be seen from metrics, once the fixed version was deployed and Node, ReplicaSets, and Pods were enabled for tracking, the number of cluster events was stable and didn’t go down.

Conclusions
The fix shows significant performance improvements. We left Nodes, ReplicaSets, and Pods enabled on large clusters.
ArgoCD UI is working smoothly.
The original issue has been resolved - users can manage Pods and ReplicaSets on large clusters.

@mpelekh mpelekh marked this pull request as ready for review October 4, 2024 08:11
@crenshaw-dev
Copy link
Member

Your analysis is excruciatingly thorough, I love it! I've posted it to SIG Scalability, and we'll start analyzing ASAP. Please be patient, it'll take us a while to give it a really thorough review.

return err
}

go c.processEvents()
Copy link
Member

Choose a reason for hiding this comment

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

I see this in the sync function comment:

// When this function exits, the cluster cache is up to date, and the appropriate resources are being watched for
// changes.

If I understand this change correctly (and the associated test changes), by processing these events in a goroutine, we're breaking the guarantee that sync will completely update the cluster cache. Is that correct?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These changes do not break the guarantee that sync will completely update the cluster cache.
sync function populates the cluster cache when it's run.

c.setNode(c.newResource(un))

processEvents goroutine processes the future events that are received in watchEvents goroutine.

go c.watchEvents(ctx, api, resClient, ns, resourceVersion)

watchEvents goroutine watches for the events from k8s resource types. Once an event is received, it's processed.

case event, ok := <-w.ResultChan():

The event is sent to the channel that is read in the processEvents goroutine, where the processing is done in bulk.

c.eventMetaCh <- eventMeta{event, un}

@crenshaw-dev
Copy link
Member

@mpelekh would you be interested in joining a SIG Scalability meeting to talk through the changes?

@crenshaw-dev
Copy link
Member

Could you open an Argo CD PR pointing to this commit so that we can run all Argo's tests?

@mpelekh mpelekh force-pushed the event-processing-performance-improvement branch 2 times, most recently from db0f61d to 2f5160b Compare October 10, 2024 14:02
@mpelekh
Copy link
Contributor Author

mpelekh commented Oct 10, 2024

@mpelekh would you be interested in joining a SIG Scalability meeting to talk through the changes?

@crenshaw-dev Yes, I’d be happy to join the SIG Scalability meeting to discuss the changes. Please let me know the time and details or if there’s anything specific I should prepare in advance.

@crenshaw-dev
Copy link
Member

Great! The event is on the Argoproj calendar, and we coordinate in CNCF Slack. The next meeting is two Wednesdays from now at 8am eastern time.

No need to prepare anything really, just be prepared to answer questions about the PR. :-)

@mpelekh
Copy link
Contributor Author

mpelekh commented Oct 10, 2024

Could you open an Argo CD PR pointing to this commit so that we can run all Argo's tests?

@crenshaw-dev Sure. Here it is - argoproj/argo-cd#20329.

@mpelekh mpelekh force-pushed the event-processing-performance-improvement branch from 2f5160b to c26baf2 Compare October 24, 2024 09:16
@crenshaw-dev
Copy link
Member

A couple things from the contributors meeting last week:

  1. we should probably make this configurable via a flag from Argo CD; the more I think about it, the more I think we should have a quick opt-out option
  2. if feasible, we should have batches process on a ticker or some max slice size; that'll help manage particularly high-churn spikes

@mpelekh
Copy link
Contributor Author

mpelekh commented Nov 6, 2024

Do we have a definitive answer yet for whether sync status/operation status are currently atomically updated vs. just very-quickly updated? Because if we're losing atomicity, that could be a big problem. If we're just slowing something down that used to be fast, I think that's relatively okay.

I provided the details in this comment - argoproj/argo-cd#20329 (comment)

tl;dr
The sync and operation status are not atomically updated. They just very quickly updated.

@mpelekh mpelekh force-pushed the event-processing-performance-improvement branch from c26baf2 to 809ba43 Compare November 7, 2024 13:37
Copy link
Contributor

@andrii-korotkov-verkada andrii-korotkov-verkada left a comment

Choose a reason for hiding this comment

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

Overall, looks great, have some minor comments.

}

func (c *clusterCache) processEvents() {
log := c.log.WithValues("fn", "processItems")
Copy link
Contributor

Choose a reason for hiding this comment

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

Is "fn" a standard thing? I'd rather use a more verbose name.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I completely agree with using a more verbose name. Fixed - 7a53eca

}

func (c *clusterCache) processEvents() {
log := c.log.WithValues("fn", "processItems")
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 extend the lock to do this assignment, or is there nothing that can override c.log?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

No need to extend the lock here, as WithValues returns a new Logger instance without modifying c.log directly. The assignment to log is local and safe from concurrent modification.
https://github.com/go-logr/logr/blob/master/logr.go#L328

select {
case em, ok := <-ch:
if !ok {
log.V(1).Info("Event processing channel closed, finish processing")
Copy link
Contributor

Choose a reason for hiding this comment

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

I think this can be debug level

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It completely makes sense. Fixed - 7a53eca


for {
select {
case em, ok := <-ch:
Copy link
Contributor

Choose a reason for hiding this comment

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

Use a more verbose name like eventMeta. em is not a standard shortening.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

case <-ticker.C:
if len(eventMetas) > 0 {
c.processEventsBatch(eventMetas)
eventMetas = eventMetas[:0]
Copy link
Contributor

Choose a reason for hiding this comment

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

Why assign eventMetas[:0] instead of nil or empty slice?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thank you for review @andrii-korotkov-verkada

Setting eventMetas to eventMetas[:0] retains the original slice’s underlying array, which can help reduce memory allocations if the slice is reused frequently. Assigning nil would release the underlying array, and creating a new empty slice would lead to additional allocations. This approach allows us to efficiently clear the slice while keeping the capacity intact for future use.

@mpelekh mpelekh force-pushed the event-processing-performance-improvement branch from d310961 to 7a53eca Compare November 12, 2024 13:05
@mpelekh
Copy link
Contributor Author

mpelekh commented Nov 12, 2024

Thanks for the review @andrii-korotkov-verkada. I addressed the comments in this commit - 7a53eca

I am going to rebase -i --autosquash it before merge.

@mpelekh mpelekh force-pushed the event-processing-performance-improvement branch 3 times, most recently from 569aa9c to 66d645e Compare December 2, 2024 12:33
@mpelekh
Copy link
Contributor Author

mpelekh commented Dec 2, 2024

we should probably make this configurable via a flag from Argo CD; the more I think about it, the more I think we should have a quick opt-out option

@crenshaw-dev As agreed, I've set this up using a flag from Argo CD.

The PRs have been updated:

return nil
}

// invalidateEventMeta closes the eventMeta channel if it is open
Copy link

Choose a reason for hiding this comment

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

nit: It looks like some functions, like this one, have an assumption that c.lock is held when they are called. In my experience that can lead to bugs with future maintenance when someone doesn't realize a lock is needed to call the function. I personally name functions in a way that this is indicated, but that is certainly a personal style choice. At the very least, it might be worth indicating the lock requirement in the go doc.

Copy link
Member

Choose a reason for hiding this comment

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

@mpelekh can you update the go doc?

@mpelekh mpelekh force-pushed the event-processing-performance-improvement branch from 8683b25 to d36c78a Compare December 12, 2024 10:00
}

// SetEventProcessingInterval allows to set the interval for processing events
func SetEventProcessingInterval(interval time.Duration) UpdateSettingsFunc {
Copy link
Member

Choose a reason for hiding this comment

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

super nit but should we add a test asserting the default gets set without an override and vice versa?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thanks for spotting that, @rumstead. I added a unit test for that.

@sonarqubecloud
Copy link

@codecov
Copy link

codecov bot commented Dec 12, 2024

Codecov Report

Attention: Patch coverage is 65.17857% with 39 lines in your changes missing coverage. Please review.

Project coverage is 54.43%. Comparing base (8849c3f) to head (af50e33).
Report is 5 commits behind head on master.

Files with missing lines Patch % Lines
pkg/cache/cluster.go 71.42% 22 Missing and 4 partials ⚠️
pkg/cache/mocks/ClusterCache.go 0.00% 13 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##           master     #629      +/-   ##
==========================================
+ Coverage   54.26%   54.43%   +0.16%     
==========================================
  Files          64       64              
  Lines        6164     6270     +106     
==========================================
+ Hits         3345     3413      +68     
- Misses       2549     2583      +34     
- Partials      270      274       +4     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

@crenshaw-dev crenshaw-dev merged commit 54992bf into argoproj:master Dec 16, 2024
5 checks passed
Comment on lines +204 to +205
batchEventsProcessing bool
eventMetaCh chan eventMeta
Copy link
Member

Choose a reason for hiding this comment

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

These need docstrings. I'd also rename to batchEventsProcessingEnabled to make clear that it's a feature flag.

Comment on lines +335 to +343
func (c *clusterCache) getProcessEventsHandlers() []OnProcessEventsHandler {
c.handlersLock.Lock()
defer c.handlersLock.Unlock()
handlers := make([]OnProcessEventsHandler, 0, len(c.processEventsHandlers))
for _, h := range c.processEventsHandlers {
handlers = append(handlers, h)
}
return handlers
}
Copy link
Member

Choose a reason for hiding this comment

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

Why not have the caller just use the map directly?

return nil
}

// invalidateEventMeta closes the eventMeta channel if it is open
Copy link
Member

Choose a reason for hiding this comment

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

@mpelekh can you update the go doc?

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants