diff --git a/go.mod b/go.mod index 6035d76c08b9b118bf8e91bd419360584485d365..e7c9744e9d47e172a4ec08f068093083003f9090 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.11 require ( contrib.go.opencensus.io/exporter/zipkin v0.1.2 + github.com/NYTimes/gziphandler v1.1.1 github.com/amoghe/go-crypt v0.0.0-20191109212615-b2ff80594b7f github.com/bbrks/wrap/v2 v2.5.0 github.com/cenkalti/backoff/v4 v4.1.1 diff --git a/go.sum b/go.sum index 1817d0e7c7a057022c01e2dcf09f78d0acefbbf3..981f144dcb9dd8f0abcf538dfa8fc174d10fc081 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ contrib.go.opencensus.io/exporter/zipkin v0.1.2/go.mod h1:mP5xM3rrgOjpn79MM8fZbj github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c h1:/IBSNwUN8+eKzUzbJPqhK839ygXJ82sde8x3ogr6R28= github.com/Azure/go-ntlmssp v0.0.0-20200615164410-66371956d46c/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/Shopify/sarama v1.19.0/go.mod h1:FVkBWblsNy7DGZRfXLU0O9RCGt5g3g3yEuWXgklEdEo= github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= @@ -30,6 +32,7 @@ github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGX github.com/coreos/go-systemd/v22 v22.3.2 h1:D9/bQk5vlXQFZ6Kwuu6zaiXJ9oTPe68++AzAJc1DzSI= github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/eapache/go-resiliency v1.1.0/go.mod h1:kFI+JgMyC7bLPUVY133qvEBtVayf5mFgVsvEsIPBvNs= github.com/eapache/go-xerial-snappy v0.0.0-20180814174437-776d5712da21/go.mod h1:+020luEh2TKB4/GOp8oxxtq0Daoen/Cii55CzbTV6DU= @@ -177,6 +180,7 @@ github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/theckman/go-flock v0.8.1 h1:kTixuOsFBOtGYSTLRLWK6GOs1hk/8OD11sR1pDd0dl4= github.com/theckman/go-flock v0.8.1/go.mod h1:kjuth3y9VJ2aNlkNEO99G/8lp9fMIKaGyBmh84IBheM= @@ -295,6 +299,7 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/serverutil/http.go b/serverutil/http.go index 3975b21c9f0fd194dc03311829bf38a64a840f82..6a2cc3d1736505fe0ed12339f89a1d458382817b 100644 --- a/serverutil/http.go +++ b/serverutil/http.go @@ -1,6 +1,7 @@ package serverutil import ( + "compress/gzip" "context" "crypto/tls" "fmt" @@ -16,12 +17,25 @@ import ( "time" "git.autistici.org/ai3/go-common/tracing" + "github.com/NYTimes/gziphandler" "github.com/coreos/go-systemd/v22/daemon" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" ) -var gracefulShutdownTimeout = 3 * time.Second +var ( + gracefulShutdownTimeout = 3 * time.Second + + gzipLevel = gzip.BestSpeed + gzipMinSize = 1300 + gzipContentTypes = []string{ + "application/json", + "application/javascript", + "text/html", + "text/plain", + "text/css", + } +) // ServerConfig stores common HTTP/HTTPS server configuration parameters. type ServerConfig struct { @@ -29,20 +43,23 @@ type ServerConfig struct { MaxInflightRequests int `yaml:"max_inflight_requests"` RequestTimeoutSecs int `yaml:"request_timeout"` TrustedForwarders []string `yaml:"trusted_forwarders"` + + // TODO: switch do disable_compression (flip default) later. + EnableCompression bool `yaml:"enable_compression"` } -func (config *ServerConfig) buildHTTPServer(h http.Handler) (*http.Server, error) { +func (config *ServerConfig) buildHTTPHandler(h http.Handler) (http.Handler, *tls.Config, error) { var tlsConfig *tls.Config var err error if config != nil { if config.TLS != nil { tlsConfig, err = config.TLS.TLSConfig() if err != nil { - return nil, err + return nil, nil, err } h, err = config.TLS.TLSAuthWrapper(h) if err != nil { - return nil, err + return nil, nil, err } } @@ -51,7 +68,7 @@ func (config *ServerConfig) buildHTTPServer(h http.Handler) (*http.Server, error if len(config.TrustedForwarders) > 0 { h, err = newProxyHeaders(h, config.TrustedForwarders) if err != nil { - return nil, err + return nil, nil, err } } @@ -68,15 +85,23 @@ func (config *ServerConfig) buildHTTPServer(h http.Handler) (*http.Server, error } } - // These are not meant to be external-facing servers, so we - // can be generous with the timeouts to keep the number of - // reconnections low. - return &http.Server{ - Handler: addDefaultHandlers(h), - ReadHeaderTimeout: 30 * time.Second, - IdleTimeout: 600 * time.Second, - TLSConfig: tlsConfig, - }, nil + // Add all the default handlers (health, monitoring, etc). + h = addDefaultHandlers(h) + + // Optionally enable compression. + if config.EnableCompression { + gzwrap, err := gziphandler.GzipHandlerWithOpts( + gziphandler.CompressionLevel(gzipLevel), + gziphandler.MinSize(gzipMinSize), + gziphandler.ContentTypes(gzipContentTypes), + ) + if err != nil { + return nil, nil, err + } + h = gzwrap(h) + } + + return h, tlsConfig, nil } // Serve HTTP(S) content on the specified address. If config.TLS is @@ -90,12 +115,22 @@ func Serve(h http.Handler, config *ServerConfig, addr string) error { // debugging endpoints). h = tracing.WrapHandler(h, guessEndpointName(addr)) - // Create the HTTP server. - srv, err := config.buildHTTPServer(h) + // Create the top-level HTTP handler with all our additions. + hh, tlsConfig, err := config.buildHTTPHandler(h) if err != nil { return err } + // These are not meant to be external-facing servers, so we + // can be generous with the timeouts to keep the number of + // reconnections low. + srv := &http.Server{ + Handler: hh, + ReadHeaderTimeout: 30 * time.Second, + IdleTimeout: 600 * time.Second, + TLSConfig: tlsConfig, + } + // Create the net.Listener first, so we can detect // initialization-time errors safely. l, err := net.Listen("tcp", addr) diff --git a/serverutil/http_test.go b/serverutil/http_test.go new file mode 100644 index 0000000000000000000000000000000000000000..77403add1d8e7715e94023f694a45558724e2b42 --- /dev/null +++ b/serverutil/http_test.go @@ -0,0 +1,142 @@ +package serverutil + +import ( + "context" + "crypto/rand" + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "git.autistici.org/ai3/go-common/clientutil" +) + +type TestRequest struct { + Data []string `json:"data"` +} + +type TestObject struct { + Name string `json:"name"` + Host string `json:"host"` + Timestamp time.Time `json:"timestamp"` + PubKey []byte `json:"pubkey"` +} + +type TestResponse struct { + Objects []*TestObject `json:"objects"` +} + +func fastRandomBytes(n int) []byte { + b := make([]byte, n) + rand.Read(b) // nolint: errcheck + return b +} + +func makeTestHandler() http.HandlerFunc { + // Generate a large-ish random response. + var resp TestResponse + now := time.Now() + n := 256 + resp.Objects = make([]*TestObject, 0, n) + for i := 0; i < n; i++ { + resp.Objects = append(resp.Objects, &TestObject{ + Name: fmt.Sprintf("test-object-%06d", i+1), + Host: "host-452-ff-bb", + Timestamp: now, + PubKey: fastRandomBytes(256), + }) + } + + return func(w http.ResponseWriter, httpReq *http.Request) { + var req TestRequest + if !DecodeJSONRequest(w, httpReq, &req) { + return + } + EncodeJSONResponse(w, &resp) + } +} + +const apiPath = "/api/v1/random" + +func makeTestRequest() *TestRequest { + var req TestRequest + n := 256 + req.Data = make([]string, 0, n) + for i := 0; i < n; i++ { + req.Data = append(req.Data, fmt.Sprintf("data-item-%06d", i)) + } + return &req +} + +func makeSingleRequest(backend clientutil.Backend, req *TestRequest) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + var resp TestResponse + return backend.Call(ctx, "", apiPath, &req, &resp) +} + +func runHTTPTest(t *testing.T, config *ServerConfig) { + mux := http.NewServeMux() + mux.HandleFunc(apiPath, makeTestHandler()) + + h, _, err := config.buildHTTPHandler(mux) + if err != nil { + t.Fatal(err) + } + srv := httptest.NewServer(h) + defer srv.Close() + + backend, err := clientutil.NewBackend(&clientutil.BackendConfig{ + URL: srv.URL, + }) + if err != nil { + t.Fatalf("NewBackend() error: %v", err) + } + defer backend.Close() + + if err := makeSingleRequest(backend, makeTestRequest()); err != nil { + t.Fatal(err) + } +} + +func TestHTTP(t *testing.T) { + runHTTPTest(t, &ServerConfig{}) +} + +func TestHTTP_Compression(t *testing.T) { + runHTTPTest(t, &ServerConfig{ + EnableCompression: true, + }) +} + +func BenchmarkLoad(b *testing.B) { + mux := http.NewServeMux() + mux.HandleFunc(apiPath, makeTestHandler()) + + config := &ServerConfig{ + EnableCompression: true, + } + h, _, _ := config.buildHTTPHandler(mux) + srv := httptest.NewServer(h) + defer srv.Close() + + backend, err := clientutil.NewBackend(&clientutil.BackendConfig{ + URL: srv.URL, + }) + if err != nil { + b.Fatalf("NewBackend() error: %v", err) + } + defer backend.Close() + + req := makeTestRequest() + + // Run clients. + b.SetParallelism(100) + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + makeSingleRequest(backend, req) // nolint: errcheck + } + }) +} diff --git a/serverutil/json.go b/serverutil/json.go index da3cfe38686f246e7090d5758e62b7a59cac7529..3034b4706305f686c91d25ae88abc5d8c05ec096 100644 --- a/serverutil/json.go +++ b/serverutil/json.go @@ -30,19 +30,15 @@ func DecodeJSONRequest(w http.ResponseWriter, r *http.Request, obj interface{}) // EncodeJSONResponse writes an application/json response to w. func EncodeJSONResponse(w http.ResponseWriter, obj interface{}) { - data, err := json.Marshal(obj) - if err != nil { - log.Printf("JSON serialization error: %v", err) - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - w.Header().Set("Content-Type", "application/json") w.Header().Set("Pragma", "no-cache") w.Header().Set("Cache-Control", "no-store") w.Header().Set("Expires", "-1") w.Header().Set("X-Content-Type-Options", "nosniff") - if _, err = w.Write(data); err != nil { - log.Printf("error writing response: %v", err) + + err := json.NewEncoder(w).Encode(obj) + if err != nil { + log.Printf("error writing JSON response: %v", err) + // Too late to return an error to the client now. } }