diff --git a/README.md b/README.md index 535c309..44a3df1 100644 --- a/README.md +++ b/README.md @@ -135,5 +135,6 @@ allowed only after every route has been removed. or DNS configuration, `/etc/hosts`, or unrelated `dns-sd` processes. See [CLI](docs/cli.md), [architecture](docs/architecture.md), +[performance benchmarks](docs/benchmarks.md), [profiles](docs/proxy-profiles.md), [runner](docs/runner.md), [sharing](docs/sharing.md), and [PKI/service](docs/pki-service.md). diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..dcb9958 --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,29 @@ +# Performance benchmarks + +Portless keeps focused microbenchmarks for the request and persistence paths +where regressions would affect normal operation. They use real loopback TCP and +TLS listeners, real proxy requests, real certificate generation, and real +temporary-file writes, renames, file synchronization, and directory +synchronization. They do not use mocks or stubs. + +Run the complete benchmark set with allocation reporting and repeated samples: + +```sh +go test -run '^$' -bench '^Benchmark' -benchmem -count=5 \ + ./internal/proxy ./internal/routes ./internal/daemon ./internal/pki +``` + +The benchmarks cover: + +- HTTP/1.1 and HTTP/2 requests through the reverse proxy to a loopback backend. +- concurrent route resolution with one atomic route-table replacement per 1,024 + measured operations; +- durable daemon registry commits containing 1, 100, and 1,024 active routes; +- cached and uncached exact-host certificate issuance, plus issuance that + evicts from a 16-certificate bounded cache. + +Use the same machine, filesystem, Go version, power mode, and package selection +when comparing results. Report the five samples rather than only the fastest +run; filesystem synchronization and cryptographic randomness introduce real +run-to-run variation. `go test` prints `ns/op`, `B/op`, and `allocs/op`; the +route concurrency benchmark also prints its measured replacement rate. diff --git a/internal/daemon/state_benchmark_test.go b/internal/daemon/state_benchmark_test.go new file mode 100644 index 0000000..1209497 --- /dev/null +++ b/internal/daemon/state_benchmark_test.go @@ -0,0 +1,54 @@ +package daemon + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/euforicio/portless/internal/client" + "github.com/euforicio/portless/internal/routes" +) + +func BenchmarkRegistryCommit(b *testing.B) { + for _, routeCount := range []int{1, 100, 1024} { + b.Run(fmt.Sprintf("routes_%d", routeCount), func(b *testing.B) { + stateDir := b.TempDir() + table := routes.NewTable() + registry := ®istry{ + path: filepath.Join(stateDir, stateFileName), + table: table, + records: make(map[string]client.Route), + active: make(map[string]bool), + } + records, active := benchmarkRegistrations(routeCount) + if err := registry.commitLocked(records, active); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if err := registry.commitLocked(records, active); err != nil { + b.Fatal(err) + } + } + }) + } +} + +func benchmarkRegistrations(count int) (map[string]client.Route, map[string]bool) { + records := make(map[string]client.Route, count) + active := make(map[string]bool, count) + for index := range count { + name := fmt.Sprintf("app-%04d.localhost", index) + records[name] = client.Route{ + Name: name, + Scheme: "http", + Host: "127.0.0.1", + Port: uint16(10000 + index), + Owner: client.Owner{Kind: client.OwnerStatic, Refresh: client.RefreshNever}, + } + active[name] = true + } + return records, active +} diff --git a/internal/pki/authority_benchmark_test.go b/internal/pki/authority_benchmark_test.go new file mode 100644 index 0000000..66e899c --- /dev/null +++ b/internal/pki/authority_benchmark_test.go @@ -0,0 +1,62 @@ +package pki + +import ( + "fmt" + "path/filepath" + "testing" +) + +func BenchmarkAuthorityCertificate(b *testing.B) { + b.Run("cached", func(b *testing.B) { + authority, err := Open(filepath.Join(b.TempDir(), "pki"), Options{}) + if err != nil { + b.Fatal(err) + } + if _, err := authority.Certificate("cached.localhost"); err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + for b.Loop() { + if _, err := authority.Certificate("cached.localhost"); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("uncached", func(b *testing.B) { + authority, err := Open(filepath.Join(b.TempDir(), "pki"), Options{}) + if err != nil { + b.Fatal(err) + } + + b.ReportAllocs() + b.ResetTimer() + for index := 0; b.Loop(); index++ { + if _, err := authority.Certificate(fmt.Sprintf("uncached-%d.localhost", index)); err != nil { + b.Fatal(err) + } + } + }) + + b.Run("bounded_eviction", func(b *testing.B) { + authority, err := Open(filepath.Join(b.TempDir(), "pki"), Options{MaxLeafCertificates: 16}) + if err != nil { + b.Fatal(err) + } + for index := range 16 { + if _, err := authority.Certificate(fmt.Sprintf("seed-%d.localhost", index)); err != nil { + b.Fatal(err) + } + } + + b.ReportAllocs() + b.ResetTimer() + for index := 0; b.Loop(); index++ { + if _, err := authority.Certificate(fmt.Sprintf("evict-%d.localhost", index)); err != nil { + b.Fatal(err) + } + } + }) +} diff --git a/internal/proxy/proxy_bench_test.go b/internal/proxy/proxy_bench_test.go new file mode 100644 index 0000000..092a09b --- /dev/null +++ b/internal/proxy/proxy_bench_test.go @@ -0,0 +1,120 @@ +package proxy_test + +import ( + "io" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/euforicio/portless/internal/proxy" + "github.com/euforicio/portless/internal/routes" +) + +const benchmarkResponse = "portless benchmark response" + +func BenchmarkProxyRequests(b *testing.B) { + b.Run("HTTP1.1", benchmarkProxyHTTP11) + b.Run("HTTP2", benchmarkProxyHTTP2) +} + +func benchmarkProxyHTTP11(b *testing.B) { + backend := httptest.NewServer(benchmarkBackend(1)) + b.Cleanup(backend.Close) + + table := routes.NewTable() + benchmarkSetRoute(b, table, "bench.localhost", backend.URL) + handler := benchmarkNewProxy(b, table, proxy.Options{}) + b.Cleanup(handler.CloseIdleConnections) + + frontend := httptest.NewServer(handler) + b.Cleanup(frontend.Close) + client := frontend.Client() + + benchmarkRequest(b, client, frontend.URL, 1) + b.ReportAllocs() + b.SetBytes(int64(len(benchmarkResponse))) + b.ResetTimer() + for b.Loop() { + benchmarkRequest(b, client, frontend.URL, 1) + } +} + +func benchmarkProxyHTTP2(b *testing.B) { + backend := httptest.NewUnstartedServer(benchmarkBackend(2)) + backend.EnableHTTP2 = true + backend.StartTLS() + b.Cleanup(backend.Close) + + table := routes.NewTable() + benchmarkSetRoute(b, table, "bench.localhost", backend.URL) + backendTransport := backend.Client().Transport.(*http.Transport).Clone() + backendTransport.ForceAttemptHTTP2 = true + b.Cleanup(backendTransport.CloseIdleConnections) + handler := benchmarkNewProxy(b, table, proxy.Options{Transport: backendTransport}) + + frontend := httptest.NewUnstartedServer(handler) + frontend.EnableHTTP2 = true + frontend.StartTLS() + b.Cleanup(frontend.Close) + client := frontend.Client() + + benchmarkRequest(b, client, frontend.URL, 2) + b.ReportAllocs() + b.SetBytes(int64(len(benchmarkResponse))) + b.ResetTimer() + for b.Loop() { + benchmarkRequest(b, client, frontend.URL, 2) + } +} + +func benchmarkBackend(protocolMajor int) http.Handler { + return http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.ProtoMajor != protocolMajor { + http.Error(writer, "unexpected upstream protocol", http.StatusInternalServerError) + return + } + writer.Header().Set("Content-Length", strconv.Itoa(len(benchmarkResponse))) + _, _ = io.WriteString(writer, benchmarkResponse) + }) +} + +func benchmarkSetRoute(b *testing.B, table *routes.Table, host, upstream string) { + b.Helper() + if _, err := table.Set(host, upstream); err != nil { + b.Fatal(err) + } +} + +func benchmarkNewProxy(b *testing.B, table *routes.Table, options proxy.Options) *proxy.Handler { + b.Helper() + handler, err := proxy.New(table, options) + if err != nil { + b.Fatal(err) + } + return handler +} + +func benchmarkRequest(b *testing.B, client *http.Client, target string, protocolMajor int) { + b.Helper() + request, err := http.NewRequest(http.MethodGet, target+"/assets/app.js?benchmark=1", nil) + if err != nil { + b.Fatal(err) + } + request.Host = "bench.localhost" + response, err := client.Do(request) + if err != nil { + b.Fatal(err) + } + body, readErr := io.ReadAll(response.Body) + closeErr := response.Body.Close() + if readErr != nil { + b.Fatal(readErr) + } + if closeErr != nil { + b.Fatal(closeErr) + } + if response.StatusCode != http.StatusOK || response.ProtoMajor != protocolMajor || string(body) != benchmarkResponse { + b.Fatalf("response = status %d, protocol %q, body %q", response.StatusCode, response.Proto, body) + } +} diff --git a/internal/routes/routes_benchmark_test.go b/internal/routes/routes_benchmark_test.go new file mode 100644 index 0000000..22706c6 --- /dev/null +++ b/internal/routes/routes_benchmark_test.go @@ -0,0 +1,62 @@ +package routes_test + +import ( + "fmt" + "sync/atomic" + "testing" + + "github.com/euforicio/portless/internal/routes" +) + +func BenchmarkTableResolveConcurrentReplace(b *testing.B) { + const routeCount = 100 + + first := benchmarkRoutes(b, routeCount, 8000) + second := benchmarkRoutes(b, routeCount, 9000) + table := routes.NewTable() + if err := table.Replace(first); err != nil { + b.Fatal(err) + } + + var operations atomic.Uint64 + var replacements atomic.Uint64 + b.ReportAllocs() + b.ResetTimer() + b.RunParallel(func(pb *testing.PB) { + for pb.Next() { + operation := operations.Add(1) + if operation%1024 == 0 { + replacement := first + if replacements.Add(1)%2 == 1 { + replacement = second + } + if err := table.Replace(replacement); err != nil { + b.Error(err) + return + } + continue + } + if _, ok := table.Resolve("app-050.localhost:443"); !ok { + b.Error("registered route was not resolved") + return + } + } + }) + b.ReportMetric(float64(replacements.Load())/float64(b.N), "replaces/op") +} + +func benchmarkRoutes(b *testing.B, count, basePort int) []routes.Route { + b.Helper() + result := make([]routes.Route, count) + for index := range count { + route, err := routes.NewRoute( + fmt.Sprintf("app-%03d.localhost", index), + fmt.Sprintf("http://127.0.0.1:%d", basePort+index), + ) + if err != nil { + b.Fatal(err) + } + result[index] = route + } + return result +}