diff --git a/Dockerfile b/Dockerfile index 6e773f1..120900e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,9 @@ FROM golang:latest as build WORKDIR /src -COPY go.mod /src/go.mod -COPY main.go /src/main.go +COPY . /src -RUN go build -o /memory-leak +RUN go build -o /leak FROM busybox:latest -COPY --from=build /memory-leak /memory-leak -CMD ["/memory-leak"] \ No newline at end of file +COPY --from=build /leak /leak \ No newline at end of file diff --git a/README.md b/README.md index bfd8845..81679c2 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,30 @@ podman build . -t memory-leak ~~~ ## Run +Locally ~~~ -$ podman run --memory 500m --name=memory-leak --rm --memory-swap=0 localhost/memory-leak +go run main.go memory --max-memory 1GB --block-size 100MB --pause=1s +~~~ + +Via Podman +~~~ +$ podman run \ +--memory=1024m \ +--name=memory-leak \ +--rm \ +--memory-swap=0 \ +localhost/memory-leak \ +./leak memory --max-memory 1GB --block-size 100MB --pause=1s +~~~ +By default Podman will enable swap memory with a size equal to the memory limit specified. +This will turn off swap to make sure OOMs can be recreated at the expected memory limit. + +TCP Leak +~~~ +$ podman run \ +--memory=1024m \ +--name=tcp-leak \ +--rm \ +localhost/memory-leak \ +./leak tcp ~~~ -By default Podman will enable swap memory with a size equal to the memory limit specified. -This will turn off swap to make sure OOMs can be recreated at the expected memory limit. \ No newline at end of file diff --git a/command/memory/leak.go b/command/memory/leak.go new file mode 100644 index 0000000..f34de8b --- /dev/null +++ b/command/memory/leak.go @@ -0,0 +1,38 @@ +package memory + +import ( + "fmt" + "github.com/strategicpause/memory-leak/metrics" + "time" +) + +type Params struct { + MaxMemoryInBytes uint64 + BlockSizeInBytes uint64 + PauseTimeInSeconds time.Duration +} + +func memoryLeak(params *Params) error { + PrintParams(params) + + numEntries := int(params.MaxMemoryInBytes / params.BlockSizeInBytes) + list := make([][]byte, numEntries) + + for i := 0; i < numEntries; i++ { + list[i] = make([]byte, params.BlockSizeInBytes) + for j := 0; j < int(params.BlockSizeInBytes); j++ { + list[i][j] = 0 + } + metrics.PrintMemory() + time.Sleep(params.PauseTimeInSeconds) + } + + fmt.Println("Done") + + return nil +} + +func PrintParams(params *Params) { + fmt.Printf("MaxMemory = %v MiB\tBlockSize = %v MiB\tPauseTime = %v.\n", + metrics.BToMiB(params.MaxMemoryInBytes), metrics.BToMiB(params.BlockSizeInBytes), params.PauseTimeInSeconds) +} diff --git a/command/memory/register.go b/command/memory/register.go new file mode 100644 index 0000000..5d544eb --- /dev/null +++ b/command/memory/register.go @@ -0,0 +1,64 @@ +package memory + +import ( + "errors" + "github.com/inhies/go-bytesize" + "github.com/urfave/cli" +) + +const MaxMemoryName = "max-memory" +const BlockSizeName = "block-size" +const PauseDurationName = "pause" + +func Register() cli.Command { + return cli.Command{ + Name: "memory", + Usage: "Reproduces a memory leak.", + Action: action, + Flags: flags(), + } +} + +func flags() []cli.Flag { + return []cli.Flag{ + cli.StringFlag{ + Name: MaxMemoryName, + Usage: "Specify the maximum amount of memory to acquire.", + Value: "1GB", + }, + cli.StringFlag{ + Name: BlockSizeName, + Usage: "Specify the block size of memory which will be allocated at any given time.", + Value: "10MB", + }, + cli.DurationFlag{ + Name: PauseDurationName, + Usage: "Time between allocations in seconds.", + Value: 1, + }, + } +} + +func action(ctx *cli.Context) error { + maxMemory, err := bytesize.Parse(ctx.String(MaxMemoryName)) + if err != nil { + return err + } + + blockSize, err := bytesize.Parse(ctx.String(BlockSizeName)) + if err != nil { + return err + } + + if blockSize > maxMemory { + return errors.New("block-size must be less than or equal to max-memory") + } + + params := &Params{ + MaxMemoryInBytes: uint64(maxMemory), + BlockSizeInBytes: uint64(blockSize), + PauseTimeInSeconds: ctx.Duration(PauseDurationName), + } + + return memoryLeak(params) +} diff --git a/command/tcp/leak.go b/command/tcp/leak.go new file mode 100644 index 0000000..30f9ad1 --- /dev/null +++ b/command/tcp/leak.go @@ -0,0 +1,33 @@ +package tcp + +import ( + "fmt" + "github.com/strategicpause/memory-leak/metrics" + "net/http" + "time" +) + +func tcpLeak() error { + go func() { + fmt.Println("Listening on port", Port) + http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(time.Now().String())) + }) + _ = http.ListenAndServe(Port, nil) + }() + for { + go func() { + req := Must(http.NewRequest("GET", "http://localhost:8080/", nil)) + client := http.Client{} + Must(client.Do(req)) + metrics.PrintMemory() + }() + } +} + +func Must[T any](obj T, err error) T { + if err != nil { + panic(err) + } + return obj +} diff --git a/command/tcp/register.go b/command/tcp/register.go new file mode 100644 index 0000000..57a55c1 --- /dev/null +++ b/command/tcp/register.go @@ -0,0 +1,24 @@ +package tcp + +import ( + "github.com/urfave/cli" +) + +const Port = ":8080" + +func Register() cli.Command { + return cli.Command{ + Name: "tcp", + Usage: "Reproduces a TCP socket leak.", + Action: action, + Flags: flags(), + } +} + +func flags() []cli.Flag { + return []cli.Flag{} +} + +func action(_ *cli.Context) error { + return tcpLeak() +} diff --git a/go.mod b/go.mod index 95fe558..f17cfb1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,13 @@ -module memory-leak +module github.com/strategicpause/memory-leak go 1.19 + +require ( + github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf + github.com/urfave/cli v1.22.14 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5591ee3 --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ= +github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s= +github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/urfave/cli v1.22.14 h1:ebbhrRiGK2i4naQJr+1Xj92HXZCrK7MsyTS/ob3HnAk= +github.com/urfave/cli v1.22.14/go.mod h1:X0eDS6pD6Exaclxm99NJ3FiCDRED7vIHpx2mDOHLvkA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index f2f6770..3e7bc99 100644 --- a/main.go +++ b/main.go @@ -1,77 +1,26 @@ package main import ( - "fmt" - "net/http" - "runtime" - "sync" - "time" -) - -const ( - MiB = 1024 * 1024 - Port = ":8080" - TcpLeak = true + "github.com/strategicpause/memory-leak/command/memory" + "github.com/strategicpause/memory-leak/command/tcp" + "github.com/urfave/cli" + "log" + "os" ) func main() { - if TcpLeak { - tcpLeak() - } else { - memoryLeak() + app := &cli.App{ + Commands: RegisterCommands(), } -} - -func tcpLeak() { - wg := sync.WaitGroup{} - wg.Add(1) - go func() { - fmt.Println("Listening on port", Port) - http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - w.Write([]byte(time.Now().String())) - }) - http.ListenAndServe(Port, nil) - wg.Done() - }() - for { - go func() { - req := Must(http.NewRequest("GET", "http://localhost:8080/", nil)) - client := http.Client{} - Must(client.Do(req)) - PrintMemory() - }() + if err := app.Run(os.Args); err != nil { + log.Fatal(err) } - wg.Wait() } -func Must[T any](obj T, err error) T { - if err != nil { - panic(err) +func RegisterCommands() cli.Commands { + return cli.Commands{ + memory.Register(), + tcp.Register(), } - return obj -} - -func memoryLeak() { - // Allocate 10 GiB in 100 MiB increments - list := make([][]byte, 100) - for i := 0; i < 100; i++ { - list[i] = make([]byte, 100*MiB) - for j := 0; j < 100*MiB; j++ { - list[i][j] = 0 - } - PrintMemory() - time.Sleep(time.Second) - } - fmt.Println("Done") -} - -func PrintMemory() { - var m runtime.MemStats - runtime.ReadMemStats(&m) - fmt.Printf("TotalAlloc = %v MiB\tSys = %v MiB\n", bToMiB(m.TotalAlloc), bToMiB(m.Sys)) -} - -func bToMiB(b uint64) uint64 { - return b / 1024 / 1024 } diff --git a/metrics/metrics.go b/metrics/metrics.go new file mode 100644 index 0000000..eb6b323 --- /dev/null +++ b/metrics/metrics.go @@ -0,0 +1,41 @@ +package metrics + +import ( + "fmt" + "runtime" +) + +func PrintMemory() { + var m runtime.MemStats + runtime.ReadMemStats(&m) + + var avgGCTime uint64 + if m.NumGC > 0 { + avgGCTime = NsToUs(m.PauseTotalNs / uint64(m.NumGC)) + } + + fmt.Printf("TotalAlloc = %v MiB\tHeapAlloc = %v MiB\tSys = %v MiB\tNextGC = %v MiB\tPauseTotalUs = %v\tNumGC = %v\tAvgGCTime = %v\n", + // Total number of bytes allocated for the heap. Does not decrease when objects are freed. + BToMiB(m.TotalAlloc), + // Total number of bytes allocated on the heap. This includes reachable objects and objects + // not yet freed by the GC. This value should decrease when objects are cleaned by the GC. + BToMiB(m.HeapAlloc), + // The total number of bytes obtained from the OS. + BToMiB(m.Sys), + // The target heap size of the next GC cycle. The GC goal is to kep HeapAlloc <= NextGC. + BToMiB(m.NextGC), + // The cumulative nanoseconds in GC stop-the-world pauses since the program started. + NsToUs(m.PauseTotalNs), + // Number of completed GC cycles. + m.NumGC, + // Average time spent in GC across. Pretty hacky way to get some idea of how GC time is being spent during the leak. + avgGCTime) +} + +func BToMiB[T uint64 | uint32](b T) uint64 { + return uint64(b) / (1024 * 1024) +} + +func NsToUs(s uint64) uint64 { + return s / 1000 +}