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 instancesI 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:
git clone https://github.com/roniel-rhack/config-server-go.gitcd config-server-gogo build -o config-server .
# Check binary sizels -lh config-server# -rwxr-xr-x 1 user staff 9.8M Mar 30 10:42 config-serverThe 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:
package main
import ( "encoding/json" "fmt" "log" "net/http" "os" "path/filepath" "strings")
// Response matches Spring Cloud Config Server JSON formattype 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:
#!/bin/bash
# Start Spring Config Server (JVM)java -jar spring-cloud-config-server.jar &JVM_PID=$!sleep 5JVM_MEM=$(ps -o rss= -p $JVM_PID | awk '{print $1/1024 " MB"}')echo "JVM Config Server: $JVM_MEM"
# Kill JVM versionkill $JVM_PID
# Start Go Config Server./config-server &GO_PID=$!sleep 1GO_MEM=$(ps -o rss= -p $GO_PID | awk '{print $1/1024 " MB"}')echo "Go Config Server: $GO_MEM"
kill $GO_PIDJVM Config Server: 187 MBGo Config Server: 12 MBThe 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:
# Spring Config Server responsecurl http://localhost:8888/myapp/production
# Go Config Server responsecurl http://localhost:8889/myapp/production
# Both should return identical JSON structure{ "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:
- Git backend support: The Go version had limited Git backend features compared to Spring
- Encryption/decryption: Spring’s property encryption wasn’t implemented
- Webhook refresh: The
/refreshendpoint behaved differently - 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:
<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)
# Requires Dockermvn spring-boot:build-image
# Run the native containerdocker run -p 8888:8888 config-server:native
# Check memorydocker stats --no-streamOption B: Native Image Directly (More Control)
# Install GraalVM and native-image# Using SDKMAN:sdk install java 21.0.2-graalsdk use java 21.0.2-graalgu install native-image
# Build the JAR firstmvn clean package
# Generate native imagenative-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 sizels -lh config-server-native# -rwxr-xr-x 1 user staff 42M Mar 30 10:42 config-server-nativeHandling 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:
[ { "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:
{ "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:
# Static configuration (works in native)spring.cloud.config.server.git.uri=https://github.com/myorg/config-repospring.cloud.config.server.git.default-label=main
# Disable features that don't work well in nativespring.cloud.config.server.git.clone-on-start=truespring.cloud.config.server.git.refresh-rate=300Memory Results with GraalVM Native
Binary Size: 42 MBRuntime Memory (idle): 35 MBRuntime Memory (under load): 65 MBStartup Time: 0.08 secondsThe 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:
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.jarRuntime Memory (idle): 145 MBRuntime Memory (under load): 180 MBStartup Time: 2.3 secondsBetter, but still ~150MB. The JVM overhead is unavoidable.
Comparison: Making the Choice
Here’s my decision matrix after trying all three approaches:
+----------------------+----------------+------------------+----------------+| 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
-
GraalVM Native isn’t “just slap it on” - The Reddit comment oversimplified it. Expect to spend time on reflection configuration.
-
Go rewrite loses ecosystem benefits - Spring Cloud Config Server has years of edge-case handling in Git operations, encryption, and refresh logic.
-
Memory matters more in constrained environments - If you’re running on Kubernetes with resource quotas, every MB counts.
-
Test client compatibility thoroughly - The JSON format seems simple, but Spring clients expect specific behaviors.
-
Profile your actual usage - My “200MB” was actually 187MB at startup but grew under load. Your numbers may vary.
+---------------------+ | 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:
- 👨💻 config-server-go GitHub Repository
- 👨💻 Reddit Discussion: Config Server in Go
- 👨💻 GraalVM Native Image Documentation
- 👨💻 Spring Native Project
- 👨💻 JVM Memory Tuning Guide
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments