diff --git a/HACKING.md b/HACKING.md index aa1e4258..1660732c 100644 --- a/HACKING.md +++ b/HACKING.md @@ -29,6 +29,12 @@ Build by running: $ go build ./cmd/image-builder/ ``` +To include optional **profiling** support (`--memprofile`, `--memprofile-goroutine`, `--memprofile-rate`), set **`DEBUG`** to any non-empty value when invoking `make build`, for example: + +```console +$ DEBUG=1 make build +``` + ## Unit tests Run the unit tests via: diff --git a/Makefile b/Makefile index b3303842..5aacb49a 100644 --- a/Makefile +++ b/Makefile @@ -99,9 +99,12 @@ $(BUILDDIR)/%/: # keep in sync with: # https://github.com/containers/podman/blob/2981262215f563461d449b9841741339f4d9a894/Makefile#L51 TAGS := containers_image_openpgp,exclude_graphdriver_btrfs,exclude_graphdriver_devicemapper +ifneq ($(DEBUG),) +TAGS := $(TAGS),profiling +endif .PHONY: build -build: $(BUILDDIR)/bin/ ## build the binary from source +build: $(BUILDDIR)/bin/ ## build the binary from source (set DEBUG=1 to include extra build tags) go build -tags="$(TAGS)" -ldflags="-X main.version=${VERSION}" -o $= 0 { + runtime.MemProfileRate = memProfileRateOpt + } else { + runtime.MemProfileRate = defaultMemProfileRate + } + } +} + +func memProfileFlush() error { + var errs []error + if memProfilePath != "" { + if err := writeHeapProfile(memProfilePath); err != nil { + errs = append(errs, err) + } + } + if memProfileGoroutinePath != "" { + if err := writeGoroutineProfile(memProfileGoroutinePath); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +func writeHeapProfile(path string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("memprofile: create heap profile %q: %w", path, err) + } + defer f.Close() + if err := pprof.WriteHeapProfile(f); err != nil { + return fmt.Errorf("memprofile: write heap profile %q: %w", path, err) + } + return nil +} + +func writeGoroutineProfile(path string) error { + f, err := os.Create(path) + if err != nil { + return fmt.Errorf("memprofile: create goroutine profile %q: %w", path, err) + } + defer f.Close() + prof := pprof.Lookup("goroutine") + if prof == nil { + return fmt.Errorf("memprofile: goroutine profile unavailable") + } + if err := prof.WriteTo(f, 0); err != nil { + return fmt.Errorf("memprofile: write goroutine profile %q: %w", path, err) + } + return nil +} diff --git a/cmd/image-builder/memprofile_stub.go b/cmd/image-builder/memprofile_stub.go new file mode 100644 index 00000000..ca58a30d --- /dev/null +++ b/cmd/image-builder/memprofile_stub.go @@ -0,0 +1,23 @@ +//go:build !profiling + +// Memory profiling hooks: this file is the no-op implementation when the binary is built +// without -tags profiling. See memprofile.go for the profiling implementation. + +package main + +import "github.com/spf13/cobra" + +// memProfileResetForProcessStart is called at the start of run() before cobra runs; it keeps +// default allocation sampling behavior. With profiling, it forces sampling off until flags apply. +func memProfileResetForProcessStart() {} + +// registerMemProfileFlags would attach --memprofile* flags to the root command; no flags are +// registered without the profiling build tag. +func registerMemProfileFlags(_ *cobra.Command) {} + +// memProfilePersistentPreRun would set runtime.MemProfileRate from --memprofile* flags; it does +// nothing without the profiling build tag. +func memProfilePersistentPreRun(_ *cobra.Command, _ []string) {} + +// memProfileFlush would write heap and/or goroutine profiles on exit; it always returns nil here. +func memProfileFlush() error { return nil } diff --git a/cmd/image-builder/memprofile_test.go b/cmd/image-builder/memprofile_test.go new file mode 100644 index 00000000..be71be7e --- /dev/null +++ b/cmd/image-builder/memprofile_test.go @@ -0,0 +1,42 @@ +//go:build profiling + +package main_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + main "github.com/osbuild/image-builder-cli/cmd/image-builder" + testrepos "github.com/osbuild/images/test/data/repositories" +) + +func TestMemProfileWritesHeapAndGoroutineFiles(t *testing.T) { + restore := main.MockNewRepoRegistry(testrepos.New) + defer restore() + + dir := t.TempDir() + heapPath := filepath.Join(dir, "heap.pprof") + gPath := filepath.Join(dir, "goroutine.pprof") + + restore = main.MockOsArgs([]string{"list", "--format=json", "--memprofile", heapPath, "--memprofile-goroutine", gPath}) + defer restore() + + var fakeStdout bytes.Buffer + restore = main.MockOsStdout(&fakeStdout) + defer restore() + + err := main.Run() + require.NoError(t, err) + + st, err := os.Stat(heapPath) + require.NoError(t, err) + require.Greater(t, st.Size(), int64(0)) + + stg, err := os.Stat(gPath) + require.NoError(t, err) + require.Greater(t, stg.Size(), int64(0)) +}