Engineering

A very happy Golang memory profiling story at CloudQuery

Introduction

At CloudQuery, we provide simple and efficient ELT for a broad range of customers. Recently, I was tasked with investigating an OutOfMemory issue in one of our popular sync configurations: syncing from an AWS source to an S3 destination.

Humorous illustration of an explosion labeled Sync AWS to S3, representing an OutOfMemory crash in production

Our syncs involve significant parallelism, and since we hadn’t encountered major memory issues before, we feared it was time to refine our concurrency model. But first, we needed a memory profile to understand the problem better.

How to run a memory profile?

We’re a Go shop, so our sync tool and most of our software is written in Go.

While I hadn’t done much profiling in Go before, I knew they use a variant of Google’s pprof from reading Russ Cox's blog post.

The full instructions are here, but the simplest way to run a memory profile using pprof is to add this import to main.go:

import _ "net/http/pprof"

Ensure you have a server running (don’t do this if you already have one):

go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()

Then, you can take a snapshot of your memory usage by calling this endpoint:

go tool pprof http://localhost:6060/debug/pprof/heap

This returns a pprof file you can chart. For a quick chart:

go tool pprof -png http://localhost:6060/debug/pprof/heap > out.webp

However, this chart is only useful if you know the precise moment to take it, as it’s a snapshot in time:
pprof ideal scenario

(Image from Julia Evans's blog post).

To chart memory over time, I turned to GC debug logs.

GC Debug Logs

Garbage collection information is crucial for us because streaming data from sources to destinations is bound to cause memory churn. You can add a debug flag to your Go binary's environment:

GODEBUG=gctrace=1 cloudquery sync ...

This outputs a line to stderr every time your binary runs a GC:

gc 1966 @134.752s 2%: 0.76+5.7+0.036 ms clock, 6.1+0.52/10/2.4+0.29 ms cpu, 68->70->35 MB, 71 MB goal, 0 MB stacks, 0 MB globals, 8 P

This provides:

  • When GC ran, in seconds, since the binary started.
  • CPU % spent in GC.
  • Heap size before GC, heap size after GC, and live heap after GC.

The full docs for GODEBUG are here. Dave Cheney has an article on charting GC output using his tool gcvis. I created a similar tool for more customization:

Pipeline diagram: GODEBUG=gctrace=1 cloudquery sync piping standard error output to error_file.txt, then a parse script, then result.csv
  • Pipe the stderr output to a file.
  • Write a regex-based parse script to extract metrics to a CSV file.
  • Plot it with matplotlib.

Profiling CloudQuery syncs with gctrace

This was the initial chart I saw:

Three stacked charts showing GC time percentage, live heap size spiking to 1000 MB, and heap size reduction over 40 seconds

These weird spikes initially made me think we had a rogue allocation bug. Closer inspection revealed three distinct processes reporting GC information:

Diagram of the three sync processes: AWS source, CLI, and S3 destination connected by arrows
Live heap size chart with three overlapping process traces highlighted by red trend lines showing memory growth

After updating the profiling setup, I found that only the S3 process was consuming a lot of memory:

S3 process heap and live heap charts peaking at 2500 MB and 1300 MB respectively, with a long tail over 100 seconds

Key observations:

  • An initial spike of 2.5GB, leading to an OOM at peak, with a long tail.
  • The total heap is twice as high as the live heap, meaning 50% of the total heap is garbage.

GOMEMLIMIT to the rescue?

Since 50% of memory was garbage, a straightforward solution was to trigger GC more often using GOMEMLIMIT. The GOMEMLIMIT variable sets a soft memory limit for the runtime, running GC more often when this threshold is crossed (read more here).

While this was a valiant attempt, it had drawbacks:

  • Setting an absolute GOMEMLIMIT value for each customer setup is challenging.
  • Legitimate high memory use will still cause OOM.
  • Frequent GC increases CPU usage, slowing down sync time.
Four charts showing GOMEMLIMIT effect: reduced heap to 1200 MB but increased GC time to 9% and longer sync duration

Key points:

  • Total heap usage decreased by over 50%.
  • GC ran more frequently, consuming more CPU.
  • Sync time increased significantly.

This was a no-go. The best way forward was to look at the code for improvements.

Revisiting pprof

A pprof heap snapshot at peak memory usage revealed a large amount of memory used by the S3 SDK's memory pooling mechanism:

Go pprof heap snapshot showing S3 SDK maxSlicePool.newSlice consuming 1145 MB (90%) of allocated memory

This raised questions about:

  • A bug in the SDK.
  • Our misuse or misconfiguration of the SDK
  • Legitimate but inefficient use of the SDK.

Three engineers look at three processes 👀

Our Framework team, consisting of Erez, Aleksandr, and myself, examined the code of the three components of our sync for bugs or optimizations.

Diagram showing three engineers investigating each process: Mariano on AWS source concurrency, Aleksandr on CLI Arrow batching, Erez on S3 SDK memory over-allocation

Source back pressure

I found that we were not limiting the number of tables being synced concurrently. By setting a default concurrency limit of 125 tables, peak memory usage and the tail disappeared without increasing the total sync time.

Heap charts after adding concurrency limit: peak dropped from 1400 MB to under 750 MB with same sync duration

Key points:

  • 42% reduction in memory usage.
  • Total sync time remained the same.

We didn't merge the PR, as we didn't need it.

Apache Arrow's RecordBatch

The three processes stream Apache Arrow records via gRPC connections.

Aleksandr noted we were sending one record at a time instead of using RecordBatches to stream multiple records at once. This change significantly improved memory usage.

Aleksandr describes the implementation in his blog post.

Bar chart of RAM consumption by plugin before and after RecordBatch optimization: AWS dropped from 61 GiB to 9 GiB, S3 from 60 GiB to 5 GiB

S3 SDK's memory over-allocation

Erez found that the S3 SDK allocated 5MB per uploaded chunk if the supplied io.Reader didn’t support seeking, only freeing memory when the upload succeeded. He fixed this by providing an io.Reader that supported seeking.

Heap charts after io.Seeker fix: peak heap dropped to 450 MB and live heap to 260 MB, an 84% reduction

Key points:

  • 84% memory usage reduction!
  • Total sync time remained the same.

We merged the fix and reported it to AWS for consideration in a rewrite.

Conclusion

After a week’s work, we managed to:

Resource utilization issues will resurface as we deal with new sources, destinations, scaling challenges, and new features, but we’re better prepared now.

ELT at scale is complex, so if you’re rolling out your solution and facing difficulties, give CloudQuery a try.

Want to see CloudQuery in action? Schedule a demo with our team or check out the platform documentation to learn more.

Want help getting started? Join the CloudQuery community to connect with other users and experts, or message our team directly here if you have any questions.

Try CloudQuery yourself

Self-hosted, open source, and free to start. Or schedule a demo to see the managed version.

Related posts

Engineering

We benchmarked our ETL tool and synced 1 million rows in 2 seconds on a MacBook

We benchmarked our ETL tool and synced 1 million rows in 2 seconds on laptop hardware, which is 68% faster than enterprise platforms.

Joe Karlsson

Joe Karlsson

August 27, 2026

Engineering

Third Normal Form (3NF) vs Star Schema: When to Use Each

Learn the differences between Third Normal Form (3NF) and star schema with SQL examples, table diagrams, and guidance on when to use each in a data warehouse.

Tim Armstrong

Tim Armstrong

February 25, 2026

Cloud Asset Inventory

Engineering

Platform Engineering

6 Cloud CMDB Best Practices for Platform Engineers (2026 Guide)

Cloud CMDB best practices for multi-account environments: automated discovery, tiered sync strategies, SQL-based querying, and historical data retention.

Joe Karlsson

Joe Karlsson

October 29, 2025

Cloud Asset Inventory

Engineering

The Real-Time Cloud CMDB - Why Ephemeral Infrastructure Killed Traditional Discovery

Traditional CMDBs miss ephemeral resources because containers live minutes. Modern cloud CMDBs use tiered sync strategies for real-time accuracy and coverage.

Joe Karlsson

Joe Karlsson

October 24, 2025

AI

Engineering

Tutorials

Scale GitHub Issues semantic search with CloudQuery, BigQuery, and Vertex AI

Sync GitHub issues to BigQuery with CloudQuery, generate Vertex AI embeddings via BigQuery remote models, and power fast, fully managed semantic search.

Mariano Gappa

Mariano Gappa

October 9, 2025

AWS

Cloud Infrastructure

Engineering

How to Design a Cloud Asset Inventory for AWS

Learn five essential design principles for cloud asset inventories: freshness, completeness, normalization, queryability, and automation for real AWS results.

Joe Karlsson

Joe Karlsson

October 3, 2025

AI

Engineering

Tutorials

Improve GitHub Issues search effortlessly with CloudQuery, pgvector and OpenAI

Turn GitHub issues into instant insights by syncing with CloudQuery, generating pgvector embeddings in PostgreSQL, and querying with natural language.

Mariano Gappa

Mariano Gappa

October 1, 2025

Engineering

CloudQuery Performance Benchmark Analysis: 581 GB/Hour Throughput and the Reality of Cloud API Constraints

Performance analysis of CloudQuery across AWS, GCP, and Azure achieving 581 GB/hour throughput on consumer hardware, with real-world API constraint data.

Joe Karlsson

Joe Karlsson

September 15, 2025

Engineering

How to Work with CloudQuery Syncs with Snowflake and Backstage

Learn how to query CloudQuery sync data on Snowflake using Backstage, including data transformation and visualization of your cloud asset inventory.

Duru Can Celasun

Duru Can Celasun

August 7, 2025

Own your pipeline.
Own your data.