deployments/customer360-promotions — c360 Promotions on Kubernetes (GreenNode VKS)
Ad decisioning (GET /serve/{placement}, /ads/{id}, /placements/{key}) runs here as an
autoscaled Deployment — 1-2 pods on UAT, 2-8 on prod. It reads its own leo_ads
schema in the shared customer360 database and uses the shared Redis as a response cache.
It used to run as a single docker container on the shared api box (uat) with a dedicated
ads vServer planned for prod. Kubernetes now owns replication, restarts, rollouts and
scaling. The cluster, the GHCR pull secret, the namespace and the CI identity are shared
with customer360-event-api, which migrated
first — see its runbook for the cluster-level
background. Where this service deliberately differs from it is covered below.
Layout
deploy-promotions.sh renders generated/<env> and applies it
overlays/<env>.tfvars app CONFIG: port, root_path, schema, DB pool, otel
k8s/
base/promotions.yaml every resource: Deployment + Service (LoadBalancer :9009)
+ PodDisruptionBudget + HorizontalPodAutoscaler
overlays/uat/ UAT profile: 1-2 pods
overlays/prod/ PROD profile: 2-8 pods, bigger pods
generated/<env>/ rendered by ./deploy-promotions.sh — GITIGNORED, holds credentials
Three layers: k8s/base/ is env-agnostic, k8s/overlays/<env>/ carries the committed
per-env scaling profile (so a capacity change is a reviewable diff), and
k8s/generated/<env>/ is what actually gets applied. Per-env configuration stays in
overlays/<env>.tfvars, where it already was.
To change capacity, edit the k8s overlay and redeploy:
# k8s/overlays/uat/kustomization.yaml
- { op: replace, path: /spec/maxReplicas, value: 4 }Deploy
cd deployments/customer360-promotions
export KUBECONFIG=../server/kubeconfig-vks-uat.yaml # from https://vks.console.greennode.ai
export GHCR_PULL_TOKEN=<a token with read:packages> # the image is private
./deploy-promotions.sh uat
./deploy-promotions.sh uat destroy # remove the workload (namespace and data are kept)deploy-promotions.sh resolves the DB host from ../postgres and the Redis host from
../cache + ../server — the same Terraform state the vServer deploy used — renders them
into a hash-suffixed ConfigMap/Secret, pins the image by digest, and applies the overlay.
The schema is NOT bootstrapped by this script
deploy-promotions.sh used to psql sql-scripts/db-schema-init.sql onto the box it deployed to.
There is no box any more. ../postgres/run-sql.sh already reaches the private vDB through
the bastion, so the leo_ads schema moved there and runs as part of the db-schema
step of deploy-all.sh:
../postgres/run-sql.sh uat # schema + demo data (uat default)
PROMOTIONS_SEED_SAMPLE=false ../postgres/run-sql.sh uat # schema onlyBoth files are idempotent (CREATE ... IF NOT EXISTS, ON CONFLICT DO NOTHING), so this
is safe to re-run. PROMOTIONS_SEED_SAMPLE defaults to true on uat and false on prod, which
is what the old ads_seed_sample overlay flag did.
Run it before the first deploy into a fresh database: the app raises on startup when it
cannot reach Postgres, so missing tables surface as CrashLoopBackOff, not as a 500.
Expose it
The Service is type: LoadBalancer on 9009, so Caddy’s ads_upstream keeps its ip:9009
shape. After the first deploy:
# ../proxy/overlays/uat.tfvars
ads_upstream = "<EXTERNAL-IP>:9009" # was 127.0.0.1:9009then ../proxy/deploy-caddy.sh uat. Caddy forwards /ads/* unstripped and the app
sets root_path=/ads, so the public URL shape does not change.
ads_upstream accepts several space-separated addresses, so
"127.0.0.1:9009 <EXTERNAL-IP>:9009" canaries the new pods alongside the old container.
Autoscaling: a plain HPA, not KEDA
event-api scales on real ingest requests/sec via a KEDA ScaledObject reading a bundled
Prometheus. This service exposes no metrics, so that trigger would have nothing to
query and KEDA would collapse to its CPU trigger — which is a HorizontalPodAutoscaler
with an operator and two CRDs in front of it. metrics-server is already installed
cluster-wide, so plain CPU utilisation works today.
Swap in a KEDA ScaledObject once the app grows a /metrics endpoint;
../customer360-event-api/base/event-api.yaml is the template.
Dependencies
- Postgres (
../postgres) — schemaleo_adsin thecustomer360DB. Required:PromotionsApplication._check_database()raises on startup when it is unreachable.leo_adshas no RLS, so noapp.tenant_id/PGOPTIONSis needed. - Redis (
../cache) — response cache, reached at the api box’s private VPC address (not127.0.0.1: the pods no longer run on that box). The app fails open without it. - VKS node group (
../vks) — the pods must sit in the same subnet as the vServers to reach the private DB and Redis.
Each pod opens its own SQLAlchemy pool, so DB connections scale with replicas —
promotions_db_pool_size / promotions_db_max_overflow in the overlay keep the ceiling affordable.
prod
../vks/overlays/prod.tfvars is still cluster_id = "CHANGE_ME" — there is no prod VKS
cluster, so ./deploy-promotions.sh prod has nowhere to go. The prod overlay is committed so
the capacity profile is reviewable and the cutover is a deploy rather than a design task.