Add plugins for server listeners#12562
Conversation
6515ed9 to
7f3c40f
Compare
7f3c40f to
ab69b6d
Compare
c2a5b88 to
9f021cc
Compare
9f021cc to
a4a8b6d
Compare
Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Derek McGowan <derek@mcg.dev>
a4a8b6d to
b4f06bf
Compare
b4f06bf to
b6a2dbe
Compare
| config.Plugins["io.containerd.server.v1.ttrpc"] = ttrpcConfig | ||
| } | ||
|
|
||
| return nil |
There was a problem hiding this comment.
This one will return early and skip applyPlatformFlags below, I don't think this was intended?
| Address string `toml:"address"` | ||
| GRPCHistogram bool `toml:"grpc_histogram"` | ||
| // deprecated: use server plugin io.containerd.server.v1.metrics | ||
| Address string `toml:"address"` |
There was a problem hiding this comment.
Should this one also be omitempty like the others?
prakleumas
left a comment
There was a problem hiding this comment.
beep boop
Hello! I am @samuelkarp's friendly code review robot 🤖! Sam asked me to take a look and post my comments directly on GitHub.
Please treat my comments as suggestions and advisory, not mandates. Since I am an LLM and my output is used here directly, you should evaluate carefully and see if my suggestions make sense. Feel free to directly resolve any you disagree with.
This is not an automated action. This bot does not read or reply to comments.
| srv, ok := instance.(server) | ||
| if !ok { | ||
| log.G(ctx).WithField("id", id).Warn("plugin does not implement server interface, will not be started") | ||
| } | ||
| s.servers = append(s.servers, srv) |
There was a problem hiding this comment.
If the plugin instance does not implement the server interface, appending a nil interface here introduces the opportunity for a nil pointer dereference when Start is later invoked on s.servers. Would it be safer to protect the append operation within the type assertion's success branch?
| srv, ok := instance.(server) | |
| if !ok { | |
| log.G(ctx).WithField("id", id).Warn("plugin does not implement server interface, will not be started") | |
| } | |
| s.servers = append(s.servers, srv) | |
| if srv, ok := instance.(server); ok { | |
| s.servers = append(s.servers, srv) | |
| } else { | |
| log.G(ctx).WithField("id", id).Warn("plugin does not implement server interface, will not be started") | |
| } |
| go func() { | ||
| defer l.Close() | ||
| if err := server.ServeGRPC(l); err != nil { | ||
| log.G(ctx).WithError(err).WithField("address", srvconfig.GRPC.Address).Fatal("serve failure") | ||
| defer server.Stop() | ||
| if err := server.Start(ctx); err != nil { | ||
| log.G(ctx).WithError(err).WithField("address", defaultAddress).Fatal("serve failure") | ||
| } | ||
| }() |
There was a problem hiding this comment.
Because server.Start(ctx) is now non-blocking (it delegates listener execution to background goroutines via internal.Serve), this wrapper goroutine will return almost immediately. This introduces a race condition where defer server.Stop() executes instantly, tearing down the daemon before the fuzzer can meaningfully exercise it. Is there a specific reason to run this asynchronously, or would it be safer to bind the shutdown phase to the context's lifecycle?
| go func() { | |
| defer l.Close() | |
| if err := server.ServeGRPC(l); err != nil { | |
| log.G(ctx).WithError(err).WithField("address", srvconfig.GRPC.Address).Fatal("serve failure") | |
| defer server.Stop() | |
| if err := server.Start(ctx); err != nil { | |
| log.G(ctx).WithError(err).WithField("address", defaultAddress).Fatal("serve failure") | |
| } | |
| }() | |
| go func() { | |
| defer server.Stop() | |
| if err := server.Start(ctx); err != nil { | |
| log.G(ctx).WithError(err).WithField("address", defaultAddress).Fatal("serve failure") | |
| } | |
| <-ctx.Done() | |
| }() |
| for _, k := range key { | ||
| if v, ok := properties[k]; ok { | ||
| switch v := v.(type) { | ||
| case map[string]any: | ||
| properties = v | ||
| continue | ||
| case string: | ||
| return v | ||
| case fmt.Stringer: | ||
| return v.String() | ||
| case []byte: | ||
| return string(v) | ||
| } | ||
| } |
There was a problem hiding this comment.
It appears that readString will eagerly return a value if an intermediate key in the path resolves to a string, fmt.Stringer, or []byte. For example, if a configuration mistakenly provides a string for a parent key, this function returns that string instead of continuing the search or returning an empty string. Could we ensure that it only returns a leaf value if it has successfully reached the final key in the path?
| for _, k := range key { | |
| if v, ok := properties[k]; ok { | |
| switch v := v.(type) { | |
| case map[string]any: | |
| properties = v | |
| continue | |
| case string: | |
| return v | |
| case fmt.Stringer: | |
| return v.String() | |
| case []byte: | |
| return string(v) | |
| } | |
| } | |
| for i, k := range key { | |
| if v, ok := properties[k]; ok { | |
| switch v := v.(type) { | |
| case map[string]any: | |
| properties = v | |
| continue | |
| case string: | |
| if i == len(key)-1 { | |
| return v | |
| } | |
| case fmt.Stringer: | |
| if i == len(key)-1 { | |
| return v.String() | |
| } | |
| case []byte: | |
| if i == len(key)-1 { | |
| return string(v) | |
| } | |
| } | |
| } |
| return nil | ||
| } | ||
|
|
||
| func serviceMigrate(ctx context.Context, c *Config) error { |
There was a problem hiding this comment.
Can we add a new test that covers the correctness of serviceMigrate? This is implicitly covered by higher-level configuration test flows, but it'd be good to validate at least some of the basic property mapping logic (e.g. ensuring GRPC and TTRPC settings migrate accurately to the new plugins map) directly in a unit test.
| // deprecated: use server plugins io.containerd.server.v1.grpc and io.containerd.server.v1.grpc-tcp | ||
| GRPC GRPCConfig `toml:"grpc,omitempty"` | ||
| // TTRPC configuration settings | ||
| // deprecated: use server plugin io.containerd.server.v1.ttrpc | ||
| TTRPC TTRPCConfig `toml:"ttrpc,omitempty"` | ||
| // Metrics and monitoring settings | ||
| // deprecated: use server plugin io.containerd.server.v1.metrics | ||
| Metrics MetricsConfig `toml:"metrics,omitempty"` |
There was a problem hiding this comment.
To establish a formal contract with standard Go static analysis tools (such as go doc and go vet), deprecation notices rely on the exact prefix Deprecated: (with a capital 'D'). It is generally safer to align with this convention to ensure IDEs and downstream consumers accurately recognize these fields as phased out during the version 3 to 4 lifecycle transition.
| // deprecated: use server plugins io.containerd.server.v1.grpc and io.containerd.server.v1.grpc-tcp | |
| GRPC GRPCConfig `toml:"grpc,omitempty"` | |
| // TTRPC configuration settings | |
| // deprecated: use server plugin io.containerd.server.v1.ttrpc | |
| TTRPC TTRPCConfig `toml:"ttrpc,omitempty"` | |
| // Metrics and monitoring settings | |
| // deprecated: use server plugin io.containerd.server.v1.metrics | |
| Metrics MetricsConfig `toml:"metrics,omitempty"` | |
| // Deprecated: use server plugins io.containerd.server.v1.grpc and io.containerd.server.v1.grpc-tcp | |
| GRPC GRPCConfig `toml:"grpc,omitempty"` | |
| // TTRPC configuration settings | |
| // Deprecated: use server plugin io.containerd.server.v1.ttrpc | |
| TTRPC TTRPCConfig `toml:"ttrpc,omitempty"` | |
| // Metrics and monitoring settings | |
| // Deprecated: use server plugin io.containerd.server.v1.metrics | |
| Metrics MetricsConfig `toml:"metrics,omitempty"` |
(It is recommended to apply this capitalization to the field-level comments inside the GRPCConfig, TTRPCConfig, Debug, and MetricsConfig structs as well.)
| Requires: []plugin.Type{ | ||
| plugins.HTTPHandler, | ||
| }, |
There was a problem hiding this comment.
Is there a specific reason plugins.HTTPHandler is listed as a requirement here? Unlike the debug server plugin, I don't see this plugin querying the initialization context for HTTP handlers. If it's not strictly necessary, it is generally safer to omit the requirement to prevent unintentional initialization ordering constraints.
| Requires: []plugin.Type{ | |
| plugins.HTTPHandler, | |
| }, |
| grpcAddress = readString(config.Plugins, "io.containerd.server.v1.grpc", "address") | ||
| ttrpcAddress = readString(config.Plugins, "io.containerd.server.v1.ttrpc", "address") |
There was a problem hiding this comment.
What is the expected behavior for ttrpcAddress if a user provides a v4 configuration that specifies a custom GRPC address but omits the TTRPC plugin block entirely? Previously, the TTRPC address dynamically tracked the GRPC address (grpcAddress + ".ttrpc"). Without that fallback here, readString will return an empty string, meaning plugins.PropertyTTRPCAddress will be empty for dependent plugins. Additionally, the TTRPC server plugin itself may bind to the global default rather than tracking the custom GRPC address. It might be worth re-introducing a fallback mechanism to guarantee they stay linked when unconfigured.
There was a problem hiding this comment.
I think this is a reasonable change for v4 configuration. Since any migrated configuration would keep the same behavior, this would not be a breaking change, since v4 is a new configuration version. I'll make sure to document this, that the values are now independent and when ttrpc is not set, it will default to the ttrpc default, not derived from the grpc address.
e1591b4 to
02e1eda
Compare
There was a problem hiding this comment.
Pull request overview
Adds a new plugin-driven model for containerd server listeners (gRPC/TTRPC/debug/metrics) and introduces config version 4 to migrate legacy top-level listener settings into plugin configuration, simplifying daemon startup and making listeners extensible via the plugin lifecycle.
Changes:
- Bump config version to 4 and add a v3→v4 migration that moves GRPC/TTRPC/debug/metrics settings into
[plugins."io.containerd.server.v1.*"]. - Introduce new plugin types (
io.containerd.server.v1,io.containerd.metrics.v1) and implement listener plugins for gRPC/TCP gRPC/TTRPC/debug/metrics. - Refactor daemon startup to start listener plugins via
Server.Start(), and update docs/tests/fuzz config generation accordingly.
Reviewed changes
Copilot reviewed 30 out of 30 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| version/version.go | Bumps ConfigVersion to 4. |
| plugins/types.go | Adds new plugin types for server listeners and metrics. |
| plugins/server/ttrpc/server_other.go | Adds non-linux/windows/solaris ttrpc server constructor. |
| plugins/server/ttrpc/server_otel.go | Adds windows/solaris ttrpc server constructor with otel interceptor. |
| plugins/server/ttrpc/server_linux.go | Updates linux ttrpc server constructor (same-user handshaker + otel). |
| plugins/server/ttrpc/plugin.go | Registers TTRPC listener as a ServerPlugin and serves via shared helper. |
| plugins/server/metrics/plugin.go | Adds metrics HTTP listener as a ServerPlugin. |
| plugins/server/internal/serve.go | Introduces shared Serve helper for listener goroutines + error filtering. |
| plugins/server/internal/listen_windows.go | Adds helper to detect local (named pipe) addresses on Windows. |
| plugins/server/internal/listen_unix.go | Adds helper to detect local (absolute path) addresses on non-Windows. |
| plugins/server/grpc/tls_windows.go | Moves Windows TLS resource management into grpc plugin package. |
| plugins/server/grpc/tls_other.go | Adds non-Windows no-op TLS resource helpers. |
| plugins/server/grpc/plugin.go | Adds gRPC and TCP gRPC listener ServerPlugins (incl. TLS + metrics hooks). |
| plugins/server/grpc/namespace.go | Updates package path for namespace interceptors. |
| plugins/server/grpc/metrics.go | Adds MetricsPlugin implementations for grpc prometheus + otel stats handler. |
| plugins/server/debug/plugin.go | Adds debug listener ServerPlugin wired to the existing pprof HTTP handler plugin. |
| integration/client/restart_monitor_test.go | Updates test config parsing to read gRPC address from new server plugin config. |
| docs/man/containerd-config.toml.5.md | Documents config v4, deprecates top-level listener sections, adds v4 examples. |
| contrib/fuzz/daemon.go | Updates fuzz daemon to configure listener via plugins and call Server.Start(). |
| cmd/containerd/server/server_other.go | Simplifies non-linux apply stub and removes legacy server constructors. |
| cmd/containerd/server/server_linux.go | Removes legacy in-package ttrpc constructor/TLS helpers from linux server. |
| cmd/containerd/server/server.go | Refactors server init to collect/start ServerPlugins and adds plugin-based address lookup. |
| cmd/containerd/server/config/config_test.go | Updates migration expectations and adds tests for v4 service migration. |
| cmd/containerd/server/config/config.go | Adds v4 migration (serviceMigrate) and marks legacy listener fields deprecated/omitempty. |
| cmd/containerd/command/main_windows.go | Removes duplicated local-address helper (now in plugins/server/internal). |
| cmd/containerd/command/main_unix.go | Removes duplicated local-address helper (now in plugins/server/internal). |
| cmd/containerd/command/main.go | Starts listeners via server.Start(ctx) and updates --address flag handling to plugin config. |
| cmd/containerd/command/config.go | Updates default config generation to configure gRPC listener via server plugin block. |
| cmd/containerd/builtins/builtins.go | Adds built-in imports for new server listener plugin packages. |
| RELEASES.md | Updates documentation to reflect latest config version is 4. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The new server plugin type allows services which have listeners to be configured as plugins rather than defined directly in the global configuration. This provides more configuration consistency and allows containerd to be extended for new types of api handlers. Signed-off-by: Derek McGowan <derek@mcg.dev>
Migrate configuration and move server initialization Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Derek McGowan <derek@mcg.dev>
Signed-off-by: Albin Kerouanton <albin.kerouanton@docker.com>
Document the new server plugin configuration blocks for GRPC, TTRPC, debug, and metrics. Mark the legacy top-level sections as deprecated. Note that in version 4, the TTRPC plugin is configured independently from GRPC and uses its own defaults when its plugin block is omitted. Signed-off-by: Derek McGowan <derek@mcg.dev>
02e1eda to
5098311
Compare
fuweid
left a comment
There was a problem hiding this comment.
LGTM
Nice refactor.
Follow-up:
It looks like it migrate before merge
there is case
# root.toml
version = 3
root = "/tmp/pr12562-repro-grpc/root"
state = "/tmp/pr12562-repro-grpc/state"
imports = ["dropin.toml"]
[grpc]
address = "/tmp/pr12562-grpc.sock"
uid = 0
gid = 0
# dropin.toml
version = 3
[grpc]
max_send_message_size = 33554432
It will show
WARN[2026-04-22T13:26:57.901574474-04:00] failed to load plugin error="grpc address cannot be empty: invalid argument" id=io.containerd.server.v1.grpc type=io.containerd.server.v1
| go func() { | ||
| defer l.Close() | ||
|
|
||
| if err := serveFunc(l); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, ttrpc.ErrServerClosed) { |
There was a problem hiding this comment.
maybe we need to helper function for that.
There was a problem hiding this comment.
We can handle it in the follow-up
@fuweid This caused #13316. When any non empty value in |
Add new plugin types for server listeners and metrics (which are used by the servers).