Repository: moby/moby (language: go)

Issue:
Configure JSON-formatted logging for dockerd daemon logs
### Description

Would it be possible to make the logging output for dockerd configurable so users could enable structured logs? This is currently set to use TextFormatter and all of the `--log*` options only configure the format of the container logs, not the daemon itself.

Ref: https://github.com/moby/moby/blob/c758d8b0406da2c3705c6efbdcdb6e920ee3ab75/cmd/dockerd/daemon.go#L792-L796


Discussion / hints:
[`logrus.TextFormatter` _is_ structured.](https://github.com/sirupsen/logrus#:~:text=With%20the%20default%20log.SetFormatter(%26log.TextFormatter%7B%7D)%20when%20a%20TTY%20is%20not%20attached%2C%20the%20output%20is%20compatible%20with%20the%20logfmt%20format%3A) What sorts of configuration knobs are you looking for?
> [`logrus.TextFormatter` _is_ structured.](https://github.com/sirupsen/logrus#:~:text=With%20the%20default%20log.SetFormatter(%26log.TextFormatter%7B%7D)%20when%20a%20TTY%20is%20not%20attached%2C%20the%20output%20is%20compatible%20with%20the%20logfmt%20format%3A) What sorts of configuration knobs are you looking for?

Just looking for a way to configure use of the `JSONFormatter` instead of the `TextFormatter`.
This would be a great addition! @corhere would you accept a PR for this or do you need more info?
I'd be glad to accept a PR which adds an option for the daemon to format its logs as JSON

Fix patch:
diff --git a/cmd/dockerd/daemon.go b/cmd/dockerd/daemon.go
index fd7b50f5b4ef1..a6478b9a40a7f 100644
--- a/cmd/dockerd/daemon.go
+++ b/cmd/dockerd/daemon.go
@@ -478,6 +478,7 @@ func loadDaemonCliConfig(opts *daemonOptions) (*config.Config, error) {
 	conf.Debug = opts.Debug
 	conf.Hosts = opts.Hosts
 	conf.LogLevel = opts.LogLevel
+	conf.LogFormat = opts.LogFormat
 
 	if flags.Changed(FlagTLS) {
 		conf.TLS = &opts.TLS
@@ -657,6 +658,10 @@ func (cli *DaemonCli) getContainerdDaemonOpts() ([]supervisor.DaemonOpt, error)
 		opts = append(opts, supervisor.WithLogLevel(cli.LogLevel))
 	}
 
+	if logFormat := cli.Config.LogFormat; logFormat != "" {
+		opts = append(opts, supervisor.WithLogFormat(logFormat))
+	}
+
 	if !cli.CriContainerd {
 		// CRI support in the managed daemon is currently opt-in.
 		//
@@ -867,11 +872,26 @@ func configureDaemonLogs(conf *config.Config) {
 	} else {
 		logrus.SetLevel(logrus.InfoLevel)
 	}
-	logrus.SetFormatter(&logrus.TextFormatter{
-		TimestampFormat: jsonmessage.RFC3339NanoFixed,
-		DisableColors:   conf.RawLogs,
-		FullTimestamp:   true,
-	})
+	logFormat := conf.LogFormat
+	if logFormat == "" {
+		logFormat = log.TextFormat
+	}
+	var formatter logrus.Formatter
+	switch logFormat {
+	case log.JSONFormat:
+		formatter = &logrus.JSONFormatter{
+			TimestampFormat: jsonmessage.RFC3339NanoFixed,
+		}
+	case log.TextFormat:
+		formatter = &logrus.TextFormatter{
+			TimestampFormat: jsonmessage.RFC3339NanoFixed,
+			DisableColors:   conf.RawLogs,
+			FullTimestamp:   true,
+		}
+	default:
+		panic("unsupported log format " + logFormat)
+	}
+	logrus.SetFormatter(formatter)
 }
 
 func configureProxyEnv(conf *config.Config) {
diff --git a/cmd/dockerd/options.go b/cmd/dockerd/options.go
index 8d0396ce5fcc1..c272977932acd 100644
--- a/cmd/dockerd/options.go
+++ b/cmd/dockerd/options.go
@@ -1,9 +1,11 @@
 package main
 
 import (
+	"fmt"
 	"os"
 	"path/filepath"
 
+	"github.com/containerd/containerd/log"
 	"github.com/docker/docker/daemon/config"
 	"github.com/docker/docker/opts"
 	"github.com/docker/docker/pkg/homedir"
@@ -72,6 +74,7 @@ type daemonOptions struct {
 	Debug        bool
 	Hosts        []string
 	LogLevel     string
+	LogFormat    string
 	TLS          bool
 	TLSVerify    bool
 	TLSOptions   *tlsconfig.Options
@@ -104,6 +107,8 @@ func (o *daemonOptions) installFlags(flags *pflag.FlagSet) {
 	flags.BoolVarP(&o.Debug, "debug", "D", false, "Enable debug mode")
 	flags.BoolVar(&o.Validate, "validate", false, "Validate daemon configuration and exit")
 	flags.StringVarP(&o.LogLevel, "log-level", "l", "info", `Set the logging level ("debug"|"info"|"warn"|"error"|"fatal")`)
+	flags.StringVar(&o.LogFormat, "log-format", log.TextFormat,
+		fmt.Sprintf(`Set the logging format ("%s"|"%s")`, log.TextFormat, log.JSONFormat))
 	flags.BoolVar(&o.TLS, FlagTLS, DefaultTLSValue, "Use TLS; implied by --tlsverify")
 	flags.BoolVar(&o.TLSVerify, FlagTLSVerify, dockerTLSVerify || DefaultTLSValue, "Use TLS and verify the remote")
 
diff --git a/daemon/config/config.go b/daemon/config/config.go
index 7e1cdf99a8fa7..ea6f6930e5836 100644
--- a/daemon/config/config.go
+++ b/daemon/config/config.go
@@ -187,6 +187,7 @@ type CommonConfig struct {
 	Debug     bool     `json:"debug,omitempty"`
 	Hosts     []string `json:"hosts,omitempty"`
 	LogLevel  string   `json:"log-level,omitempty"`
+	LogFormat string   `json:"log-format,omitempty"`
 	TLS       *bool    `json:"tls,omitempty"`
 	TLSVerify *bool    `json:"tlsverify,omitempty"`
 
@@ -594,6 +595,16 @@ func Validate(config *Config) error {
 		}
 	}
 
+	// validate log-format
+	if logFormat := config.LogFormat; logFormat != "" {
+		switch logFormat {
+		case log.TextFormat, log.JSONFormat:
+			// These are valid
+		default:
+			return errors.Errorf("invalid log format: %s", logFormat)
+		}
+	}
+
 	// validate DNS
 	for _, dns := range config.DNS {
 		if _, err := opts.ValidateIPAddress(dns); err != nil {
diff --git a/libcontainerd/supervisor/remote_daemon_options.go b/libcontainerd/supervisor/remote_daemon_options.go
index 49ca83cf4c612..54359cf8adad5 100644
--- a/libcontainerd/supervisor/remote_daemon_options.go
+++ b/libcontainerd/supervisor/remote_daemon_options.go
@@ -13,6 +13,15 @@ func WithLogLevel(lvl string) DaemonOpt {
 	}
 }
 
+// WithLogFormat defines the containerd log format.
+// This only makes sense if WithStartDaemon() was set to true.
+func WithLogFormat(format string) DaemonOpt {
+	return func(r *remote) error {
+		r.Debug.Format = format
+		return nil
+	}
+}
+
 // WithCRIDisabled disables the CRI plugin.
 func WithCRIDisabled() DaemonOpt {
 	return func(r *remote) error {


Test patch:
diff --git a/cmd/dockerd/daemon_test.go b/cmd/dockerd/daemon_test.go
index 4b783edaf9487..7f8d2c7383fed 100644
--- a/cmd/dockerd/daemon_test.go
+++ b/cmd/dockerd/daemon_test.go
@@ -3,6 +3,7 @@ package main
 import (
 	"testing"
 
+	"github.com/containerd/containerd/log"
 	"github.com/docker/docker/daemon/config"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/pflag"
@@ -155,6 +156,26 @@ func TestLoadDaemonCliConfigWithLogLevel(t *testing.T) {
 	assert.Check(t, is.Equal("warn", loadedConfig.LogLevel))
 }
 
+func TestLoadDaemonCliConfigWithLogFormat(t *testing.T) {
+	tempFile := fs.NewFile(t, "config", fs.WithContent(`{"log-format": "json"}`))
+	defer tempFile.Remove()
+
+	opts := defaultOptions(t, tempFile.Path())
+	loadedConfig, err := loadDaemonCliConfig(opts)
+	assert.NilError(t, err)
+	assert.Assert(t, loadedConfig != nil)
+	assert.Check(t, is.Equal(log.JSONFormat, loadedConfig.LogFormat))
+}
+
+func TestLoadDaemonCliConfigWithInvalidLogFormat(t *testing.T) {
+	tempFile := fs.NewFile(t, "config", fs.WithContent(`{"log-format": "foo"}`))
+	defer tempFile.Remove()
+
+	opts := defaultOptions(t, tempFile.Path())
+	_, err := loadDaemonCliConfig(opts)
+	assert.Check(t, is.ErrorContains(err, "invalid log format: foo"))
+}
+
 func TestLoadDaemonConfigWithEmbeddedOptions(t *testing.T) {
 	content := `{"tlscacert": "/etc/certs/ca.pem", "log-driver": "syslog"}`
 	tempFile := fs.NewFile(t, "config", fs.WithContent(content))