| name | k8s |
|---|---|
| created | 2026-05-23 |
| modified | 2026-05-23 |
| reviewed | 2026-05-23 |
| description | Kubernetes cluster ops for Glaze: reading events, probing health, diagnosing probe failures, Helm release management, and the convergence rule. |
| allowed-tools | Bash, Read, WebFetch |
No manual steps. cd.yml fully converges.
Every cluster state that requires a kubectl or helm command belongs in the
Helm chart or CD pipeline. If you fix something by hand, commit it immediately —
otherwise the next CD run reverts it.
Workflow for any cluster change:
- Apply experimentally via
scp+helm upgrade(see below) - Validate on the live cluster
- Commit to the Helm chart in the worktree
- Push, open PR, merge
- Verify the CD deploy re-applies the same config from git
The production SSH target is $GLAZE_PROD_HOST from .env.local. The default
value is root@glaze-prod — a Tailscale MagicDNS hostname. SSH is restricted
to the tailnet; you must be connected to Tailscale and tagged as an
admin-client. Tailscale SSH handles auth with no extra keys.
Note: While local development and manual commands use $GLAZE_PROD_HOST from .env.local, the CI/CD pipeline in cd.yml resolves the host from the GitHub Action variable ${{ vars.DEPLOY_HOST_TAILSCALE }}.
Ask the user for the value if not already established in the conversation, then
store it as PROD_HOST for the rest of the session. All kubectl and helm
commands run over SSH with KUBECONFIG=/etc/rancher/k3s/k3s.yaml:
# Single command
ssh $PROD_HOST "export KUBECONFIG=/etc/rancher/k3s/k3s.yaml && kubectl get pods"
# Interactive session
ssh $PROD_HOST
export KUBECONFIG=/etc/rancher/k3s/k3s.yamlIf the user has already established the host earlier in the conversation, use that value — don't ask again.
There are two distinct TLS termination paths in this cluster. The ACME solver used for each is the critical difference — do not mix them up when adding new hostnames.
Internet → public Traefik LoadBalancer (159.223.154.68:443)
→ Traefik terminates TLS (cert: glaze-tls)
→ Django web pod
- cert-manager issues the cert via HTTP-01 challenge through the public
Traefik
LoadBalancerservice. The ACME server hitshttp://potterdoc.com/.well-known/acme-challenge/...on port 80. - Cloudflare proxies the apex (
potterdoc.com) — the public IP is159.223.154.68. ClusterIssuer:letsencrypt-prodwithhttp01.ingress.class: traefik.
Tailscale client → Tailscale IP (100.x.x.x)
→ Tailscale Operator Proxy Pod
→ traefik-tailscale Service (port 9443 / websecure-tailscale)
→ Traefik terminates TLS (cert: glaze-admin-tls / headlamp-tls)
→ Django admin / Headlamp
The integration dynamically configures DNS and ingress for tailnet-only subdomains:
- Traefik Configuration: In traefik.yaml, an additional
tailscaleservice (traefik-tailscale) is defined withloadBalancerClass: "tailscale"and annotated with:external-dns.alpha.kubernetes.io/hostname: "admin.potterdoc.com,headlamp.potterdoc.com" - Tailscale Proxy Provisioning: The Tailscale Kubernetes Operator running in the cluster detects this LoadBalancer service, registers a new device in the Tailnet, spawns a Tailscale proxy pod to handle CGNAT routing, and updates the
traefik-tailscaleService LoadBalancer status with the proxy's100.x.x.xCGNAT address. - Cloudflare DNS Records: ExternalDNS running in the cluster watches the services, extracts the assigned Tailscale IP (
100.x.x.x) from the service status, and automatically updates Cloudflare DNS records foradmin.potterdoc.comandheadlamp.potterdoc.com. These records must be DNS-only (grey cloud in Cloudflare) because Cloudflare cannot proxy to private CGNAT IPs. - Secrets Replication: cert-manager needs
CLOUDFLARE_API_TOKENto complete DNS-01 challenges. AClusterExternalSecretnamedglaze-cloudflare-token(managed by theglazeHelm chart) replicates this credential from Infisical into thecert-managernamespace.
- Requests to
admin.potterdoc.comandheadlamp.potterdoc.comresolve to the Tailscale proxy's CGNAT IP, failing immediately for non-tailnet clients. - For authorized tailnet clients, the traffic lands on the operator-managed Tailscale proxy pod, which forwards it to Traefik on port
9443(thewebsecure-tailscaleentrypoint). - Traefik terminates TLS. The certs are issued by cert-manager's
ClusterIssuerusing thedns01.cloudflaresolver (as cert-manager cannot reach a tailnet-only IP via HTTP-01). - Traefik routes the requests using host headers to either the
glaze-webservice (foradmin.potterdoc.com/admin/) orheadlampservice (forheadlamp.potterdoc.com).
| Hostname reachable from public internet? | Use |
|---|---|
| Yes | HTTP-01 via public Traefik ingress |
| No (tailnet-only) | DNS-01 via Cloudflare; add to selector.dnsNames in clusterissuer.yaml |
If adding a new tailnet-only hostname, also add it to the
external-dns.alpha.kubernetes.io/hostname annotation on the
traefik-tailscale service in infra/k3s/traefik.yaml so ExternalDNS
creates the Cloudflare record automatically.
/api/health/ready/ is restricted to the k3s internal network by the
health-internal-only Traefik middleware. Django's SECURE_SSL_REDIRECT also
redirects plain HTTP requests — so kubectl exec into the web pod will fail
with an SSL error or redirect loop.
Correct approach — curl from the host node using the pod's cluster IP:
# $PROD_HOST established at session start (see "Connecting to the cluster")
POD_IP=$(ssh $PROD_HOST \
"KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl get pod \
-l app.kubernetes.io/name=glaze,app.kubernetes.io/component=web \
-o jsonpath='{.items[0].status.podIP}'")
ssh $PROD_HOST "curl -s http://${POD_IP}:8000/api/health/ready/"Or exec into a sidecar that has network access (e.g. glaze-otelcol) rather
than the web pod itself.
# All warnings, most recent last
kubectl get events -n default --sort-by='.lastTimestamp' | grep Warning
# Events for a specific pod
kubectl get events --field-selector involvedObject.name=glaze-redis-0 \
--sort-by='.lastTimestamp' -o wide
# Full event detail including first-seen, last-seen, and count
kubectl get events -n default -o wide --sort-by='.firstTimestamp'Key fields to correlate:
FIRST SEEN+COUNT+LAST SEENtogether reveal whether failures are sporadic (deploy-coincident) or continuous (real health issue)- A count of N that matches the number of recent deploys strongly suggests deployment-load contention, not a service problem
exec-based probes (redis-cli ping, pg_isready) fork a subprocess inside
the container on every probe cycle. On a single-core node under deployment load
(image pulls, migration jobs, multiple pods starting), this fork/exec can exceed
timeoutSeconds: 1 (the K8s default), triggering false failures for healthy
services.
Signs of false probe failures:
- Probe timeouts occur only during deploys, never during idle operation
- The service itself is healthy when probed manually after the event
- Event
COUNTmatches the number of recent deploys
Fix: prefer tcpSocket over exec where possible
| Service | exec probe | tcpSocket replacement |
|---|---|---|
| Redis | redis-cli ping |
tcpSocket: port: 6379 |
| Postgres | pg_isready -U ... |
tcpSocket: port: 5432 |
tcpSocket has zero subprocess overhead — kubelet opens the socket directly.
A listening socket is sufficient health signal for single-node stateful services.
Always set timeoutSeconds: 3 explicitly. The default of 1s is too tight for
a loaded single-core node.
Django's SECURE_SSL_REDIRECT = True redirects plain HTTP to HTTPS. The web
probes send HTTP with Host: potterdoc.com, which Django redirects to
https://potterdoc.com. K8s 1.25+ treats cross-scheme redirects as a
ProbeWarning (not a failure), so the pod stays healthy but emits noise on
every probe cycle.
Fix: add SECURE_REDIRECT_EXEMPT = [r"^api/health/"] to backend/settings.py
so the health endpoint responds directly over HTTP.
The web readiness probe hits /api/health/ready/ which checks DB connectivity
and migration state. Django startup (loading settings, connecting to the DB) can
take longer than initialDelaySeconds: 15 + timeoutSeconds: 1 on a loaded
node.
This is expected transient behavior during rolling deploys. If it persists after the pod has been running for >60s, check DB connectivity and migration state.
The production cluster runs on a single-core, resource-constrained VM. To avoid control-plane thrashing and out-of-memory crashes, do not run manual helm upgrade commands.
Instead, use tools/helm_deploy.sh. This script automates a sequential rollout strategy:
- Pause Deployments: It runs
kubectl rollout pauseon theglaze-webandglaze-workerdeployments. This lets Kubernetes accept the new configuration spec without immediately spawning new pods. - Apply Chart: It runs
helm upgrade --installto update the manifests. - Sequential Resume: It resumes the deployments one by one (
kubectl rollout resume), waiting for each to fully converge (kubectl rollout status) before starting the next. This prevents concurrent CPU/memory spikes during rolling restarts.
To run a manual deployment from the workspace:
# $PROD_HOST established at session start (see "Connecting to the cluster")
# Usage: helm_deploy.sh <deploy_host> <chart_dir> <values_override_file>
tools/helm_deploy.sh "${PROD_HOST}" chart/glaze /tmp/values-override.yamlImportant: The CD pipeline also copies the chart from Git and runs this script. Any manual changes applied to the cluster will be reverted on the next CD run unless committed to Git.
# Release history
helm history glaze -n default --max 10
# Current values in use
helm get values glaze -n default
# Diff a local chart against the live release (requires helm-diff plugin)
helm diff upgrade glaze ~/glaze-chart-deploy/glaze/ -f ~/glaze-values-override.yaml
# Roll back a stuck pending-upgrade
helm rollback glaze -n default --wait --timeout 2mAfter merging and waiting for the CD job:
- Check
helm history— confirm revision number incremented - Confirm the live probe/config matches git:
kubectl get <resource> -o json - Scan warning events:
kubectl get events | grep Warning - Check restart counts:
kubectl get pods— no unexpected restarts - No new
UnhealthyorProbeWarningevents since the deploy
This cluster runs on a single-core node. Rules of thumb:
- Never use
execprobes for stateful services — usetcpSocketorhttpGet - Always set
timeoutSeconds: 3(or more) — never rely on the 1s default initialDelaySecondsshould be ≥ the service's typical cold-start timefailureThreshold: 3withperiodSeconds: 10gives 30s grace before kill — sufficient for transient deploy-time contention
Note on HelmChartConfig limitations: K3s HelmChartConfig files only apply to resources managed by the k3s Helm controller. They are ineffective for static deployments (like CoreDNS). For CoreDNS, tools/ensure_cluster.sh applies idempotent kubectl patch modifications to dynamically raise probe timeouts to 5s. cert-manager is managed as a declarative k3s HelmChart manifest, meaning its timeouts are configured natively inside its resource values.
DigitalOcean DHCP and Tailscale configurations can push a node's nameserver list past the 3-entry resolver limit, or introduce duplicate resolver entries. This causes pods to emit continuous warnings:
Nameserver limits were exceeded, some nameservers have been omitted (DNSConfigForming)
To prevent this:
- Static Resolv.conf: tools/ensure_cluster.sh creates a static
/etc/k3s-resolv.confon the host node containing only the two unique DigitalOcean nameservers (67.207.67.2and67.207.67.3). Kubelet is configured in/etc/rancher/k3s/config.yaml(resolv-conf=/etc/k3s-resolv.conf) to read this file instead of dynamic paths. - CoreDNS Override: A custom CoreDNS override ConfigMap (
coredns-custom) is applied underkube-system, mappingforward.overrideto the two DO DNS hosts to bypass node-level resolver resolution for pod DNS lookup.
To keep the droplet's public surface small, the host firewall is managed programmatically via nftables:
- Service:
glaze-host-firewall.service(systemd unit configured on the node). - Rules: The firewall drops public control-plane traffic (SSH, Kubernetes API server, kubelet API). It accepts incoming packets on loopback (
lo), Tailscale interface (tailscale0), pod networks (cni0/flannel.1), and public web ports (80 / 443). - NodePorts: NodePort access is blocked by disabling NodePort allocations in the Helm config (
allocateLoadBalancerNodePorts: false) and dynamically stripping existing ports from thetraefik-tailscaleservice in tools/ensure_cluster.sh.