From binary to cluster
You have a Go binary. It listens on port 8080. It handles requests. Now you need to run it on Kubernetes. Kubernetes doesn't care about Go. It cares about containers. Your job is to turn that binary into a container image and tell Kubernetes how to run it. The path from go run main.go to a rolling update in a cluster involves three steps: building a minimal image, defining the deployment manifest, and applying it. Get the image right, and Kubernetes does the heavy lifting. Get it wrong, and you spend hours debugging why the pod restarts immediately.
Go is built for containers. A compiled Go binary includes everything it needs. No shared libraries. No runtime installation. You can drop a Go binary onto an empty filesystem and it runs. This means your container image can be tiny. Small images pull faster, scan quicker, and attack surface shrinks. Kubernetes loves small images. The deployment manifest tells the cluster how many copies to run, how much CPU and memory to reserve, and how to check if the app is alive.
The minimal deployment
Start with a simple HTTP server. This code handles requests and includes a health check endpoint. Kubernetes uses health checks to decide if your app is working.
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"time"
)
// HealthCheck responds with 200 OK to indicate the service is running.
// Kubernetes probes call this endpoint to verify liveness and readiness.
func HealthCheck(w http.ResponseWriter, r *http.Request) {
// Return 200 immediately.
// If this returns an error, Kubernetes assumes the app is broken.
w.WriteHeader(http.StatusOK)
}
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", HealthCheck)
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello from Go on Kubernetes")
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Start server in a goroutine so we can listen for signals.
// This allows graceful shutdown when Kubernetes sends SIGTERM.
go func() {
log.Println("Listening on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server failed: %v", err)
}
}()
// Wait for interrupt signal to gracefully shutdown the server.
// Kubernetes sends SIGTERM before killing the pod.
// Without this handler, in-flight requests drop immediately.
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// Give in-flight requests 5 seconds to complete.
// context.WithTimeout ensures shutdown doesn't hang forever.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatalf("Server forced to shutdown: %v", err)
}
log.Println("Server exited")
}
Convention aside: context.Context always goes as the first parameter in Go functions that need cancellation. Here, context.WithTimeout creates a context that cancels after five seconds. The server respects this deadline. Functions that accept a context should check ctx.Done() and return early if the context is cancelled.
Build the binary into a container image using a multi-stage Dockerfile. Multi-stage builds keep the final image small. The first stage compiles the code. The second stage copies only the binary.
# Stage 1: Build the binary
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# CGO_ENABLED=0 creates a static binary.
# This removes dependency on libc, allowing the binary to run on scratch.
RUN CGO_ENABLED=0 GOOS=linux go build -o server .
# Stage 2: Minimal runtime image
FROM scratch
# Copy ca-certificates if your app makes HTTPS calls to external services.
# scratch has no root certificates by default.
# COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=builder /app/server /server
# Run as non-root user for security.
# UID 65532 is the 'nobody' user, a standard convention for containers.
USER 65532:65532
EXPOSE 8080
ENTRYPOINT ["/server"]
Build and push the image:
docker build -t your-registry/go-app:v1.0.0 .
docker push your-registry/go-app:v1.0.0
Define the deployment manifest. This YAML tells Kubernetes how to run the container.
apiVersion: apps/v1
kind: Deployment
metadata:
name: go-app
spec:
replicas: 2
selector:
matchLabels:
app: go-app
template:
metadata:
labels:
app: go-app
spec:
containers:
- name: go-app
image: your-registry/go-app:v1.0.0
ports:
- containerPort: 8080
# Liveness probe checks if the process is hung.
# If this fails, Kubernetes restarts the container.
livenessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
# Readiness probe checks if the app is ready to accept traffic.
# If this fails, Kubernetes removes the pod from the service load balancer.
readinessProbe:
httpGet:
path: /healthz
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
Apply the manifest:
kubectl apply -f deployment.yaml
Verify the deployment:
kubectl get pods -l app=go-app
Small images are fast images. Every megabyte slows down the rollout.
How it works
The build stage runs on a large image containing the Go toolchain. It downloads modules, compiles the code, and produces a binary. The CGO_ENABLED=0 flag is critical. Go can use C libraries via CGO. When CGO is enabled, the binary links against libc. The scratch image has no libc. If you build with CGO enabled and run on scratch, the container crashes with exec format error because the dynamic linker is missing. Setting CGO_ENABLED=0 forces a pure Go build. The binary is fully static. It runs anywhere.
The runtime stage starts from scratch. This is an empty image. You copy the binary and set the entrypoint. The image size drops from hundreds of megabytes to roughly ten megabytes. Kubernetes pulls the image quickly. Updates roll out faster.
The deployment manifest defines a Deployment. A deployment manages a set of identical pods. The replicas field sets the count. The selector and labels link the deployment to the pod template. The containers section specifies the image, ports, probes, and resources.
Probes are how Kubernetes monitors your app. The liveness probe restarts the container if the app is stuck. The readiness probe stops sending traffic if the app is initializing or overloaded. If your health check endpoint returns a non-200 status, Kubernetes reacts. If the readiness probe fails, the pod is removed from the service endpoints. Traffic stops. If the liveness probe fails, the container restarts.
Resources define CPU and memory. Requests are the guaranteed minimum. Limits are the maximum. If a pod exceeds its memory limit, the kernel kills it with an OOMKilled error. Go's garbage collector respects memory limits, but sudden spikes can still trigger eviction. Set limits based on testing. Don't guess.
Convention aside: gofmt is mandatory in Go. Run gofmt -w . before committing. The community expects consistent formatting. Most editors run it on save. Don't argue about indentation. Let the tool decide.
Realistic production setup
Production apps need more than a basic deployment. You need environment variables, config maps, and graceful shutdown handling. Go apps should read configuration from environment variables. Kubernetes injects these into the container.
Update the Go code to read config:
func main() {
// Read port from environment variable with a fallback.
// Kubernetes sets environment variables via the pod spec.
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
srv := &http.Server{
Addr: ":" + port,
Handler: mux,
}
// ... rest of main
}
Update the deployment to inject the variable:
env:
- name: PORT
value: "8080"
If you have complex configuration, use ConfigMaps or Secrets. Mount them as files or environment variables. Never hardcode secrets in the image.
Graceful shutdown is essential. Kubernetes sends SIGTERM to the container before killing it. The container has a grace period, usually thirty seconds. Your app must listen for the signal, stop accepting new connections, and finish in-flight requests. The signal.Notify channel in the example code handles this. If you ignore the signal, the kernel sends SIGKILL after the grace period. Connections drop. Clients see errors.
Convention aside: if err != nil { return err } is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Check errors. Don't ignore them. In main, log.Fatalf is acceptable. In handlers, return errors or write error responses.
Pitfalls and errors
Port mismatches break deployments. If your Go code binds to port 8080 but the manifest specifies containerPort: 80, the port mapping is wrong. Kubernetes doesn't enforce that the container port matches the app port. The app binds to 8080. The manifest says 80. Probes fail. Traffic fails. Ensure the port in the code matches the containerPort in the YAML.
CGO traps cause runtime failures. Some Go libraries require CGO. Database drivers like lib/pq or go-sqlite3 often use CGO. If you build with CGO_ENABLED=0, the compiler rejects the build with undefined: C.xxx or similar errors. If you need CGO, you cannot use scratch. You must use an image with libc, like alpine or distroless. Build with CGO_ENABLED=1 and copy the necessary shared libraries into the image.
Scratch images lack CA certificates. If your app makes HTTPS calls to external services, the TLS handshake fails. The error is x509: certificate signed by unknown authority. The scratch image has no root certificates. Copy ca-certificates.crt from the build stage into the runtime image, or use distroless which includes them.
User permissions cause bind errors. If your app binds to port 80 or 443, the kernel requires root privileges. If the container runs as a non-root user, the bind fails with permission denied. Bind to a high port like 8080 inside the container. Map the port in the manifest. Run as non-root for security.
Resource limits cause OOMKilled. If you set a memory limit that is too low, the kernel kills the pod. The pod status shows OOMKilled. Go's memory usage includes heap, stack, and GC overhead. Profile your app. Set limits with headroom. Requests should match typical usage. Limits should match peak usage.
Convention aside: Public names start with a capital letter. Private names start lowercase. No keywords like public or private. HealthCheck is exported. main is not. This controls visibility across packages.
Decision matrix
Use scratch when you have a pure Go binary with no CGO dependencies and no external file requirements.
Use gcr.io/distroless/static when you want a minimal image but need a safer base than scratch, such as for debug symbols or certificate management.
Use alpine when your app requires CGO or shell tools for debugging, and you accept the larger image size and additional attack surface.
Use CGO_ENABLED=0 when you want a fully static binary that runs on any Linux distribution without shared library dependencies.
Use CGO_ENABLED=1 when you depend on C libraries like OpenSSL or SQLite, and you are willing to manage the base image carefully to include the required libraries.
Use resource limits when your app handles untrusted input or runs in a shared cluster to prevent noisy neighbor issues and ensure fair scheduling.
Use readiness probes when your app needs time to initialize connections or load data before accepting traffic.
Use liveness probes when your app can enter a stuck state that requires a restart to recover.
Kubernetes kills pods that don't respond. Add probes or get evicted.
Where to go next
- Go workspaces for managing dependencies across multiple modules.
- Test HTTP handlers for verifying the endpoints you just deployed.
- Private modules if your code lives behind a firewall.