Kubernetes has become the standard deployment platform for Go microservices. This guide covers everything from a production-ready Dockerfile to Kubernetes manifests, health checks, autoscaling, and using the client-go library to interact with Kubernetes from within your Go code.
Production Dockerfile for Go
A well-structured multi-stage Dockerfile produces minimal, secure images:
# Stage 1: Build
FROM golang:1.22-bookworm AS builder
WORKDIR /app
# Cache dependencies separately from source code
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
# CGO_ENABLED=0 produces a fully static binary — no libc required
# -ldflags "-s -w" strips debug symbols (reduces binary ~30%)
RUN CGO_ENABLED=0 GOOS=linux go build \
-ldflags="-s -w -X main.version=$(git describe --tags --always)" \
-o /app/server ./cmd/server
# Stage 2: Runtime — distroless has no shell, no package manager
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=builder /app/server /server
# Run as non-root (distroless:nonroot uses uid 65532)
USER nonroot:nonroot
EXPOSE 8080
ENTRYPOINT ["/server"]
Build and push:
docker build -t myregistry/myapp:$(git rev-parse --short HEAD) .
docker push myregistry/myapp:$(git rev-parse --short HEAD)
Health Check Endpoints
Kubernetes uses three probes. Implement all three properly:
package main
import (
"context"
"database/sql"
"encoding/json"
"net/http"
"sync/atomic"
"time"
)
type HealthChecker struct {
db *sql.DB
started atomic.Bool
ready atomic.Bool
}
func (h *HealthChecker) Startup(w http.ResponseWriter, r *http.Request) {
// Startup probe: runs during initialization, stops once started
// Kubernetes won't send liveness/readiness probes until this passes
if !h.started.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "starting"})
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "started"})
}
func (h *HealthChecker) Live(w http.ResponseWriter, r *http.Request) {
// Liveness: is this process healthy? If not, Kubernetes restarts the pod.
// Don't check external dependencies here — only internal process health.
json.NewEncoder(w).Encode(map[string]string{
"status": "alive",
"time": time.Now().UTC().Format(time.RFC3339),
})
}
func (h *HealthChecker) Ready(w http.ResponseWriter, r *http.Request) {
// Readiness: can this instance handle traffic?
// If not, Kubernetes removes it from the Service endpoints (no traffic routed).
if !h.ready.Load() {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "not ready"})
return
}
// Check DB connectivity
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
if err := h.db.PingContext(ctx); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
json.NewEncoder(w).Encode(map[string]string{"status": "db unavailable"})
return
}
json.NewEncoder(w).Encode(map[string]string{"status": "ready"})
}
func main() {
db, _ := sql.Open("postgres", os.Getenv("DATABASE_URL"))
health := &HealthChecker{db: db}
mux := http.NewServeMux()
mux.HandleFunc("/health/startup", health.Startup)
mux.HandleFunc("/health/live", health.Live)
mux.HandleFunc("/health/ready", health.Ready)
mux.HandleFunc("/api/", apiHandler)
// Mark ready after startup tasks
go func() {
connectDB(db)
runMigrations(db)
health.started.Store(true)
health.ready.Store(true)
}()
http.ListenAndServe(":8080", mux)
}
Kubernetes Manifests
Deployment
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: order-service
namespace: production
labels:
app: order-service
version: "1.0"
spec:
replicas: 3
# Zero-downtime rolling update
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1 # at most 1 pod unavailable during update
maxSurge: 1 # at most 1 extra pod during update
selector:
matchLabels:
app: order-service
template:
metadata:
labels:
app: order-service
version: "1.0"
annotations:
prometheus.io/scrape: "true"
prometheus.io/path: "/metrics"
prometheus.io/port: "8080"
spec:
# Graceful shutdown: allow in-flight requests to complete
terminationGracePeriodSeconds: 30
containers:
- name: order-service
image: myregistry/order-service:abc1234
ports:
- containerPort: 8080
name: http
env:
- name: PORT
value: "8080"
- name: LOG_LEVEL
valueFrom:
configMapKeyRef:
name: order-service-config
key: log_level
- name: DATABASE_URL
valueFrom:
secretKeyRef:
name: order-service-secrets
key: database_url
# Resource management — always set both requests and limits
resources:
requests:
memory: "128Mi"
cpu: "100m"
limits:
memory: "512Mi"
cpu: "500m"
# Startup probe — checks during slow initialization
startupProbe:
httpGet:
path: /health/startup
port: 8080
failureThreshold: 30 # 30 × 10s = 5 minutes max startup time
periodSeconds: 10
# Liveness — restart if dead
livenessProbe:
httpGet:
path: /health/live
port: 8080
initialDelaySeconds: 0
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
# Readiness — stop sending traffic if not ready
readinessProbe:
httpGet:
path: /health/ready
port: 8080
initialDelaySeconds: 0
periodSeconds: 5
timeoutSeconds: 3
failureThreshold: 3
# Graceful shutdown signal
lifecycle:
preStop:
exec:
command: ["/bin/sleep", "5"] # Allow load balancer to drain
# Spread pods across nodes for high availability
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
app: order-service
Service
# service.yaml
apiVersion: v1
kind: Service
metadata:
name: order-service
namespace: production
spec:
selector:
app: order-service
ports:
- name: http
port: 80
targetPort: 8080
protocol: TCP
type: ClusterIP # Internal only; use Ingress for external access
ConfigMap and Secrets
# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: order-service-config
namespace: production
data:
log_level: "info"
environment: "production"
max_connections: "25"
---
# secrets.yaml — create with kubectl, never commit to git
# kubectl create secret generic order-service-secrets \
# --from-literal=database_url="postgres://..." \
# --from-literal=jwt_secret="..."
apiVersion: v1
kind: Secret
metadata:
name: order-service-secrets
namespace: production
type: Opaque
# Values are base64-encoded in the manifest
# NEVER commit real secrets — create them with kubectl or a secrets operator
Ingress
# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: order-service
namespace: production
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
tls:
- hosts:
- api.example.com
secretName: api-tls
rules:
- host: api.example.com
http:
paths:
- path: /api/orders
pathType: Prefix
backend:
service:
name: order-service
port:
number: 80
Horizontal Pod Autoscaler
# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: order-service
namespace: production
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: order-service
minReplicas: 2
maxReplicas: 20
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300 # wait 5 min before scaling down
policies:
- type: Percent
value: 10
periodSeconds: 60
scaleUp:
stabilizationWindowSeconds: 60
policies:
- type: Pods
value: 4
periodSeconds: 60
Using client-go from Go Code
When your Go service needs to interact with Kubernetes (operator pattern, custom controllers):
go get k8s.io/[email protected]
go get k8s.io/[email protected]
go get k8s.io/[email protected]
In-Cluster Config (Running Inside Kubernetes)
import (
"context"
"fmt"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)
func newInClusterClient() (*kubernetes.Clientset, error) {
// Uses the service account token mounted at /var/run/secrets/kubernetes.io/serviceaccount/
config, err := rest.InClusterConfig()
if err != nil {
return nil, fmt.Errorf("in-cluster config: %w", err)
}
return kubernetes.NewForConfig(config)
}
func listPods(ctx context.Context, client *kubernetes.Clientset, namespace string) error {
pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{
LabelSelector: "app=order-service",
})
if err != nil {
return fmt.Errorf("listing pods: %w", err)
}
for _, pod := range pods.Items {
fmt.Printf("Pod: %s, Phase: %s, Node: %s\n",
pod.Name, pod.Status.Phase, pod.Spec.NodeName)
}
return nil
}
Watching Resources
import (
"k8s.io/apimachinery/pkg/watch"
corev1 "k8s.io/api/core/v1"
)
func watchDeployments(ctx context.Context, client *kubernetes.Clientset, namespace string) {
watcher, err := client.AppsV1().Deployments(namespace).Watch(ctx, metav1.ListOptions{})
if err != nil {
log.Fatalf("watch error: %v", err)
}
defer watcher.Stop()
for event := range watcher.ResultChan() {
switch event.Type {
case watch.Added:
fmt.Printf("Deployment added: %s\n", event.Object.(metav1.Object).GetName())
case watch.Modified:
fmt.Printf("Deployment modified: %s\n", event.Object.(metav1.Object).GetName())
case watch.Deleted:
fmt.Printf("Deployment deleted: %s\n", event.Object.(metav1.Object).GetName())
}
}
}
Out-of-Cluster Config (Development)
import "k8s.io/client-go/tools/clientcmd"
func newOutOfClusterClient() (*kubernetes.Clientset, error) {
// Uses ~/.kube/config
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
configOverrides := &clientcmd.ConfigOverrides{}
kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(
loadingRules, configOverrides,
)
config, err := kubeConfig.ClientConfig()
if err != nil {
return nil, err
}
return kubernetes.NewForConfig(config)
}
Essential kubectl Commands for Go Services
# Deploy
kubectl apply -f deployment.yaml -f service.yaml
# Watch rollout
kubectl rollout status deployment/order-service -n production
# View logs (all replicas)
kubectl logs -l app=order-service -n production --follow
# Get events (for debugging crashes)
kubectl describe pod <pod-name> -n production
# Scale manually
kubectl scale deployment/order-service --replicas=5 -n production
# Rolling update: change image
kubectl set image deployment/order-service order-service=myregistry/order-service:v2.0
# Rollback to previous version
kubectl rollout undo deployment/order-service -n production
# Port forward for local debugging
kubectl port-forward svc/order-service 8080:80 -n production
# Execute command in pod
kubectl exec -it <pod-name> -n production -- /bin/sh
# View resource usage
kubectl top pods -n production -l app=order-service
# Check HPA status
kubectl get hpa -n production
RBAC for client-go
When your Go service uses client-go, it needs Kubernetes RBAC permissions:
# rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: order-service
namespace: production
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: order-service-role
namespace: production
rules:
- apiGroups: [""]
resources: ["pods", "services"]
verbs: ["get", "list", "watch"]
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: order-service-binding
namespace: production
subjects:
- kind: ServiceAccount
name: order-service
namespace: production
roleRef:
kind: Role
name: order-service-role
apiGroup: rbac.authorization.k8s.io
Summary
- Use multi-stage Docker builds with distroless or alpine for minimal, secure images
- Always set both
requestsandlimitson CPU and memory - Implement all three health probes: startup, liveness, and readiness — each serves a different purpose
- Use
terminationGracePeriodSeconds+preStophook for zero-downtime deployments - Add HPA for production services — Kubernetes won’t auto-scale without it
- Use
client-gowith in-cluster config for operators and controllers running inside Kubernetes
Comments