Adds a Docker log source.

When Postfix is running in a Docker container, it's most useful to use
the built-in Docker logging (as with Systemd). Setting
`--docker.enable` allows that.

The log source is using `client.NewEnvClient`, which reads environment
variables to determine which Docker to connect to, and how TLS is
handled.
This commit is contained in:
Tommie Gannert 2020-06-21 08:24:36 +02:00
parent 1451d25af5
commit c588b342a0
9 changed files with 219 additions and 9 deletions

View File

@ -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 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 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 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 ## 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.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` | | `--postfix.logfile_path` | Path where Postfix writes log entries | `/var/log/maillog` |
| `--log.unsupported` | Log all unsupported lines | `false` | | `--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.unit` | Name of the Postfix systemd unit | `postfix.service` |
| `--systemd.slice` | Name of the Postfix systemd slice. | `""` | | `--systemd.slice` | Name of the Postfix systemd slice. | `""` |
| `--systemd.journal_path` | Path to the systemd journal | `""` | | `--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 ## Events from log file
The log file is tailed when processed. Rotating the log files while the exporter The log file is tailed when processed. Rotating the log files while the exporter

7
go.mod
View File

@ -5,8 +5,15 @@ go 1.13
require ( require (
github.com/alecthomas/kingpin v2.2.6+incompatible github.com/alecthomas/kingpin v2.2.6+incompatible
github.com/coreos/go-systemd/v22 v22.0.0 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/fsnotify/fsnotify v1.4.7 // indirect
github.com/hpcloud/tail v1.0.0 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_golang v1.4.1
github.com/prometheus/client_model v0.2.0 github.com/prometheus/client_model v0.2.0
github.com/stretchr/testify v1.4.0 github.com/stretchr/testify v1.4.0

12
go.sum
View File

@ -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.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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 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= 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 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/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/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.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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 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-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/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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=

View File

@ -1,6 +1,7 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"io" "io"
@ -17,7 +18,7 @@ type LogSourceFactory interface {
// New attempts to create a new log source. This is called after // New attempts to create a new log source. This is called after
// flags have been parsed. Returning `nil, nil`, means the user // flags have been parsed. Returning `nil, nil`, means the user
// didn't want this log source. // didn't want this log source.
New() (LogSourceCloser, error) New(context.Context) (LogSourceCloser, error)
} }
type LogSourceCloser interface { type LogSourceCloser interface {
@ -48,9 +49,9 @@ func InitLogSourceFactories(app *kingpin.Application) {
// NewLogSourceFromFactories iterates through the factories and // NewLogSourceFromFactories iterates through the factories and
// attempts to instantiate a log source. The first factory to return // attempts to instantiate a log source. The first factory to return
// success wins. // success wins.
func NewLogSourceFromFactories() (LogSourceCloser, error) { func NewLogSourceFromFactories(ctx context.Context) (LogSourceCloser, error) {
for _, f := range logSourceFactories { for _, f := range logSourceFactories {
src, err := f.New() src, err := f.New(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }

96
logsource_docker.go Normal file
View File

@ -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{})
}

79
logsource_docker_test.go Normal file
View File

@ -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
}

View File

@ -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) 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 == "" { if f.path == "" {
return nil, nil return nil, nil
} }

View File

@ -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) 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 { if !f.enable {
return nil, nil return nil, nil
} }

View File

@ -13,6 +13,7 @@ import (
func main() { func main() {
var ( var (
ctx = context.Background()
app = kingpin.New("postfix_exporter", "Prometheus metrics exporter for postfix") 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() 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() metricsPath = app.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").String()
@ -23,7 +24,7 @@ func main() {
InitLogSourceFactories(app) InitLogSourceFactories(app)
kingpin.MustParse(app.Parse(os.Args[1:])) kingpin.MustParse(app.Parse(os.Args[1:]))
logSrc, err := NewLogSourceFromFactories() logSrc, err := NewLogSourceFromFactories(ctx)
if err != nil { if err != nil {
log.Fatalf("Error opening log source: %s", err) log.Fatalf("Error opening log source: %s", err)
} }
@ -53,7 +54,7 @@ func main() {
panic(err) panic(err)
} }
}) })
ctx, cancelFunc := context.WithCancel(context.Background()) ctx, cancelFunc := context.WithCancel(ctx)
defer cancelFunc() defer cancelFunc()
go exporter.StartMetricCollection(ctx) go exporter.StartMetricCollection(ctx)
log.Print("Listening on ", *listenAddress) log.Print("Listening on ", *listenAddress)