As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let's talk about making Go applications run well inside containers. Containers are like small, isolated boxes for your software. They come with their own rules about how much memory and CPU they can use. If your Go program doesn't understand these rules, it can be slow, crash, or waste resources. I'll show you how to make your Go code a good citizen in this container world.
First, think about memory. In a regular server, your Go program might use as much memory as it wants. In a container, there's a strict limit. The Go garbage collector, which cleans up unused memory, needs a different setup here. By default, it's tuned for a big server, not a small container.
The key is to adjust the GOGC environment variable. This controls how often the garbage collector runs. In a tight memory space, you want it to run more often to keep memory usage low and avoid hitting the limit. Here's how you can make your program aware of its container limits and adjust itself.
package main
import (
"os"
"runtime"
"runtime/debug"
"strconv"
)
func configureMemoryForContainer() {
// First, try to read the container's memory limit.
// This is usually in a special file.
memLimitMB := getContainerMemoryLimitMB()
if memLimitMB == 0 {
// No limit found, maybe we're not in a container.
// Use the default Go behavior.
return
}
// Adjust GOGC based on the available memory.
// Less memory means we need to collect garbage more aggressively.
if memLimitMB < 512 {
// Very small container
debug.SetGCPercent(50) // Trigger GC when heap grows by 50%
} else if memLimitMB < 1024 {
// Small container
debug.SetGCPercent(75)
} else {
// Container with decent memory, can use a higher threshold.
// This is better for performance.
debug.SetGCPercent(100) // This is Go's default.
}
// For containers with more than 1GB, we can use a 'heap ballast'.
// This is a trick to make GC more predictable.
if memLimitMB > 1024 {
// Allocate a large, unused slice of memory.
ballast := make([]byte, 256*1024*1024) // 256MB
// Touch it so the OS actually gives us the memory.
for i := 0; i < len(ballast); i += 4096 {
ballast[i] = 0
}
// Keep a reference so it's not collected.
runtime.KeepAlive(ballast)
}
}
func getContainerMemoryLimitMB() int {
// In Linux containers, the limit is here.
data, err := os.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes")
if err != nil {
return 0 // Probably not in a container
}
limit, err := strconv.ParseInt(string(data), 10, 64)
if err != nil {
return 0
}
// Docker uses a huge number to mean "no limit".
if limit > 1<<62 {
return 0
}
// Convert bytes to megabytes.
return int(limit / 1024 / 1024)
}
Next, let's talk about CPU. Your laptop might have 8 cores, but your container might only be allowed to use one or two. The Go scheduler tries to use all available cores. In a container, this can lead to too many threads fighting for the limited CPUs, which slows everything down.
You need to tell Go exactly how many CPUs it can use. This is done with GOMAXPROCS. You should set it to match the container's CPU quota.
func configureCPUForContainer() {
cpuLimit := getContainerCPULimit()
if cpuLimit > 0 {
runtime.GOMAXPROCS(cpuLimit)
} else {
// If we can't find a limit, use the number of cores the OS sees.
// In a container, this is often the host's core count, which is wrong for us.
// It's safer to default to 1 if we're unsure in a constrained environment.
runtime.GOMAXPROCS(1)
}
}
func getContainerCPULimit() int {
// Read the CPU quota and period.
quotaData, err := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
if err != nil {
return 0
}
periodData, err := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
if err != nil {
return 0
}
quota, _ := strconv.Atoi(string(quotaData))
period, _ := strconv.Atoi(string(periodData))
if quota > 0 && period > 0 {
// The limit is quota / period.
// Example: quota=100000, period=100000 means 1 CPU.
return quota / period
}
return 0
}
Now, startup time. In an orchestration system like Kubernetes, containers are started and stopped all the time. A slow-starting application hurts your service's responsiveness. The goal is to be ready to serve requests as fast as possible.
A common mistake is doing all setup work before you start your web server. You can split this work. Do the absolute minimum first, start the server, and then finish the rest in the background.
package main
import (
"log"
"net/http"
"time"
)
func main() {
// 1. Do the CRITICAL setup that the server absolutely needs.
// This should be very fast: loading a port number, a secret key.
log.Println("Starting critical init...")
// 2. Start the HTTP server in a goroutine IMMEDIATELY.
server := &http.Server{Addr: ":8080"}
go func() {
// This will start listening right away.
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
}()
// 3. Do the HEAVY setup in the background: connecting to a database,
// loading large files, warming up caches.
go func() {
log.Println("Beginning heavy background initialization...")
time.Sleep(2 * time.Second) // Simulate slow work
log.Println("Background init complete. App is fully ready.")
}()
// 4. Your /health endpoint should initially report "starting up",
// then "healthy" once the background work is done.
// This tells Kubernetes when the container is truly ready for traffic.
// Keep the main goroutine alive.
select {}
}
You need a good health check system. Kubernetes uses "liveness" and "readiness" probes. Your app must provide endpoints for these.
func setupHealthEndpoints() {
isReady := false
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
// Liveness probe: is the process alive?
w.WriteHeader(http.StatusOK)
w.Write([]byte("alive"))
})
http.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
// Readiness probe: is the app ready for traffic?
if !isReady {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
w.Write([]byte("ready"))
})
// Simulate background work finishing after 5 seconds.
go func() {
time.Sleep(5 * time.Second)
isReady = true
log.Println("Service is now ready.")
}()
}
Containers get a signal to stop. Usually, it's SIGTERM. Your application must catch this signal and shut down gracefully. This means finishing current requests, closing database connections, and flushing logs.
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
func mainWithGracefulShutdown() {
server := &http.Server{Addr: ":8080"}
// Simple handler
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(1 * time.Second) // Simulate work
w.Write([]byte("Hello\n"))
})
// Start server in a goroutine
go func() {
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Set up channel to listen for shutdown signals
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
// Block until we get a signal
<-stop
log.Println("Shutdown signal received")
// Give connections 10 seconds to finish
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("Error during shutdown: %v", err)
}
log.Println("Server stopped gracefully")
}
Let's put all these ideas together into a single, cohesive example. This is a skeleton for a container-optimized Go application.
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"runtime"
"runtime/debug"
"strconv"
"syscall"
"time"
)
// App holds our application state.
type App struct {
server *http.Server
isReady bool
memoryLimitMB int
}
// NewApp creates and configures the application.
func NewApp() *App {
app := &App{}
app.detectContainerLimits()
app.configureRuntime()
return app
}
func (app *App) detectContainerLimits() {
// Detect memory limit
app.memoryLimitMB = getContainerMemoryLimitMB()
log.Printf("Container memory limit detected: %d MB\n", app.memoryLimitMB)
}
func (app *App) configureRuntime() {
// Set GOMAXPROCS based on CPU limit
cpuLimit := getContainerCPULimit()
if cpuLimit > 0 {
runtime.GOMAXPROCS(cpuLimit)
log.Printf("GOMAXPROCS set to container CPU limit: %d\n", cpuLimit)
}
// Configure GC based on memory
if app.memoryLimitMB > 0 && app.memoryLimitMB < 512 {
debug.SetGCPercent(50)
log.Println("Aggressive GC enabled for low-memory container.")
}
}
// setupServer creates and configures the HTTP server.
func (app *App) setupServer() {
mux := http.NewServeMux()
// Simple main endpoint
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "OK from container-optimized app\n")
})
// Liveness probe
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
// Readiness probe
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if !app.isReady {
http.Error(w, "not ready", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
// Memory usage endpoint
mux.HandleFunc("/debug/memory", func(w http.ResponseWriter, r *http.Request) {
var m runtime.MemStats
runtime.ReadMemStats(&m)
fmt.Fprintf(w, "Alloc = %v MiB", m.Alloc/1024/1024)
})
app.server = &http.Server{
Addr: ":8080",
Handler: mux,
ReadTimeout: 5 * time.Second, // Important for slow clients
WriteTimeout: 10 * time.Second, // Important for slow responses
IdleTimeout: 60 * time.Second, // Important for connection reuse
}
}
// performBackgroundInit simulates slow startup work.
func (app *App) performBackgroundInit() {
log.Println("Starting background initialization...")
time.Sleep(3 * time.Second) // Simulate loading data, connecting to DB
app.isReady = true
log.Println("Background init complete. App is ready.")
}
// run starts the application and waits for shutdown.
func (app *App) run() error {
// Start background init
go app.performBackgroundInit()
// Start server
go func() {
log.Printf("Server starting on %s\n", app.server.Addr)
if err := app.server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Could not start server: %v\n", err)
}
}()
// Wait for shutdown signal
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Graceful shutdown with timeout
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
if err := app.server.Shutdown(ctx); err != nil {
return fmt.Errorf("server forced to shutdown: %w", err)
}
log.Println("Server stopped")
return nil
}
func main() {
app := NewApp()
app.setupServer()
if err := app.run(); err != nil {
log.Fatal(err)
}
}
// Helper functions (same as earlier examples)
func getContainerMemoryLimitMB() int {
data, err := os.ReadFile("/sys/fs/cgroup/memory/memory.limit_in_bytes")
if err != nil {
return 0
}
limit, err := strconv.ParseInt(string(data), 10, 64)
if err != nil {
return 0
}
if limit > 1<<62 {
return 0
}
return int(limit / 1024 / 1024)
}
func getContainerCPULimit() int {
quotaData, err := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us")
if err != nil {
return 0
}
periodData, err := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us")
if err != nil {
return 0
}
quota, _ := strconv.Atoi(string(quotaData))
period, _ := strconv.Atoi(string(periodData))
if quota > 0 && period > 0 {
return quota / period
}
return 0
}
A few more practical tips. Be careful with file descriptors. Each network connection uses one. In a container, there might be a low limit. Use connection pooling for databases and external APIs. Set idle timeouts so unused connections are closed.
For logging, write to stdout and stderr. The container runtime collects these logs. Don't write log files inside the container unless you have a specific logging sidecar. Use structured logging (JSON) so it's easy to parse later.
Keep your dependencies light. A large binary takes longer to start and uses more memory. Use go mod to manage dependencies and regularly update them. Use multi-stage Docker builds to keep your final container image small. Only copy the Go binary into the final image, not the entire Go toolchain.
Here's an example Dockerfile that follows these principles:
# Stage 1: Build the application
FROM golang:1.21-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o main .
# Stage 2: Create a tiny final image
FROM alpine:latest
RUN apk --no-cache add ca-certificates
WORKDIR /root/
# Copy ONLY the binary from the builder stage
COPY --from=builder /app/main .
EXPOSE 8080
# Run as a non-root user for better security
USER 1001
CMD ["./main"]
Test your application under memory pressure. You can use Docker to limit memory and see how your app behaves. Does it crash? Does it slow down? The goal is to stay within the limit and degrade gracefully.
Finally, monitor your application in production. The /debug/memory endpoint we added can be a starting point. You can also expose Prometheus metrics. Track memory usage, goroutine count, and garbage collection pauses. This data helps you choose the right container size and find memory leaks.
Running Go in containers is not just about writing the code. It's about understanding the environment your code lives in. By respecting CPU and memory limits, starting quickly, shutting down cleanly, and providing good health signals, your application will be reliable and efficient. It will work well with Kubernetes and other orchestration tools, making your whole system more stable. Start with the simple patterns I've shown here, and adapt them as your application grows.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)