diff --git a/integration/conn_stats_test.go b/integration/conn_stats_test.go new file mode 100644 index 0000000000..489e57e2d2 --- /dev/null +++ b/integration/conn_stats_test.go @@ -0,0 +1,142 @@ +package main + +import ( + "encoding/json" + "errors" + "io/ioutil" + "net/http" + "os" + "os/exec" + "strings" + "sync" + "time" + + "github.com/containous/traefik/integration/utils" + "github.com/go-check/check" + + checker "github.com/vdemeester/shakers" +) + +// ConnStats test suites (using libcompose) +type ConnStatsSuite struct { + BaseSuite + dummyConnStats ConnStats + whoamiReq *http.Request +} + +type ConnStats struct { + File struct { + Backends struct { + Whoami struct { + MaxConn int `json:"max_conn"` + TotalConn int `json:"total_conn"` + } `json:"whoami"` + } `json:"backends"` + } `json:"file"` +} + +func (s *ConnStatsSuite) SetUpSuite(c *check.C) { + s.createComposeProject(c, "conn_stats") + s.composeProject.Start(c) + + err := json.Unmarshal( + []byte(`{"file":{"backends":{"whoami":{"max_conn":2,"total_conn":0}}}}`), + &s.dummyConnStats, + ) + c.Assert(err, checker.IsNil) + + s.whoamiReq, err = http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami", nil) + c.Assert(err, checker.IsNil) +} + +func (s *ConnStatsSuite) TearDownSuite(c *check.C) { + if s.composeProject != nil { + s.composeProject.Stop(c) + } +} + +func (s *ConnStatsSuite) ValidateWhoamiResponse(c *check.C, expectedResponseStatusCode int) { + whoamiResp, err := http.DefaultClient.Do(s.whoamiReq) + c.Assert(err, checker.IsNil) + c.Assert(whoamiResp.StatusCode, checker.Equals, expectedResponseStatusCode) +} + +func (s *ConnStatsSuite) ValidateConnStats(c *check.C, totalConn int) { + resp, err := http.Get("http://127.0.0.1:8080/api/conn_stats") + c.Assert(err, checker.IsNil) + + connStatsPayload, err := ioutil.ReadAll(resp.Body) + c.Assert(err, checker.IsNil) + + var connStats ConnStats + err = json.Unmarshal( + connStatsPayload, + &connStats, + ) + c.Assert(err, checker.IsNil) + + s.dummyConnStats.File.Backends.Whoami.TotalConn = totalConn + c.Assert(connStats, checker.DeepEquals, s.dummyConnStats) +} + +func (s *ConnStatsSuite) TestEndToEndWorkflow(c *check.C) { + whoamiHost := s.composeProject.Container(c, "whoami").NetworkSettings.IPAddress + + file := s.adaptFile(c, "fixtures/conn_stats/simple.toml", struct { + Server string + }{whoamiHost}) + defer os.Remove(file) + cmd := exec.Command(traefikBinary, "--configFile="+file) + + err := cmd.Start() + c.Assert(err, checker.IsNil) + defer cmd.Process.Kill() + + // Wait for traefik to start + err = utils.TryRequest( + "http://127.0.0.1:8080/api/providers", + 60*time.Second, + func(res *http.Response) error { + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return err + } + + if !strings.Contains(string(body), "Path:/whoami") { + return errors.New("Incorrect traefik config: " + string(body)) + } + return nil + }, + ) + c.Assert(err, checker.IsNil) + + // Make sure we can reach the whoami backend server + s.ValidateWhoamiResponse(c, http.StatusOK) + s.ValidateConnStats(c, 0) + + // Start two long running requests to the backend to reach the connection limit + whoamiWaitReq, err := http.NewRequest(http.MethodGet, "http://127.0.0.1:8000/whoami?wait=2s", nil) + c.Assert(err, checker.IsNil) + var wg sync.WaitGroup + wg.Add(2) + for i := 0; i < 2; i++ { + go func() { + resp, err := http.DefaultClient.Do(whoamiWaitReq) + c.Assert(err, checker.IsNil) + c.Assert(resp.StatusCode, checker.Equals, http.StatusOK) + wg.Done() + }() + } + + // Wait a bit for the backend to become saturated + time.Sleep(time.Second * 1) + + s.ValidateWhoamiResponse(c, http.StatusTooManyRequests) + s.ValidateConnStats(c, 2) + + // Wait for the backend to become available again + wg.Wait() + + s.ValidateWhoamiResponse(c, http.StatusOK) + s.ValidateConnStats(c, 0) +} diff --git a/integration/fixtures/conn_stats/simple.toml b/integration/fixtures/conn_stats/simple.toml new file mode 100644 index 0000000000..b7e3fa8fed --- /dev/null +++ b/integration/fixtures/conn_stats/simple.toml @@ -0,0 +1,25 @@ +defaultEntryPoints = ["http"] + +logLevel = "DEBUG" + +[entryPoints] + [entryPoints.http] + address = ":8000" + +[web] + address = ":8080" + +[file] +[frontends] + [frontends.whoami] + backend = "whoami" + [frontends.whoami.routes.route] + rule = "Path:/whoami" + +[backends] + [backends.whoami] + [backends.whoami.maxconn] + amount = 2 + extractorfunc = "request.host" + [backends.whoami.servers.server] + url = "http://{{.Server}}:80" \ No newline at end of file diff --git a/integration/integration_test.go b/integration/integration_test.go index 2205002c1b..f5812852f2 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -37,6 +37,7 @@ func init() { check.Suite(&EurekaSuite{}) check.Suite(&AcmeSuite{}) check.Suite(&DynamoDBSuite{}) + check.Suite(&ConnStatsSuite{}) } var traefikBinary = "../dist/traefik" diff --git a/integration/resources/compose/conn_stats.yml b/integration/resources/compose/conn_stats.yml new file mode 100644 index 0000000000..bf98841145 --- /dev/null +++ b/integration/resources/compose/conn_stats.yml @@ -0,0 +1,2 @@ +whoami: + image: emilevauge/whoami \ No newline at end of file diff --git a/server.go b/server.go index f9c93e7ee4..f7c652bd8c 100644 --- a/server.go +++ b/server.go @@ -53,6 +53,7 @@ type Server struct { loggerMiddleware *middlewares.Logger routinesPool *safe.Pool leadership *cluster.Leadership + backendConnLimits map[string]*connlimit.ConnLimiter } type serverEntryPoints map[string]*serverEntryPoint @@ -88,6 +89,7 @@ func NewServer(globalConfiguration GlobalConfiguration) *Server { // leadership creation if cluster mode server.leadership = cluster.NewLeadership(server.routinesPool.Ctx(), globalConfiguration.Cluster) } + server.backendConnLimits = make(map[string]*connlimit.ConnLimiter) return server } @@ -732,12 +734,15 @@ func (server *Server) loadConfig(configurations configs, globalConfiguration Glo continue frontend } log.Debugf("Creating load-balancer connlimit") - lb, err = connlimit.New(lb, extractFunc, maxConns.Amount, connlimit.Logger(oxyLogger)) + cl, err := connlimit.New(lb, extractFunc, maxConns.Amount, connlimit.Logger(oxyLogger)) if err != nil { log.Errorf("Error creating connlimit: %v", err) log.Errorf("Skipping frontend %s...", frontendName) continue frontend } + + lb = cl + server.backendConnLimits[frontend.Backend] = cl } // retry ? if globalConfiguration.Retry != nil { diff --git a/web.go b/web.go index e71e75f20d..dd43cc5239 100644 --- a/web.go +++ b/web.go @@ -6,6 +6,7 @@ import ( "fmt" "io/ioutil" "net/http" + "reflect" "runtime" "github.com/codegangsta/negroni" @@ -20,6 +21,7 @@ import ( "github.com/prometheus/client_golang/prometheus/promhttp" thoas_stats "github.com/thoas/stats" "github.com/unrolled/render" + "github.com/vulcand/oxy/connlimit" ) var ( @@ -122,6 +124,10 @@ func (provider *WebProvider) Provide(configurationChan chan<- types.ConfigMessag systemRouter.Methods("GET").Path(provider.Path + "api/providers/{provider}/frontends/{frontend}/routes").HandlerFunc(provider.getRoutesHandler) systemRouter.Methods("GET").Path(provider.Path + "api/providers/{provider}/frontends/{frontend}/routes/{route}").HandlerFunc(provider.getRouteHandler) + // Expose connection stats + systemRouter.Methods("GET").Path(provider.Path + "api/conn_stats").HandlerFunc(provider.getConnStatsHandler) + systemRouter.Methods("GET").Path(provider.Path + "api/providers/{provider}/backends/{backend}/conn_stats").HandlerFunc(provider.getBackendConnStatsHandler) + // Expose dashboard systemRouter.Methods("GET").Path(provider.Path).HandlerFunc(func(response http.ResponseWriter, request *http.Request) { http.Redirect(response, request, provider.Path+"dashboard/", 302) @@ -330,3 +336,78 @@ func expvarHandler(w http.ResponseWriter, r *http.Request) { }) fmt.Fprintf(w, "\n}\n") } + +type connStats struct { + MaxConn int64 `json:"max_conn"` + TotalConn int64 `json:"total_conn"` +} + +func (provider *WebProvider) getBackendConnStatsHandler(response http.ResponseWriter, request *http.Request) { + vars := mux.Vars(request) + providerID := vars["provider"] + backendID := vars["backend"] + currentConfigurations := provider.server.currentConfigurations.Get().(configs) + if providerConf, ok := currentConfigurations[providerID]; ok { + if backendConf, ok := providerConf.Backends[backendID]; ok { + if connLimiter, ok := provider.server.backendConnLimits[backendID]; ok { + if totalConn, ok := getTotalConn(connLimiter); ok { + templatesRenderer.JSON(response, http.StatusOK, &connStats{ + MaxConn: backendConf.MaxConn.Amount, + TotalConn: totalConn, + }) + return + } + } + } + } + + http.NotFound(response, request) +} + +type providers struct { + Backends map[string]connStats `json:"backends"` +} + +func (provider *WebProvider) getConnStatsHandler(response http.ResponseWriter, request *http.Request) { + payload := make(map[string]providers) + + currentConfigurations := provider.server.currentConfigurations.Get().(configs) + for p := range currentConfigurations { + payload[p] = providers{ + Backends: make(map[string]connStats), + } + for b := range currentConfigurations[p].Backends { + if connLimiter, ok := provider.server.backendConnLimits[b]; ok { + if totalConn, ok := getTotalConn(connLimiter); ok { + payload[p].Backends[b] = connStats{ + MaxConn: currentConfigurations[p].Backends[b].MaxConn.Amount, + TotalConn: totalConn, + } + } + } + } + } + + templatesRenderer.JSON(response, http.StatusOK, payload) +} + +func getTotalConn(cl *connlimit.ConnLimiter) (int64, bool) { + clPtr := reflect.ValueOf(cl) + if !clPtr.IsValid() && clPtr.Kind() != reflect.Ptr { + log.Debugf("Expecting Ptr type but got %s instead", clPtr.Kind()) + return 0, false + } + + clData := clPtr.Elem() + if !clData.IsValid() && clData.Kind() != reflect.Struct { + log.Debugf("Expecting Struct type but got %s instead", clData.Kind()) + return 0, false + } + + totalConnField := clData.FieldByName("totalConnections") + if !totalConnField.IsValid() && totalConnField.Kind() != reflect.Int64 { + log.Debugf("Expecting Int64 type but got %s instead", totalConnField.Kind()) + return 0, false + } + return totalConnField.Int(), true +} diff --git a/webui/readme.md b/webui/readme.md index 7c42346f42..fb92bf7805 100644 --- a/webui/readme.md +++ b/webui/readme.md @@ -7,6 +7,7 @@ Access to Træfɪk Web UI, ex: http://localhost:8080 Træfɪk Web UI provide 2 types of informations: - Providers with their backends and frontends information. - Health of the web server. +- Connection stats for each backend. ## How to build (for backends developer) @@ -55,6 +56,7 @@ make generate-webui # Generate static contents in `traefik/static/` folder. - Træfɪk API connections are defined in: - `webui/src/app/core/health.resource.js` - `webui/src/app/core/providers.resource.js` + - `webui/src/app/core/conn_stats.resource.js` - The pages contents are in the directory `webui/src/app/sections`. diff --git a/webui/src/app/core/conn_stats.resource.js b/webui/src/app/core/conn_stats.resource.js new file mode 100644 index 0000000000..ebb13843b3 --- /dev/null +++ b/webui/src/app/core/conn_stats.resource.js @@ -0,0 +1,14 @@ +'use strict'; +var angular = require('angular'); + +var traefikCoreConnStats = 'traefik.core.conn_stats'; +module.exports = traefikCoreConnStats; + +angular + .module(traefikCoreConnStats, ['ngResource']) + .factory('ConnStats', ConnStats); + + /** @ngInject */ + function ConnStats($resource) { + return $resource('../api/conn_stats'); + } diff --git a/webui/src/app/sections/conn_stats/conn_stats.controller.js b/webui/src/app/sections/conn_stats/conn_stats.controller.js new file mode 100644 index 0000000000..e40c206e89 --- /dev/null +++ b/webui/src/app/sections/conn_stats/conn_stats.controller.js @@ -0,0 +1,163 @@ +'use strict'; +var d3 = require('d3'); + +/** @ngInject */ +function ConnStatsController($scope, $interval, $log, $filter, ConnStats) { + var vm = this, + refreshInterval = 2000, // milliseconds + // Store and display data for the past 30 minutes + maxHistoricDataPointCount = 1800 / (refreshInterval / 1000), + dataPointStorageTime = 1800 * 1000; + + vm.graph = { + historicalConnCount: {}, + currentConnCount: {} + }; + + vm.graph.historicalConnCount.options = { + chart: { + type: 'lineChart', + height: 300, + margin: { + top: 20, + right: 40, + bottom: 40, + left: 55 + }, + xScale: d3.time.scale(), + xAxis: { + tickFormat: function (d) { + return d3.time.format('%X')(new Date(d)); + } + }, + forceY: [0, 1], // This prevents the chart from showing -1 on Oy when all the input data points + // have y = 0. It won't disable the automatic adjustment of the max value. + useInteractiveGuideline: true, + duration: 0 // Bug: Markers will not be drawn if you set this to some other value... + }, + title: { + enable: true, + text: 'Historical Connection Counts' + } + }; + + vm.graph.currentConnCount.options = { + chart: { + type: 'discreteBarChart', + height: 500, + margin: { + top: 20, + right: 40, + bottom: 240, + left: 55 + }, + x: function(d) { return d.label; }, + y: function(d) { return d.value; }, + valueFormat: d3.format('d'), + showValues: true, + rotateLabels: 90 + }, + title: { + enable: true, + text: 'Current Connection Counts' + } + }; + + function updateHistoricalConnCountGraph() { + var currentDate = Date.now(); + + angular.forEach(vm.connStats, function(provider, providerId) { + angular.forEach(provider.backends, function(backend, backendId) { + var dataPointKey = providerId + '/' + backendId, + newDataPoint = { + x: currentDate, + y: backend['total_conn'] + }; + + // Check if the new data point belongs to an existing plot + var existingPlot = $filter('filter')(vm.graph.historicalConnCount.data, {'key': dataPointKey}) + if (existingPlot.length > 0) { + // There should be only one + existingPlot[0].values.push(newDataPoint); + existingPlot[0].lastUpdated = currentDate; + } else { + vm.graph.historicalConnCount.data.push({ + values: [newDataPoint], + key: dataPointKey, + type: 'line', + lastUpdated: currentDate + }); + } + }); + }); + + // Limit plot data points + for (var i = 0; i < vm.graph.historicalConnCount.data.length; i++) { + var values = vm.graph.historicalConnCount.data[i].values; + if (values.length > maxHistoricDataPointCount) { + values.shift(); + } + + // Remove dead entries + if (values.length == 0 || + values[values.length - 1].lastUpdated < Date.now() - dataPointStorageTime) { + + vm.graph.historicalConnCount.data.splice(i, 1); + } + } + } + + function updateCurrentConnCountGraph() { + vm.graph.currentConnCount.data = [{ + values: [] + }]; + + angular.forEach(vm.connStats, function(provider, providerId) { + angular.forEach(provider.backends, function(backend, backendId) { + var dataPointLabel = providerId + '/' + backendId, + maxConnDataPoint = { + label: dataPointLabel, + value: backend['max_conn'] + }, + totalConnDataPoint = { + label: dataPointLabel, + value: backend['total_conn'] + }; + + vm.graph.currentConnCount.data[0].values.push(maxConnDataPoint); + vm.graph.currentConnCount.data[0].values.push(totalConnDataPoint); + }); + }); + } + + function loadData(connStats) { + // Set the current data point + vm.connStats = connStats; + + updateHistoricalConnCountGraph(); + updateCurrentConnCountGraph(); + } + + function errData(error) { + vm.connStats = {}; + $log.error(error); + } + + // Get the initial data points + vm.connStats = ConnStats.get(loadData, errData); + + // Initialize the view data for the historical connection count chart + vm.graph.historicalConnCount.data = []; + + var intervalId = $interval(function () { + ConnStats.get(loadData, errData); + }, + refreshInterval + ); + + $scope.$on('$destroy', function () { + $interval.cancel(intervalId); + }); +} + +module.exports = ConnStatsController; diff --git a/webui/src/app/sections/conn_stats/conn_stats.html b/webui/src/app/sections/conn_stats/conn_stats.html new file mode 100644 index 0000000000..61ba77082d --- /dev/null +++ b/webui/src/app/sections/conn_stats/conn_stats.html @@ -0,0 +1,14 @@ +