-
-
Notifications
You must be signed in to change notification settings - Fork 0
fix(operator): back off exponentially on 429 and fail liveness when the loop stalls #258
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,72 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| package controller | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| import ( | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "context" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "fmt" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "net/http" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "sync" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "time" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| "sigs.k8s.io/controller-runtime/pkg/healthz" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| fvv1alpha1 "github.com/FerrLabs/FerrVault/api/ferrvault/v1alpha1" | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| type Heartbeat struct { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| mu sync.Mutex | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| last time.Time | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| func NewHeartbeat(now time.Time) *Heartbeat { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return &Heartbeat{last: now} | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| func (h *Heartbeat) Beat(now time.Time) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if h == nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| h.mu.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| defer h.mu.Unlock() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if now.After(h.last) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| h.last = now | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| func (h *Heartbeat) Idle(now time.Time) time.Duration { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if h == nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return 0 | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| h.mu.Lock() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| defer h.mu.Unlock() | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return now.Sub(h.last) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| func StallChecker(c client.Client, hb *Heartbeat, threshold time.Duration) healthz.Checker { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return func(req *http.Request) error { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| idle := hb.Idle(time.Now()) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if idle <= threshold { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| watching, err := hasWatchedResources(req.Context(), c) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err != nil || !watching { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return fmt.Errorf("no reconcile completed for %s", idle.Truncate(time.Second)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| func hasWatchedResources(ctx context.Context, c client.Client) (bool, error) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| var conns fvv1alpha1.FerrVaultConnectionList | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := c.List(ctx, &conns, client.Limit(1)); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false, err | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if len(conns.Items) > 0 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return true, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| var secrets fvv1alpha1.FerrVaultSecretList | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| if err := c.List(ctx, &secrets, client.Limit(1)); err != nil { | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return false, err | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| return len(secrets.Items) > 0, nil | ||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+59
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Blocking: "a FerrVaultSecret exists" does not imply a reconcile inside the threshold. The justification in the description holds for connections: So a cluster with at least one FerrVaultSecret and zero FerrVaultConnections reconciles each secret once, takes Narrowing the check to connections keeps the invariant the threshold was chosen against. A cluster holding only secrets is quiet by design, and nothing there could sync anyway:
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| package controller | ||
|
|
||
| import ( | ||
| "context" | ||
| "errors" | ||
| "net/http" | ||
| "testing" | ||
| "time" | ||
|
|
||
| metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" | ||
| "sigs.k8s.io/controller-runtime/pkg/client" | ||
| "sigs.k8s.io/controller-runtime/pkg/client/fake" | ||
| "sigs.k8s.io/controller-runtime/pkg/client/interceptor" | ||
|
|
||
| fvv1alpha1 "github.com/FerrLabs/FerrVault/api/ferrvault/v1alpha1" | ||
| ) | ||
|
|
||
| const stallThresholdForTest = 15 * time.Minute | ||
|
|
||
| func connectionFixture() *fvv1alpha1.FerrVaultConnection { | ||
| return &fvv1alpha1.FerrVaultConnection{ | ||
| ObjectMeta: metav1.ObjectMeta{Namespace: "default", Name: "ferrvault-dev"}, | ||
| } | ||
| } | ||
|
|
||
| func checkerFor(t *testing.T, hb *Heartbeat, objs ...client.Object) error { | ||
| t.Helper() | ||
| c := fake.NewClientBuilder().WithScheme(newTestScheme(t)).WithObjects(objs...).Build() | ||
| return StallChecker(c, hb, stallThresholdForTest)(&http.Request{}) | ||
| } | ||
|
|
||
| func TestABeatingOperatorIsLive(t *testing.T) { | ||
| hb := NewHeartbeat(time.Now().Add(-stallThresholdForTest + time.Minute)) | ||
| if err := checkerFor(t, hb, connectionFixture()); err != nil { | ||
| t.Fatalf("reported dead while still reconciling: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestAStalledOperatorWithWorkIsNotLive(t *testing.T) { | ||
| hb := NewHeartbeat(time.Now().Add(-stallThresholdForTest - time.Minute)) | ||
| if err := checkerFor(t, hb, connectionFixture()); err == nil { | ||
| t.Fatal("reported live after reconciling nothing for longer than the threshold") | ||
| } | ||
| } | ||
|
|
||
| func TestAnIdleClusterIsNotRestarted(t *testing.T) { | ||
| hb := NewHeartbeat(time.Now().Add(-24 * time.Hour)) | ||
| if err := checkerFor(t, hb); err != nil { | ||
| t.Fatalf("reported dead with no FerrVault resources to reconcile: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestAnUnreadableCacheIsNotRestarted(t *testing.T) { | ||
| c := fake.NewClientBuilder(). | ||
| WithScheme(newTestScheme(t)). | ||
| WithInterceptorFuncs(interceptor.Funcs{ | ||
| List: func(context.Context, client.WithWatch, client.ObjectList, ...client.ListOption) error { | ||
| return errors.New("cache not synced") | ||
| }, | ||
| }). | ||
| Build() | ||
|
|
||
| hb := NewHeartbeat(time.Now().Add(-24 * time.Hour)) | ||
| if err := StallChecker(c, hb, stallThresholdForTest)(&http.Request{}); err != nil { | ||
| t.Fatalf("reported dead because the cache was unreadable: %v", err) | ||
| } | ||
| } | ||
|
|
||
| func TestBeatNeverMovesBackwards(t *testing.T) { | ||
| now := time.Now() | ||
| hb := NewHeartbeat(now) | ||
| hb.Beat(now.Add(-time.Hour)) | ||
| if idle := hb.Idle(now); idle != 0 { | ||
| t.Fatalf("an older beat moved the heartbeat back by %s", idle) | ||
| } | ||
| } | ||
|
|
||
| func TestANilHeartbeatIsInert(t *testing.T) { | ||
| var hb *Heartbeat | ||
| hb.Beat(time.Now()) | ||
| if idle := hb.Idle(time.Now()); idle != 0 { | ||
| t.Fatalf("a nil heartbeat reported %s idle", idle) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Blocking: on a non-leader replica this check can never pass.
values.yamldocuments multi-replica as a supported configuration ("With leader election enabled, running more than one replica is safe (only the elected leader reconciles)"). Controllers are leader-election runnables and only start once the lease is won. The health probe server is not, and serves from process start. So on a standby replicaReconcilenever runs, nothing ever callsBeat, andlaststays at theNewHeartbeat(time.Now())fromcmd/main.go.Once
stallThresholdelapses and any FerrVaultConnection exists,/healthzreturns 500 and the kubelet restarts the standby container, roughly every 15m, indefinitely. That is worse than no HA: the replica that exists to take over sits in CrashLoopBackOff, and it re-enters the lease race on every restart.The fix spans this file and
cmd/main.go, so no suggestion block: passmgr.Elected()through toStallCheckerand return nil while that channel is still open. A replica that was never elected has no reconciles to be missing, which is the same reasoning that already exempts an idle cluster.