Skip to content

Commit fb2d098

Browse files
committed
fix: terminate nydusd left behind on startup failure
1 parent 49b32aa commit fb2d098

4 files changed

Lines changed: 176 additions & 6 deletions

File tree

pkg/filesystem/fs.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,9 @@ func NewFileSystem(ctx context.Context, opt ...NewFSOpt) (*Filesystem, error) {
109109
// TODO: We still need to consider shared daemon the time sequence of initializing daemon,
110110
// start daemon commit its state to DB and retrieving its state.
111111
if fscacheManager, ok := fs.enabledManagers[config.FsDriverFscache]; ok {
112+
fscacheManager.IsSharedDaemonRetained = func(d *daemon.Daemon) bool {
113+
return fs.fscacheSharedDaemon != nil && fs.fscacheSharedDaemon.ID() == d.ID()
114+
}
112115
if !hasFscacheSharedDaemon && fs.fscacheSharedDaemon == nil {
113116
log.L.Infof("initializing shared nydus daemon for fscache")
114117
if err := fs.initSharedDaemon(fscacheManager); err != nil {
@@ -119,6 +122,9 @@ func NewFileSystem(ctx context.Context, opt ...NewFSOpt) (*Filesystem, error) {
119122
return nil, errors.Errorf("shared fscache daemon is present, but manager is missing")
120123
}
121124
if fusedevManager, ok := fs.enabledManagers[config.FsDriverFusedev]; ok {
125+
fusedevManager.IsSharedDaemonRetained = func(d *daemon.Daemon) bool {
126+
return fs.fusedevSharedDaemon != nil && fs.fusedevSharedDaemon.ID() == d.ID()
127+
}
122128
if config.IsFusedevSharedModeEnabled() && !hasFusedevSharedDaemon && fs.fusedevSharedDaemon == nil {
123129
log.L.Infof("initializing shared nydus daemon for fusedev")
124130
if err := fs.initSharedDaemon(fusedevManager); err != nil {
@@ -929,6 +935,10 @@ func (fs *Filesystem) initSharedDaemon(fsManager *manager.Manager) (err error) {
929935
return errors.Wrap(err, "start shared daemon")
930936
}
931937

938+
if err := d.WaitUntilState(types.DaemonStateRunning); err != nil {
939+
return errors.Wrap(err, "wait shared daemon to become running")
940+
}
941+
932942
fs.TryRetainSharedDaemon(d)
933943

934944
return

pkg/manager/daemon_adaptor.go

Lines changed: 97 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"os"
1212
"os/exec"
1313
"strings"
14+
"syscall"
1415
"time"
1516

1617
"github.com/containerd/log"
@@ -28,6 +29,11 @@ import (
2829

2930
const endpointGetBackend string = "/api/v1/daemons/%s/backend"
3031

32+
// defaultDaemonTerminationTimeout is how long we wait for a failed nydusd to
33+
// exit after SIGTERM before escalating to SIGKILL. It is a var so tests can
34+
// shorten it.
35+
var defaultDaemonTerminationTimeout = 5 * time.Second
36+
3137
// Spawn a nydusd daemon to serve the daemon instance.
3238
//
3339
// When returning from `StartDaemon()` with out error:
@@ -81,22 +87,42 @@ func (m *Manager) StartDaemon(d *daemon.Daemon) error {
8187
log.L.Errorf("Fail to update daemon info (%+v) to DB: %v", d, err)
8288
}
8389

84-
// If nydusd fails startup, manager can't subscribe its death event.
85-
// So we can ignore the subscribing error.
90+
// Verify the daemon actually comes up in the background. If it does not,
91+
// terminate the spawned process so it cannot linger as an orphan that keeps
92+
// pulling from the registry (see #771). `proc` is captured here so we act on
93+
// exactly the process we started, regardless of any concurrent pid changes.
94+
proc := cmd.Process
8695
go func() {
8796
if err := daemon.WaitUntilSocketExisted(d.GetAPISock(), d.States.ProcessID); err != nil {
88-
// FIXME: Should clean the daemon record in DB if the nydusd fails starting
89-
log.L.Errorf("Nydusd %s probably not started", d.ID())
97+
log.L.Errorf("Nydusd %s probably not started, terminating it: %v", d.ID(), err)
98+
m.terminateFailedDaemon(d, proc)
9099
return
91100
}
92101

93102
if err = m.SubscribeDaemonEvent(d); err != nil {
94-
log.L.Errorf("Nydusd %s probably not started", d.ID())
103+
log.L.Errorf("Failed to subscribe nydusd %s events, terminating it: %v", d.ID(), err)
104+
m.terminateFailedDaemon(d, proc)
95105
return
96106
}
97107

98108
if err := d.WaitUntilState(types.DaemonStateRunning); err != nil {
99-
log.L.WithError(err).Errorf("daemon %s is not managed to reach RUNNING state", d.ID())
109+
// A failover-managed daemon (recover_policy=failover) legitimately
110+
// stays in INIT/READY while the failover/upgrade flow drives
111+
// INIT -> TakeOver -> Start after StartDaemon returns. Do not treat
112+
// that as a failed startup: killing or unsubscribing it here would
113+
// wreck the takeover. Its cleanup belongs to the failover flow.
114+
if d.Supervisor != nil {
115+
log.L.WithError(err).Warnf("daemon %s did not reach RUNNING yet, leaving it to the failover flow", d.ID())
116+
return
117+
}
118+
log.L.WithError(err).Errorf("daemon %s is not managed to reach RUNNING state, terminating it", d.ID())
119+
// Unsubscribe before killing, otherwise the liveness monitor would
120+
// observe the kill as a death event and (with recover_policy=restart)
121+
// immediately respawn what we just decided to reap.
122+
if uerr := m.UnsubscribeDaemonEvent(d); uerr != nil {
123+
log.L.WithError(uerr).Warnf("unsubscribe daemon %s after startup failure", d.ID())
124+
}
125+
m.terminateFailedDaemon(d, proc)
100126
return
101127
}
102128

@@ -119,6 +145,71 @@ func (m *Manager) StartDaemon(d *daemon.Daemon) error {
119145
return nil
120146
}
121147

148+
// terminateFailedDaemon best-effort stops a nydusd that failed startup
149+
// verification, so it stops pulling from the registry instead of lingering as
150+
// an orphan. SIGTERM is tried first and escalated to SIGKILL if the process,
151+
// for example one wedged on a slow registry, does not exit in time. The process
152+
// is finally reaped to avoid leaving a zombie. It operates on the captured
153+
// process handle, so it is safe against pid reuse and concurrent teardown.
154+
func (m *Manager) terminateFailedDaemon(d *daemon.Daemon, proc *os.Process) {
155+
if proc == nil {
156+
return
157+
}
158+
159+
// A failover-managed daemon (recover_policy=failover) legitimately stays in
160+
// INIT/READY while the failover flow drives INIT -> TakeOver -> Start after
161+
// StartDaemon returns, so a "not RUNNING yet" verdict here may be a takeover
162+
// in progress rather than a failed startup. Killing it would destroy the
163+
// takeover, so leave its cleanup to the failover flow.
164+
if d.Supervisor != nil {
165+
log.L.Warnf("nydusd %s (pid %d) failed startup verification, leaving cleanup to the failover flow", d.ID(), proc.Pid)
166+
return
167+
}
168+
169+
// For a shared daemon, if it fails to reach RUNNING, it won't be retained
170+
// by TryRetainSharedDaemon. We must kill it here so it doesn't linger.
171+
// But if it is already retained (e.g., this is a spurious timeout or
172+
// TryRetainSharedDaemon was called concurrently), killing it would drop
173+
// the active shared daemon. We check IsSharedDaemon() and whether it is
174+
// the currently active one.
175+
if d.IsSharedDaemon() && m.isDaemonRetainedAsShared(d) {
176+
log.L.Warnf("nydusd %s (pid %d) failed startup verification but is already retained as shared daemon, not killing it", d.ID(), proc.Pid)
177+
return
178+
}
179+
180+
log.L.Warnf("terminating nydusd %s (pid %d) that failed to start", d.ID(), proc.Pid)
181+
182+
done := make(chan struct{})
183+
go func() {
184+
_, _ = proc.Wait()
185+
close(done)
186+
}()
187+
188+
if err := proc.Signal(syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) {
189+
log.L.WithError(err).Warnf("send SIGTERM to nydusd %s (pid %d)", d.ID(), proc.Pid)
190+
}
191+
192+
select {
193+
case <-done:
194+
case <-time.After(defaultDaemonTerminationTimeout):
195+
log.L.Warnf("nydusd %s (pid %d) did not exit after SIGTERM, sending SIGKILL", d.ID(), proc.Pid)
196+
if err := proc.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
197+
log.L.WithError(err).Warnf("send SIGKILL to nydusd %s (pid %d)", d.ID(), proc.Pid)
198+
}
199+
<-done
200+
}
201+
}
202+
203+
// isDaemonRetainedAsShared checks if the daemon is currently retained as the
204+
// active shared daemon in the filesystem manager. This is a callback provided
205+
// by the manager to avoid circular dependencies.
206+
func (m *Manager) isDaemonRetainedAsShared(d *daemon.Daemon) bool {
207+
if m.IsSharedDaemonRetained != nil {
208+
return m.IsSharedDaemonRetained(d)
209+
}
210+
return false
211+
}
212+
122213
// Build commandline according to nydusd daemon configuration.
123214
func (m *Manager) BuildDaemonCommand(d *daemon.Daemon, bin string, upgrade bool) (*exec.Cmd, error) {
124215
var cmdOpts []command.Opt

pkg/manager/daemon_adaptor_test.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
/*
2+
* Copyright (c) 2022. Nydus Developers. All rights reserved.
3+
*
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package manager
8+
9+
import (
10+
"os"
11+
"os/exec"
12+
"syscall"
13+
"testing"
14+
"time"
15+
16+
"github.com/stretchr/testify/assert"
17+
"github.com/stretchr/testify/require"
18+
19+
"github.com/containerd/nydus-snapshotter/pkg/daemon"
20+
)
21+
22+
func TestTerminateFailedDaemon(t *testing.T) {
23+
orig := defaultDaemonTerminationTimeout
24+
t.Cleanup(func() { defaultDaemonTerminationTimeout = orig })
25+
26+
m := &Manager{}
27+
28+
t.Run("nil process is a no-op", func(t *testing.T) {
29+
d, err := daemon.NewDaemon()
30+
require.NoError(t, err)
31+
m.terminateFailedDaemon(d, nil)
32+
})
33+
34+
t.Run("SIGTERM stops a well-behaved process", func(t *testing.T) {
35+
defaultDaemonTerminationTimeout = 5 * time.Second
36+
37+
cmd := exec.Command("sleep", "300")
38+
require.NoError(t, cmd.Start())
39+
d, err := daemon.NewDaemon()
40+
require.NoError(t, err)
41+
42+
start := time.Now()
43+
m.terminateFailedDaemon(d, cmd.Process)
44+
45+
// The process is reaped inside the helper, so it is marked done.
46+
assert.ErrorIs(t, cmd.Process.Signal(syscall.Signal(0)), os.ErrProcessDone)
47+
// A SIGTERM-able process must exit well before the SIGKILL escalation.
48+
assert.Less(t, time.Since(start), defaultDaemonTerminationTimeout)
49+
})
50+
51+
t.Run("escalates to SIGKILL when SIGTERM is ignored", func(t *testing.T) {
52+
defaultDaemonTerminationTimeout = 200 * time.Millisecond
53+
54+
// Ignore SIGTERM so only SIGKILL can stop it.
55+
cmd := exec.Command("sh", "-c", "trap '' TERM; sleep 300")
56+
require.NoError(t, cmd.Start())
57+
d, err := daemon.NewDaemon()
58+
require.NoError(t, err)
59+
60+
m.terminateFailedDaemon(d, cmd.Process)
61+
62+
assert.ErrorIs(t, cmd.Process.Signal(syscall.Signal(0)), os.ErrProcessDone)
63+
})
64+
}

pkg/manager/manager.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,11 @@ type Manager struct {
4949
NydusdBinaryPath string
5050
RecoverPolicy config.DaemonRecoverPolicy
5151
SupervisorSet *supervisor.SupervisorsSet
52+
53+
// IsSharedDaemonRetained is a callback to check if a daemon is currently
54+
// retained as the active shared daemon. It is used to avoid killing a
55+
// shared daemon that has already been retained.
56+
IsSharedDaemonRetained func(d *daemon.Daemon) bool
5257
}
5358

5459
type Opt struct {

0 commit comments

Comments
 (0)