From binary to cluster
You have a Go web server running on your laptop. It handles requests, connects to a database, and feels great. Now you need to run it on a cluster where it can survive restarts, scale when traffic spikes, and update without downtime. Kubernetes is the tool for that, but it doesn't care about Go. It cares about containers. The bridge between your Go binary and Kubernetes is a container image and a manifest that tells the cluster how to run it.
Go gives you a superpower here: a single static binary. Unlike Java or Node.js, you don't need a virtual machine or a runtime environment installed on the host. Your Go binary contains everything it needs to execute. Kubernetes wraps that binary in a container, which adds the minimal OS layer required to run it. The deployment workflow is linear: compile Go to binary, pack binary into image, push image to registry, tell Kubernetes to pull and run that image.
The container contract
Kubernetes treats every workload as a container. A container is an isolated process with its own filesystem, network namespace, and resource limits. Your Go binary lives inside that container. The container image is the blueprint. It defines the base operating system, the binary itself, the entry point command, and the environment variables.
Think of the Go binary as a recipe and the container image as a meal kit. The recipe is just instructions. The meal kit packages the ingredients, the tools, and the instructions into a box that can be cooked anywhere. Kubernetes receives the box, opens it, and runs the cooking process. If the process crashes, Kubernetes throws away the box and starts a new one.
Go binaries are portable, but they are not magic. If your binary depends on C libraries, you must include those libraries in the image. If your binary expects a specific timezone database, you must include that too. The most common mistake is assuming the container has the same environment as your development machine. It does not. The container has exactly what you put in the image.
Minimal deployment
Start with the simplest possible setup. A Go HTTP server, a multi-stage Dockerfile, and a Deployment manifest. This combination covers 90% of use cases and establishes the patterns you will extend later.
Here is the application code. It listens on port 8080 and exposes a health endpoint.
package main
import (
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
)
// main starts the HTTP server and handles graceful shutdown.
func main() {
mux := http.NewServeMux()
// Register a health check endpoint for Kubernetes probes.
mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, "ok")
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprint(w, "Hello from Go on Kubernetes")
})
srv := &http.Server{
Addr: ":8080",
Handler: mux,
}
// Create a channel to listen for OS signals.
quit := make(chan os.Signal, 1)
// Notify the channel when SIGINT or SIGTERM arrives.
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
// Start the server in a goroutine so we can block on signals.
go func() {
log.Println("Starting server on :8080")
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("Server error: %v", err)
}
}()
// Block until a signal is received.
<-quit
log.Println("Shutting down server...")
// Close the server gracefully, waiting for active requests to finish.
if err := srv.Close(); err != nil {
log.Fatalf("Shutdown error: %v", err)
}
}
The Dockerfile uses a multi-stage build. This is the standard pattern for Go. You compile the binary in a heavy image with the Go toolchain, then copy only the binary to a tiny runtime image. This keeps the final image small and reduces the attack surface.
# Stage 1: Build the binary.
# Use the official Go image with a specific version for reproducibility.
FROM golang:1.22-alpine AS builder
# Set the working directory inside the builder container.
WORKDIR /app
# Copy go.mod and go.sum first to leverage Docker layer caching.
COPY go.mod go.sum ./
# Download dependencies. This layer is cached unless modules change.
RUN go mod download
# Copy the rest of the source code.
COPY . .
# Build the binary with CGO disabled for a static executable.
# Static binaries do not depend on shared C libraries.
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o server .
# Stage 2: Create the minimal runtime image.
# Distroless images contain only the binary and its runtime dependencies.
# This image has no shell, no package manager, and minimal packages.
FROM gcr.io/distroless/static-debian12
# Copy the binary from the builder stage.
COPY --from=builder /app/server /server
# Run the binary as the entrypoint.
ENTRYPOINT ["/server"]
The Deployment manifest tells Kubernetes how to run the container. It defines the number of replicas, the container image, the ports, and the labels used for selection.
apiVersion: apps/v1
kind: Deployment
metadata:
name: go-app
labels:
app: go-app
spec:
# Run 3 replicas for high availability.
replicas: 3
# Selector matches pods created by this deployment.
selector:
matchLabels:
app: go-app
template:
metadata:
labels:
app: go-app
spec:
containers:
- name: go-app
# Reference the image from the registry.
image: my-registry/my-go-app:latest
ports:
# Expose port 8080 inside the container.
- containerPort: 8080
# Resource requests and limits help the scheduler place pods.
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
Build the image, push it to a registry, and apply the manifest. The commands are straightforward.
# Build the Docker image with a tag.
docker build -t my-registry/my-go-app:latest .
# Push the image to the registry so the cluster can pull it.
docker push my-registry/my-go-app:latest
# Apply the deployment to the cluster.
kubectl apply -f deployment.yaml
Go binaries are portable. Container images are the transport. Keep the image small and the binary static.
What happens under the hood
When you run kubectl apply, the YAML is sent to the Kubernetes API server. The API server validates the manifest and stores the desired state in etcd. The controller manager detects the new Deployment and creates ReplicaSets to manage the pods. The scheduler watches for new pods without a node assignment and selects a node based on resource availability and constraints.
Once a node is selected, the kubelet on that node pulls the container image from the registry. If the image is not cached, this can take time. The kubelet then starts the container using the container runtime, usually containerd. The container starts, the Go binary executes, and the HTTP server begins listening on port 8080.
Kubernetes monitors the pod. If the container exits, the kubelet restarts it according to the restart policy. The default policy is Always, which means Kubernetes will keep restarting the container until you delete the deployment. This is why your Go app must be resilient. If it panics, Kubernetes restarts it. If it leaks memory, Kubernetes eventually kills it and restarts it. Design your app to handle restarts gracefully.
Convention aside: CGO_ENABLED=0 is the Go community standard for container builds. Enabling CGO links your binary against C libraries, which means your binary depends on glibc or musl being present in the container. Disabling CGO produces a truly static binary that runs on any Linux kernel. Most Go packages support pure Go implementations. If you must use CGO, choose a base image that includes the required C libraries, such as golang:1.22-alpine or debian:bookworm-slim.
Realistic configuration
Production deployments need more than replicas and ports. You need health probes to let Kubernetes know when the app is ready and alive. You need resource limits to prevent a runaway goroutine from starving the node. You need environment variables for configuration.
Here is an enhanced deployment manifest with probes and configuration.
apiVersion: apps/v1
kind: Deployment
metadata:
name: go-app
spec:
replicas: 3
selector:
matchLabels:
app: go-app
template:
metadata:
labels:
app: go-app
spec:
containers:
- name: go-app
image: my-registry/my-go-app:v1.2.0
ports:
- containerPort: 8080
# Liveness probe checks if the app is stuck.
# If this fails, Kubernetes kills and restarts the container.
livenessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 5
periodSeconds: 10
# Readiness probe checks if the app can accept traffic.
# If this fails, Kubernetes removes the pod from the service load balancer.
readinessProbe:
httpGet:
path: /health
port: 8080
initialDelaySeconds: 3
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
cpu: 500m
memory: 128Mi
env:
# Inject configuration from environment variables.
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: go-app-secrets
key: database-url
- name: LOG_LEVEL
value: "info"
The liveness probe tells Kubernetes when to restart the container. If your Go app deadlocks or enters a bad state where it stops responding, the liveness probe fails and Kubernetes kills the pod. This is a safety net. The readiness probe tells Kubernetes when to send traffic. If your app needs time to initialize a database connection or load a cache, the readiness probe prevents traffic from hitting the pod until it is ready.
Convention aside: context.Context should flow through your Go application. When Kubernetes sends a SIGTERM during a rolling update, your app should cancel all in-flight work using a context. The graceful shutdown pattern shown in the minimal example uses signal.Notify to catch SIGTERM and call srv.Close(). This stops accepting new requests and waits for active requests to finish. If your app ignores SIGTERM, Kubernetes waits for the termination grace period (default 30 seconds) and then sends SIGKILL, which drops active requests. Always handle signals in Go apps deployed to Kubernetes.
Pitfalls and errors
Deployment failures usually fall into three categories: image issues, configuration errors, and application crashes.
If you forget to push the image to the registry, the node cannot pull it. The pod status shows ImagePullBackOff. The events log contains Failed to pull image "my-registry/my-go-app:latest": rpc error: code = Unknown desc = Error response from daemon: manifest for my-registry/my-go-app:latest not found. Check your registry credentials and image tag.
If your app panics on startup, Kubernetes restarts it repeatedly. The pod status shows CrashLoopBackOff. The events log contains Back-off restarting failed container. Use kubectl logs to see the panic output. Common causes include missing environment variables, database connection failures, or binding to a port that is already in use.
If you enable CGO but use a distroless image, the binary fails to start. The container exits immediately with an error like error while loading shared libraries: libc.so.6: cannot open shared object file. The pod status shows CrashLoopBackOff or Error. Build with CGO_ENABLED=0 or switch to a base image with C libraries.
If your app listens on localhost instead of 0.0.0.0, the probes fail. Kubernetes runs probes from outside the container namespace. Binding to 127.0.0.1 makes the port inaccessible to the kubelet. The probe logs show connection refused. Always bind to 0.0.0.0 in containerized Go apps.
The compiler rejects the build with exec: "gcc": executable file not found in $PATH if CGO is enabled and the build environment lacks a C compiler. This happens when cross-compiling on a host without the necessary toolchain. Install gcc or disable CGO.
Probes are not optional. Without them, Kubernetes is flying blind. It cannot distinguish between a slow startup and a crash, and it cannot route traffic away from unhealthy pods.
When to use what
Kubernetes offers several workload resources. Choose the right one based on your application's requirements.
Use a Deployment when you need stateless replicas that can be updated with zero downtime. Deployments manage ReplicaSets, which handle rolling updates and rollbacks. This is the default choice for web servers, APIs, and microservices.
Use a StatefulSet when your pods need stable network identities and persistent storage that survives restarts. StatefulSets assign each pod a unique ordinal index and guarantee the order of scaling and termination. Use this for databases, message queues, and distributed systems.
Use a DaemonSet when you need exactly one copy of a pod running on every node in the cluster. DaemonSets are used for node-level tasks like log collection, monitoring agents, and network plugins.
Use a Job when you have a batch task that runs to completion and exits. Jobs ensure that a specified number of pods successfully terminate. Use this for data migrations, report generation, and one-off processing.
Kubernetes restarts what fails. Make your app restartable and idempotent.