Skip to content
CalliCoder

Deploying a Containerized Go App on Kubernetes

Published Updated DevOps 12 min read

Deployment, Service and Ingress for a Go binary — plus the four settings that decide whether a rollout drops requests: probes that mean different things, resource requests, terminationGracePeriod and preStop.

A Go binary is close to the ideal Kubernetes workload: one static file, fast startup, small memory footprint, no runtime to install. The manifests are correspondingly short.

What takes the time is the handful of fields that decide whether a deploy is invisible to users or drops a few hundred requests every time. Those are the parts this covers in detail.

Assumes an image built as in Docker containers for Go applications, and a cluster you can reach with kubectl. Written against Kubernetes 1.29 and Go 1.22.

The application needs two things first

Before any YAML, the binary has to cooperate with the platform in two ways.

A health endpoint that means something. Not return 200, a check that reflects whether this instance can serve:

func main() {
    mux := http.NewServeMux()

    // liveness: is the process itself wedged?
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    // readiness: can it serve traffic right now?
    mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
        ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
        defer cancel()
        if err := db.PingContext(ctx); err != nil {
            http.Error(w, "database unavailable", http.StatusServiceUnavailable)
            return
        }
        w.WriteHeader(http.StatusOK)
    })

    srv := &http.Server{Addr: ":8080", Handler: mux}
    // ... see graceful shutdown below
}

The distinction is the single most consequential thing here. Liveness answers “should this container be killed and restarted”. Readiness answers “should traffic be sent to it”. Pointing a liveness probe at a database check means a database blip restarts every replica simultaneously: a self-inflicted outage, and a crash loop that keeps the service down after the database recovers.

Graceful shutdown. Kubernetes sends SIGTERM and waits:

    go func() {
        if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
            log.Fatalf("listen: %v", err)
        }
    }()

    stop := make(chan os.Signal, 1)
    signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
    <-stop

    log.Println("shutting down")
    ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("forced shutdown: %v", err)
    }

srv.Shutdown stops accepting new connections and lets in-flight requests finish. Without it every rollout kills requests mid-flight.

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: notes-api
  labels:
    app: notes-api
spec:
  replicas: 3
  revisionHistoryLimit: 5
  selector:
    matchLabels:
      app: notes-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: notes-api
    spec:
      terminationGracePeriodSeconds: 30
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        fsGroup: 65532
      containers:
        - name: notes-api
          image: ghcr.io/example/notes-api:1.4.0
          imagePullPolicy: IfNotPresent
          ports:
            - name: http
              containerPort: 8080
          env:
            - name: PORT
              value: "8080"
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: notes-secrets
                  key: db-password
          envFrom:
            - configMapRef:
                name: notes-config
          resources:
            requests:
              cpu: 50m
              memory: 32Mi
            limits:
              memory: 128Mi
          startupProbe:
            httpGet: { path: /healthz, port: http }
            failureThreshold: 30
            periodSeconds: 1
          livenessProbe:
            httpGet: { path: /healthz, port: http }
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /readyz, port: http }
            periodSeconds: 5
            failureThreshold: 2
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"]
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: ["ALL"]

Several of those fields are load-bearing rather than decorative.

maxUnavailable: 0 means the rollout adds a new pod before removing an old one, so capacity never dips below replicas. The default is 25% unavailable, which on three replicas means serving with two during every deploy.

A pinned image tag, never :latest. With latest you cannot tell which version is running, kubectl rollout undo has nothing distinct to roll back to, and imagePullPolicy behaviour becomes surprising. Tag with the version or the commit SHA.

requests matter more than limits. Requests are what the scheduler uses to place the pod and what guarantees it capacity. A Go service that idles at 20m CPU and needs 200m under load should request something realistic: request too little and it is scheduled onto a full node and throttled.

No CPU limit here, deliberately. A CPU limit throttles via CFS quota, and a Go program with several goroutines can exhaust its quota slice and stall for the rest of the period even when the node is idle. Latency gets worse, not more predictable. Memory is limited, because memory is incompressible. Exceeding it should kill the pod rather than the node.

If you do set a CPU limit, set GOMAXPROCS to match it. Go reads the number of host cores, not the cgroup quota, so a container limited to 500m still starts 64 OS threads on a 64-core node — automaxprocs or an explicit env var fixes it.

startupProbe protects a slow start. Without it, a liveness probe with failureThreshold: 3 and periodSeconds: 10 kills anything taking over 30 seconds to boot, and then again, forever. The startup probe suspends liveness until the app is up. For a Go binary this is usually unnecessary; for one that warms a cache it is essential.

preStop: sleep 5 is not a hack. Pod termination and Service endpoint removal happen concurrently: the kubelet sends SIGTERM while kube-proxy is still updating iptables on every node. For a few hundred milliseconds to a few seconds, traffic still arrives at a pod that has begun shutting down. Sleeping before SIGTERM gives endpoint propagation time to finish, and it is the standard answer to “why do I see connection errors during a rollout when I have graceful shutdown”.

Note the ordering: preStop runs before SIGTERM, and terminationGracePeriodSeconds covers preStop plus shutdown together. So 5 + 25 must be under 30.

Service and Ingress

apiVersion: v1
kind: Service
metadata:
  name: notes-api
spec:
  type: ClusterIP
  selector:
    app: notes-api
  ports:
    - name: http
      port: 80
      targetPort: http
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: notes-api
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts: [api.example.com]
      secretName: notes-api-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: notes-api
                port:
                  number: 80

targetPort: http by name rather than 8080 by number. Change the container port and the Service follows; a hardcoded number silently points at nothing, and the symptom is a Service with no working endpoints rather than an error.

ClusterIP plus an Ingress, not LoadBalancer per service: one load balancer for the cluster rather than one per service.

Configuration and secrets

apiVersion: v1
kind: ConfigMap
metadata:
  name: notes-config
data:
  LOG_LEVEL: info
  FEATURE_SEARCH: "true"
---
apiVersion: v1
kind: Secret
metadata:
  name: notes-secrets
type: Opaque
stringData:
  db-password: change-me

stringData rather than data so you write plaintext and Kubernetes does the base64. And be clear about what a Secret is: base64 is not encryption. Anyone with read access to Secrets in the namespace, or to etcd, can read it. Enable encryption at rest, restrict RBAC, and for anything serious use an external secret store.

A ConfigMap change does not restart pods. Environment variables are read once at container start, so a config update has no effect until a rollout:

$ kubectl rollout restart deployment/notes-api

Mounted-as-file ConfigMaps do update in place, after a delay, but only if your app re-reads the file.

Deploy and verify

$ kubectl apply -f k8s/
$ kubectl rollout status deployment/notes-api --timeout=120s
deployment "notes-api" successfully rolled out

$ kubectl get pods -l app=notes-api
NAME                         READY   STATUS    RESTARTS   AGE
notes-api-7d4b8c9f5-2xk4p    1/1     Running   0          40s
notes-api-7d4b8c9f5-8mn2q    1/1     Running   0          38s
notes-api-7d4b8c9f5-qw7rt    1/1     Running   0          35s

$ kubectl port-forward svc/notes-api 8080:80
$ curl -s localhost:8080/healthz

kubectl rollout status with a timeout is what belongs in CI. It exits non-zero if the rollout does not complete, which kubectl apply alone does not: apply succeeds the moment the API server accepts the object, whether or not a single pod ever becomes ready.

Where to look when something is wrong, in order:

$ kubectl describe pod <name>        # Events at the bottom: scheduling, pull, probe failures
$ kubectl logs <name> --previous     # the CRASHED container's logs, not the new one
$ kubectl get endpoints notes-api    # empty means no pod is Ready or the selector is wrong

--previous is the one people forget. After a CrashLoopBackOff the current container has barely started; the reason is in the previous one’s logs.

An empty endpoints list with running pods means either the readiness probe is failing or the Service selector does not match the pod labels. Those are the two causes, and describe distinguishes them.

Scaling

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: notes-api
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: notes-api
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

averageUtilization is a percentage of the request, not of the node or the limit, so 70% of a 50m request is 35m, which is a very low bar. Autoscaling only behaves sensibly when requests reflect real usage, which is another reason to measure rather than guess them.

An HPA needs metrics-server installed, and it will fight a hardcoded replicas in your Deployment. Remove that field once an HPA owns the count, or leave it and accept that each apply resets scale.

Add a PodDisruptionBudget so voluntary disruptions: a node drain, a cluster upgrade — cannot take everything at once:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: notes-api
spec:
  minAvailable: 2
  selector:
    matchLabels:
      app: notes-api

Frequently asked questions

Why do I see errors during a rollout even with graceful shutdown?

Endpoint removal and SIGTERM happen concurrently, so traffic can still arrive at a terminating pod. Add a preStop sleep of a few seconds to let endpoint propagation finish first.

Should liveness and readiness use the same endpoint?

No. Liveness restarts the container; readiness only removes it from the Service. A liveness probe that checks a database restarts every replica when the database blips.

Why is my pod in CrashLoopBackOff?

Read kubectl logs <pod> --previous, the current container has just started, and the reason is in the one that died.

Why does my Service have no endpoints?

Either no pod is passing its readiness probe, or the Service selector does not match the pod labels. kubectl describe tells you which.

Should I set a CPU limit?

Usually not for a Go service. CFS throttling can stall goroutines for the rest of a period even on an idle node. Always set a memory limit, since memory is incompressible.

Do I need to set GOMAXPROCS?

If you set a CPU limit, yes. Go reads host cores, not the cgroup quota, so a 500m-limited container otherwise starts threads for every core on the node. automaxprocs handles it automatically.

Why is my ConfigMap change not taking effect?

Environment variables are read once at container start. Run kubectl rollout restart, or mount the ConfigMap as a file and re-read it.

Are Kubernetes Secrets encrypted?

No. They are base64-encoded by default. Enable encryption at rest, restrict RBAC, and use an external secret store for anything sensitive.

Why did apply succeed but nothing works?

apply only confirms the API server accepted the object. Use kubectl rollout status --timeout=…, which fails when pods never become ready.

What does averageUtilization 70 actually mean?

70% of the CPU request, not of the node or the limit. Unrealistic requests make an HPA scale at the wrong time.

Where should I go next?

Docker containers for Go applications covers building the image this deploys, and the DevOps guides cover the rest of the pipeline.