gcloud-lab/README.md
Sirius DevOps 4f235b52f4 Archive cleanup and rewrite README as a lab write-up.
Drop duplicate app/Helm trees, planning notes, and the vwap-monitor
source tree so this repo is GitOps + Terraform only. Document the
GKE cluster as shut down, with file-level pointers for vLLM, KEDA
scale-to-zero, Flux, and SOPS.
2026-09-04 22:38:00 -04:00

12 KiB
Raw Permalink Blame History

gcloud-lab

GitOps + Terraform source of truth for a GKE lab I designed, ran, and then shut down once the GPU bill stopped being worth it.

This is not a live cluster. It is the manifests, node-pool definitions, and GitOps wiring from a real environment that served vLLM inference, CNPG databases, and a handful of in-cluster apps. The same cost model that made the GPU pools scale to zero is why the whole footprint went to zero.

Canonical copy: forgejo.siriusdevops.com/sirius/gcloud-lab


What this is evidence of

If you are reading this as a hiring screen, start here. Every claim below maps to a file in this repo.

Claim Where to look
GKE cluster, custom VPC, dual-stack, Cilium datapath modules/gke.tf, modules/vpc.tf
CPU + three GPU node pools (L4, RTX PRO 6000, A100 80GB), all SPOT except CPU modules/nodepool.tf, modules/nodepool-gpu.tf, modules/pro6000-nodepool.tf, modules/a100-nodepool.tf
Flux CD applies the tree; SOPS decrypts secrets in-cluster clusters/devops-lab/, .sops.yaml
Production-style vLLM OpenAI-compatible servers (not Ollama) infrastructure/gpus/base/vllm-servers/
KEDA HTTP scale-to-zero on the expensive GPUs infrastructure/gpus/base/keda-gpu-scaling/
CloudNative-PG operator + per-app Postgres infrastructure/controllers/base/cnpg/, apps/base/customer1/*-db/
Gateway API (not legacy Ingress) + Tailscale for internals infrastructure/gatewayapi/, infrastructure/tailnet/
Workload NetworkPolicies (trading stack) apps/base/customer1/trading-platform/network-policies/

I run 24/7 infrastructure now on a Raspberry Pi (Forgejo, Cloudflare tunnel, containerized sites). This repo is the cloud chapter that came before that.


Architecture

                         Git (this repo, branch master)
                                      │
                         Flux source-controller (1m)
                                      │
                    ┌─────────────────┼──────────────────┐
                    ▼                 ▼                  ▼
           infra-controllers    infra-gpus         apps/staging
           CNPG / KEDA          vLLM + KEDA        customer1 overlay
           cert-manager         HTTPScaledObject   (kustomize)
           Tailscale            L4 / RTX6000 / A100
           kube-prometheus
                    │                 │                  │
                    └──────────── GKE us-central1-a ─────┘
                                      │
         ┌───────────────┬────────────┼────────────┬──────────────┐
         ▼               ▼            ▼            ▼              ▼
   e2-standard-2    g2-standard-8  g4-standard-48  a2-ultragpu-1g
   CPU pool         L4 SPOT        RTX PRO 6000    A100 80GB SPOT
   15 nodes        1 node         SPOT 01        SPOT 01
                   (always on)     KEDA 0↔1        KEDA 0↔1

Datapath: GKE ADVANCED_DATAPATH + enable_cilium_clusterwide_network_policy. Dual-stack VPC (10.0.0.0/16, pods 192.168.32.0/20, services 192.168.16.0/24). GPU nodes are tainted (nvidia.com/gpu…=present:NoSchedule) so only inference pods land on them.


GPU inference (vLLM)

Three independent OpenAI-compatible servers, each pinned to a pool via nodeSelector + matching taint/toleration. Model weights cached on PVC so a scale-up does not re-pull 2040 GB from Hugging Face.

L4 dispatcher — always on

infrastructure/gpus/base/vllm-servers/vllm-l4.yaml

  • Machine: g2-standard-8 + nvidia-l4, SPOT
  • Image: vllm/vllm-openai
  • Model: p-e-w/Qwen3-8B-heretic
  • Flags that matter: --kv-cache-dtype=fp8, --enable-chunked-prefill, --enable-prefix-caching, --enable-auto-tool-choice, --tool-call-parser=hermes, --max-model-len=32768

Cheap, tool-capable, left running so agents had a low-latency brain even when the big cards were scaled out.

RTX PRO 6000 — deep context, scale to zero

infrastructure/gpus/base/vllm-servers/rtx6000-vllm.yaml
infrastructure/gpus/base/keda-gpu-scaling/keda-vllm.yaml

  • Machine: g4-standard-48 + nvidia-rtx-pro-6000, SPOT, min 0 / max 1
  • Image: vllm/vllm-openai:latest-cu129-ubuntu2404
  • Model: edp1096/Huihui-Qwen3.6-27B-abliterated-FP8
  • --max-model-len=262144, --enable-chunked-prefill, --tool-call-parser=qwen3_xml, --reasoning-parser=qwen3, MTP speculative decoding (num_speculative_tokens: 2)
  • Startup / liveness / readiness probes on /health and /v1/models (model load is slow; failureThreshold: 60 on startup)
  • KEDA HTTPScaledObject: replicas.min: 0, max: 1, host rtx6000-brain-service.customer1.svc.cluster.local

Idle deep-thinker capacity cost nothing. A request woke the Deployment; GKE then scaled the node pool off zero.

A100 80GB — same pattern

infrastructure/gpus/base/vllm-servers/a100-vllm.yaml
modules/a100-nodepool.tf

  • Machine: a2-ultragpu-1g + nvidia-a100-80gb, SPOT, min 0
  • NVFP4 / ModelOpt quantized 27B, --quantization=modelopt, --attention-backend=flash_attn, --kv-cache-dtype=fp8

GitOps

Flux watches master and applies layered Kustomizations:

clusters/devops-lab/
  flux-system/          Flux controllers + GitRepository
  infra-controllers.yaml  → infrastructure/controllers/staging
                            CNPG 0.26.1, KEDA ≥2.14, cert-manager, Tailscale, kube-prometheus
  infra-gpus.yaml         → infrastructure/gpus/staging
  infra-gatewayapi.yaml   → Gateway API + HTTPRoutes
  customer1-strimzi.yaml  → Strimzi (Kafka) first
  customer1.yaml          → apps/staging/customer1  (dependsOn strimzi)

Every Flux Kustomization has decryption.provider: sops and secretRef: sops-age. Encrypted data/stringData in Git; Flux decrypts at apply time. Age recipient is in .sops.yaml. The private key is not in this repo.

Bootstrap was originally:

flux bootstrap github \
  --owner=sirius0xdev \
  --repository=gcloud-lab \
  --branch=master \
  --path=clusters/devops-lab

gotk-sync.yaml still records that GitHub URL. This Forgejo copy is the archive; the cluster is gone, so the GitRepository object was never re-pointed.


Terraform (cluster birth)

modules/ is the IaC that created the cluster, not the day-to-day delivery path.

File Resource
gke.tf devops-lab-cluster in us-central1-a, default pool removed, Cilium datapath, dual-stack IP policy
vpc.tf devops-lab-network / devops-lab-subnetwork, ULA IPv6, secondary ranges
nodepool.tf CPU e2-standard-2, 15, pd-standard 100 Gi
nodepool-gpu.tf L4 SPOT g2-standard-8, autoscaling 11, GPU taint
pro6000-nodepool.tf RTX PRO 6000 SPOT g4-standard-48, 01, hyperdisk-balanced
a100-nodepool.tf A100 80GB SPOT a2-ultragpu-1g, 01, pd-ssd 200 Gi
db-bucket.tf GCS bucket for CNPG backups
providers.tf Google provider, Helm talking to the cluster via gke-gcloud-auth-plugin

State files and *.tfvars are gitignored. Do not terraform apply this against a live billing account unless you intend to recreate it.


Data plane and apps

Flux overlay: apps/staging/customer1/kustomization.yaml

Piece Role
CloudNative-PG Operator 0.26.1; per-app clusters (Hermes memory, waitlist, n8n, trading) with GCS backup
Strimzi Kafka for the trading data path; Flux dependsOn so apps wait for the operator
Redis In-cluster cache next to the trading services
Trading platform data / execute / news / dashboard Deployments, HTTPRoutes, NetworkPolicies
Hermes agent In-cluster agent + CNPG, talking to the vLLM services
News bot CronJobs: scrape → analyst (vLLM) → Telegram
Waitlist API FastAPI + CNPG, Gateway HTTPRoute
n8n Workflow engine on its own Postgres
Gateway API Public HTTPRoutes; Grafana/Prometheus stayed off the public internet (Tailscale / port-forward)

App images were built in a separate code repo and pulled from GHCR. This repo is manifests only (after cleanup: no Dockerfiles, no Helm-of-the-app, no planning markdown).


Networking and security

  • Cilium as GKE datapath, clusterwide NetworkPolicy enabled from Terraform.
  • Gateway API HTTPRoute for public paths; internals not published.
  • Tailscale operator for operator access to Grafana and cluster services without a public LB.
  • NetworkPolicies on the trading namespace: DNS, Postgres, Redis, Kafka, in-namespace HTTP, egress HTTPS to market APIs. Everything else denied.
  • SOPS + age for Secrets in Git. Flux sops-age Secret in flux-system held the private key.
  • GPU nodes tainted so a random Deployment cannot schedule onto a $2+/hr card.

Cost model (why it was torn down)

This was the whole point of the GPU design:

  1. L4 SPOT stayed at 1 node — cheap enough to keep a dispatcher warm.
  2. RTX 6000 and A100 pools autoscaled 01. KEDA HTTPScaledObject set the Deployment to 0 when there was no traffic; the node pool followed.
  3. Weights on PVC, long startup probes — first request after idle paid a cold-start, not a 40 GB pull.
  4. CronJobs for batch work instead of idle inference pods.
  5. When even the L4 + control-plane bill stopped making sense, the cluster was destroyed. Scale-to-zero was the rehearsal for scale-to-nothing.

Repository layout

gcloud-lab/
├── modules/                         Terraform: VPC, GKE, node pools, GCS
├── clusters/devops-lab/             Flux entry (GitRepository + Kustomizations)
├── infrastructure/
│   ├── controllers/                 CNPG, KEDA, cert-manager, Tailscale, Prometheus
│   ├── gpus/                        vLLM Deployments + KEDA HTTPScaledObjects
│   ├── gatewayapi/                  Gateway + HTTPRoutes
│   └── tailnet/                     Tailscale ProxyGroup
├── apps/
│   ├── base/customer1/              Namespaced workloads (kustomize)
│   ├── base/osint-dashboard/        Helm chart used on this cluster
│   └── staging/                     Overlays Flux actually syncs
├── monitoring/                      Extra Grafana/Prometheus config
├── .sops.yaml                       Age recipient for secret encryption
├── .github/workflows/               Historical GH Actions (pgvector image, etc.)
└── mise.toml                        Local CLI pin (gcloud, kubectl, helm, sops, terraform, k9s)

Reading the code (suggested order)

  1. modules/gke.tf + modules/vpc.tf — cluster shape.
  2. GPU pools, then the matching vLLM YAML — taint keys must match or the pod never schedules.
  3. clusters/devops-lab/customer1.yaml — Flux dependsOn, SOPS, prune/force.
  4. apps/staging/customer1/kustomization.yaml — what actually shipped in customer1.
  5. One NetworkPolicy under apps/base/customer1/trading-platform/network-policies/ — default-deny thinking.

Local tooling

mise trust && mise install   # kubectl, helm, sops, terraform, k9s, gcloud

Decrypt a secret locally (needs the age key, which is not in Git):

sops -d apps/base/customer1/waitlist-api/waitlist-telegram-secret.yaml

Status

Cluster Destroyed (GCP project devops-lab-cluster, GKE devops-lab-cluster, us-central1-a)
This repo Archive of what ran
Live infra today Self-hosted on a Pi — see siriusdevops.com/lab

Lance Walters — siriusdevops.com