Merge pull request #1320 from containous/configure-healthcheck-via-marathon-label

Make Traefik health checks label-configurable with Marathon.
This commit is contained in:
Timo Reimann 2017-04-28 18:59:35 +02:00 committed by GitHub
commit ce492895e2
11 changed files with 377 additions and 50 deletions

View file

@ -989,6 +989,8 @@ Labels can be used on containers to override default behaviour:
- `traefik.backend.loadbalancer.method=drr`: override the default `wrr` load balancer algorithm - `traefik.backend.loadbalancer.method=drr`: override the default `wrr` load balancer algorithm
- `traefik.backend.loadbalancer.sticky=true`: enable backend sticky sessions - `traefik.backend.loadbalancer.sticky=true`: enable backend sticky sessions
- `traefik.backend.circuitbreaker.expression=NetworkErrorRatio() > 0.5`: create a [circuit breaker](/basics/#backends) to be used against the backend - `traefik.backend.circuitbreaker.expression=NetworkErrorRatio() > 0.5`: create a [circuit breaker](/basics/#backends) to be used against the backend
- `traefik.backend.healthcheck.path=/health`: set the Traefik health check path [default: no health checks]
- `traefik.backend.healthcheck.interval=5s`: sets a custom health check interval in Go-parseable (`time.ParseDuration`) format [default: 30s]
- `traefik.portIndex=1`: register port by index in the application's ports array. Useful when the application exposes multiple ports. - `traefik.portIndex=1`: register port by index in the application's ports array. Useful when the application exposes multiple ports.
- `traefik.port=80`: register the explicit application port value. Cannot be used alongside `traefik.portIndex`. - `traefik.port=80`: register the explicit application port value. Cannot be used alongside `traefik.portIndex`.
- `traefik.protocol=https`: override the default `http` protocol - `traefik.protocol=https`: override the default `http` protocol

View file

@ -12,6 +12,9 @@ import (
"github.com/vulcand/oxy/roundrobin" "github.com/vulcand/oxy/roundrobin"
) )
// DefaultInterval is the default health check interval.
const DefaultInterval = 30 * time.Second
var singleton *HealthCheck var singleton *HealthCheck
var once sync.Once var once sync.Once
@ -23,16 +26,19 @@ func GetHealthCheck() *HealthCheck {
return singleton return singleton
} }
// BackendHealthCheck HealthCheck configuration for a backend // Options are the public health check options.
type BackendHealthCheck struct { type Options struct {
Path string Path string
Interval time.Duration Interval time.Duration
DisabledURLs []*url.URL LB LoadBalancer
requestTimeout time.Duration
lb loadBalancer
} }
var launch = false // BackendHealthCheck HealthCheck configuration for a backend
type BackendHealthCheck struct {
Options
disabledURLs []*url.URL
requestTimeout time.Duration
}
//HealthCheck struct //HealthCheck struct
type HealthCheck struct { type HealthCheck struct {
@ -40,7 +46,8 @@ type HealthCheck struct {
cancel context.CancelFunc cancel context.CancelFunc
} }
type loadBalancer interface { // LoadBalancer includes functionality for load-balancing management.
type LoadBalancer interface {
RemoveServer(u *url.URL) error RemoveServer(u *url.URL) error
UpsertServer(u *url.URL, options ...roundrobin.ServerOption) error UpsertServer(u *url.URL, options ...roundrobin.ServerOption) error
Servers() []*url.URL Servers() []*url.URL
@ -53,12 +60,10 @@ func newHealthCheck() *HealthCheck {
} }
// NewBackendHealthCheck Instantiate a new BackendHealthCheck // NewBackendHealthCheck Instantiate a new BackendHealthCheck
func NewBackendHealthCheck(Path string, interval time.Duration, lb loadBalancer) *BackendHealthCheck { func NewBackendHealthCheck(options Options) *BackendHealthCheck {
return &BackendHealthCheck{ return &BackendHealthCheck{
Path: Path, Options: options,
Interval: interval,
requestTimeout: 5 * time.Second, requestTimeout: 5 * time.Second,
lb: lb,
} }
} }
@ -98,24 +103,24 @@ func (hc *HealthCheck) execute(ctx context.Context, backendID string, backend *B
} }
func checkBackend(currentBackend *BackendHealthCheck) { func checkBackend(currentBackend *BackendHealthCheck) {
enabledURLs := currentBackend.lb.Servers() enabledURLs := currentBackend.LB.Servers()
var newDisabledURLs []*url.URL var newDisabledURLs []*url.URL
for _, url := range currentBackend.DisabledURLs { for _, url := range currentBackend.disabledURLs {
if checkHealth(url, currentBackend) { if checkHealth(url, currentBackend) {
log.Debugf("HealthCheck is up [%s]: Upsert in server list", url.String()) log.Debugf("HealthCheck is up [%s]: Upsert in server list", url.String())
currentBackend.lb.UpsertServer(url, roundrobin.Weight(1)) currentBackend.LB.UpsertServer(url, roundrobin.Weight(1))
} else { } else {
log.Warnf("HealthCheck is still failing [%s]", url.String()) log.Warnf("HealthCheck is still failing [%s]", url.String())
newDisabledURLs = append(newDisabledURLs, url) newDisabledURLs = append(newDisabledURLs, url)
} }
} }
currentBackend.DisabledURLs = newDisabledURLs currentBackend.disabledURLs = newDisabledURLs
for _, url := range enabledURLs { for _, url := range enabledURLs {
if !checkHealth(url, currentBackend) { if !checkHealth(url, currentBackend) {
log.Warnf("HealthCheck has failed [%s]: Remove from server list", url.String()) log.Warnf("HealthCheck has failed [%s]: Remove from server list", url.String())
currentBackend.lb.RemoveServer(url) currentBackend.LB.RemoveServer(url)
currentBackend.DisabledURLs = append(currentBackend.DisabledURLs, url) currentBackend.disabledURLs = append(currentBackend.disabledURLs, url)
} }
} }
} }

View file

@ -148,12 +148,16 @@ func TestSetBackendsConfiguration(t *testing.T) {
defer ts.Close() defer ts.Close()
lb := &testLoadBalancer{RWMutex: &sync.RWMutex{}} lb := &testLoadBalancer{RWMutex: &sync.RWMutex{}}
backend := NewBackendHealthCheck("/path", healthCheckInterval, lb) backend := NewBackendHealthCheck(Options{
Path: "/path",
Interval: healthCheckInterval,
LB: lb,
})
serverURL := MustParseURL(ts.URL) serverURL := MustParseURL(ts.URL)
if test.startHealthy { if test.startHealthy {
lb.servers = append(lb.servers, serverURL) lb.servers = append(lb.servers, serverURL)
} else { } else {
backend.DisabledURLs = append(backend.DisabledURLs, serverURL) backend.disabledURLs = append(backend.disabledURLs, serverURL)
} }
healthCheck := HealthCheck{ healthCheck := HealthCheck{

View file

@ -13,7 +13,7 @@ logLevel = "DEBUG"
[backends] [backends]
[backends.backend1] [backends.backend1]
[backends.backend1.healthcheck] [backends.backend1.healthcheck]
url = "/health" path = "/health"
interval = "1s" interval = "1s"
[backends.backend1.servers.server1] [backends.backend1.servers.server1]
url = "http://{{.Server1}}:80" url = "http://{{.Server1}}:80"

View file

@ -16,16 +16,16 @@ import (
checker "github.com/vdemeester/shakers" checker "github.com/vdemeester/shakers"
) )
// HealchCheck test suites (using libcompose) // HealthCheck test suites (using libcompose)
type HealchCheckSuite struct{ BaseSuite } type HealthCheckSuite struct{ BaseSuite }
func (s *HealchCheckSuite) SetUpSuite(c *check.C) { func (s *HealthCheckSuite) SetUpSuite(c *check.C) {
s.createComposeProject(c, "healthcheck") s.createComposeProject(c, "healthcheck")
s.composeProject.Start(c) s.composeProject.Start(c)
} }
func (s *HealchCheckSuite) TestSimpleConfiguration(c *check.C) { func (s *HealthCheckSuite) TestSimpleConfiguration(c *check.C) {
whoami1Host := s.composeProject.Container(c, "whoami1").NetworkSettings.IPAddress whoami1Host := s.composeProject.Container(c, "whoami1").NetworkSettings.IPAddress
whoami2Host := s.composeProject.Container(c, "whoami2").NetworkSettings.IPAddress whoami2Host := s.composeProject.Container(c, "whoami2").NetworkSettings.IPAddress

View file

@ -27,6 +27,7 @@ func init() {
check.Suite(&AccessLogSuite{}) check.Suite(&AccessLogSuite{})
check.Suite(&HTTPSSuite{}) check.Suite(&HTTPSSuite{})
check.Suite(&FileSuite{}) check.Suite(&FileSuite{})
check.Suite(&HealthCheckSuite{})
check.Suite(&DockerSuite{}) check.Suite(&DockerSuite{})
check.Suite(&ConsulSuite{}) check.Suite(&ConsulSuite{})
check.Suite(&ConsulCatalogSuite{}) check.Suite(&ConsulCatalogSuite{})

View file

@ -24,8 +24,10 @@ import (
) )
const ( const (
labelPort = "traefik.port" labelPort = "traefik.port"
labelPortIndex = "traefik.portIndex" labelPortIndex = "traefik.portIndex"
labelBackendHealthCheckPath = "traefik.backend.healthcheck.path"
labelBackendHealthCheckInterval = "traefik.backend.healthcheck.interval"
) )
var _ provider.Provider = (*Provider)(nil) var _ provider.Provider = (*Provider)(nil)
@ -157,6 +159,9 @@ func (p *Provider) loadMarathonConfig() *types.Configuration {
"getLoadBalancerMethod": p.getLoadBalancerMethod, "getLoadBalancerMethod": p.getLoadBalancerMethod,
"getCircuitBreakerExpression": p.getCircuitBreakerExpression, "getCircuitBreakerExpression": p.getCircuitBreakerExpression,
"getSticky": p.getSticky, "getSticky": p.getSticky,
"hasHealthCheckLabels": p.hasHealthCheckLabels,
"getHealthCheckPath": p.getHealthCheckPath,
"getHealthCheckInterval": p.getHealthCheckInterval,
} }
applications, err := p.marathonClient.Applications(nil) applications, err := p.marathonClient.Applications(nil)
@ -461,6 +466,24 @@ func (p *Provider) getCircuitBreakerExpression(application marathon.Application)
return "NetworkErrorRatio() > 1" return "NetworkErrorRatio() > 1"
} }
func (p *Provider) hasHealthCheckLabels(application marathon.Application) bool {
return p.getHealthCheckPath(application) != ""
}
func (p *Provider) getHealthCheckPath(application marathon.Application) string {
if label, ok := p.getLabel(application, labelBackendHealthCheckPath); ok {
return label
}
return ""
}
func (p *Provider) getHealthCheckInterval(application marathon.Application) string {
if label, ok := p.getLabel(application, labelBackendHealthCheckInterval); ok {
return label
}
return ""
}
func processPorts(application marathon.Application, task marathon.Task) (int, error) { func processPorts(application marathon.Application, task marathon.Task) (int, error) {
if portLabel, ok := (*application.Labels)[labelPort]; ok { if portLabel, ok := (*application.Labels)[labelPort]; ok {
port, err := strconv.Atoi(portLabel) port, err := strconv.Atoi(portLabel)

View file

@ -361,10 +361,10 @@ func TestMarathonLoadConfig(t *testing.T) {
} else { } else {
// Compare backends // Compare backends
if !reflect.DeepEqual(actualConfig.Backends, c.expectedBackends) { if !reflect.DeepEqual(actualConfig.Backends, c.expectedBackends) {
t.Errorf("got backend %v, want %v", spew.Sdump(actualConfig.Backends), spew.Sdump(c.expectedBackends)) t.Errorf("got backend\n%v\nwant\n\n%v", spew.Sdump(actualConfig.Backends), spew.Sdump(c.expectedBackends))
} }
if !reflect.DeepEqual(actualConfig.Frontends, c.expectedFrontends) { if !reflect.DeepEqual(actualConfig.Frontends, c.expectedFrontends) {
t.Errorf("got frontend %v, want %v", spew.Sdump(actualConfig.Frontends), spew.Sdump(c.expectedFrontends)) t.Errorf("got frontend\n%v\nwant\n\n%v", spew.Sdump(actualConfig.Frontends), spew.Sdump(c.expectedFrontends))
} }
} }
}) })
@ -1430,6 +1430,125 @@ func TestMarathonGetSubDomain(t *testing.T) {
} }
} }
func TestMarathonHasHealthCheckLabels(t *testing.T) {
tests := []struct {
desc string
value *string
want bool
}{
{
desc: "label missing",
value: nil,
want: false,
},
{
desc: "empty path",
value: stringp(""),
want: false,
},
{
desc: "non-empty path",
value: stringp("/path"),
want: true,
},
}
for _, test := range tests {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
app := marathon.Application{
Labels: &map[string]string{},
}
if test.value != nil {
app.AddLabel(labelBackendHealthCheckPath, *test.value)
}
prov := &Provider{}
got := prov.hasHealthCheckLabels(app)
if got != test.want {
t.Errorf("got %t, want %t", got, test.want)
}
})
}
}
func TestMarathonGetHealthCheckPath(t *testing.T) {
tests := []struct {
desc string
value *string
want string
}{
{
desc: "label missing",
value: nil,
want: "",
},
{
desc: "path existing",
value: stringp("/path"),
want: "/path",
},
}
for _, test := range tests {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
app := marathon.Application{}
app.EmptyLabels()
if test.value != nil {
app.AddLabel(labelBackendHealthCheckPath, *test.value)
}
prov := &Provider{}
got := prov.getHealthCheckPath(app)
if got != test.want {
t.Errorf("got %s, want %s", got, test.want)
}
})
}
}
func TestMarathonGetHealthCheckInterval(t *testing.T) {
tests := []struct {
desc string
value *string
want string
}{
{
desc: "label missing",
value: nil,
want: "",
},
{
desc: "interval existing",
value: stringp("5m"),
want: "5m",
},
}
for _, test := range tests {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
app := marathon.Application{
Labels: &map[string]string{},
}
if test.value != nil {
app.AddLabel(labelBackendHealthCheckInterval, *test.value)
}
prov := &Provider{}
got := prov.getHealthCheckInterval(app)
if got != test.want {
t.Errorf("got %s, want %s", got, test.want)
}
})
}
}
func stringp(s string) *string {
return &s
}
func TestGetBackendServer(t *testing.T) { func TestGetBackendServer(t *testing.T) {
appID := "appId" appID := "appId"
host := "host" host := "host"

View file

@ -659,16 +659,9 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo
log.Errorf("Skipping frontend %s...", frontendName) log.Errorf("Skipping frontend %s...", frontendName)
continue frontend continue frontend
} }
if configuration.Backends[frontend.Backend].HealthCheck != nil { hcOpts := parseHealthCheckOptions(rebalancer, frontend.Backend, configuration.Backends[frontend.Backend].HealthCheck)
var interval time.Duration if hcOpts != nil {
if configuration.Backends[frontend.Backend].HealthCheck.Interval != "" { backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(*hcOpts)
interval, err = time.ParseDuration(configuration.Backends[frontend.Backend].HealthCheck.Interval)
if err != nil {
log.Errorf("Wrong healthcheck interval: %s", err)
interval = time.Second * 30
}
}
backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(configuration.Backends[frontend.Backend].HealthCheck.Path, interval, rebalancer)
} }
} }
case types.Wrr: case types.Wrr:
@ -692,16 +685,9 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo
continue frontend continue frontend
} }
} }
if configuration.Backends[frontend.Backend].HealthCheck != nil { hcOpts := parseHealthCheckOptions(rr, frontend.Backend, configuration.Backends[frontend.Backend].HealthCheck)
var interval time.Duration if hcOpts != nil {
if configuration.Backends[frontend.Backend].HealthCheck.Interval != "" { backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(*hcOpts)
interval, err = time.ParseDuration(configuration.Backends[frontend.Backend].HealthCheck.Interval)
if err != nil {
log.Errorf("Wrong healthcheck interval: %s", err)
interval = time.Second * 30
}
}
backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(configuration.Backends[frontend.Backend].HealthCheck.Path, interval, rr)
} }
} }
maxConns := configuration.Backends[frontend.Backend].MaxConn maxConns := configuration.Backends[frontend.Backend].MaxConn
@ -861,6 +847,31 @@ func (server *Server) buildDefaultHTTPRouter() *mux.Router {
return router return router
} }
func parseHealthCheckOptions(lb healthcheck.LoadBalancer, backend string, hc *types.HealthCheck) *healthcheck.Options {
if hc == nil || hc.Path == "" {
return nil
}
interval := healthcheck.DefaultInterval
if hc.Interval != "" {
intervalOverride, err := time.ParseDuration(hc.Interval)
switch {
case err != nil:
log.Errorf("Illegal healthcheck interval for backend '%s': %s", backend, err)
case intervalOverride <= 0:
log.Errorf("Healthcheck interval smaller than zero for backend '%s', backend", backend)
default:
interval = intervalOverride
}
}
return &healthcheck.Options{
Path: hc.Path,
Interval: interval,
LB: lb,
}
}
func getRoute(serverRoute *serverRoute, route *types.Route) error { func getRoute(serverRoute *serverRoute, route *types.Route) error {
rules := Rules{route: serverRoute} rules := Rules{route: serverRoute}
newRoute, err := rules.Parse(route.Rule) newRoute, err := rules.Parse(route.Rule)

157
server/server_test.go Normal file
View file

@ -0,0 +1,157 @@
package server
import (
"fmt"
"net/url"
"reflect"
"testing"
"time"
"github.com/containous/traefik/healthcheck"
"github.com/containous/traefik/types"
"github.com/vulcand/oxy/roundrobin"
)
type testLoadBalancer struct{}
func (lb *testLoadBalancer) RemoveServer(u *url.URL) error {
return nil
}
func (lb *testLoadBalancer) UpsertServer(u *url.URL, options ...roundrobin.ServerOption) error {
return nil
}
func (lb *testLoadBalancer) Servers() []*url.URL {
return []*url.URL{}
}
func TestServerLoadConfigHealthCheckOptions(t *testing.T) {
healthChecks := []*types.HealthCheck{
nil,
{
Path: "/path",
},
}
for _, lbMethod := range []string{"Wrr", "Drr"} {
for _, healthCheck := range healthChecks {
t.Run(fmt.Sprintf("%s/hc=%t", lbMethod, healthCheck != nil), func(t *testing.T) {
globalConfig := GlobalConfiguration{
EntryPoints: EntryPoints{
"http": &EntryPoint{},
},
}
dynamicConfigs := configs{
"config": &types.Configuration{
Frontends: map[string]*types.Frontend{
"frontend": {
EntryPoints: []string{"http"},
Backend: "backend",
},
},
Backends: map[string]*types.Backend{
"backend": {
Servers: map[string]types.Server{
"server": {
URL: "http://localhost",
},
},
LoadBalancer: &types.LoadBalancer{
Method: lbMethod,
},
HealthCheck: healthCheck,
},
},
},
}
srv := NewServer(globalConfig)
if _, err := srv.loadConfig(dynamicConfigs, globalConfig); err != nil {
t.Fatalf("got error: %s", err)
}
wantNumHealthCheckBackends := 0
if healthCheck != nil {
wantNumHealthCheckBackends = 1
}
gotNumHealthCheckBackends := len(healthcheck.GetHealthCheck().Backends)
if gotNumHealthCheckBackends != wantNumHealthCheckBackends {
t.Errorf("got %d health check backends, want %d", gotNumHealthCheckBackends, wantNumHealthCheckBackends)
}
})
}
}
}
func TestServerParseHealthCheckOptions(t *testing.T) {
lb := &testLoadBalancer{}
tests := []struct {
desc string
hc *types.HealthCheck
wantOpts *healthcheck.Options
}{
{
desc: "nil health check",
hc: nil,
wantOpts: nil,
},
{
desc: "empty path",
hc: &types.HealthCheck{
Path: "",
},
wantOpts: nil,
},
{
desc: "unparseable interval",
hc: &types.HealthCheck{
Path: "/path",
Interval: "unparseable",
},
wantOpts: &healthcheck.Options{
Path: "/path",
Interval: healthcheck.DefaultInterval,
LB: lb,
},
},
{
desc: "sub-zero interval",
hc: &types.HealthCheck{
Path: "/path",
Interval: "-15s",
},
wantOpts: &healthcheck.Options{
Path: "/path",
Interval: healthcheck.DefaultInterval,
LB: lb,
},
},
{
desc: "parseable interval",
hc: &types.HealthCheck{
Path: "/path",
Interval: "5m",
},
wantOpts: &healthcheck.Options{
Path: "/path",
Interval: 5 * time.Minute,
LB: lb,
},
},
}
for _, test := range tests {
test := test
t.Run(test.desc, func(t *testing.T) {
t.Parallel()
gotOpts := parseHealthCheckOptions(lb, "backend", test.hc)
if !reflect.DeepEqual(gotOpts, test.wantOpts) {
t.Errorf("got health check options %+v, want %+v", gotOpts, test.wantOpts)
}
})
}
}

View file

@ -20,6 +20,11 @@
[backends."backend{{getFrontendBackend . }}".circuitbreaker] [backends."backend{{getFrontendBackend . }}".circuitbreaker]
expression = "{{getCircuitBreakerExpression . }}" expression = "{{getCircuitBreakerExpression . }}"
{{end}} {{end}}
{{ if hasHealthCheckLabels . }}
[backends."backend{{getFrontendBackend . }}".healthcheck]
path = "{{getHealthCheckPath . }}"
interval = "{{getHealthCheckInterval . }}"
{{end}}
{{end}} {{end}}
[frontends]{{range .Applications}} [frontends]{{range .Applications}}