fix(manager): start the shutdown watcher before slow startup calls

A signal arriving during address recovery was only observed once the
call returned on its own, delaying shutdown by the length of the API
round trip. Start the watcher first, and make Kill non-blocking so a
second signal cannot deadlock against the first.

Signed-off-by: Marcel Fest <marcel.fest@telekom.de>
This commit is contained in:
Marcel Fest
2026-09-07 09:54:32 +02:00
committed by GitHub
parent 5febac0496
commit 072bbf4199
3 changed files with 320 additions and 45 deletions

View File

@@ -2,6 +2,7 @@ package manager
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"os" "os"
@@ -17,7 +18,6 @@ import (
"github.com/kube-vip/kube-vip/pkg/arp" "github.com/kube-vip/kube-vip/pkg/arp"
"github.com/kube-vip/kube-vip/pkg/bgp" "github.com/kube-vip/kube-vip/pkg/bgp"
"github.com/kube-vip/kube-vip/pkg/cluster"
"github.com/kube-vip/kube-vip/pkg/election" "github.com/kube-vip/kube-vip/pkg/election"
"github.com/kube-vip/kube-vip/pkg/iptables" "github.com/kube-vip/kube-vip/pkg/iptables"
"github.com/kube-vip/kube-vip/pkg/k8s" "github.com/kube-vip/kube-vip/pkg/k8s"
@@ -51,8 +51,13 @@ type Manager struct {
// This channel is used to catch an OS signal and trigger a shutdown // This channel is used to catch an OS signal and trigger a shutdown
signalChan chan os.Signal signalChan chan os.Signal
killChan chan struct{}
sigint sync.Once sigint sync.Once
killChanOnce sync.Once
dumpWG sync.WaitGroup
dumping atomic.Bool
dump func(context.Context)
svcProcessor *services.Processor svcProcessor *services.Processor
@@ -81,6 +86,8 @@ type Manager struct {
// Will handle routes // Will handle routes
routeMgr *route.Manager routeMgr *route.Manager
modeWorker func() worker.Worker
} }
// New will create a new managing object // New will create a new managing object
@@ -272,6 +279,10 @@ func New(ctx context.Context, configMap string, config *kubevip.Config) (*Manage
// Start will begin the Manager, which will start services and watch the configmap // Start will begin the Manager, which will start services and watch the configmap
func (sm *Manager) Start(ctx context.Context) error { func (sm *Manager) Start(ctx context.Context) error {
if err := runtimeFailure(ctx); err != nil {
return err
}
wg := sync.WaitGroup{} wg := sync.WaitGroup{}
defer wg.Wait() defer wg.Wait()
@@ -325,52 +336,67 @@ func (sm *Manager) Start(ctx context.Context) error {
} }
} }
return sm.startMode(ctx) if err := sm.startMode(ctx); err != nil {
return err
}
return runtimeFailure(ctx)
}
func runtimeFailure(ctx context.Context) error {
err := context.Cause(ctx)
if err == nil || errors.Is(err, context.Canceled) {
return nil
}
return err
} }
// Start will begin the Manager, which will start services and watch the configmap // Start will begin the Manager, which will start services and watch the configmap
func (sm *Manager) startMode(ctx context.Context) error { func (sm *Manager) startMode(ctx context.Context) error {
var cpCluster *cluster.Cluster
var err error var err error
w := worker.New(sm.arpMgr, sm.intfMgr, sm.config, &sm.closing, sm.Kill, w := sm.newModeWorker()
sm.svcProcessor, &sm.mutex, sm.clientSet, sm.bgpServer, sm.electionMgr,
sm.leaseMgr, sm.routeMgr, sm.nodeLabelManager)
// use a Go context so we can tell the leaderelection code when we // use a Go context so we can tell the leaderelection code when we
// want to step down // want to step down
wg := sync.WaitGroup{} wg := sync.WaitGroup{}
modeCtx, cancel := context.WithCancel(ctx) modeCtx, cancel := context.WithCancel(ctx)
defer func() { defer func() {
cancel()
wg.Wait() wg.Wait()
w.Cleanup() w.Cleanup()
cancel()
log.Info("Shutting down Kube-Vip") log.Info("Shutting down Kube-Vip")
}() }()
log.Info("starting Kube-vip Manager", "mode", w.Name()) log.Info("starting Kube-vip Manager", "mode", w.Name())
if err := w.Configure(modeCtx, &wg); err != nil { if err := w.Configure(modeCtx, &wg); err != nil {
defer cancel()
return fmt.Errorf("failed to configure %s mode: %w", w.Name(), err) return fmt.Errorf("failed to configure %s mode: %w", w.Name(), err)
} }
if sm.config.EnableControlPlane { if sm.config.EnableControlPlane {
err = w.InitControlPlane() err = w.InitControlPlane()
if err != nil { if err != nil {
defer cancel()
return err return err
} }
} }
// Start the shutdown watcher before any slow startup API calls, so a
// Shutdown function that will wait on this signal, unless we call it ourselves // signal received during RecoverAddresses cancels modeCtx immediately
// instead of waiting for it to return on its own.
wg.Go(func() { wg.Go(func() {
sm.waitForShutdown(modeCtx, cancel, cpCluster) sm.waitForShutdown(modeCtx, cancel)
}) })
if sm.config.EnableControlPlane || sm.config.EnableServices {
if err := sm.svcProcessor.RecoverAddresses(modeCtx); err != nil {
log.Warn("skipping kube-vip address recovery", "err", err)
}
}
if sm.config.EnableControlPlane { if sm.config.EnableControlPlane {
wg.Go(func() { wg.Go(func() {
w.StartControlPlane(modeCtx, sm.electionMgr) w.StartControlPlane(modeCtx, sm.electionMgr)
if modeCtx.Err() == nil {
sm.Kill()
}
}) })
} }
@@ -387,11 +413,11 @@ func (sm *Manager) startMode(ctx context.Context) error {
// TODO: Deprecate the iptables code v1.2.x // TODO: Deprecate the iptables code v1.2.x
err = vip.ClearIPTables(sm.config.EgressWithNftables, sm.config.ServiceNamespace, iptables.ProtocolIPv4) err = vip.ClearIPTables(sm.config.EgressWithNftables, sm.config.ServiceNamespace, iptables.ProtocolIPv4)
if err != nil { if err != nil {
log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", "IPv4", "error", err) log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", utils.IPv4Family, "error", err)
} }
err = vip.ClearIPTables(sm.config.EgressWithNftables, sm.config.ServiceNamespace, iptables.ProtocolIPv6) err = vip.ClearIPTables(sm.config.EgressWithNftables, sm.config.ServiceNamespace, iptables.ProtocolIPv6)
if err != nil { if err != nil {
log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", "IPv6", "error", err) log.Info("[egress]", "legacy-iptables", sm.config.EgressWithNftables, "mode", utils.IPv6Family, "error", err)
} }
} }
w.ConfigureServices() w.ConfigureServices()
@@ -405,43 +431,87 @@ func (sm *Manager) startMode(ctx context.Context) error {
if utils.IsPanicError(err) { if utils.IsPanicError(err) {
sm.Kill() sm.Kill()
return fmt.Errorf("failed to reconcile services, non-recoverable error: %w", err) return fmt.Errorf("failed to reconcile services, non-recoverable error: %w", err)
} else {
log.Error("failed to reconcile services, restarting", "error", err)
} }
log.Error("failed to reconcile services, restarting", "error", err)
}
select {
case <-modeCtx.Done():
return nil
case <-time.After(200 * time.Millisecond):
} }
} }
} }
} }
if sm.config.EnableControlPlane {
<-modeCtx.Done()
}
return nil return nil
} }
func (sm *Manager) waitForShutdown(ctx context.Context, cancel context.CancelFunc, cpCluster *cluster.Cluster) { func (sm *Manager) newModeWorker() worker.Worker {
if sm.modeWorker != nil {
return sm.modeWorker()
}
return worker.New(sm.arpMgr, sm.intfMgr, sm.config, &sm.closing, sm.Kill,
sm.svcProcessor, &sm.mutex, sm.clientSet, sm.bgpServer, sm.electionMgr,
sm.leaseMgr, sm.routeMgr, sm.nodeLabelManager)
}
func (sm *Manager) waitForShutdown(ctx context.Context, cancel context.CancelFunc) {
defer sm.dumpWG.Wait()
for { for {
sig := <-sm.signalChan var sig os.Signal
select {
case <-ctx.Done():
return
case <-sm.killChannel():
sm.shutdown(cancel)
return
case sig = <-sm.signalChan:
}
switch sig { switch sig {
case syscall.SIGUSR1: case syscall.SIGUSR1:
log.Info("Received SIGUSR1, dumping configuration") if sm.dumping.CompareAndSwap(false, true) {
sm.dumpConfiguration(ctx) log.Info("Received SIGUSR1, dumping configuration")
case syscall.SIGINT, syscall.SIGTERM: dump := sm.dump
sm.closing.Store(true) if dump == nil {
log.Info("Received kube-vip termination, signaling shutdown") dump = sm.dumpConfiguration
if cpCluster != nil { }
cpCluster.Stop() sm.dumpWG.Add(1)
go func() {
defer sm.dumpWG.Done()
defer sm.dumping.Store(false)
dump(ctx)
}()
} }
// Cancel the context, which will in turn cancel the leadership and all goroutines case syscall.SIGINT, syscall.SIGTERM:
cancel() sm.shutdown(cancel)
return return
} }
} }
} }
func (sm *Manager) shutdown(cancel context.CancelFunc) {
sm.closing.Store(true)
log.Info("Received kube-vip termination, signaling shutdown")
// Cancel the context, which will in turn cancel the leadership and all goroutines.
cancel()
}
func (sm *Manager) Kill() { func (sm *Manager) Kill() {
sm.sigint.Do(func() { sm.sigint.Do(func() {
sm.signalChan <- syscall.SIGINT close(sm.killChannel())
}) })
} }
func (sm *Manager) killChannel() chan struct{} {
sm.killChanOnce.Do(func() {
sm.killChan = make(chan struct{})
})
return sm.killChan
}
// normalizeNodeName ensures the local machine hostname conforms to // normalizeNodeName ensures the local machine hostname conforms to
// Kubernetes RFC1123 node naming conventions (lowercase). // Kubernetes RFC1123 node naming conventions (lowercase).
func normalizeNodeName(name string) string { func normalizeNodeName(name string) string {

View File

@@ -104,21 +104,18 @@ func (sm *Manager) dumpServicesSection(ctx context.Context) {
fmt.Printf("Service Security Enabled: %t\n", sm.config.EnableServiceSecurity) fmt.Printf("Service Security Enabled: %t\n", sm.config.EnableServiceSecurity)
if sm.svcProcessor != nil { if sm.svcProcessor != nil {
instances := sm.svcProcessor.ServiceInstances services := sm.svcProcessor.ServiceSnapshots()
fmt.Printf("Kube-vip Active Service Instances: %d\n", len(instances)) fmt.Printf("Kube-vip Active Service Instances: %d\n", len(services))
for i, inst := range instances { for i, svc := range services {
if inst.ServiceSnapshot != nil { vipConfigs := ""
svc := inst.ServiceSnapshot for j, cfg := range svc.Status.LoadBalancer.Ingress {
vipConfigs := "" if j > 0 {
for j, cfg := range svc.Status.LoadBalancer.Ingress { vipConfigs += ", "
if j > 0 {
vipConfigs += ", "
}
vipConfigs += cfg.IP
} }
fmt.Printf(" Service %d: %s/%s (Type: %s, VIPs: %s)\n", vipConfigs += cfg.IP
i+1, svc.Namespace, svc.Name, svc.Spec.Type, vipConfigs)
} }
fmt.Printf(" Service %d: %s/%s (Type: %s, VIPs: %s)\n",
i+1, svc.Namespace, svc.Name, svc.Spec.Type, vipConfigs)
} }
} }
if sm.clientSet != nil { if sm.clientSet != nil {
@@ -132,8 +129,6 @@ func (sm *Manager) dumpServicesSection(ctx context.Context) {
fmt.Println("Unable to retrieve all Services") fmt.Println("Unable to retrieve all Services")
} else { } else {
for x := range svcList.Items { for x := range svcList.Items {
// Build all addresses
vipConfigs := "" vipConfigs := ""
for j, cfg := range svcList.Items[x].Status.LoadBalancer.Ingress { for j, cfg := range svcList.Items[x].Status.LoadBalancer.Ingress {
if j > 0 { if j > 0 {

View File

@@ -1,11 +1,39 @@
package manager package manager
import ( import (
"context"
"errors"
"os"
"sync"
"sync/atomic"
"syscall"
"testing" "testing"
"time"
"github.com/kube-vip/kube-vip/pkg/election"
"github.com/kube-vip/kube-vip/pkg/kubevip"
"github.com/kube-vip/kube-vip/pkg/manager/worker"
"github.com/kube-vip/kube-vip/pkg/node/noop"
"github.com/kube-vip/kube-vip/pkg/services"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
type controlPlaneTestWorker struct {
started chan struct{}
}
func (w *controlPlaneTestWorker) Configure(context.Context, *sync.WaitGroup) error { return nil }
func (w *controlPlaneTestWorker) InitControlPlane() error { return nil }
func (w *controlPlaneTestWorker) ConfigureServices() {}
func (w *controlPlaneTestWorker) StartServices(context.Context) error { return nil }
func (w *controlPlaneTestWorker) Name() string { return "control-plane-test" }
func (w *controlPlaneTestWorker) Cleanup() {}
func (w *controlPlaneTestWorker) StartControlPlane(ctx context.Context, _ *election.Manager) {
close(w.started)
<-ctx.Done()
}
func TestNormalizeNodeName(t *testing.T) { func TestNormalizeNodeName(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -36,3 +64,185 @@ func TestNormalizeNodeName(t *testing.T) {
}) })
} }
} }
func TestStartReturnsCancellationCause(t *testing.T) {
cause := errors.New("default interface is down")
ctx, cancel := context.WithCancelCause(context.Background())
cancel(cause)
manager := &Manager{
config: &kubevip.Config{},
nodeLabelManager: noop.NewManager(),
}
err := manager.Start(ctx)
if !errors.Is(err, cause) {
t.Fatalf("Start() error = %v, want cancellation cause %v", err, cause)
}
}
func TestStartModeControlPlaneOnlyWaitsForShutdown(t *testing.T) {
config := &kubevip.Config{EnableControlPlane: true}
w := &controlPlaneTestWorker{started: make(chan struct{})}
manager := &Manager{
config: config,
signalChan: make(chan os.Signal),
svcProcessor: services.NewServicesProcessor(config, nil, nil, nil, nil, nil, noop.NewManager(), nil, nil, nil),
modeWorker: func() worker.Worker { return w },
}
done := make(chan error, 1)
go func() {
done <- manager.startMode(context.Background())
}()
select {
case <-w.started:
case <-time.After(time.Second):
t.Fatal("control-plane worker did not start")
}
select {
case err := <-done:
t.Fatalf("control-plane-only mode returned before shutdown: %v", err)
case <-time.After(20 * time.Millisecond):
}
manager.Kill()
select {
case err := <-done:
if err != nil {
t.Fatalf("control-plane-only mode returned an error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("control-plane-only mode did not stop after Kill")
}
}
func TestKillSignalsShutdownWhileSignalIsQueued(t *testing.T) {
manager := &Manager{
config: &kubevip.Config{},
signalChan: make(chan os.Signal, 1),
}
manager.signalChan <- syscall.SIGUSR1
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
shutdownDone := make(chan struct{})
go func() {
manager.waitForShutdown(ctx, cancel)
close(shutdownDone)
}()
returned := make(chan struct{})
go func() {
manager.Kill()
close(returned)
}()
select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("Kill blocked while the OS signal channel was full")
}
select {
case <-shutdownDone:
case <-time.After(time.Second):
t.Fatal("Kill did not signal shutdown")
}
}
func TestKillDoesNotBlockAfterShutdownWithPendingSignal(t *testing.T) {
manager := &Manager{
config: &kubevip.Config{},
signalChan: make(chan os.Signal, 1),
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
shutdownDone := make(chan struct{})
go func() {
manager.waitForShutdown(ctx, func() {})
close(shutdownDone)
}()
select {
case <-shutdownDone:
case <-time.After(time.Second):
t.Fatal("waitForShutdown did not return after context cancellation")
}
manager.signalChan <- syscall.SIGUSR1
returned := make(chan struct{})
go func() {
manager.Kill()
close(returned)
}()
select {
case <-returned:
case <-time.After(time.Second):
t.Fatal("Kill blocked after shutdown with a pending signal")
}
}
func TestWaitForShutdownTracksAndCoalescesConfigurationDump(t *testing.T) {
dumpStarted := make(chan struct{})
releaseDump := make(chan struct{})
var dumpCalls atomic.Int64
manager := &Manager{
signalChan: make(chan os.Signal, 3),
dump: func(context.Context) {
dumpCalls.Add(1)
close(dumpStarted)
<-releaseDump
},
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
manager.waitForShutdown(ctx, cancel)
close(done)
}()
manager.signalChan <- syscall.SIGUSR1
select {
case <-dumpStarted:
case <-time.After(time.Second):
t.Fatal("configuration dump did not start")
}
manager.signalChan <- syscall.SIGUSR1
manager.Kill()
select {
case <-done:
t.Fatal("waitForShutdown returned before the configuration dump completed")
case <-time.After(20 * time.Millisecond):
}
close(releaseDump)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("waitForShutdown did not return after the configuration dump completed")
}
if got := dumpCalls.Load(); got != 1 {
t.Fatalf("configuration dump calls = %d, want 1", got)
}
}
func TestWaitForShutdownHandlesKillSignal(t *testing.T) {
manager := &Manager{
signalChan: make(chan os.Signal, 1),
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan struct{})
go func() {
manager.waitForShutdown(ctx, cancel)
close(done)
}()
manager.Kill()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("waitForShutdown did not return after Kill")
}
if !manager.closing.Load() {
t.Fatal("Kill did not mark the manager as closing")
}
}