feat(pprof): add pprof HTTP server (#807)

- `PPROF_ENABLED=no`
- `PPROF_BLOCK_PROFILE_RATE=0`
- `PPROF_MUTEX_PROFILE_RATE=0`
- `PPROF_HTTP_SERVER_ADDRESS=":6060"`
This commit is contained in:
Quentin McGaw
2022-01-26 17:23:55 -05:00
committed by GitHub
parent 55e609cbf4
commit 9de6428585
26 changed files with 1659 additions and 4 deletions

View File

@@ -0,0 +1,36 @@
package pprof
import (
"regexp"
"time"
gomock "github.com/golang/mock/gomock"
)
func boolPtr(b bool) *bool { return &b }
func stringPtr(s string) *string { return &s }
func durationPtr(d time.Duration) *time.Duration { return &d }
var _ gomock.Matcher = (*regexMatcher)(nil)
type regexMatcher struct {
regexp *regexp.Regexp
}
func (r *regexMatcher) Matches(x interface{}) bool {
s, ok := x.(string)
if !ok {
return false
}
return r.regexp.MatchString(s)
}
func (r *regexMatcher) String() string {
return "regular expression " + r.regexp.String()
}
func newRegexMatcher(regex string) *regexMatcher {
return &regexMatcher{
regexp: regexp.MustCompile(regex),
}
}

View File

@@ -0,0 +1,70 @@
// Code generated by MockGen. DO NOT EDIT.
// Source: github.com/qdm12/gluetun/internal/httpserver (interfaces: Logger)
// Package pprof is a generated GoMock package.
package pprof
import (
reflect "reflect"
gomock "github.com/golang/mock/gomock"
)
// MockLogger is a mock of Logger interface.
type MockLogger struct {
ctrl *gomock.Controller
recorder *MockLoggerMockRecorder
}
// MockLoggerMockRecorder is the mock recorder for MockLogger.
type MockLoggerMockRecorder struct {
mock *MockLogger
}
// NewMockLogger creates a new mock instance.
func NewMockLogger(ctrl *gomock.Controller) *MockLogger {
mock := &MockLogger{ctrl: ctrl}
mock.recorder = &MockLoggerMockRecorder{mock}
return mock
}
// EXPECT returns an object that allows the caller to indicate expected use.
func (m *MockLogger) EXPECT() *MockLoggerMockRecorder {
return m.recorder
}
// Error mocks base method.
func (m *MockLogger) Error(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Error", arg0)
}
// Error indicates an expected call of Error.
func (mr *MockLoggerMockRecorder) Error(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), arg0)
}
// Info mocks base method.
func (m *MockLogger) Info(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Info", arg0)
}
// Info indicates an expected call of Info.
func (mr *MockLoggerMockRecorder) Info(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), arg0)
}
// Warn mocks base method.
func (m *MockLogger) Warn(arg0 string) {
m.ctrl.T.Helper()
m.ctrl.Call(m, "Warn", arg0)
}
// Warn indicates an expected call of Warn.
func (mr *MockLoggerMockRecorder) Warn(arg0 interface{}) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), arg0)
}

40
internal/pprof/server.go Normal file
View File

@@ -0,0 +1,40 @@
package pprof
import (
"fmt"
"net/http"
"net/http/pprof"
"runtime"
"github.com/qdm12/gluetun/internal/httpserver"
)
// New creates a new Pprof server and configure profiling
// with the settings given. It returns an error
// if one of the settings is not valid.
func New(settings Settings) (server *httpserver.Server, err error) {
runtime.SetBlockProfileRate(settings.BlockProfileRate)
runtime.SetMutexProfileFraction(settings.MutexProfileRate)
handler := http.NewServeMux()
handler.HandleFunc("/debug/pprof/", pprof.Index)
handler.HandleFunc("/debug/pprof/cmdline", pprof.Cmdline)
handler.HandleFunc("/debug/pprof/profile", pprof.Profile)
handler.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
handler.HandleFunc("/debug/pprof/trace", pprof.Trace)
handler.Handle("/debug/pprof/block", pprof.Handler("block"))
handler.Handle("/debug/pprof/goroutine", pprof.Handler("goroutine"))
handler.Handle("/debug/pprof/heap", pprof.Handler("heap"))
handler.Handle("/debug/pprof/threadcreate", pprof.Handler("threadcreate"))
httpServerName := "pprof"
settings.HTTPServer.Name = &httpServerName
settings.HTTPServer.Handler = handler
settings.SetDefaults()
if err = settings.Validate(); err != nil {
return nil, fmt.Errorf("pprof settings failed validation: %w", err)
}
return httpserver.New(settings.HTTPServer)
}

View File

@@ -0,0 +1,124 @@
package pprof
import (
"context"
"io/ioutil"
"net/http"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/qdm12/gluetun/internal/httpserver"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
//go:generate mockgen -destination=logger_mock_test.go -package $GOPACKAGE github.com/qdm12/gluetun/internal/httpserver Logger
func Test_Server(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
const address = "127.0.0.1:0"
logger := NewMockLogger(ctrl)
logger.EXPECT().Info(newRegexMatcher("^pprof http server listening on 127.0.0.1:[1-9][0-9]{0,4}$"))
logger.EXPECT().Warn("pprof http server shutting down: context canceled")
const httpServerShutdownTimeout = 10 * time.Second // 10s in case test worker is slow
settings := Settings{
HTTPServer: httpserver.Settings{
Address: address,
Logger: logger,
ShutdownTimeout: durationPtr(httpServerShutdownTimeout),
},
}
server, err := New(settings)
require.NoError(t, err)
require.NotNil(t, server)
ctx, cancel := context.WithCancel(context.Background())
ready := make(chan struct{})
done := make(chan struct{})
go server.Run(ctx, ready, done)
select {
case <-ready:
case err := <-done:
t.Fatalf("server crashed before being ready: %s", err)
}
serverAddress := server.GetAddress()
const clientTimeout = 2 * time.Second
httpClient := &http.Client{Timeout: clientTimeout}
pathsToCheck := []string{
"debug/pprof/",
"debug/pprof/cmdline",
"debug/pprof/profile?seconds=1",
"debug/pprof/symbol",
"debug/pprof/trace?seconds=1",
"debug/pprof/block",
"debug/pprof/goroutine",
"debug/pprof/heap",
"debug/pprof/threadcreate",
}
type httpResult struct {
url string
response *http.Response
err error
}
results := make(chan httpResult)
for _, pathToCheck := range pathsToCheck {
url := "http://" + serverAddress + "/" + pathToCheck
request, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
require.NoError(t, err)
go func(client *http.Client, request *http.Request, results chan<- httpResult) {
response, err := client.Do(request) //nolint:bodyclose
results <- httpResult{
url: request.URL.String(),
response: response,
err: err,
}
}(httpClient, request, results)
}
for range pathsToCheck {
httpResult := <-results
require.NoErrorf(t, httpResult.err, "unexpected error for URL %s: %s", httpResult.url, httpResult.err)
assert.Equalf(t, http.StatusOK, httpResult.response.StatusCode,
"unexpected status code for URL %s: %s", httpResult.url, http.StatusText(httpResult.response.StatusCode))
b, err := ioutil.ReadAll(httpResult.response.Body)
require.NoErrorf(t, err, "unexpected error for URL %s: %s", httpResult.url, err)
assert.NotEmptyf(t, b, "response body is empty for URL %s", httpResult.url)
err = httpResult.response.Body.Close()
assert.NoErrorf(t, err, "unexpected error for URL %s: %s", httpResult.url, err)
}
cancel()
<-done
}
func Test_Server_BadSettings(t *testing.T) {
t.Parallel()
settings := Settings{
BlockProfileRate: -1,
}
server, err := New(settings)
assert.Nil(t, server)
assert.ErrorIs(t, err, ErrBlockProfileRateNegative)
const expectedErrMessage = "pprof settings failed validation: block profile rate cannot be negative"
assert.EqualError(t, err, expectedErrMessage)
}

View File

@@ -0,0 +1,96 @@
package pprof
import (
"errors"
"github.com/qdm12/gluetun/internal/configuration/settings/helpers"
"github.com/qdm12/gluetun/internal/httpserver"
"github.com/qdm12/gotree"
)
// Settings are the settings for the Pprof service.
type Settings struct {
// Enabled can be false or true.
// It defaults to false.
Enabled *bool
// See runtime.SetBlockProfileRate
// Set to 0 to disable profiling.
BlockProfileRate int
// See runtime.SetMutexProfileFraction
// Set to 0 to disable profiling.
MutexProfileRate int
// HTTPServer contains settings to configure
// the HTTP server serving pprof data.
HTTPServer httpserver.Settings
}
func (s *Settings) SetDefaults() {
s.Enabled = helpers.DefaultBool(s.Enabled, false)
s.HTTPServer.Name = helpers.DefaultStringPtr(s.HTTPServer.Name, "pprof")
s.HTTPServer.Address = helpers.DefaultString(s.HTTPServer.Address, "localhost:6060")
s.HTTPServer.SetDefaults()
}
func (s Settings) Copy() (copied Settings) {
return Settings{
Enabled: helpers.CopyBoolPtr(s.Enabled),
BlockProfileRate: s.BlockProfileRate,
MutexProfileRate: s.MutexProfileRate,
HTTPServer: s.HTTPServer.Copy(),
}
}
func (s *Settings) MergeWith(other Settings) {
s.Enabled = helpers.MergeWithBool(s.Enabled, other.Enabled)
s.BlockProfileRate = helpers.MergeWithInt(s.BlockProfileRate, other.BlockProfileRate)
s.MutexProfileRate = helpers.MergeWithInt(s.MutexProfileRate, other.MutexProfileRate)
s.HTTPServer.MergeWith(other.HTTPServer)
}
func (s *Settings) OverrideWith(other Settings) {
s.Enabled = helpers.OverrideWithBool(s.Enabled, other.Enabled)
s.BlockProfileRate = helpers.OverrideWithInt(s.BlockProfileRate, other.BlockProfileRate)
s.MutexProfileRate = helpers.OverrideWithInt(s.MutexProfileRate, other.MutexProfileRate)
s.HTTPServer.OverrideWith(other.HTTPServer)
}
var (
ErrBlockProfileRateNegative = errors.New("block profile rate cannot be negative")
ErrMutexProfileRateNegative = errors.New("mutex profile rate cannot be negative")
)
func (s Settings) Validate() (err error) {
if s.BlockProfileRate < 0 {
return ErrBlockProfileRateNegative
}
if s.MutexProfileRate < 0 {
return ErrMutexProfileRateNegative
}
return s.HTTPServer.Validate()
}
func (s Settings) ToLinesNode() (node *gotree.Node) {
if !*s.Enabled {
return nil
}
node = gotree.New("Pprof settings:")
if s.BlockProfileRate > 0 {
node.Appendf("Block profile rate: %d", s.BlockProfileRate)
}
if s.MutexProfileRate > 0 {
node.Appendf("Mutex profile rate: %d", s.MutexProfileRate)
}
node.AppendNode(s.HTTPServer.ToLinesNode())
return node
}
func (s Settings) String() string {
return s.ToLinesNode().String()
}

View File

@@ -0,0 +1,352 @@
package pprof
import (
"net/http"
"testing"
"time"
"github.com/qdm12/gluetun/internal/httpserver"
"github.com/qdm12/govalid/address"
"github.com/stretchr/testify/assert"
)
func Test_Settings_SetDefaults(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
initial Settings
expected Settings
}{
"empty settings": {
expected: Settings{
Enabled: boolPtr(false),
HTTPServer: httpserver.Settings{
Name: stringPtr("pprof"),
Address: "localhost:6060",
ShutdownTimeout: durationPtr(3 * time.Second),
},
},
},
"non empty settings": {
initial: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Name: stringPtr("custom"),
Address: ":6061",
ShutdownTimeout: durationPtr(time.Second),
},
},
expected: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Name: stringPtr("custom"),
Address: ":6061",
ShutdownTimeout: durationPtr(time.Second),
},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.initial.SetDefaults()
assert.Equal(t, testCase.expected, testCase.initial)
})
}
}
func Test_Settings_Copy(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
initial Settings
expected Settings
}{
"empty settings": {},
"non empty settings": {
initial: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Name: stringPtr("custom"),
Address: ":6061",
ShutdownTimeout: durationPtr(time.Second),
},
},
expected: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Name: stringPtr("custom"),
Address: ":6061",
ShutdownTimeout: durationPtr(time.Second),
},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
copied := testCase.initial.Copy()
assert.Equal(t, testCase.expected, copied)
})
}
}
func Test_Settings_MergeWith(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
settings Settings
other Settings
expected Settings
}{
"merge empty with empty": {},
"merge empty with filled": {
other: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
expected: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
},
"merge filled with empty": {
settings: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
expected: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.settings.MergeWith(testCase.other)
assert.Equal(t, testCase.expected, testCase.settings)
})
}
}
func Test_Settings_OverrideWith(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
settings Settings
other Settings
expected Settings
}{
"override empty with empty": {},
"override empty with filled": {
other: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
expected: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
},
"override filled with empty": {
settings: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
expected: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
},
"override filled with filled": {
settings: Settings{
Enabled: boolPtr(false),
BlockProfileRate: 1,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Address: ":8001",
},
},
other: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 2,
MutexProfileRate: 3,
HTTPServer: httpserver.Settings{
Address: ":8002",
},
},
expected: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 2,
MutexProfileRate: 3,
HTTPServer: httpserver.Settings{
Address: ":8002",
},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
testCase.settings.OverrideWith(testCase.other)
assert.Equal(t, testCase.expected, testCase.settings)
})
}
}
func Test_Settings_Validate(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
settings Settings
errWrapped error
errMessage string
}{
"negative block profile rate": {
settings: Settings{
BlockProfileRate: -1,
},
errWrapped: ErrBlockProfileRateNegative,
errMessage: ErrBlockProfileRateNegative.Error(),
},
"negative mutex profile rate": {
settings: Settings{
MutexProfileRate: -1,
},
errWrapped: ErrMutexProfileRateNegative,
errMessage: ErrMutexProfileRateNegative.Error(),
},
"http server validation error": {
settings: Settings{
HTTPServer: httpserver.Settings{},
},
errWrapped: address.ErrValueNotValid,
errMessage: "value is not valid: missing port in address",
},
"valid settings": {
settings: Settings{
HTTPServer: httpserver.Settings{
Address: ":8000",
Handler: http.NewServeMux(),
Logger: &MockLogger{},
ShutdownTimeout: durationPtr(time.Second),
},
},
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
err := testCase.settings.Validate()
assert.ErrorIs(t, err, testCase.errWrapped)
if err != nil {
assert.EqualError(t, err, testCase.errMessage)
}
})
}
}
func Test_Settings_String(t *testing.T) {
t.Parallel()
testCases := map[string]struct {
settings Settings
s string
}{
"disabled pprof": {
settings: Settings{
Enabled: boolPtr(false),
},
},
"all values": {
settings: Settings{
Enabled: boolPtr(true),
BlockProfileRate: 2,
MutexProfileRate: 1,
HTTPServer: httpserver.Settings{
Name: stringPtr("name"),
Address: ":8000",
ShutdownTimeout: durationPtr(time.Second),
},
},
s: `Pprof settings:
├── Block profile rate: 2
├── Mutex profile rate: 1
└── Name HTTP server settings:
├── Listening address: :8000
└── Shutdown timeout: 1s`,
},
}
for name, testCase := range testCases {
testCase := testCase
t.Run(name, func(t *testing.T) {
t.Parallel()
s := testCase.settings.String()
assert.Equal(t, testCase.s, s)
})
}
}