Skip to content
:tech
← Articles

AI-translated. Read the original in KoreanEmbedded slides, videos and images remain in their original language.

Go embed: Reducing Unnecessary Memory Use with Minimal Changes

Elric Lim
Elric Lim

Server Engineer

Go embed: Reducing Unnecessary Memory Use with Minimal Changes

Three-point summary

  1. We found that reading an external file—the GeoIP database—accounted for a large share of the heap.
  2. We compared reading the database through mmap with embedding it directly as []byte, and verified that both remove the heap copy.
  3. We chose direct embedding to eliminate unnecessary memory use while preserving our existing deployment structure.

Buzzvil's advertising and rewards API server, referred to here as BuzzScreen, provides BenefitHub placements and server-to-server ad allocation APIs to around 500 publishers. It handles traffic at the front of the ad supply path. While using Pyroscope to look for memory improvements, we noticed that `embed.FS.ReadFile` accounted for a large share, as shown below.

A memory bottleneck found in Pyroscope

embed.FS.ReadFile is a Go standard library method. Our existing code used it to read the GeoLite2 City DB and create a reader. Although this did not allocate new memory for every request, it occupied a large share of the memory profile, and we needed to understand why.

This post compares the approaches we explored to reduce the GeoIP library's memory footprint and explains the choice that ultimately optimized server memory.

The problem: memory occupied by the GeoIP DB

Looking at retained heap instead of a CPU profile

When we first investigated in July this year, Pyroscope's inuse_space query attributed 11.72 GB of the total 39.11 GB—about 29.96%—to embed.FS.ReadFile.

inuse_space estimates the bytes occupied by Go heap objects that were alive when the profile was collected: objects still live and not reclaimed by garbage collection. Zooming into the flame graph showed that embed.FS.ReadFile, called during process initialization, accounted for a large proportion of retained heap. This differs from alloc_space, the cumulative memory allocated since the program started, which is useful for identifying unnecessary temporary allocations that drive GC.

Following the call path led from application initialization through CreateGeoDB to embed.FS.ReadFile. This was closer to a baseline memory cost paid by every process than a leak that accumulated database copies with each request.

The cause: copying data already in the binary

The existing initialization code was:

//go:embed geodb
var fs embed.FS

func CreateGeoDB() (*geoip2.Reader, func() error, error) {
    data, err := fs.ReadFile("geodb/GeoLite2-City.mmdb")
    // ...
    db, err := geoip2.FromBytes(data)
    // ...
    return db, db.Close, nil
}

go:embed, supported since Go 1.16, includes file contents in a Go program at build time.

Example

import _ "embed"

//go:embed config.json
var config []byte

Here, the executable contains the contents of config.json. Since go:embed includes the file in the executable, the application does not need to open a separate file at runtime. When embedded data is accessed, the OS maps it into memory; if those pages are not already in the page cache, disk I/O can still occur at the OS level.

It might seem that embedding the file means reading that data directly. However, ReadFile returns a byte slice ([]byte), copying the entire GeoLite2 City DB in the process.

// ReadFile 내부 구현의 핵심
return []byte(ofile.f.data), nil

What does geoip2.FromBytes do next? It references the supplied byte slice inside the reader without copying it again. The metadata, search tree, and data section are all based on this buffer.

Because the reader keeps referencing it, the copied array is not reclaimed when CreateGeoDB returns. Each process pays for the database in the binary and also retains a heap copy of the same size. That duplication of a relatively large database explains why embed.FS.ReadFile occupied so much of the initialization memory profile.

Option 1: read the file with mmap

Our first candidate was to keep the GeoIP DB as a file outside the binary and read it with mmap. Calling geoip2.Open(path) was sufficient: the library version we used opens MMDB files through mmap on typical Unix systems.

Rather than loading the entire file into a Go []byte, mmap maps the file into the process's virtual address space. The reader queries the database through that address range, while the OS and page cache manage loading and evicting the actual pages.

The difference from the existing approach is:

기존 방식
바이너리에 포함된 DB → ReadFile() → Go heap에 DB 전체 복사본 생성 → reader

mmap 방식
컨테이너의 DB 파일 별도 생성 → 가상 메모리에 파일 매핑 → reader

This avoids creating a new Go heap array the size of the entire database. The database still uses process memory and the OS file cache. The goal is to remove the extra Go heap copy created by ReadFile(), not the database data itself.

Implementation and issues

We implemented a file-backed reader, falling back to the existing embedded approach when the file was unavailable.

path := geoDBPath()
if _, err := os.Stat(path); err == nil {
    db, err := geoip2.Open(path) // MMDB 파일을 mmap으로 연결
    if err != nil {
        return nil, nil, fmt.Errorf("open geo db file: %w", err)
    }
    return db, db.Close, nil
}

// 파일이 없으면 기존 방식으로 동작
data, err := geodbFS.ReadFile(embeddedGeoDBPath)
db, err := geoip2.FromBytes(data)

The production container also needed to include the database file. We made the path configurable through BUZZSCREEN_GEODB_PATH, with /usr/share/buzzscreen/geodb/GeoLite2-City.mmdb as the default.

COPY --from=builder /app/components/buzzscreen/infra/database/geodb/GeoLite2-City.mmdb /usr/share/buzzscreen/geodb/GeoLite2-City.mmdb

Adding a separate file and deployment rules simply to remove a memory copy felt excessive. It seemed like overengineering, so we started looking for another approach.

The operational cost of mmap

mmap was a good way to remove the heap copy, but it also changed how the application was deployed.

ItemExisting approachmmap approach
Deployment artifactsOne binaryBinary plus separate MMDB file
InitializationRead DB from binaryCheck file path and open DB
Missing fileNot applicableFall back to embedded approach
Remove heap copyNoYes, when the file is present

With a correct Docker copy step, the file should not be missing. But if an engineer or operator unfamiliar with this work changed the file configuration, the application could fall back to the old approach. The possibility of high heap usage would remain.

mmap is a good choice when an external file must be replaced independently or the database and application need separate release cycles. Our goal, however, was to remove an unnecessary initialization copy, not change deployment. We needed an option with the same benefit and no change in operations.

Option 2: embed without copying

Did we need embed.FS at all?

Go's embed feature also supports string and []byte variables. Because geoip2.FromBytes directly references the supplied MMDB byte slice, embedding the DB as []byte from the outset lets us skip the ReadFile copy.

//go:embed geodb/GeoLite2-City.mmdb
var

Embedding the prepared MMDB data directly into a byte slice includes GeoLite2-City.mmdb in the binary at build time. At runtime, geoDBData is a slice pointing to those contents. Creating the reader therefore does not require copying the whole database into the dynamic heap. Since code can modify a byte slice, we explicitly documented the requirement that geoDBData []byte must never be changed.

Comparing three approaches with identical data

To verify this, we ran a local experiment separate from production code.

ApproachDB loading pathFull Go heap copy
Existingembed.FS → ReadFile → FromBytesYes
Direct embed[]byte embed → FromBytesUnnecessary
mmapseparate file → Open → mmapUnnecessary

Putting all three into one binary could mix multiple embedded databases into the measurements. We therefore built separate binaries from the same source database and ran them in separate processes. We took a baseline after forcing GC before reader creation, then compared heap growth after creating the reader and running GC again. We used runtime.KeepAlive to prevent the reader from being collected during measurement.

We checked functional equivalence before considering memory improvement. We compared the database SHA256, metadata, and hashes of the full City structure and error results for identical IP lookups across all three approaches. We verified that coordinates, regions, postal codes, and other returned data remained unchanged.

The medians recorded in the experiment were:

ApproachRetained heap increase after reader creationReader creation time
embed.FS + ReadFile72,107,496 B46.49 ms
Direct []byte embed1,512 B0.045 ms
File mmap1,800 B0.403 ms

Experiment run with Go 1.26.8 and GOMAXPROCS=2.

The database was 72 MB (72,100,729 B). Database and lookup-result hashes matched in every repetition. Both direct embedding and mmap eliminated approximately 68.8 MiB of heap growth relative to the original approach. Since their benefits were similar, we chose embedding for its lower operational and maintenance cost.

CriterionFile mmapDirect embed
Remove full database heap copyYesYes
Separate DB file in runtime imageRequiredNot required
Handle file paths and missing filesRequiredNot required
Preserve existing binary deploymentAdds a separate-file contractYes
Application contractManage mapping and reader lifetimesNever modify shared bytes

Result: removing the copy through direct embedding

Before

//go:embed geodb
var fs embed.FS

func CreateGeoDB() (*geoip2.Reader, func() error, error) {
    data, err := fs.ReadFile("geodb/GeoLite2-City.mmdb")
    // 오류 처리 생략
    db, err := geoip2.FromBytes(data)
    // 오류 처리 생략
    return db, db.Close, nil
}

After

//go:embed geodb/GeoLite2-City.mmdb
var geoDBData []byte

func CreateGeoDB() (*geoip2.Reader, func() error, error) {
    db, err := geoip2.FromBytes(geoDBData)
    if err != nil {
        return nil, nil, fmt.Errorf("open geo db: %w", err)
    }
    return db, db.Close, nil
}

The new part is //go:embed geodb/GeoLite2-City.mmdb. We now embed only the single MMDB file, while preserving the existing reader return behavior, parse-error handling, and use of the closer at shutdown.

The allocation path that disappeared from Pyroscope

We deployed the direct-embed improvement to production and checked the profiles again.

StageLoader observed
Before deploymentembed.FS.ReadFile
After deploymentDirect []byte embed

Before deployment, embed.FS.ReadFile accounted for 43.17% of flat memory in the memory:inuse_space:bytes:space:bytes profile. Following the stack showed database.CreateGeoDB → embed.FS.ReadFile.

After deployment, the same filtered query showed no samples for CreateGeoDB → embed.FS.ReadFile. This confirmed that the GeoIP copy-allocation path had disappeared from the profile. It does not mean that all physical memory used by the GeoIP DB disappeared.

Memory improvement in Pyroscope

The graph above shows memory savings for the BuzzScreen API server; the graph below shows the BuzzScreen consumer. The improvement was larger in the consumer because it initialized GeoIP twice. We changed two separate initialization factories that had each copied the entire database, producing a relatively larger saving.

Memory improvement in Pyroscope

MetricAverage beforeAverage afterObserved difference
Go live heap173.9 MiB101.9 MiB−41.4%
Go runtime memory: total − heap released437.0 MiB269.7 MiB−38.3%

Closing thoughts

We evaluated both mmap and direct embedding. mmap removes the heap copy, but requires Dockerfile changes, a separately deployed file, and a stable file path. We judged that to be a relatively risky change.

Direct embedding also has a requirement: the shared byte slice must not be modified. But it preserves our single-binary deployment structure and carries less operational overhead. AI helped us verify both options quickly, but the choice was ultimately the engineer's responsibility.

We chose the approach that fit our operational and maintenance needs. What supported that decision was not an assumption that embedding is better than mmap, but measurements showing that we could achieve the desired benefit while preserving identical results.

References

All articles