Home About Me

Using pprof to Track Down a Go Service's Memory Spike

A server recently stopped responding. After digging in, I traced the trigger to a zip upload and extraction feature in one of its web services. The code didn't make it obvious which step was ballooning memory, so I turned to pprof. Here's how the investigation went.

Enabling pprof

Pull in pprof and expose an HTTP service on port 6060 (a common convention, not a hard requirement).

package main

import (
    "net/http"
    _ "net/http/pprof"
    // ...
)

func main() {
    go func() {
        // pprof server, will be exposed on port 6060
        if err := http.ListenAndServe(":6060", nil); err != nil {
            panic(err)
        }
    }()
    // ...
}

Once the program is running, open http://localhost:6060/debug/pprof/ to see what can be captured — CPU, memory, and more.

Profiling memory

I was after memory usage, so I chose the heap profile. During this window I manually hit the endpoint to reproduce the high-memory moment.

# Fetch performance data
curl -o heap.pprof 'http://localhost:6060/debug/pprof/heap'

Then I copied the resulting heap.pprof down to my local machine.

Visual analysis

The web visualization relies on graphviz, so install it first. On macOS I used brew:

$ brew install graphviz

Then run the analysis against the captured profile:

$ go tool pprof -http=:8081 heap.pprof

The command opens a browser automatically. A few screenshots:

image

Something doesn't add up

The zip was only 32MB, and even after extraction it was 75MB.

image

There was no reason for it to consume nearly 600+MB of memory...

To keep verifying, I installed gnu-time on macOS. Unlike the built-in time, gtime reports peak memory usage during a run.

image

Extracting quietly showed that the unzip tool itself uses very little memory — only 3664KB.

My suspicion fell on the Unzip function I'd previously taken from an AI suggestion, which likely had a performance problem.

Testing the hypothesis

Here's the code:

func Unzip(src string, dest string) error {
    r, err := zip.OpenReader(src)
    if err != nil {
        return err
    }
    defer r.Close()

    for _, f := range r.File {
        fileInArchive, err := f.Open()
        if err != nil {
            return err
        }
        defer fileInArchive.Close()

        path := filepath.Join(dest, f.Name)
        if f.FileInfo().IsDir() {
            os.MkdirAll(path, f.Mode())
        } else {
            f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
            if err != nil {
                return err
            }
            _, err = io.Copy(f, fileInArchive)
            if err != nil {
                return err
            }
        }
    }
    return nil
}

The caller:

main.go

func main() {
    err := utility.Unzip("assets.zip", "/tmp/unzip-folder")
    if err != nil {
        fmt.Println(err)
    }
}

Build and run:

$ go build -o unzip-test main.go

# Run
$ gtime ./unzip-test

image

It really did consume 728528 KB. Once the problem is pinned to a specific function, fixing it becomes much easier.

The fix

Looking through the code, I found the issue: the snippet below uses defer fileInArchive.Close() inside the loop to close files. But defer only fires when the function returns — it doesn't run per iteration.

for _, f := range r.File {
    fileInArchive, err := f.Open()
    if err != nil {
        return err
    }
    defer fileInArchive.Close()

    // ...
}

The fix is to close files directly inside the loop.

func Unzip(src string, dest string) error {
    r, err := zip.OpenReader(src)
    if err != nil {
        return err
    }
    defer r.Close()

    for _, f := range r.File {
        fileInArchive, err := f.Open()
        if err != nil {
            return err
        }

        path := filepath.Join(dest, f.Name)
        if f.FileInfo().IsDir() {
            os.MkdirAll(path, f.Mode())
        } else {
            f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
            if err != nil {
                return err
            }
            _, err = io.Copy(f, fileInArchive)
            if err != nil {
                return err
            }
            f.Close()
            fileInArchive.Close()
        }
    }
    return nil
}

Retested:

image

Memory usage dropped to 35.5MB, right where it should be. Problem solved.

Takeaways

  1. pprof is built into Go services — import it and expose an HTTP endpoint to use it. Just don't expose it in production.
  2. Using defer to close files inside a loop is a classic mistake worth watching for.
  3. AI-provided code is easy to get wrong. Code review and unit tests are not optional.