diff --git a/README.md b/README.md index e59f4f9..1be73b8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ This exporter provides histogram metrics for the size and age of messages stored the mail queue. It extracts these metrics from Postfix by connecting to a UNIX socket under `/var/spool`. It also counts events by parsing Postfix's log entries, using regular expression matching. The log entries are retrieved from -the systemd journal or from a log file. +the systemd journal, the Docker logs, or from a log file. ## Options @@ -18,11 +18,25 @@ These options can be used when starting the `postfix_exporter` | `--postfix.showq_path` | Path at which Postfix places its showq socket | `/var/spool/postfix/public/showq` | | `--postfix.logfile_path` | Path where Postfix writes log entries | `/var/log/maillog` | | `--log.unsupported` | Log all unsupported lines | `false` | -| `--systemd.enable` | Read from the systemd journal instead of log | `false` | +| `--docker.enable` | Read from the Docker logs instead of a file | `false` | +| `--docker.container.id` | The container to read Docker logs from | `postfix` | +| `--systemd.enable` | Read from the systemd journal instead of file | `false` | | `--systemd.unit` | Name of the Postfix systemd unit | `postfix.service` | | `--systemd.slice` | Name of the Postfix systemd slice. | `""` | | `--systemd.journal_path` | Path to the systemd journal | `""` | +## Events from Docker + +Postfix servers running in a [Docker](https://www.docker.com/) +container can be monitored using the `--docker.enable` flag. The +default container ID is `postfix`, but can be customized with the +`--docker.container.id` flag. + +The default is to connect to the local Docker, but this can be +customized using [the `DOCKER_HOST` and +similar](https://pkg.go.dev/github.com/docker/docker/client?tab=doc#NewEnvClient) +environment variables. + ## Events from log file The log file is tailed when processed. Rotating the log files while the exporter diff --git a/go.mod b/go.mod index 3d5e663..f35270f 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,15 @@ go 1.13 require ( github.com/alecthomas/kingpin v2.2.6+incompatible github.com/coreos/go-systemd/v22 v22.0.0 + github.com/docker/distribution v2.7.1+incompatible // indirect + github.com/docker/docker v1.13.1 + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.4.0 // indirect github.com/fsnotify/fsnotify v1.4.7 // indirect github.com/hpcloud/tail v1.0.0 + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect github.com/prometheus/client_golang v1.4.1 github.com/prometheus/client_model v0.2.0 github.com/stretchr/testify v1.4.0 diff --git a/go.sum b/go.sum index bea5d93..fedcb99 100644 --- a/go.sum +++ b/go.sum @@ -17,6 +17,14 @@ github.com/coreos/go-systemd/v22 v22.0.0/go.mod h1:xO0FLkIi5MaZafQlIrOotqXZ90ih+ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/distribution v2.7.1+incompatible h1:a5mlkVzth6W5A4fOsS3D2EO5BUmsJpcB+cRlLU7cSug= +github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v1.13.1 h1:IkZjBSIc8hBjLpqeAbeE5mca5mNgeatLHBy3GO78BWo= +github.com/docker/docker v1.13.1/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.4.0 h1:3uh0PgVws3nIA0Q+MwDC8yjEPf9zjRfZZWXZYDct3Tw= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -53,7 +61,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -83,6 +94,7 @@ github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81P golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190613194153-d28f0bde5980 h1:dfGZHvZk057jK2MCeWus/TowKpJ8y4AmooUzdBSR9GU= golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/logsource.go b/logsource.go index 3f8318b..492d2e2 100644 --- a/logsource.go +++ b/logsource.go @@ -1,6 +1,7 @@ package main import ( + "context" "fmt" "io" @@ -17,7 +18,7 @@ type LogSourceFactory interface { // New attempts to create a new log source. This is called after // flags have been parsed. Returning `nil, nil`, means the user // didn't want this log source. - New() (LogSourceCloser, error) + New(context.Context) (LogSourceCloser, error) } type LogSourceCloser interface { @@ -48,9 +49,9 @@ func InitLogSourceFactories(app *kingpin.Application) { // NewLogSourceFromFactories iterates through the factories and // attempts to instantiate a log source. The first factory to return // success wins. -func NewLogSourceFromFactories() (LogSourceCloser, error) { +func NewLogSourceFromFactories(ctx context.Context) (LogSourceCloser, error) { for _, f := range logSourceFactories { - src, err := f.New() + src, err := f.New(ctx) if err != nil { return nil, err } diff --git a/logsource_docker.go b/logsource_docker.go new file mode 100644 index 0000000..fa182a7 --- /dev/null +++ b/logsource_docker.go @@ -0,0 +1,96 @@ +// +build !nodocker + +package main + +import ( + "bufio" + "context" + "io" + "log" + "strings" + + "github.com/alecthomas/kingpin" + "github.com/docker/docker/api/types" + "github.com/docker/docker/client" +) + +// A DockerLogSource reads log records from the given Docker +// journal. +type DockerLogSource struct { + client DockerClient + containerID string + reader *bufio.Reader +} + +// A DockerClient is the client interface that client.Client +// provides. See https://pkg.go.dev/github.com/docker/docker/client +type DockerClient interface { + io.Closer + ContainerLogs(context.Context, string, types.ContainerLogsOptions) (io.ReadCloser, error) +} + +// NewDockerLogSource returns a log source for reading Docker logs. +func NewDockerLogSource(ctx context.Context, c DockerClient, containerID string) (*DockerLogSource, error) { + r, err := c.ContainerLogs(ctx, containerID, types.ContainerLogsOptions{ + ShowStdout: true, + ShowStderr: true, + Follow: true, + Tail: "0", + }) + if err != nil { + return nil, err + } + + logSrc := &DockerLogSource{ + client: c, + containerID: containerID, + reader: bufio.NewReader(r), + } + + return logSrc, nil +} + +func (s *DockerLogSource) Close() error { + return s.client.Close() +} + +func (s *DockerLogSource) Path() string { + return "docker:" + s.containerID +} + +func (s *DockerLogSource) Read(ctx context.Context) (string, error) { + line, err := s.reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSpace(line), nil +} + +// A dockerLogSourceFactory is a factory that can create +// DockerLogSources from command line flags. +type dockerLogSourceFactory struct { + enable bool + containerID string +} + +func (f *dockerLogSourceFactory) Init(app *kingpin.Application) { + app.Flag("docker.enable", "Read from Docker logs. Environment variable DOCKER_HOST can be used to change the address. See https://pkg.go.dev/github.com/docker/docker/client?tab=doc#NewEnvClient for more information.").Default("false").BoolVar(&f.enable) + app.Flag("docker.container.id", "ID/name of the Postfix Docker container.").Default("postfix").StringVar(&f.containerID) +} + +func (f *dockerLogSourceFactory) New(ctx context.Context) (LogSourceCloser, error) { + if !f.enable { + return nil, nil + } + + log.Println("Reading log events from Docker") + c, err := client.NewEnvClient() + if err != nil { + return nil, err + } + return NewDockerLogSource(ctx, c, f.containerID) +} + +func init() { + RegisterLogSourceFactory(&dockerLogSourceFactory{}) +} diff --git a/logsource_docker_test.go b/logsource_docker_test.go new file mode 100644 index 0000000..74231c2 --- /dev/null +++ b/logsource_docker_test.go @@ -0,0 +1,79 @@ +// +build !nodocker + +package main + +import ( + "context" + "io" + "io/ioutil" + "strings" + "testing" + + "github.com/docker/docker/api/types" + "github.com/stretchr/testify/assert" +) + +func TestNewDockerLogSource(t *testing.T) { + ctx := context.Background() + c := &fakeDockerClient{} + src, err := NewDockerLogSource(ctx, c, "acontainer") + if err != nil { + t.Fatalf("NewDockerLogSource failed: %v", err) + } + + assert.Equal(t, []string{"acontainer"}, c.containerLogsCalls, "A call to ContainerLogs should be made.") + + if err := src.Close(); err != nil { + t.Fatalf("Close failed: %v", err) + } + + assert.Equal(t, 1, c.closeCalls, "A call to Close should be made.") +} + +func TestDockerLogSource_Path(t *testing.T) { + ctx := context.Background() + c := &fakeDockerClient{} + src, err := NewDockerLogSource(ctx, c, "acontainer") + if err != nil { + t.Fatalf("NewDockerLogSource failed: %v", err) + } + defer src.Close() + + assert.Equal(t, "docker:acontainer", src.Path(), "Path should be set by New.") +} + +func TestDockerLogSource_Read(t *testing.T) { + ctx := context.Background() + + c := &fakeDockerClient{ + logsReader: ioutil.NopCloser(strings.NewReader("Feb 13 23:31:30 ahost anid[123]: aline\n")), + } + src, err := NewDockerLogSource(ctx, c, "acontainer") + if err != nil { + t.Fatalf("NewDockerLogSource failed: %v", err) + } + defer src.Close() + + s, err := src.Read(ctx) + if err != nil { + t.Fatalf("Read failed: %v", err) + } + assert.Equal(t, "Feb 13 23:31:30 ahost anid[123]: aline", s, "Read should get data from the journal entry.") +} + +type fakeDockerClient struct { + logsReader io.ReadCloser + + containerLogsCalls []string + closeCalls int +} + +func (c *fakeDockerClient) ContainerLogs(ctx context.Context, containerID string, opts types.ContainerLogsOptions) (io.ReadCloser, error) { + c.containerLogsCalls = append(c.containerLogsCalls, containerID) + return c.logsReader, nil +} + +func (c *fakeDockerClient) Close() error { + c.closeCalls++ + return nil +} diff --git a/logsource_file.go b/logsource_file.go index dfe85e8..b906a84 100644 --- a/logsource_file.go +++ b/logsource_file.go @@ -69,7 +69,7 @@ func (f *fileLogSourceFactory) Init(app *kingpin.Application) { app.Flag("postfix.logfile_path", "Path where Postfix writes log entries.").Default("/var/log/maillog").StringVar(&f.path) } -func (f *fileLogSourceFactory) New() (LogSourceCloser, error) { +func (f *fileLogSourceFactory) New(ctx context.Context) (LogSourceCloser, error) { if f.path == "" { return nil, nil } diff --git a/logsource_systemd.go b/logsource_systemd.go index 76d3be8..60f5cb6 100644 --- a/logsource_systemd.go +++ b/logsource_systemd.go @@ -112,7 +112,7 @@ func (f *systemdLogSourceFactory) Init(app *kingpin.Application) { app.Flag("systemd.journal_path", "Path to the systemd journal").Default("").StringVar(&f.path) } -func (f *systemdLogSourceFactory) New() (LogSourceCloser, error) { +func (f *systemdLogSourceFactory) New(ctx context.Context) (LogSourceCloser, error) { if !f.enable { return nil, nil } diff --git a/main.go b/main.go index 2e5e8cb..c92d7d5 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( func main() { var ( + ctx = context.Background() app = kingpin.New("postfix_exporter", "Prometheus metrics exporter for postfix") listenAddress = app.Flag("web.listen-address", "Address to listen on for web interface and telemetry.").Default(":9154").String() metricsPath = app.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").String() @@ -23,7 +24,7 @@ func main() { InitLogSourceFactories(app) kingpin.MustParse(app.Parse(os.Args[1:])) - logSrc, err := NewLogSourceFromFactories() + logSrc, err := NewLogSourceFromFactories(ctx) if err != nil { log.Fatalf("Error opening log source: %s", err) } @@ -53,7 +54,7 @@ func main() { panic(err) } }) - ctx, cancelFunc := context.WithCancel(context.Background()) + ctx, cancelFunc := context.WithCancel(ctx) defer cancelFunc() go exporter.StartMetricCollection(ctx) log.Print("Listening on ", *listenAddress)