Skip to content

How to Reduce Spring Cloud Config Server Memory Footprint: Go vs GraalVM Native

I was deploying a Spring Cloud Config Server to a resource-constrained Kubernetes cluster when I hit a wall: each instance was consuming over 200MB of memory just to serve configuration files. My cluster’s resource quotas were being eaten alive by what should have been a simple configuration service.

I started investigating alternatives and stumbled upon an interesting Reddit discussion where someone had rewritten the entire Config Server in Go. The comments were split between “just use GraalVM Native” and “Go is overkill.” I decided to explore all three approaches and document what I found.

The Problem: Why 200MB Matters

At first glance, 200MB doesn’t seem like much. On a modern cloud server with gigabytes of RAM, who cares? But my deployment scenario made it matter:

  • Container resource limits: Each pod had 256MB limits, and the Config Server was dangerously close to OOM
  • Horizontal scaling: Running 10 instances = 2GB just for config serving
  • Edge deployments: Some nodes only had 512MB total
  • Cold start penalties: JVM took 3-5 seconds to start, affecting auto-scaling responsiveness
+------------------+ +------------------+
| Config Server | 200MB | Your App |
| Instance 1 | <------> | Instance 1 |
+------------------+ +------------------+
| |
v v
+------------------+ +------------------+
| Config Server | 200MB | Your App |
| Instance 2 | <------> | Instance 2 |
+------------------+ +------------------+
| |
... ...
| |
v v
+------------------+ +------------------+
| Config Server | 200MB | Your App |
| Instance N | <------> | Instance N |
+------------------+ +------------------+
Total Config Server Memory: 200MB x N instances

I needed a solution. Here’s what I tried.

Approach 1: Rewriting in Go

I found a project called config-server-go on GitHub that implements the same JSON API format as Spring Cloud Config Server. The author claimed ~10MB binary size and millisecond startup times.

I cloned the repository and gave it a shot:

Cloning and building config-server-go
git clone https://github.com/roniel-rhack/config-server-go.git
cd config-server-go
go build -o config-server .
# Check binary size
ls -lh config-server
# -rwxr-xr-x 1 user staff 9.8M Mar 30 10:42 config-server

The binary was indeed around 10MB. But I needed to understand the implementation to ensure it would work with my existing Spring Cloud clients.

Go Implementation Structure

Here’s a simplified version of what the Go config server does:

config-server-go/main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"path/filepath"
"strings"
)
// Response matches Spring Cloud Config Server JSON format
type ConfigResponse struct {
Name string `json:"name"`
Profiles []string `json:"profiles"`
Label string `json:"label"`
Version string `json:"version"`
State string `json:"state,omitempty"`
PropertySources []PropertySource `json:"propertySources"`
}
type PropertySource struct {
Name string `json:"name"`
Source map[string]interface{} `json:"source"`
}
type ConfigServer struct {
configPath string
gitRepo string
}
func (cs *ConfigServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Parse URL: /{application}/{profile}[/{label}]
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
if len(parts) < 2 {
http.Error(w, "Invalid path format", http.StatusBadRequest)
return
}
application := parts[0]
profile := parts[1]
label := "main"
if len(parts) > 2 {
label = parts[2]
}
response := cs.loadConfig(application, profile, label)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)
}
func (cs *ConfigServer) loadConfig(app, profile, label string) *ConfigResponse {
// Load YAML/properties files and merge
// This is simplified - actual implementation handles:
// - application.yml (base config)
// - application-{profile}.yml (profile-specific)
// - {app}.yml (app-specific)
// - {app}-{profile}.yml (app + profile)
return &ConfigResponse{
Name: app,
Profiles: []string{profile},
Label: label,
PropertySources: []PropertySource{
{
Name: fmt.Sprintf("file:./config/%s-%s.yml", app, profile),
Source: map[string]interface{}{
"server.port": 8080,
"app.message": "Hello from Go Config Server",
},
},
},
}
}
func main() {
configPath := os.Getenv("CONFIG_PATH")
if configPath == "" {
configPath = "./config"
}
server := &ConfigServer{configPath: configPath}
port := os.Getenv("SERVER_PORT")
if port == "" {
port = "8888"
}
fmt.Printf("Config Server starting on port %s\n", port)
fmt.Printf("Config path: %s\n", configPath)
log.Fatal(http.ListenAndServe(":"+port, server))
}

Memory and Startup Comparison

I ran both side by side to measure actual memory usage:

Memory comparison script
#!/bin/bash
# Start Spring Config Server (JVM)
java -jar spring-cloud-config-server.jar &
JVM_PID=$!
sleep 5
JVM_MEM=$(ps -o rss= -p $JVM_PID | awk '{print $1/1024 " MB"}')
echo "JVM Config Server: $JVM_MEM"
# Kill JVM version
kill $JVM_PID
# Start Go Config Server
./config-server &
GO_PID=$!
sleep 1
GO_MEM=$(ps -o rss= -p $GO_PID | awk '{print $1/1024 " MB"}')
echo "Go Config Server: $GO_MEM"
kill $GO_PID
Actual measurements on my machine
JVM Config Server: 187 MB
Go Config Server: 12 MB

The difference was dramatic. But I wasn’t done - I needed to verify client compatibility.

Testing Client Compatibility

My Spring Boot applications use spring-cloud-starter-config to fetch configuration. I needed to ensure the Go server would return the same JSON format:

Testing API compatibility
# Spring Config Server response
curl http://localhost:8888/myapp/production
# Go Config Server response
curl http://localhost:8889/myapp/production
# Both should return identical JSON structure
Expected response format
{
"name": "myapp",
"profiles": ["production"],
"label": "main",
"version": "abc123",
"propertySources": [
{
"name": "file:./config/myapp-production.yml",
"source": {
"server.port": 8080,
"database.url": "jdbc:postgresql://prod-db:5432/myapp"
}
}
]
}

The Go version worked, but I hit some issues:

  1. Git backend support: The Go version had limited Git backend features compared to Spring
  2. Encryption/decryption: Spring’s property encryption wasn’t implemented
  3. Webhook refresh: The /refresh endpoint behaved differently
  4. Monitoring: No built-in actuator endpoints

For simple use cases, Go was perfect. But I had invested heavily in the Spring ecosystem.

Approach 2: GraalVM Native Image

The Reddit comments kept mentioning GraalVM Native Image. The top comment with 16 votes said: “If you already were that committed to Spring, you could have just slapped Spring Native on it and you would get more or less the same result.”

I decided to try this approach with my existing Spring Cloud Config Server.

Adding Spring Native Support

First, I added the Spring Native dependency:

pom.xml
<project>
<dependencies>
<!-- Existing Spring Cloud Config Server dependency -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-config-server</artifactId>
</dependency>
<!-- Spring Native for GraalVM compilation -->
<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-native</artifactId>
<version>0.12.1</version>
</dependency>
<!-- For native hints -->
<dependency>
<groupId>org.springframework.experimental</groupId>
<artifactId>spring-aot</artifactId>
<version>0.12.1</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<image>
<builder>paketobuildpacks/builder:tiny</builder>
<env>
<BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
</env>
</image>
</configuration>
</plugin>
</plugins>
</build>
</project>

Building the Native Image

I tried two approaches: using Spring Boot’s build-image and using GraalVM’s native-image directly.

Option A: Spring Boot Build Image (Easier)

Build native image with Spring Boot
# Requires Docker
mvn spring-boot:build-image
# Run the native container
docker run -p 8888:8888 config-server:native
# Check memory
docker stats --no-stream

Option B: Native Image Directly (More Control)

Build with GraalVM native-image directly
# Install GraalVM and native-image
# Using SDKMAN:
sdk install java 21.0.2-graal
sdk use java 21.0.2-graal
gu install native-image
# Build the JAR first
mvn clean package
# Generate native image
native-image \
-jar target/config-server-0.0.1-SNAPSHOT.jar \
-H:Name=config-server-native \
-H:ReflectionConfigurationFiles=src/main/resources/META-INF/native-image/reflect-config.json \
--no-fallback \
--enable-url-protocols=http,https \
--initialize-at-build-time=org.springframework \
-J-Xmx4g
# Check binary size
ls -lh config-server-native
# -rwxr-xr-x 1 user staff 42M Mar 30 10:42 config-server-native

Handling Reflection Issues

The main challenge with GraalVM Native Image is reflection. Spring Cloud Config Server uses reflection extensively for property binding and Git operations. I needed to provide hints:

META-INF/native-image/reflect-config.json
[
{
"name": "org.springframework.cloud.config.server.environment.JdbcEnvironmentProperties",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.springframework.cloud.config.server.environment.NativeEnvironmentProperties",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
},
{
"name": "org.eclipse.jgit.transport.JschConfigSessionFactory",
"allDeclaredConstructors": true,
"allPublicConstructors": true,
"allDeclaredMethods": true,
"allPublicMethods": true
}
]

For complex Git-based configuration, I also needed resource configuration:

META-INF/native-image/resource-config.json
{
"resources": {
"includes": [
{"pattern": ".*\\.yml$"},
{"pattern": ".*\\.yaml$"},
{"pattern": ".*\\.properties$"},
{"pattern": "application.*"},
{"pattern": "bootstrap.*"}
]
}
}

Runtime Configuration

The native image had different behavior for dynamic configuration. I needed to pre-configure some things:

application.properties
# Static configuration (works in native)
spring.cloud.config.server.git.uri=https://github.com/myorg/config-repo
spring.cloud.config.server.git.default-label=main
# Disable features that don't work well in native
spring.cloud.config.server.git.clone-on-start=true
spring.cloud.config.server.git.refresh-rate=300

Memory Results with GraalVM Native

GraalVM Native Image measurements
Binary Size: 42 MB
Runtime Memory (idle): 35 MB
Runtime Memory (under load): 65 MB
Startup Time: 0.08 seconds

The memory footprint dropped from ~200MB to ~35-65MB, similar to what the Reddit commenters suggested.

Approach 3: JVM Tuning (If You Must Stay on JVM)

Sometimes you can’t rewrite or use native images. Maybe you have:

  • Dependencies that don’t support GraalVM Native
  • Dynamic class loading that breaks AOT compilation
  • Regulatory requirements for JVM monitoring tools

Here’s what I tried to squeeze the JVM:

Aggressive JVM memory tuning
java \
-Xmx96m \
-Xms32m \
-XX:+UseG1GC \
-XX:MaxMetaspaceSize=64m \
-XX:CompressedClassSpaceSize=16m \
-XX:ReservedCodeCacheSize=32m \
-XX:+UseStringDeduplication \
-XX:+UseCompressedOops \
-XX:+UseCompressedClassPointers \
-Xss256k \
-XX:MinHeapFreeRatio=10 \
-XX:MaxHeapFreeRatio=20 \
-jar spring-cloud-config-server.jar
JVM tuning results
Runtime Memory (idle): 145 MB
Runtime Memory (under load): 180 MB
Startup Time: 2.3 seconds

Better, but still ~150MB. The JVM overhead is unavoidable.

Comparison: Making the Choice

Here’s my decision matrix after trying all three approaches:

Comparison matrix
+----------------------+----------------+------------------+----------------+
| Metric | Go Rewrite | GraalVM Native | JVM (Tuned) |
+----------------------+----------------+------------------+----------------+
| Binary Size | ~10 MB | ~40 MB | ~200 MB+ |
| Runtime Memory | 12-30 MB | 35-65 MB | 145-200 MB |
| Startup Time | < 10 ms | ~80 ms | 2-3 sec |
| Implementation Effort | High (rewrite)| Low (config) | None |
| Spring Ecosystem | Lost | Preserved | Full |
| Git Backend Support | Limited | Full | Full |
| Property Encryption | Manual | Full | Full |
| Actuator Metrics | Custom | Partial | Full |
| Build Complexity | Simple | Complex | Simple |
| Debugging Tools | Go tools | Limited | Mature JVM |
| Production Ready | Evaluate | Stable | Stable |
+----------------------+----------------+------------------+----------------+

When to Choose Go Rewrite

  • Starting a new project without Spring ecosystem dependency
  • Need absolute minimal memory and binary size
  • Edge/IoT deployments with severe resource constraints
  • Team has strong Go expertise
  • Simple configuration serving without advanced Spring features

When to Choose GraalVM Native

  • Existing Spring Cloud Config Server codebase
  • Want memory savings without rewriting
  • Need to preserve Spring ecosystem integration
  • Can handle native-image build complexity
  • Have time to debug reflection issues

When to Stick with JVM

  • Dependencies incompatible with GraalVM Native
  • Require JVM profiling and debugging tools
  • Need dynamic class loading/features
  • Regulatory requirements for JVM-based monitoring
  • 200MB is acceptable in your deployment context

What I Chose

For my situation - an existing Spring Cloud Config Server with Git backend, property encryption, and actuator metrics - I went with GraalVM Native Image.

The build process was painful (spent 2 days debugging reflection issues with JGit), but the result was worth it:

  • Memory dropped from ~200MB to ~50MB per instance
  • Startup went from 3 seconds to 80ms
  • All existing Spring features preserved
  • No client code changes needed

If I were starting fresh with simple file-based configuration, I’d choose Go for its simplicity. But for existing Spring investments, GraalVM Native is the pragmatic choice.

Lessons Learned

  1. GraalVM Native isn’t “just slap it on” - The Reddit comment oversimplified it. Expect to spend time on reflection configuration.

  2. Go rewrite loses ecosystem benefits - Spring Cloud Config Server has years of edge-case handling in Git operations, encryption, and refresh logic.

  3. Memory matters more in constrained environments - If you’re running on Kubernetes with resource quotas, every MB counts.

  4. Test client compatibility thoroughly - The JSON format seems simple, but Spring clients expect specific behaviors.

  5. Profile your actual usage - My “200MB” was actually 187MB at startup but grew under load. Your numbers may vary.

Decision flowchart
+---------------------+
| Reduce Config Server|
| Memory Usage? |
+----------+----------+
|
v
+----------+----------+
| Existing Spring |
| Cloud Config Server? |
+----------+----------+
|
+----------------+----------------+
| |
v v
+---------+--------+ +---------+--------+
| YES | | NO |
+---------+--------+ +---------+--------+
| |
v v
+---------+--------+ +---------+--------+
| GraalVM Native | | Go Rewrite |
| (Preserve | | (Start Fresh) |
| Ecosystem) | +------------------+
+------------------+

The Reddit discussion exposed an important trade-off: engineering effort vs. runtime efficiency. Go gives you better efficiency at the cost of a rewrite. GraalVM Native gives you better efficiency at the cost of build complexity. JVM gives you simplicity at the cost of memory.

Choose based on your constraints, not just the “cool factor” of a new language.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments