diff --git a/.gitignore b/.gitignore index 1f15930..ca5586e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,4 @@ /out .dockerconfigjson *.prof -proxy.test \ No newline at end of file +*.test \ No newline at end of file diff --git a/Makefile b/Makefile index 2ad215c..71e0fd0 100644 --- a/Makefile +++ b/Makefile @@ -131,7 +131,7 @@ tools/grafterm/bin/grafterm: GOBIN=$(PWD)/tools/grafterm/bin go install github.com/slok/grafterm/cmd/grafterm@v0.2.0 bench: - go test -bench=. -run '^$$' -count=10 ./... + go test -bench=. -run '^$$' ./internal/bench tools/benchstat/bin/benchstat: mkdir -p tools/benchstat/bin diff --git a/doc/fr/tutorials/profiling.md b/doc/fr/tutorials/profiling.md index f925285..906ca42 100644 --- a/doc/fr/tutorials/profiling.md +++ b/doc/fr/tutorials/profiling.md @@ -1,31 +1,54 @@ # Étudier les performances de Bouncer -1. Lancer un benchmark du proxy +Le package `./internal` est dédié à l'étude des performances de Bouncer. Il contient une suite de benchmarks simulant de proxies avec différentes configurations de layers afin d'évaluer les points d'engorgement sur le traitement des requêtes. - ```shell - go test -bench=. -run '^$' -count=5 -cpuprofile bench_proxy.prof ./internal/proxy - ``` +Voir le répertoire `./internal/bench/testdata/proxies` pour voir les différentes configurations de cas. -2. Visualiser les temps d'exécution +## Lancer les benchmarks - ```shell - go tool pprof -web bench_proxy.prof - ``` +Le plus simple est d'utiliser la commande `make bench` qui exécutera séquentiellement tous les benchmarks. Il est également possible de lancer un benchmark spécifique via la commande suivante: -3. Comparer les performances d'une exécution à l'autre +```bash +go test -bench="BenchmarkProxies/$BENCH_CASE" -run='^$' ./internal/bench +``` - ```shell - # Lancer un premier benchmark - go test -bench=. -run '^$' -count=10 ./internal/proxy > bench_before.txt +Par exemple: - # Faire des modifications sur les sources +```bash +# Pour exécuter ./internal/bench/testdata/proxies/basic-auth.yml +go test -bench='BenchmarkProxies/basic-auth' -run='^$' ./internal/bench +``` - # Lancer un second benchmark - go test -bench=. -run '^$' -count=10 ./internal/proxy > bench_after.txt +## Visualiser les profils d'exécution - # Installer l'outil benchstat - make tools/benchstat/bin/benchstat +Vous pouvez visualiser les profils d'exécution via la commande suivante: - # Comparer les rapports - tools/benchstat/bin/benchstat bench_before.txt bench_after.txt - ``` +```shell +go tool pprof -web path/to/file.prof +``` + +Par défaut l'exécution des benchmarks créera automatiquement des fichiers de profil dans le répertoire `./internal/bench/testdata/proxies`. + +Par exemple: + +```shell +go tool pprof -web ./internal/bench/testdata/proxies/basic-auth.prof +``` + +## Comparer les évolutions + +```bash +# Lancer un premier benchmark +go test -bench="BenchmarkProxies/$BENCH_CASE" -run='^$' ./internal/bench + +# Faire une sauvegarde du fichier de profil +cp ./internal/bench/testdata/proxies/$BENCH_CASE.prof ./internal/bench/testdata/proxies/$BENCH_CASE-prev.prof + +# Faire des modifications sur les sources + +# Lancer un second benchmark +go test -bench="BenchmarkProxies/$BENCH_CASE" -run='^$' ./internal/bench + +# Visualiser la différence entre les deux profils +go tool pprof -web -base=./internal/bench/testdata/proxies/$BENCH_CASE-prev.prof ./internal/bench/testdata/proxies/$BENCH_CASE.prof +``` diff --git a/internal/bench/proxy_test.go b/internal/bench/proxy_test.go new file mode 100644 index 0000000..54d7b23 --- /dev/null +++ b/internal/bench/proxy_test.go @@ -0,0 +1,300 @@ +package proxy_test + +import ( + "context" + "io" + "log" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "os" + "path/filepath" + "runtime/pprof" + "strings" + "testing" + "time" + + "forge.cadoles.com/Cadoles/go-proxy" + "forge.cadoles.com/cadoles/bouncer/internal/cache/memory" + "forge.cadoles.com/cadoles/bouncer/internal/cache/ttl" + "forge.cadoles.com/cadoles/bouncer/internal/config" + "forge.cadoles.com/cadoles/bouncer/internal/proxy/director" + "forge.cadoles.com/cadoles/bouncer/internal/store" + redisStore "forge.cadoles.com/cadoles/bouncer/internal/store/redis" + "github.com/pkg/errors" + "github.com/redis/go-redis/v9" + "gopkg.in/yaml.v3" + + "forge.cadoles.com/cadoles/bouncer/internal/setup" +) + +func BenchmarkProxies(b *testing.B) { + proxyFiles, err := filepath.Glob("testdata/proxies/*.yml") + if err != nil { + b.Fatalf("%+v", errors.WithStack(err)) + } + + for _, f := range proxyFiles { + name := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f)) + + b.Run(name, func(b *testing.B) { + conf, err := loadProxyBenchConfig(f) + if err != nil { + b.Fatalf("%+v", errors.Wrapf(err, "could notre load bench config")) + } + + proxy, backend, err := createProxy(name, conf, b.Logf) + if err != nil { + b.Fatalf("%+v", errors.Wrapf(err, "could not create proxy")) + } + + defer proxy.Close() + + if backend != nil { + defer backend.Close() + } + + client := proxy.Client() + + proxyURL, err := url.Parse(proxy.URL) + if err != nil { + b.Fatalf("%+v", errors.Wrapf(err, "could not parse proxy url")) + } + + if conf.Fetch.URL.Path != "" { + proxyURL.Path = conf.Fetch.URL.Path + } + + if conf.Fetch.URL.RawQuery != "" { + proxyURL.RawQuery = conf.Fetch.URL.RawQuery + } + + if conf.Fetch.URL.User.Username != "" || conf.Fetch.URL.User.Password != "" { + proxyURL.User = url.UserPassword(conf.Fetch.URL.User.Username, conf.Fetch.URL.User.Password) + } + + rawProxyURL := proxyURL.String() + + b.Logf("fetching url '%s'", rawProxyURL) + + profile, err := os.Create(filepath.Join("testdata", "proxies", name+".prof")) + if err != nil { + b.Fatalf("%+v", errors.Wrapf(err, "could not create cpu profile")) + } + + defer profile.Close() + + if err := pprof.StartCPUProfile(profile); err != nil { + log.Fatal(err) + } + + defer pprof.StopCPUProfile() + + b.ResetTimer() + + for i := 0; i < b.N; i++ { + res, err := client.Get(rawProxyURL) + if err != nil { + b.Errorf("could not fetch proxy url: %+v", errors.WithStack(err)) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + b.Errorf("could not read response body: %+v", errors.WithStack(err)) + } + + b.Logf("%s \n %v", res.Status, string(body)) + + if err := res.Body.Close(); err != nil { + b.Errorf("could not close response body: %+v", errors.WithStack(err)) + } + } + }) + } +} + +type proxyBenchConfig struct { + Proxy config.BootstrapProxyConfig `yaml:"proxy"` + Fetch fetchBenchConfig `yaml:"fetch"` +} + +type fetchBenchConfig struct { + URL fetchURLBenchConfig `yaml:"url"` +} + +type fetchURLBenchConfig struct { + Path string `yaml:"path"` + RawQuery string `yaml:"rawQuery"` + User fetchURLUserBenchConfig `yaml:"user"` +} + +type fetchURLUserBenchConfig struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +func loadProxyBenchConfig(filename string) (*proxyBenchConfig, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, errors.Wrapf(err, "could not read file '%s'", filename) + } + + conf := proxyBenchConfig{} + + if err := yaml.Unmarshal(data, &conf); err != nil { + return nil, errors.Wrapf(err, "could not unmarshal config") + } + + return &conf, nil +} + +func createProxy(name string, conf *proxyBenchConfig, logf func(format string, a ...any)) (*httptest.Server, *httptest.Server, error) { + redisEndpoint := os.Getenv("BOUNCER_BENCH_REDIS_ADDR") + if redisEndpoint == "" { + redisEndpoint = "127.0.0.1:6379" + } + + client := redis.NewUniversalClient(&redis.UniversalOptions{ + Addrs: []string{redisEndpoint}, + }) + + proxyRepository := redisStore.NewProxyRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay) + layerRepository := redisStore.NewLayerRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay) + + var backend *httptest.Server + + if conf.Proxy.To == "" { + backend = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + if _, err := w.Write([]byte("Hello, world.")); err != nil { + logf("[ERROR] %+v", errors.WithStack(err)) + } + })) + + if err := waitFor(backend.URL, 5*time.Second); err != nil { + return nil, nil, errors.WithStack(err) + } + + logf("started backend '%s'", backend.URL) + } + + ctx := context.Background() + + proxyName := store.ProxyName("bench-" + name) + + proxies, err := proxyRepository.QueryProxy(ctx) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + // Cleanup existing proxies + for _, p := range proxies { + if err := proxyRepository.DeleteProxy(ctx, p.Name); err != nil { + return nil, nil, errors.WithStack(err) + } + } + + logf("creating proxy '%s'", proxyName) + + to := string(conf.Proxy.To) + if to == "" { + to = backend.URL + } + + if _, err := proxyRepository.CreateProxy(ctx, proxyName, to, conf.Proxy.From...); err != nil { + return nil, nil, errors.WithStack(err) + } + + if _, err := proxyRepository.UpdateProxy(ctx, proxyName, store.WithProxyUpdateEnabled(true)); err != nil { + return nil, nil, errors.WithStack(err) + } + + for layerName, layerConf := range conf.Proxy.Layers { + if err := layerRepository.DeleteLayer(ctx, proxyName, store.LayerName(layerName)); err != nil { + return nil, nil, errors.WithStack(err) + } + + _, err := layerRepository.CreateLayer(ctx, proxyName, store.LayerName(layerName), store.LayerType(layerConf.Type), layerConf.Options.Data) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + _, err = layerRepository.UpdateLayer(ctx, proxyName, store.LayerName(layerName), store.WithLayerUpdateEnabled(bool(layerConf.Enabled))) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + } + + layers, err := setup.GetLayers(context.Background(), config.NewDefault()) + if err != nil { + return nil, nil, errors.WithStack(err) + } + + director := director.New( + proxyRepository, layerRepository, + director.WithLayerCache( + ttl.NewCache( + memory.NewCache[string, []*store.Layer](), + memory.NewCache[string, time.Time](), + 30*time.Second, + ), + ), + director.WithProxyCache( + ttl.NewCache( + memory.NewCache[string, []*store.Proxy](), + memory.NewCache[string, time.Time](), + 30*time.Second, + ), + ), + director.WithLayers(layers...), + ) + + directorMiddleware := director.Middleware() + + handler := proxy.New( + proxy.WithRequestTransformers( + director.RequestTransformer(), + ), + proxy.WithResponseTransformers( + director.ResponseTransformer(), + ), + proxy.WithReverseProxyFactory(func(ctx context.Context, target *url.URL) *httputil.ReverseProxy { + reverse := httputil.NewSingleHostReverseProxy(target) + reverse.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + logf("[ERROR] %s", errors.WithStack(err)) + } + return reverse + }), + ) + + server := httptest.NewServer(directorMiddleware(handler)) + + return server, backend, nil +} + +func waitFor(url string, ttl time.Duration) error { + var lastErr error + timeout := time.After(ttl) + for { + select { + case <-timeout: + if lastErr != nil { + return lastErr + } + + return errors.New("wait timed out") + default: + res, err := http.Get(url) + if err != nil { + lastErr = errors.WithStack(err) + continue + } + + if res.StatusCode >= 200 && res.StatusCode < 400 { + return nil + } + } + } +} diff --git a/internal/bench/testdata/proxies/basic-auth.yml b/internal/bench/testdata/proxies/basic-auth.yml new file mode 100644 index 0000000..dad03c6 --- /dev/null +++ b/internal/bench/testdata/proxies/basic-auth.yml @@ -0,0 +1,20 @@ +proxy: + from: ["*"] + to: "" + layers: + basic-auth: + type: authn-basic + enabled: true + options: + users: + - username: foo + passwordHash: "$2y$10$ShTc856wMB8PCxyr46qJRO8z06MpV4UejAVRDJ/bixhu0XTGn7Giy" + attributes: + email: foo@bar.com + rules: + - set_header("Remote-User-Attr-Email", user.attrs.email) +fetch: + url: + user: + username: foo + password: bar diff --git a/internal/bench/testdata/proxies/noop.yml b/internal/bench/testdata/proxies/noop.yml new file mode 100644 index 0000000..767ad66 --- /dev/null +++ b/internal/bench/testdata/proxies/noop.yml @@ -0,0 +1,3 @@ +proxy: + from: ["*"] + to: "" diff --git a/internal/bench/testdata/proxies/rewriter.yml b/internal/bench/testdata/proxies/rewriter.yml new file mode 100644 index 0000000..bbaa8b2 --- /dev/null +++ b/internal/bench/testdata/proxies/rewriter.yml @@ -0,0 +1,12 @@ +proxy: + from: ["*"] + to: "" + layers: + host-rewriter: + type: rewriter + enabled: true + options: + rules: + request: + - set_host(request.url.host) + - set_header("X-Proxied-With", "bouncer") diff --git a/internal/config/bootstrap.go b/internal/config/bootstrap.go index e1a2b67..97bc97a 100644 --- a/internal/config/bootstrap.go +++ b/internal/config/bootstrap.go @@ -80,24 +80,33 @@ func loadBootstrapDir(dir string) (map[store.ProxyName]BootstrapProxyConfig, err proxies := make(map[store.ProxyName]BootstrapProxyConfig) for _, f := range files { - data, err := os.ReadFile(f) + proxy, err := loadBootstrappedProxyConfig(f) if err != nil { - return nil, errors.Wrapf(err, "could not read file '%s'", f) - } - - proxy := BootstrapProxyConfig{} - - if err := yaml.Unmarshal(data, &proxy); err != nil { - return nil, errors.Wrapf(err, "could not unmarshal proxy") + return nil, errors.Wrapf(err, "could not load proxy bootstrap file '%s'", f) } name := store.ProxyName(strings.TrimSuffix(filepath.Base(f), filepath.Ext(f))) - proxies[name] = proxy + proxies[name] = *proxy } return proxies, nil } +func loadBootstrappedProxyConfig(filename string) (*BootstrapProxyConfig, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, errors.Wrapf(err, "could not read file '%s'", filename) + } + + proxy := BootstrapProxyConfig{} + + if err := yaml.Unmarshal(data, &proxy); err != nil { + return nil, errors.Wrapf(err, "could not unmarshal proxy") + } + + return &proxy, nil +} + func overrideProxies(base map[store.ProxyName]BootstrapProxyConfig, proxies map[store.ProxyName]BootstrapProxyConfig) map[store.ProxyName]BootstrapProxyConfig { for name, proxy := range proxies { base[name] = proxy diff --git a/internal/config/environment.go b/internal/config/environment.go index 0e3cc1d..c3a5fd6 100644 --- a/internal/config/environment.go +++ b/internal/config/environment.go @@ -127,7 +127,7 @@ func (im *InterpolatedMap) UnmarshalYAML(value *yaml.Node) error { return nil } -func (im *InterpolatedMap) interpolateRecursive(data any) (any, error) { +func (im InterpolatedMap) interpolateRecursive(data any) (any, error) { switch typ := data.(type) { case map[string]any: for key, value := range typ { diff --git a/internal/proxy/proxy_test.go b/internal/proxy/proxy_test.go deleted file mode 100644 index c1eab39..0000000 --- a/internal/proxy/proxy_test.go +++ /dev/null @@ -1,156 +0,0 @@ -package proxy_test - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "net/http/httputil" - "net/url" - "os" - "testing" - "time" - - "forge.cadoles.com/Cadoles/go-proxy" - "forge.cadoles.com/cadoles/bouncer/internal/cache/memory" - "forge.cadoles.com/cadoles/bouncer/internal/cache/ttl" - "forge.cadoles.com/cadoles/bouncer/internal/proxy/director" - "forge.cadoles.com/cadoles/bouncer/internal/store" - redisStore "forge.cadoles.com/cadoles/bouncer/internal/store/redis" - "github.com/pkg/errors" - "github.com/redis/go-redis/v9" -) - -func BenchmarkProxy(b *testing.B) { - redisEndpoint := os.Getenv("BOUNCER_BENCH_REDIS_ADDR") - if redisEndpoint == "" { - redisEndpoint = "127.0.0.1:6379" - } - - client := redis.NewUniversalClient(&redis.UniversalOptions{ - Addrs: []string{redisEndpoint}, - }) - - proxyRepository := redisStore.NewProxyRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay) - layerRepository := redisStore.NewLayerRepository(client, redisStore.DefaultTxMaxAttempts, redisStore.DefaultTxBaseDelay) - - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - if _, err := w.Write([]byte("Hello, world.")); err != nil { - b.Logf("[ERROR] %+v", errors.WithStack(err)) - } - })) - defer backend.Close() - - if err := waitFor(backend.URL, 5*time.Second); err != nil { - b.Fatalf("[FATAL] %+v", errors.WithStack(err)) - } - - b.Logf("started backend '%s'", backend.URL) - - ctx := context.Background() - - proxyName := store.ProxyName(b.Name()) - - b.Logf("creating proxy '%s'", proxyName) - - if err := proxyRepository.DeleteProxy(ctx, proxyName); err != nil { - b.Fatalf("[FATAL] %+v", errors.WithStack(err)) - } - - if _, err := proxyRepository.CreateProxy(ctx, proxyName, backend.URL, "*"); err != nil { - b.Fatalf("[FATAL] %+v", errors.WithStack(err)) - } - - if _, err := proxyRepository.UpdateProxy(ctx, proxyName, store.WithProxyUpdateEnabled(true)); err != nil { - b.Fatalf("[FATAL] %+v", errors.WithStack(err)) - } - - director := director.New( - proxyRepository, layerRepository, - director.WithLayerCache( - ttl.NewCache( - memory.NewCache[string, []*store.Layer](), - memory.NewCache[string, time.Time](), - 30*time.Second, - ), - ), - director.WithProxyCache( - ttl.NewCache( - memory.NewCache[string, []*store.Proxy](), - memory.NewCache[string, time.Time](), - 30*time.Second, - ), - ), - ) - - directorMiddleware := director.Middleware() - - handler := proxy.New( - proxy.WithRequestTransformers( - director.RequestTransformer(), - ), - proxy.WithResponseTransformers( - director.ResponseTransformer(), - ), - proxy.WithReverseProxyFactory(func(ctx context.Context, target *url.URL) *httputil.ReverseProxy { - reverse := httputil.NewSingleHostReverseProxy(target) - reverse.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { - b.Logf("[ERROR] %s", errors.WithStack(err)) - } - return reverse - }), - ) - - server := httptest.NewServer(directorMiddleware(handler)) - defer server.Close() - - b.Logf("started proxy '%s'", server.URL) - - httpClient := server.Client() - - b.ResetTimer() - - for i := 0; i < b.N; i++ { - res, err := httpClient.Get(server.URL) - if err != nil { - b.Errorf("could not fetch server url: %+v", errors.WithStack(err)) - } - - body, err := io.ReadAll(res.Body) - if err != nil { - b.Errorf("could not read response body: %+v", errors.WithStack(err)) - } - - b.Logf("%s - %v", res.Status, string(body)) - - if err := res.Body.Close(); err != nil { - b.Errorf("could not close response body: %+v", errors.WithStack(err)) - } - } -} - -func waitFor(url string, ttl time.Duration) error { - var lastErr error - timeout := time.After(ttl) - for { - select { - case <-timeout: - if lastErr != nil { - return lastErr - } - - return errors.New("wait timed out") - default: - res, err := http.Get(url) - if err != nil { - lastErr = errors.WithStack(err) - continue - } - - if res.StatusCode >= 200 && res.StatusCode < 400 { - return nil - } - } - } -} diff --git a/internal/store/redis/layer_repository.go b/internal/store/redis/layer_repository.go index 4899c23..20bd705 100644 --- a/internal/store/redis/layer_repository.go +++ b/internal/store/redis/layer_repository.go @@ -36,7 +36,7 @@ func (r *LayerRepository) CreateLayer(ctx context.Context, proxyName store.Proxy CreatedAt: wrap(now), UpdatedAt: wrap(now), - Options: wrap(store.LayerOptions{}), + Options: wrap(options), } txf := func(tx *redis.Tx) error { @@ -60,6 +60,11 @@ func (r *LayerRepository) CreateLayer(ctx context.Context, proxyName store.Proxy return errors.WithStack(err) } + layerItem, err = r.txGetLayerItem(ctx, tx, proxyName, layerName) + if err != nil { + return errors.WithStack(err) + } + return nil } @@ -70,16 +75,16 @@ func (r *LayerRepository) CreateLayer(ctx context.Context, proxyName store.Proxy return &store.Layer{ LayerHeader: store.LayerHeader{ - Name: layerName, - Proxy: proxyName, - Type: layerType, - Weight: 0, - Enabled: false, + Name: store.LayerName(layerItem.Name), + Proxy: store.ProxyName(layerItem.Proxy), + Type: store.LayerType(layerItem.Type), + Weight: layerItem.Weight, + Enabled: layerItem.Enabled, }, - CreatedAt: now, - UpdatedAt: now, - Options: store.LayerOptions{}, + CreatedAt: layerItem.CreatedAt.Value(), + UpdatedAt: layerItem.UpdatedAt.Value(), + Options: layerItem.Options.Value(), }, nil }