diff --git a/pkg/agent/anycast_linux.go b/pkg/agent/anycast_linux.go index 4aaf403..dc0f907 100644 --- a/pkg/agent/anycast_linux.go +++ b/pkg/agent/anycast_linux.go @@ -130,7 +130,7 @@ func (r *AnycastReconciler) computeDesired() map[string]anycastTarget { r.Store.Snapshot(), func(ns, name string) bool { pod, ok := r.Pods.Get(ns, name) - return ok && podReady(pod) + return ok && podAnycastEligible(pod) }, func(s string) { r.Logger.Warn(s) }, ) diff --git a/pkg/agent/podinfo.go b/pkg/agent/podinfo.go index ceb1cb7..6cd67ff 100644 --- a/pkg/agent/podinfo.go +++ b/pkg/agent/podinfo.go @@ -28,6 +28,16 @@ func podReady(pod *corev1.Pod) bool { return false } +// podAnycastEligible reports whether a pod should contribute its IP as a +// nexthop for its anycast IPs. A pod is eligible when it is Ready AND not +// being deleted. Once the apiserver sets DeletionTimestamp, kubelet has +// started teardown — kube-proxy will keep routing for terminationGracePeriod +// but the pod is on the way out; we should withdraw the nexthop immediately +// so BGP shifts traffic to a sibling before the pod actually exits. +func podAnycastEligible(pod *corev1.Pod) bool { + return pod.DeletionTimestamp == nil && podReady(pod) +} + // PodCache exposes a Get(ns, name) lookup against a node-scoped Pod // informer. ADD/DEL handlers consult it to read annotations + labels for // IPAM and (later) NetworkPolicy. Callers can subscribe to Ready @@ -58,7 +68,7 @@ func StartPodInformer(ctx context.Context, cfg *rest.Config, node string, logger _, _ = inf.AddEventHandler(cache.ResourceEventHandlerFuncs{ AddFunc: func(obj interface{}) { - if pod, ok := obj.(*corev1.Pod); ok && podReady(pod) { + if pod, ok := obj.(*corev1.Pod); ok && podAnycastEligible(pod) { pc.fireReady() } }, @@ -68,7 +78,10 @@ func StartPodInformer(ctx context.Context, cfg *rest.Config, node string, logger if oldP == nil || newP == nil { return } - if podReady(oldP) != podReady(newP) { + // Fire on Ready transition OR DeletionTimestamp transition. + // The latter catches "pod was Ready, now being deleted" so the + // reconciler withdraws the nexthop before the pod actually exits. + if podAnycastEligible(oldP) != podAnycastEligible(newP) { pc.fireReady() } }, diff --git a/pkg/agent/podinfo_test.go b/pkg/agent/podinfo_test.go new file mode 100644 index 0000000..abd0fa1 --- /dev/null +++ b/pkg/agent/podinfo_test.go @@ -0,0 +1,46 @@ +package agent + +import ( + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func readyPod(deletionTimestamp *metav1.Time) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: deletionTimestamp}, + Status: corev1.PodStatus{ + Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionTrue}, + }, + }, + } +} + +func TestPodAnycastEligible(t *testing.T) { + now := metav1.Now() + cases := []struct { + name string + pod *corev1.Pod + want bool + }{ + {"ready, not deleting", readyPod(nil), true}, + {"ready, but deleting", readyPod(&now), false}, + { + "not ready, not deleting", + &corev1.Pod{Status: corev1.PodStatus{Conditions: []corev1.PodCondition{ + {Type: corev1.PodReady, Status: corev1.ConditionFalse}, + }}}, + false, + }, + {"no conditions, not deleting", &corev1.Pod{}, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := podAnycastEligible(c.pod); got != c.want { + t.Fatalf("got %v want %v", got, c.want) + } + }) + } +}