From 074b31b5e98810181e5b02ab6bad593b3438f58f Mon Sep 17 00:00:00 2001 From: Marco Jantke Date: Mon, 10 Jul 2017 12:11:44 +0200 Subject: [PATCH] respond with 503 on empty backend --- integration/healthcheck_test.go | 6 +- middlewares/empty_backend_handler.go | 31 ++++ middlewares/empty_backend_handler_test.go | 70 +++++++++ server/server.go | 2 + server/server_test.go | 169 ++++++++++++++++++++++ 5 files changed, 275 insertions(+), 3 deletions(-) create mode 100644 middlewares/empty_backend_handler.go create mode 100644 middlewares/empty_backend_handler_test.go diff --git a/integration/healthcheck_test.go b/integration/healthcheck_test.go index 2dc1823e7..098447752 100644 --- a/integration/healthcheck_test.go +++ b/integration/healthcheck_test.go @@ -59,8 +59,8 @@ func (s *HealthCheckSuite) TestSimpleConfiguration(c *check.C) { // Waiting for Traefik healthcheck try.Sleep(2 * time.Second) - // Verify frontend health : 500 - err = try.Request(frontendHealthReq, 3*time.Second, try.StatusCodeIs(http.StatusInternalServerError)) + // Verify no backend service is available due to failing health checks + err = try.Request(frontendHealthReq, 3*time.Second, try.StatusCodeIs(http.StatusServiceUnavailable)) c.Assert(err, checker.IsNil) // Change one whoami health to 200 @@ -77,7 +77,7 @@ func (s *HealthCheckSuite) TestSimpleConfiguration(c *check.C) { c.Assert(err, checker.IsNil) frontendReq.Host = "test.localhost" - // Check if whoami1 respond + // Check if whoami1 responds err = try.Request(frontendReq, 500*time.Millisecond, try.BodyContains(whoami1Host)) c.Assert(err, checker.IsNil) diff --git a/middlewares/empty_backend_handler.go b/middlewares/empty_backend_handler.go new file mode 100644 index 000000000..dfdd216e3 --- /dev/null +++ b/middlewares/empty_backend_handler.go @@ -0,0 +1,31 @@ +package middlewares + +import ( + "net/http" + + "github.com/containous/traefik/healthcheck" +) + +// EmptyBackendHandler is a middlware that checks whether the current Backend +// has at least one active Server in respect to the healthchecks and if this +// is not the case, it will stop the middleware chain and respond with 503. +type EmptyBackendHandler struct { + lb healthcheck.LoadBalancer + next http.Handler +} + +// NewEmptyBackendHandler creates a new EmptyBackendHandler instance. +func NewEmptyBackendHandler(lb healthcheck.LoadBalancer, next http.Handler) *EmptyBackendHandler { + return &EmptyBackendHandler{lb: lb, next: next} +} + +// ServeHTTP responds with 503 when there is no active Server and otherwise +// invokes the next handler in the middleware chain. +func (h *EmptyBackendHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + if len(h.lb.Servers()) == 0 { + rw.WriteHeader(http.StatusServiceUnavailable) + rw.Write([]byte(http.StatusText(http.StatusServiceUnavailable))) + } else { + h.next.ServeHTTP(rw, r) + } +} diff --git a/middlewares/empty_backend_handler_test.go b/middlewares/empty_backend_handler_test.go new file mode 100644 index 000000000..b77232d8c --- /dev/null +++ b/middlewares/empty_backend_handler_test.go @@ -0,0 +1,70 @@ +package middlewares + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/containous/traefik/testhelpers" + "github.com/vulcand/oxy/roundrobin" +) + +func TestEmptyBackendHandler(t *testing.T) { + tests := []struct { + amountServer int + wantStatusCode int + }{ + { + amountServer: 0, + wantStatusCode: http.StatusServiceUnavailable, + }, + { + amountServer: 1, + wantStatusCode: http.StatusOK, + }, + } + + for _, test := range tests { + test := test + + t.Run(fmt.Sprintf("amount servers %d", test.amountServer), func(t *testing.T) { + t.Parallel() + + nextHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + }) + handler := NewEmptyBackendHandler(&healthCheckLoadBalancer{test.amountServer}, nextHandler) + + recorder := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "http://localhost", nil) + + handler.ServeHTTP(recorder, req) + + if recorder.Result().StatusCode != test.wantStatusCode { + t.Errorf("Received status code %d, wanted %d", recorder.Result().StatusCode, test.wantStatusCode) + } + }) + } +} + +type healthCheckLoadBalancer struct { + amountServer int +} + +func (lb *healthCheckLoadBalancer) RemoveServer(u *url.URL) error { + return nil +} + +func (lb *healthCheckLoadBalancer) UpsertServer(u *url.URL, options ...roundrobin.ServerOption) error { + return nil +} + +func (lb *healthCheckLoadBalancer) Servers() []*url.URL { + servers := make([]*url.URL, lb.amountServer) + for i := 0; i < lb.amountServer; i++ { + servers = append(servers, testhelpers.MustParseURL("http://localhost")) + } + return servers +} diff --git a/server/server.go b/server/server.go index cf9c30eac..228d73cc2 100644 --- a/server/server.go +++ b/server/server.go @@ -744,6 +744,7 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo log.Debugf("Setting up backend health check %s", *hcOpts) backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(*hcOpts) } + lb = middlewares.NewEmptyBackendHandler(rebalancer, lb) case types.Wrr: log.Debugf("Creating load-balancer wrr") if stickysession { @@ -764,6 +765,7 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo log.Debugf("Setting up backend health check %s", *hcOpts) backendsHealthcheck[frontend.Backend] = healthcheck.NewBackendHealthCheck(*hcOpts) } + lb = middlewares.NewEmptyBackendHandler(rr, lb) } if len(frontend.Errors) > 0 { diff --git a/server/server_test.go b/server/server_test.go index d0b15cb58..9822df16c 100644 --- a/server/server_test.go +++ b/server/server_test.go @@ -3,6 +3,7 @@ package server import ( "fmt" "net/http" + "net/http/httptest" "net/url" "reflect" "testing" @@ -576,3 +577,171 @@ func TestServerEntrypointWhitelistConfig(t *testing.T) { }) } } + +func TestServerResponseEmptyBackend(t *testing.T) { + const requestPath = "/path" + const routeRule = "Path:" + requestPath + + testCases := []struct { + desc string + dynamicConfig func(testServerURL string) *types.Configuration + wantStatusCode int + }{ + { + desc: "Ok", + dynamicConfig: func(testServerURL string) *types.Configuration { + return buildDynamicConfig( + withFrontend("frontend", buildFrontend(withRoute(requestPath, routeRule))), + withBackend("backend", buildBackend(withServer("testServer", testServerURL))), + ) + }, + wantStatusCode: http.StatusOK, + }, + { + desc: "No Frontend", + dynamicConfig: func(testServerURL string) *types.Configuration { + return buildDynamicConfig() + }, + wantStatusCode: http.StatusNotFound, + }, + { + desc: "Empty Backend LB-Drr", + dynamicConfig: func(testServerURL string) *types.Configuration { + return buildDynamicConfig( + withFrontend("frontend", buildFrontend(withRoute(requestPath, routeRule))), + withBackend("backend", buildBackend(withLoadBalancer("Drr", false))), + ) + }, + wantStatusCode: http.StatusServiceUnavailable, + }, + { + desc: "Empty Backend LB-Drr Sticky", + dynamicConfig: func(testServerURL string) *types.Configuration { + return buildDynamicConfig( + withFrontend("frontend", buildFrontend(withRoute(requestPath, routeRule))), + withBackend("backend", buildBackend(withLoadBalancer("Drr", true))), + ) + }, + wantStatusCode: http.StatusServiceUnavailable, + }, + { + desc: "Empty Backend LB-Wrr", + dynamicConfig: func(testServerURL string) *types.Configuration { + return buildDynamicConfig( + withFrontend("frontend", buildFrontend(withRoute(requestPath, routeRule))), + withBackend("backend", buildBackend(withLoadBalancer("Wrr", false))), + ) + }, + wantStatusCode: http.StatusServiceUnavailable, + }, + { + desc: "Empty Backend LB-Wrr Sticky", + dynamicConfig: func(testServerURL string) *types.Configuration { + return buildDynamicConfig( + withFrontend("frontend", buildFrontend(withRoute(requestPath, routeRule))), + withBackend("backend", buildBackend(withLoadBalancer("Wrr", true))), + ) + }, + wantStatusCode: http.StatusServiceUnavailable, + }, + } + + for _, test := range testCases { + test := test + + t.Run(test.desc, func(t *testing.T) { + t.Parallel() + + testServer := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.WriteHeader(http.StatusOK) + })) + defer testServer.Close() + + globalConfig := GlobalConfiguration{ + EntryPoints: EntryPoints{ + "http": &EntryPoint{}, + }, + } + dynamicConfigs := configs{"config": test.dynamicConfig(testServer.URL)} + + srv := NewServer(globalConfig) + entryPoints, err := srv.loadConfig(dynamicConfigs, globalConfig) + if err != nil { + t.Fatalf("error loading config: %s", err) + } + + responseRecorder := &httptest.ResponseRecorder{} + request := httptest.NewRequest(http.MethodGet, testServer.URL+requestPath, nil) + + entryPoints["http"].httpRouter.ServeHTTP(responseRecorder, request) + + if responseRecorder.Result().StatusCode != test.wantStatusCode { + t.Errorf("got status code %d, want %d", responseRecorder.Result().StatusCode, test.wantStatusCode) + } + }) + } +} + +func buildDynamicConfig(dynamicConfigBuilders ...func(*types.Configuration)) *types.Configuration { + config := &types.Configuration{ + Frontends: make(map[string]*types.Frontend), + Backends: make(map[string]*types.Backend), + } + for _, build := range dynamicConfigBuilders { + build(config) + } + return config +} + +func withFrontend(frontendName string, frontend *types.Frontend) func(*types.Configuration) { + return func(config *types.Configuration) { + config.Frontends[frontendName] = frontend + } +} + +func withBackend(backendName string, backend *types.Backend) func(*types.Configuration) { + return func(config *types.Configuration) { + config.Backends[backendName] = backend + } +} + +func buildFrontend(frontendBuilders ...func(*types.Frontend)) *types.Frontend { + fe := &types.Frontend{ + EntryPoints: []string{"http"}, + Backend: "backend", + Routes: make(map[string]types.Route), + } + for _, build := range frontendBuilders { + build(fe) + } + return fe +} + +func withRoute(routeName, rule string) func(*types.Frontend) { + return func(fe *types.Frontend) { + fe.Routes[routeName] = types.Route{Rule: rule} + } +} + +func buildBackend(backendBuilders ...func(*types.Backend)) *types.Backend { + be := &types.Backend{ + Servers: make(map[string]types.Server), + LoadBalancer: &types.LoadBalancer{Method: "Wrr"}, + } + for _, build := range backendBuilders { + build(be) + } + return be +} + +func withServer(name, url string) func(backend *types.Backend) { + return func(be *types.Backend) { + be.Servers[name] = types.Server{URL: url} + } +} + +func withLoadBalancer(method string, sticky bool) func(*types.Backend) { + return func(be *types.Backend) { + be.LoadBalancer = &types.LoadBalancer{Method: method, Sticky: sticky} + } +}