From 37662cc678f225f02a36033c38bd770d5032b3f8 Mon Sep 17 00:00:00 2001 From: Lukas Zapletal Date: Wed, 13 May 2026 10:27:14 +0200 Subject: [PATCH 1/2] cmd: optimize manifest writing After json.Indent fills a bytes.Buffer, stream the buffer to the output writer with WriteTo instead of fmt.Fprintf(..., pretty.String()). That avoids allocating a second copy of the entire indented manifest as a string, which matters for large manifests. Co-authored-by: Cursor --- cmd/image-builder/manifest.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/image-builder/manifest.go b/cmd/image-builder/manifest.go index 283492d1..4d4ad913 100644 --- a/cmd/image-builder/manifest.go +++ b/cmd/image-builder/manifest.go @@ -122,7 +122,10 @@ func generateManifest(repoDir string, extraRepos []string, img *imagefilter.Resu return err } - if _, err := fmt.Fprintf(output, "%s\n", pretty.String()); err != nil { + if _, err := pretty.WriteTo(output); err != nil { + return err + } + if _, err := output.Write([]byte{'\n'}); err != nil { return err } return nil From 4eb96ab45ade2584d6f2aa41225915160d66b47e Mon Sep 17 00:00:00 2001 From: Lukas Zapletal Date: Thu, 14 May 2026 16:18:56 +0200 Subject: [PATCH 2/2] cmd: add memprofile flags and tests These are only available when building with the DEBUG=1 make target. --- HACKING.md | 6 ++ Makefile | 5 +- cmd/image-builder/main.go | 9 ++- cmd/image-builder/memprofile.go | 89 ++++++++++++++++++++++++++++ cmd/image-builder/memprofile_stub.go | 23 +++++++ cmd/image-builder/memprofile_test.go | 42 +++++++++++++ 6 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 cmd/image-builder/memprofile.go create mode 100644 cmd/image-builder/memprofile_stub.go create mode 100644 cmd/image-builder/memprofile_test.go 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)) +}