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

246 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](https://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/gke.tf), [`modules/vpc.tf`](modules/vpc.tf) |
| CPU + three GPU node pools (L4, RTX PRO 6000, A100 80GB), all SPOT except CPU | [`modules/nodepool.tf`](modules/nodepool.tf), [`modules/nodepool-gpu.tf`](modules/nodepool-gpu.tf), [`modules/pro6000-nodepool.tf`](modules/pro6000-nodepool.tf), [`modules/a100-nodepool.tf`](modules/a100-nodepool.tf) |
| Flux CD applies the tree; SOPS decrypts secrets in-cluster | [`clusters/devops-lab/`](clusters/devops-lab/), [`.sops.yaml`](.sops.yaml) |
| Production-style **vLLM** OpenAI-compatible servers (not Ollama) | [`infrastructure/gpus/base/vllm-servers/`](infrastructure/gpus/base/vllm-servers/) |
| KEDA HTTP scale-to-zero on the expensive GPUs | [`infrastructure/gpus/base/keda-gpu-scaling/`](infrastructure/gpus/base/keda-gpu-scaling/) |
| CloudNative-PG operator + per-app Postgres | [`infrastructure/controllers/base/cnpg/`](infrastructure/controllers/base/cnpg/), `apps/base/customer1/*-db/` |
| Gateway API (not legacy Ingress) + Tailscale for internals | [`infrastructure/gatewayapi/`](infrastructure/gatewayapi/), [`infrastructure/tailnet/`](infrastructure/tailnet/) |
| Workload NetworkPolicies (trading stack) | [`apps/base/customer1/trading-platform/network-policies/`](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`](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/vllm-servers/rtx6000-vllm.yaml)
[`infrastructure/gpus/base/keda-gpu-scaling/keda-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`](infrastructure/gpus/base/vllm-servers/a100-vllm.yaml)
[`modules/a100-nodepool.tf`](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`](.sops.yaml). The private key is **not** in this repo.
Bootstrap was originally:
```bash
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/`](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`](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/gke.tf) + [`modules/vpc.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`](clusters/devops-lab/customer1.yaml) — Flux `dependsOn`, SOPS, prune/force.
4. [`apps/staging/customer1/kustomization.yaml`](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
```bash
mise trust && mise install # kubectl, helm, sops, terraform, k9s, gcloud
```
Decrypt a secret locally (needs the age key, which is not in Git):
```bash
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](https://siriusdevops.com/lab) |
Lance Walters — [siriusdevops.com](https://siriusdevops.com)