Skip to content

Jitter kubernetes dial backoff so builders do not retry in lockstep - #3995

Open
1991santhu wants to merge 1 commit into
docker:masterfrom
1991santhu:fix/kubernetes-dial-backoff-jitter
Open

Jitter kubernetes dial backoff so builders do not retry in lockstep#3995
1991santhu wants to merge 1 commit into
docker:masterfrom
1991santhu:fix/kubernetes-dial-backoff-jitter

Conversation

@1991santhu

Copy link
Copy Markdown

The problem

calculateBackoff is a pure function of the attempt number:

return min(time.Duration(1<<uint(attempt))*baseDelay, maxDelay)

so every builder retrying the same condition waits for exactly 500ms, 1s, 2s, 4s, 8s.

That matters specifically because of the condition this retry exists to handle. From the comment on tryWithBackoff:

// This handles the race condition where Kubernetes marks nodes as "Ready" before their
// Certificate Signing Requests (CSRs) are approved, causing transient TLS errors.

CSR approval lag is a cluster-wide, time-correlated event rather than a per-pod accident. When a node group comes up, every build scheduled onto those nodes hits the transient TLS error at roughly the same moment — and then retries at identical instants. In CI, where parallel builds are the normal case, that concentrates retries on the API server precisely while it is still working through the approval backlog.

The backoff limits how often each builder retries. It does not stop builders retrying together.

The change

Equal jitter — retain half the computed interval as a floor, randomise the remainder:

d := min(time.Duration(1<<uint(attempt))*baseDelay, maxDelay)
half := d / 2

return half + time.Duration(rand.Int63n(int64(d-half)+1))

Half is kept rather than using full jitter so a retry is never issued immediately after a TLS failure. The result never exceeds maxDelay, so no builder waits longer than it does today — the change only removes the alignment.

Testing

go test -count=2 ./driver/kubernetes/... passes, go vet clean.

The package had no test file, so this adds one covering: the result stays within [d/2, d] for attempts 0–5 across 500 samples each; a large attempt count stays capped at maxDelay; and successive calls vary, which fails if the implementation regresses to deterministic.

Note

math/rand rather than crypto/rand — this is load spreading, not a security boundary. Happy to change if the project prefers otherwise. Context for the original retry: #2668.

@1991santhu

1991santhu commented Aug 4, 2026

Copy link
Copy Markdown
Author

Corrected the direction of the jitter in my own patch.

It was equal jitter, [d/2, d]. At attempt 0 that lets a retry fire at 250ms when baseDelay is 500ms, so it hits the API server sooner than configured while it's still working through the CSR backlog. It adds now, [d, 2d], bounded by the headroom to maxDelay. With maxRetries=5 the delays used are 500ms through 4s, so the 10s cap never comes into play.

I ran the numbers rather than just asserting the problem. 100 builders hitting the same CSR lag: without jitter all 100 dial inside the same 100ms window on every attempt, with it about 28 on the first retry and 7 by the fourth. Simulated arrival times, not something I've measured on a real cluster.

go test -count=2 -run TestCalculateBackoff ./driver/kubernetes/ passes.

@crazy-max crazy-max left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The direction makes sense to me.

One small wording nit: this is additive jitter, [d, 2d] capped by maxDelay, not equal jitter anymore. That behavior is fine if preserving the current exponential delay as the floor is intentional, but the description/comment should call it that directly.

Also, please squash the commits before merge.

Comment thread driver/kubernetes/driver.go Outdated
return d
}

return d + time.Duration(rand.Int63n(int64(extra)+1))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

The new math/rand call should use the same gosec suppression pattern as podchooser.RandomPodChooser, since retry jitter doesn't need crypto randomness:

return d + time.Duration(rand.Int63n(int64(extra)+1)) // #nosec G404 -- no strong randomness required for retry jitter

@crazy-max crazy-max added this to the v0.37.0 milestone Aug 5, 2026
…n lockstep

calculateBackoff was a pure function of the attempt number, so every builder
retrying the same condition waited exactly the same durations. That matters
here because CSR approval lagging node readiness is a cluster-wide event:
concurrent builds scheduled onto newly-ready nodes hit the transient TLS error
at the same moment, then retry in unison against an API server already working
through the approval backlog.

Add jitter drawn from [d, 2d], capped by maxDelay, where d is the exponential
value for the attempt. The exponential value is the floor rather than the
midpoint, so a retry is never issued sooner than the schedule would have on its
own. Centring it would let the first retry fire at baseDelay/2, which undercuts
a configured minimum at exactly the wrong moment. With maxRetries=5 and
baseDelay=500ms the delays used are 500ms through 4s, so the 10s cap is never
reached in practice.

Marked the math/rand call with the same #nosec pattern podchooser uses.

Signed-off-by: Santhosh Kumar Somarapu <somarapu.santhosh91@gmail.com>
@1991santhu

Copy link
Copy Markdown
Author

Thanks, all three done.

Switched to the same pattern as podchooser.RandomPodChooser:

return d + time.Duration(rand.Int63n(int64(extra)+1)) // #nosec G404 -- no strong randomness required for retry jitter

You're right about the wording. The doc comment said "backoff and jitter" without saying which kind, which is worse than useless here since the whole point of the change is that the jitter is additive rather than centred. It now says so directly: additive jitter drawing from [d, 2d] capped by maxDelay, with the exponential value as the floor rather than the midpoint. That is intentional, for the reason the rest of the comment gives, so a retry can never fire sooner than the schedule alone would have.

Squashed to one commit.

Verified with golangci-lint v2.8.0 built from source against Go 1.26, matching how the lint job builds it: 0 issues. go test -count=2 -run TestCalculateBackoff ./driver/kubernetes/ passes.

@1991santhu
1991santhu force-pushed the fix/kubernetes-dial-backoff-jitter branch from dfa4bf9 to c1195f0 Compare August 5, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants