daemon: close EventsService on shutdown

On daemon shutdown, the HTTP server tries to gracefully shutdown for 5
seconds. If there's an open API connection to the '/events' endpoint, it
fails to do so as nothing interrupts that connection, thus forcing the
daemon to wait until that timeout is reached.

Add a Close method to the EventsService, and call it during daemon
shutdown. It'll close any events channel, signaling to the '/events'
handler to return and close the connection.

It now takes ~1s (or less) to shutdown the daemon when there's an active
'/events' connection, instead of 5.

Signed-off-by: Albin Kerouanton <albin.kerouanton@docker.com>
This commit is contained in:
Albin Kerouanton
2025-11-08 10:48:25 +01:00
parent 4dc87c55c7
commit d087d3c057
4 changed files with 58 additions and 1 deletions

View File

@@ -1493,6 +1493,12 @@ func (daemon *Daemon) Shutdown(ctx context.Context) error {
daemon.mdDB.Close()
}
// At this point, everything has been shut down and no containers are
// running anymore. If there are still some open connections to the
// '/events' endpoint, closing the EventsService should tear them down
// immediately.
daemon.EventsService.Close()
return daemon.cleanupMounts(cfg)
}

View File

@@ -151,3 +151,9 @@ func (e *Events) loadBufferedEvents(since, until time.Time, topic func(any) bool
}
return buffered
}
// Close all the channels returned to event subscribers.
func (e *Events) Close() error {
e.pub.Close()
return nil
}

View File

@@ -353,7 +353,12 @@ func (s *systemRouter) getEvents(ctx context.Context, w http.ResponseWriter, r *
for {
select {
case ev := <-l:
case ev, ok := <-l:
if !ok {
log.G(ctx).Debug("event channel closed")
return nil
}
jev, ok := ev.(events.Message)
if !ok {
log.G(ctx).Warnf("unexpected event message: %q", ev)

View File

@@ -6,6 +6,7 @@ import (
"net/netip"
"slices"
"testing"
"time"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/moby/moby/api/types/network"
@@ -518,3 +519,42 @@ func TestSwarmNoNftables(t *testing.T) {
assert.Check(t, is.ErrorContains(err, "daemon exited during startup"))
})
}
// TestDaemonShutsDownQuicklyDespiteEventsConnection checks whether the daemon
// shuts down in less than 5 secs when there's an active API connection to the
// '/events' endpoint.
//
// As this test verifies timing behavior, it may become flaky. Feel free to
// delete it if it's too annoying.
//
// Regression test for https://github.com/moby/moby/issues/32357#issuecomment-3496848492
func TestDaemonShutsDownQuicklyDespiteEventsConnection(t *testing.T) {
skip.If(t, testEnv.IsRemoteDaemon, "cannot start daemon on remote test run")
// The Engine is presumably working the same way on Windows and Linux.
// Avoid running on Windows as it may be more flaky there.
skip.If(t, testEnv.DaemonInfo.OSType == "windows")
t.Parallel()
ctx := testutil.StartSpan(baseContext, t)
d := daemon.New(t)
defer d.Cleanup(t)
// This is a parallel test, disable iptables integration.
d.StartWithBusybox(ctx, t, "--iptables=false", "--ip6tables=false")
defer d.Stop(t)
apiClient := d.NewClientT(t)
// Open a connection to the '/events' endpoint.
apiClient.Events(ctx, client.EventsListOptions{})
// Kill the daemon
t0 := time.Now()
d.Stop(t)
dt := time.Since(t0)
if dt.Seconds() > 5 {
t.Error("the daemon took more than 5 secs to shutdown")
}
}