Skip to content

Commit 0e02b05

Browse files
committed
cmd: add memprofile flags and tests
These are only available when building with the DEBUG=1 make target.
1 parent 4cf8cf6 commit 0e02b05

6 files changed

Lines changed: 172 additions & 2 deletions

File tree

HACKING.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ Build by running:
2929
$ go build ./cmd/image-builder/
3030
```
3131

32+
To include optional **profiling** support (`--memprofile`, `--memprofile-goroutine`, `--memprofile-rate`), set **`DEBUG`** to any non-empty value when invoking `make build`, for example:
33+
34+
```console
35+
$ DEBUG=1 make build
36+
```
37+
3238
## Unit tests
3339

3440
Run the unit tests via:

Makefile

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,9 +99,12 @@ $(BUILDDIR)/%/:
9999
# keep in sync with:
100100
# https://github.com/containers/podman/blob/2981262215f563461d449b9841741339f4d9a894/Makefile#L51
101101
TAGS := containers_image_openpgp,exclude_graphdriver_btrfs,exclude_graphdriver_devicemapper
102+
ifneq ($(DEBUG),)
103+
TAGS := $(TAGS),profiling
104+
endif
102105

103106
.PHONY: build
104-
build: $(BUILDDIR)/bin/ ## build the binary from source
107+
build: $(BUILDDIR)/bin/ ## build the binary from source (set DEBUG=1 to include extra build tags)
105108
go build -tags="$(TAGS)" -ldflags="-X main.version=${VERSION}" -o $<image-builder ./cmd/image-builder/
106109
# Note that this is only needed for the bib container to detect if qemu-user is available
107110
for arch in amd64 arm64; do \

cmd/image-builder/main.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,7 @@ func normalizeRootArgs(_ *pflag.FlagSet, name string) pflag.NormalizedName {
596596
func run() error {
597597
// Initialize console logger (stderr, no prefix)
598598
log.SetFlags(0)
599+
memProfileResetForProcessStart()
599600

600601
rootCmd := &cobra.Command{
601602
Use: "image-builder",
@@ -634,6 +635,8 @@ operating systems like Fedora, CentOS and RHEL with easy customizations support.
634635
rootCmd.PersistentFlags().StringArray("force-repo", nil, `Override the base repositories during build (these will not be part of the final image)`)
635636
rootCmd.PersistentFlags().String("output-dir", "", `Put output into the specified directory`)
636637
rootCmd.PersistentFlags().BoolP("verbose", "v", false, `Switch to verbose mode (more logging on stderr and verbose progress)`)
638+
registerMemProfileFlags(rootCmd)
639+
rootCmd.PersistentPreRun = memProfilePersistentPreRun
637640

638641
rootCmd.SetOut(osStdout)
639642
rootCmd.SetErr(osStderr)
@@ -764,7 +767,11 @@ operating systems like Fedora, CentOS and RHEL with easy customizations support.
764767
ilog.SetDefault(log.New(os.Stderr, "", 0))
765768
}
766769

767-
return rootCmd.Execute()
770+
execErr := rootCmd.Execute()
771+
if flushErr := memProfileFlush(); flushErr != nil {
772+
return errors.Join(execErr, flushErr)
773+
}
774+
return execErr
768775
}
769776

770777
func main() {

cmd/image-builder/memprofile.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
//go:build profiling
2+
3+
// See @cmd/image-builder/memprofile_stub.go for API documentation shared with the non-profiling build.
4+
5+
package main
6+
7+
import (
8+
"errors"
9+
"fmt"
10+
"os"
11+
"runtime"
12+
"runtime/pprof"
13+
14+
"github.com/spf13/cobra"
15+
)
16+
17+
// Go default allocation sampling rate (see runtime.MemProfileRate).
18+
const defaultMemProfileRate = 512 * 1024
19+
20+
var (
21+
memProfilePath string
22+
memProfileGoroutinePath string
23+
memProfileRateOpt int = -1
24+
)
25+
26+
func memProfileResetForProcessStart() {
27+
runtime.MemProfileRate = 0
28+
}
29+
30+
func registerMemProfileFlags(root *cobra.Command) {
31+
f := root.PersistentFlags()
32+
f.StringVar(&memProfilePath, "memprofile", "", "write a heap memory profile in pprof format when the program exits (use with go tool pprof)")
33+
f.StringVar(&memProfileGoroutinePath, "memprofile-goroutine", "", "write a goroutine profile in pprof format on exit (optional; use with go tool pprof)")
34+
f.IntVar(&memProfileRateOpt, "memprofile-rate", -1, "when --memprofile is set, sets runtime.MemProfileRate for allocation sampling (-1 uses Go's default 524288; 0 samples only live heap at exit with minimal runtime overhead)")
35+
}
36+
37+
func memProfilePersistentPreRun(_ *cobra.Command, _ []string) {
38+
runtime.MemProfileRate = 0
39+
if memProfilePath != "" {
40+
if memProfileRateOpt >= 0 {
41+
runtime.MemProfileRate = memProfileRateOpt
42+
} else {
43+
runtime.MemProfileRate = defaultMemProfileRate
44+
}
45+
}
46+
}
47+
48+
func memProfileFlush() error {
49+
var errs []error
50+
if memProfilePath != "" {
51+
if err := writeHeapProfile(memProfilePath); err != nil {
52+
errs = append(errs, err)
53+
}
54+
}
55+
if memProfileGoroutinePath != "" {
56+
if err := writeGoroutineProfile(memProfileGoroutinePath); err != nil {
57+
errs = append(errs, err)
58+
}
59+
}
60+
return errors.Join(errs...)
61+
}
62+
63+
func writeHeapProfile(path string) error {
64+
f, err := os.Create(path)
65+
if err != nil {
66+
return fmt.Errorf("memprofile: create heap profile %q: %w", path, err)
67+
}
68+
defer f.Close()
69+
if err := pprof.WriteHeapProfile(f); err != nil {
70+
return fmt.Errorf("memprofile: write heap profile %q: %w", path, err)
71+
}
72+
return nil
73+
}
74+
75+
func writeGoroutineProfile(path string) error {
76+
f, err := os.Create(path)
77+
if err != nil {
78+
return fmt.Errorf("memprofile: create goroutine profile %q: %w", path, err)
79+
}
80+
defer f.Close()
81+
prof := pprof.Lookup("goroutine")
82+
if prof == nil {
83+
return fmt.Errorf("memprofile: goroutine profile unavailable")
84+
}
85+
if err := prof.WriteTo(f, 0); err != nil {
86+
return fmt.Errorf("memprofile: write goroutine profile %q: %w", path, err)
87+
}
88+
return nil
89+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
//go:build !profiling
2+
3+
// Memory profiling hooks: this file is the no-op implementation when the binary is built
4+
// without -tags profiling. See memprofile.go for the profiling implementation.
5+
6+
package main
7+
8+
import "github.com/spf13/cobra"
9+
10+
// memProfileResetForProcessStart is called at the start of run() before cobra runs; it keeps
11+
// default allocation sampling behavior. With profiling, it forces sampling off until flags apply.
12+
func memProfileResetForProcessStart() {}
13+
14+
// registerMemProfileFlags would attach --memprofile* flags to the root command; no flags are
15+
// registered without the profiling build tag.
16+
func registerMemProfileFlags(_ *cobra.Command) {}
17+
18+
// memProfilePersistentPreRun would set runtime.MemProfileRate from --memprofile* flags; it does
19+
// nothing without the profiling build tag.
20+
func memProfilePersistentPreRun(_ *cobra.Command, _ []string) {}
21+
22+
// memProfileFlush would write heap and/or goroutine profiles on exit; it always returns nil here.
23+
func memProfileFlush() error { return nil }
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
//go:build profiling
2+
3+
package main_test
4+
5+
import (
6+
"bytes"
7+
"os"
8+
"path/filepath"
9+
"testing"
10+
11+
"github.com/stretchr/testify/require"
12+
13+
main "github.com/osbuild/image-builder-cli/cmd/image-builder"
14+
testrepos "github.com/osbuild/images/test/data/repositories"
15+
)
16+
17+
func TestMemProfileWritesHeapAndGoroutineFiles(t *testing.T) {
18+
restore := main.MockNewRepoRegistry(testrepos.New)
19+
defer restore()
20+
21+
dir := t.TempDir()
22+
heapPath := filepath.Join(dir, "heap.pprof")
23+
gPath := filepath.Join(dir, "goroutine.pprof")
24+
25+
restore = main.MockOsArgs([]string{"list", "--format=json", "--memprofile", heapPath, "--memprofile-goroutine", gPath})
26+
defer restore()
27+
28+
var fakeStdout bytes.Buffer
29+
restore = main.MockOsStdout(&fakeStdout)
30+
defer restore()
31+
32+
err := main.Run()
33+
require.NoError(t, err)
34+
35+
st, err := os.Stat(heapPath)
36+
require.NoError(t, err)
37+
require.Greater(t, st.Size(), int64(0))
38+
39+
stg, err := os.Stat(gPath)
40+
require.NoError(t, err)
41+
require.Greater(t, stg.Size(), int64(0))
42+
}

0 commit comments

Comments
 (0)