Skip to content
Zever
Home

Deploy to Kubernetes

Run the generated server and worker on Kubernetes with probes, graceful shutdown, config and secrets, a PodDisruptionBudget, and an HorizontalPodAutoscaler.

4 min readEdit on GitHub

This guide turns the deployment checklist into Kubernetes manifests for a project made with zever new: one image per entrypoint, config in a ConfigMap, secrets in a Secret, health probes against /healthz and /readyz, and a shutdown sequence that drains traffic before the process exits. The manifests are a starting point to copy and adapt; they are not generated by the CLI.

zever new writes a multi-stage Dockerfile that builds ./cmd/server into a distroless, non-root image. Build the worker the same way by pointing the builder at ./cmd/worker (the Dockerfile ships a commented example for it), and tag the two images separately so web and worker scale independently.

Terminal window
docker build -t registry.example.com/myapp-server:1.0.0 \
--build-arg VERSION=1.0.0 --build-arg REVISION="$(git rev-parse HEAD)" .

Keep structure in a ConfigMap and anything sensitive in a Secret. The layering is defaults < .env < .env.local < process env, so the Secret only needs the values that differ per environment, and secrets never go in the ConfigMap or the image. The .env file is committed (example) or injected via CI.

apiVersion: v1
kind: ConfigMap
metadata:
name: myapp-config
data:
periodSeconds: 2
failureThreshold: 30
livenessProbe:
httpGet: {path: /healthz, port: http}
periodSeconds: 10
readinessProbe:
httpGet: {path: /readyz, port: http}
periodSeconds: 5
timeoutSeconds: 4
lifecycle:
preStop:
sleep: {seconds: 5}
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {memory: 256Mi}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: {drop: ["ALL"]}
volumes:
- name: config
configMap: {name: myapp-config}

Why these settings:

  • Probes. /healthz always returns 200 with no dependency checks, so it is the liveness and startup probe: a down database must not restart healthy pods. /readyz pings the database (3 second timeout) and the gRPC health state and returns 503 on failure, so it gates traffic. The readinessProbe timeout is above that 3 seconds.
  • Graceful shutdown. On SIGTERM the generated server flips gRPC health to NOT_SERVING, calls GracefulStop(), shuts the HTTP server down, and closes the container, all within a 10 second grace. Keep terminationGracePeriodSeconds above that plus any preStop delay, so the kubelet does not SIGKILL a server that is still draining.
  • Endpoint removal races. Kubernetes removes a pod from Service endpoints at the same time it starts termination, and some proxies keep sending for a moment. A short preStop delay lets the endpoint removal propagate before SIGTERM. The reference image is distroless and has no shell, so a preStop exec of sleep is not available; the example uses the native sleep lifecycle handler (Kubernetes 1.30 and later). On older clusters, add a tiny static sleep binary to the image. Count the delay inside the 30 seconds.
  • Read-only root filesystem. The server needs no writable path unless you use the local storage adapter or a sqlite file; mount an emptyDir or a volume there.
  • Config reload. Log level and the rate limiter are swapped on SIGHUP; everything else needs a rollout. A rolling restart (kubectl rollout restart deployment/myapp-server) is the supported way to change anything else.

Service, PodDisruptionBudget, and autoscaling

Section titled “Service, PodDisruptionBudget, and autoscaling”
apiVersion: v1
kind: Service
metadata:
name: myapp-server
spec:
selector: {app: myapp, component: server}
ports:
- {name: http, port: 80, targetPort: http}
- {name: grpc, port: 9090, targetPort: grpc}
---
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: myapp-server
spec:
minAvailable: 2
selector:
matchLabels: {app: myapp, component: server}
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: myapp-server
spec:
scaleTargetRef: {apiVersion: apps/v1, kind: Deployment, name: myapp-server}
minReplicas: 3
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target: {type: Utilization, averageUtilization: 70}

The PodDisruptionBudget keeps two pods serving through node drains; size minAvailable below minReplicas. CPU is a reasonable first HPA signal for the server.

The worker runs the job worker and the scheduler in one process over one shared queue, and also drains the outbox relay. Give it its own Deployment and image, and no Service or HTTP probes.

apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp-worker
spec:
replicas: 2
selector:
matchLabels: {app: myapp, component: worker}
template:
metadata:
labels: {app: myapp, component: worker}
spec:
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
containers:
- name: worker
image: registry.example.com/myapp-worker:1.0.0
envFrom:
- secretRef: {name: myapp-secrets}
volumeMounts:
resources:
requests: {cpu: 100m, memory: 128Mi}
limits: {memory: 256Mi}
volumes:
- name: config
configMap: {name: myapp-config}

Run more than one worker only with a shared queue adapter (redis or db). With more than one scheduler replica, wire a locker so a cron slot fires once; otherwise run the scheduler in a single replica. Scale workers on queue depth (queue.Length) with an autoscaler such as KEDA rather than on CPU. See Scheduler and jobs and the background jobs overview.

Run zever db migrate as a Kubernetes Job (or an init step in your pipeline) before rolling out a schema change, not inside every pod. Use a distinct Job per release so a failed migration blocks the rollout instead of racing replicas.

The generated server can expose pprof on a loopback-only listener (--pprof-addr). In a cluster, set PPROF_ADDR=127.0.0.1:6060 on the pod and reach it with a port-forward rather than a Service (the forward connects inside the pod’s network namespace, so the loopback-only bind is reachable):

Terminal window
kubectl port-forward deploy/myapp-server 6060:6060
go tool pprof http://127.0.0.1:6060/debug/pprof/heap

See Profiling with pprof. Never run zever tinker against production.