A minimal Go HTTP server packaged for Kubernetes, and the running notes of learning Kubernetes by building the cluster around it rather than hiding behind a managed one. The server handles SIGTERM with a readiness drain, splits liveness from readiness probes, logs JSON via slog, and reads its config from a ConfigMap and its token from a Secret. The deployment side is the real subject: a kind cluster, Kustomize, two Service types side by side, and MetalLB filling in the load balancer that vanilla Kubernetes does not ship.

Background

Most “deploy Go to Kubernetes” guides lean on Minikube or Docker Desktop, both of which quietly paper over the hard parts: Minikube’s tunnel is a host route, Docker Desktop maps every LoadBalancer to localhost. You never see why a LoadBalancer Service would sit at <pending>, so you never learn what actually provides one.

This project takes the opposite stance. It runs on kind (Kubernetes-in-Docker), where each node is a plain Docker container you can docker exec into and watch kubeadm do its work. The abstractions stay visible: <pending> happens, and fixing it is explicit. The repo doubles as a written walk-through of cluster anatomy, Services, and networking, kept in sync with the code as it grows.

Architecture

The Go side is deliberately small (net/http, no framework): a demo /hello guarded by a Bearer token, plus separate /livez and /readyz so probes never ride the business path. Readiness flips false during startup and again during the shutdown drain, so the cluster stops routing before the process exits. It ships as a multi-stage, distroless, non-root image.

The Kubernetes side is where the design lives: a dedicated namespace, a Kustomize root that injects the namespace, standard labels and the image tag across every manifest, and two Services against the same pods so NodePort and LoadBalancer can be compared directly.

  flowchart TB
    client["Client"]

    subgraph k["kind cluster (Docker bridge) · namespace k8s-go"]
        direction TB
        svc["Services<br/>LoadBalancer + NodePort"]
        mlb["MetalLB speaker (L2 / ARP)"]
        subgraph dep["Deployment (4 replicas)"]
            pod["Go server pod<br/>/livez · /readyz · /hello"]
        end
        cm["ConfigMap (LOG_LEVEL)"]
        sec["Secret (API_TOKEN)"]
    end

    client --> svc
    mlb -. assigns LB VIP .-> svc
    svc --> pod
    cm --> pod
    sec --> pod

Lifecycle and Traffic

The interesting runtime behaviour is the pod lifecycle: how readiness gates traffic at both ends, so a rolling update never sends requests to a pod that is still warming up or already draining.

  sequenceDiagram
    participant K as kubelet
    participant P as Go pod
    participant S as Service / kube-proxy

    K->>P: start container
    P->>P: readyz = false (warming up)
    P-->>K: livez 200, readyz 503
    P->>P: ready, readyz = true
    K->>S: add pod to endpoints
    Note over K,S: traffic flows to the pod
    K->>P: SIGTERM (pod termination)
    P->>P: readyz = false (drain)
    K->>S: remove pod from endpoints
    P->>P: finish in-flight requests, then exit

Reaching the pod from outside is the other half. On kind, LoadBalancer stays <pending> until MetalLB assigns a VIP from the Docker bridge subnet and a speaker answers ARP for it; NodePort opens :30080 on the node container, reachable from inside the kind network. Both routes land on kube-proxy, which load-balances to a healthy pod.

Design Decisions

kind over Minikube or Docker Desktop, because the goal is to see the machinery, not hide it. kind ships no load-balancer controller, so <pending> is real and the fix (MetalLB) is something you install and reason about, rather than magic that maps to localhost.

MetalLB in L2 mode, because there is no router on a kind host to peer with over BGP. One speaker wins an election per VIP and ARP-replies for it on the Docker bridge, which is a normal L2 segment. The pool has to live inside that bridge’s subnet, since L2 announcements never leave the broadcast domain. That constraint, and how it shifts across kind, k3d, Minikube, and bare metal, is documented in the repo.

Kustomize over raw manifests, because it emits resources in GVK order (namespace first), so nothing races the namespace, and it injects the namespace, labels, and image tag in one place. A version bump becomes a single localized diff instead of a grep across files. The real Secret and the MetalLB pool stay outside the Kustomize root on purpose: the Secret is gitignored, and the pool belongs to the metallb-system namespace, so letting Kustomize rewrite its namespace would break it.

Secrets follow the template-in-git, real-value-out-of-git pattern (a committed secret.example.yaml, a gitignored secret.yaml), with the repo also cataloguing the production-grade alternatives (Sealed Secrets, SOPS, External Secrets Operator, Vault) and when each earns its place.

Roadmap

This is an actively developed learning project; the page and the repo evolve together. Done so far: graceful shutdown, split probes, build-info via ldflags, structured logging, a hardened distroless image, ConfigMap and Secret wiring, a dedicated namespace, two Services, MetalLB L2, and the Kustomize migration. On deck: HorizontalPodAutoscaler, Ingress, NetworkPolicy, pod and container securityContext, a PodDisruptionBudget, a Makefile, and a GitHub Actions release pipeline, with Helm and a GitOps (Argo CD or Flux) flow further out.

Go Kubernetes Kustomize MetalLB Docker slog

Personal open-source project, actively developed since 2025. Source and full deployment walk-through on GitHub.