Kubernetes¶
The Helm chart in charts/synthkit/ is the Kubernetes equivalent of the Compose deployment. It runs the same published image with the same environment surface, so everything in Configuration and Credentials applies unchanged — the chart decides only how those values are supplied and what is reachable from outside the pod.
Kubernetes is the mode most people reach for, because synthkit exists to model Kubernetes estates. It is worth being explicit that the chart does not observe the cluster it runs in: synthkit reads no Kubernetes API, needs no RBAC, and its ServiceAccount carries no projected token. The estates it emits come from blueprints, not from the surrounding cluster.
Install¶
git clone https://github.com/rknightion/synthkit.git
cd synthkit
# Dry run first: builds and renders every estate, pushes nothing, needs no credentials.
helm install synthkit ./charts/synthkit \
--namespace synthkit --create-namespace \
--set config.blueprintNames=otlp-native
kubectl -n synthkit rollout status deploy/synthkit
Nothing is emitted until config.blueprintNames names a blueprint. Empty is setup mode — the same safe default the binary and Compose use. * loads the whole bundled catalogue and does not fit the chart's default memory limit; see Resources.
To push live, create the credential Secret and flip config.dryRun:
kubectl -n synthkit create secret generic synthkit-data \
--from-literal=GC_TOKEN=... \
--from-literal=GC_PROM_RW=... --from-literal=GC_PROM_USER=... \
--from-literal=GC_OTLP_ENDPOINT=... --from-literal=GC_OTLP_USER=... \
--from-literal=GC_LOKI=... --from-literal=GC_LOKI_USER=...
helm upgrade synthkit ./charts/synthkit --namespace synthkit --reuse-values \
--set config.dryRun=false \
--set credentials.data.existingSecret=synthkit-data
Credentials come from Secrets, grouped by destination stack¶
There is no credential value in this chart
No values file field accepts a token, and adding one has no effect. Every credential is projected from a Secret you create in the release namespace. A values file is routinely committed to a Git repository; a chart that accepted credentials there would make that a credential leak.
Credentials are grouped by where the telemetry lands, not by convenience, and the chart holds a fixed table of which environment variable belongs to which group:
| Group | Destination | Variables |
|---|---|---|
data | The synthetic-data stack | GC_TOKEN, GC_PROM_RW, GC_PROM_USER, GC_OTLP_ENDPOINT, GC_OTLP_USER, GC_LOKI, GC_LOKI_USER, GC_PROFILES_URL, GC_PROFILES_USER |
selfObs | A separate self-observability stack | GC_SELF_OTLP_ENDPOINT, GC_SELF_OTLP_USER, GC_SELF_OTLP_PASSWORD, GC_SELF_GRAFANA_URL, GC_PYROSCOPE_URL, GC_PYROSCOPE_USER, GC_PYROSCOPE_PASSWORD |
rum | Faro collector | GC_FARO_COLLECTOR, GC_FARO_APP_KEY |
sm | Synthetic Monitoring API | GC_SM_URL, GC_SM_TOKEN |
fm | Fleet Management API | GC_FM_URL, GC_FM_STACK_ID, GC_FM_TOKEN |
sigil | Sigil AI-observability ingest | GC_SIGIL_ENDPOINT, GC_SIGIL_TENANT_ID, GC_SIGIL_TOKEN |
control | The control plane itself | CONTROL_TOKEN |
git | Private git blueprint sources | GIT_TOKEN |
Two rules are enforced when the chart renders, so a mistake fails helm install rather than producing a running deployment that quietly does the wrong thing:
- A variable can only be projected from the group that owns it. Listing
GC_SELF_OTLP_PASSWORDundercredentials.datafails the render. credentials.selfObs.existingSecretmay not be the same Secret object as any synthetic-data group's. Sharing one Secret is exactly howGC_TOKENends up authenticating self-observability, which the architecture forbids: the generator's own telemetry ships to a different stack with a different token so it never intermingles with the synthetic data.
selfObs:
enabled: true
credentials:
data:
existingSecret: synthkit-data # the synthetic-data stack
selfObs:
existingSecret: synthkit-selfobs # a DIFFERENT stack, a DIFFERENT token
keys:
GC_SELF_OTLP_ENDPOINT: GC_SELF_OTLP_ENDPOINT
GC_SELF_OTLP_USER: GC_SELF_OTLP_USER
GC_SELF_OTLP_PASSWORD: GC_SELF_OTLP_PASSWORD
The keys map is ENV_VAR: secretKey, defaulting to identity, so a Secret whose keys are named after the environment variables needs no keys block. A projected key the Secret does not carry leaves the pod in CreateContainerConfigError — deliberate, so a typo cannot become a silently unauthenticated lane. Helm deep-merges maps, so removing one of the chart's default entries needs an explicit KEY: null rather than just omitting it.
extraEnv exists for names synthkit does not own, such as the operator-defined token_env_var a private git blueprint source names. Using it for a name synthkit's own configuration owns fails the render, so it cannot route a credential around the table above.
The control plane is closed by default¶
Outside Kubernetes, synthkit refuses to serve the control plane on a non-loopback address without both CONTROL_TOKEN and CONTROL_EXPOSURE_ACK. The chart keeps that friction rather than dissolving it into a Service.
Default: closed. The container binds 127.0.0.1:8088, no Service or Ingress is rendered, and a default-deny-ingress NetworkPolicy is applied. Reach the operator UI without opening anything:
Port-forwarding runs inside the pod's own network namespace, so it reaches the loopback listener without making the pod reachable from any other pod.
Opening it takes three deliberate steps, and any subset fails the render:
credentials:
control:
existingSecret: synthkit-control # 1. a Secret carrying CONTROL_TOKEN
keys:
CONTROL_TOKEN: CONTROL_TOKEN
controlPlane:
exposure:
ack: tls-proxy # 2. exactly "trusted-network" or "tls-proxy"
service:
enabled: true # 3. the Service itself
The acknowledgement means the same thing it means everywhere else: trusted-network asserts that plaintext HTTP stays on an isolated trusted path, tls-proxy that a trusted proxy terminates TLS in front of it. No other value is accepted. Setting ack also switches the container to bind all interfaces, so the binary's own startup check re-validates the token and the acknowledgement — the chart's guard and the binary's guard are independent, and the pod fails closed if either is missing.
Why SYNTHKIT_BIND is set in a Kubernetes deployment
The binary recognises Kubernetes Pods through KUBERNETES_SERVICE_HOST, including those running under containerd or CRI-O, so the container exposure branch is selected without an explicit hint. The chart still sets SYNTHKIT_BIND to mirror the host portion of JSON_HTTP_ADDR as a deliberate belt-and-braces check: both the in-container listener and the effective exposure must agree. It never presents a loopback host bind in front of an all-interfaces listener.
The NetworkPolicy is ingress-only, so egress to the telemetry backends is untouched. It is belt and braces rather than the primary control: enforcement needs a policy-capable CNI, whereas the loopback bind holds everywhere. Once exposure is acknowledged, networkPolicy.ingressFrom narrows who may reach port 8088; leaving it empty with a ClusterIP Service means any pod in the cluster, which is what a bare ClusterIP implies anyway.
State that has to survive a restart¶
/data is a PersistentVolumeClaim by default, mounted exactly as the Compose bind mount is:
| Path | Contents | Cost of losing it |
|---|---|---|
/data/control-state.json | Control-plane selections: volume multiplier, active scenarios, scaling overrides | Re-select them in the UI |
/data/blueprints/ | Custom uploads and git-sourced blueprints (custom/, git/<id>/, .boot-manifest.json) | Re-upload or re-fetch |
/data/runtime/ | Synthetic Monitoring ownership ledger, registration, adoption marker, lock, pending journal | Not recreatable. Already-provisioned remote resources become foreign until explicitly previewed and adopted |
Decisions the chart makes, and why:
fsGroup: 65532. The image is distroless nonroot. Without it, control-state saves fail withpermission deniedand every selection is silently lost on restart — the Kubernetes form of the ownership trap the Compose deployment documents. If a UI change does not survive a restart, checkpersist.last_errorin/control/status.Recreate, notRollingUpdate. A ReadWriteOnce claim cannot be mounted by the incoming pod while the outgoing one still holds it, so a rolling upgrade deadlocks.- One replica, not configurable. Two emitters generate the same series identities against the same backend — duplicate samples and out-of-order writes, not more throughput — and each keeps its own cumulative counter state.
helm.sh/resource-policy: keep. The claim surviveshelm uninstall. Delete it deliberately.persistence.enabled: falseswaps in an emptyDir. It survives a container restart but not a reschedule, so it is for throwaway labs only, and the chart refuses to combine it with the Synthetic Monitoring provisioner.
A container restart resets counters, which is a clean rate() window rather than a fault. No counter state volume exists or should.
Probes¶
Both probes run synthkit -healthcheck, which is delivery-aware: it succeeds only once every intended lane has completed a current successful push and the state volume has passed an atomic write probe.
startupProbeallows 120s, covering the 60s construct interval plus a queue flush.readinessProbeuses the same check, so the pod reports NotReady while a lane has no current push.livenessProbeis off by default. The same delivery-aware check used as liveness turns a Grafana Cloud outage into a crash-loop, because a backend that stops accepting writes would then look like a broken process. Enable it only if you accept that.
They are exec probes rather than httpGet because the default bind is loopback and the kubelet dials the pod IP.
Resources, and the measurement behind them¶
Estate size drives memory, so the defaults are derived from a measurement rather than from habit. The method, the samples, and how to re-run it against your own blueprint selection are in charts/synthkit/README.md. In short:
Measured 2026-08-27 at TICK_DEFAULT=5s over 10-minute steady-state windows, with heap read from /control/health:
| Selection | Blueprints | Heap floor | Heap peak | Peak RSS | CPU |
|---|---|---|---|---|---|
otlp-native | 1 | 253 MiB | 461 MiB | 534 MiB | 0.43 % of one core |
k8s-full-stack,otlp-native,aws-cloudwatch-infra,hostfleet | 4 | 289 MiB | 522 MiB | 603 MiB | 0.77 % of one core |
* | 26 | 1716 MiB | 3135 MiB | 3615 MiB | 24.5 % of one core |
The shipped defaults — requests {cpu: 100m, memory: 768Mi}, limits {memory: 1Gi} — cover the four-blueprint case: the request sits above its measured peak RSS so the pod is not chronically over request and first in line for eviction, and the limit is about twice its peak heap. For *, budget requests {cpu: 500m, memory: 3584Mi} and limits {memory: 5Gi}.
Estate cost is not linear in blueprint count. One blueprint to four moved the floor by 36 MiB; all 26 moved it by a factor of six. Measure your own selection rather than interpolating.
Two things make the peak roughly double the live set, and both are worth knowing before you pick a limit:
- Go's collector targets about twice the live heap by default, so the sawtooth peak is what the limit has to accommodate, not the steady figure.
- The delivery queue is sized in items per sink, not bytes, and item sizes differ substantially between a metric series, a Loki stream, an OTLP resource, a Faro beacon, a profile, and a Sigil export. Do not estimate queue memory as capacity times one universal struct size; use the pressure-test calculation in Deployment.
There is deliberately no CPU limit. synthkit is a fixed-cadence tick loop, so throttling it lengthens ticks and applies queue backpressure rather than saving anything.
Synthetic Monitoring provisioning¶
The chart ships the same opt-in, one-shot provisioner the Compose deployment does, with the same preview-then-apply sequence and the same least privilege — it receives GC_SM_URL and GC_SM_TOKEN and no other credential.
# 1. Preview. No remote writes.
helm upgrade synthkit ./charts/synthkit --namespace synthkit --reuse-values \
--set smProvision.enabled=true --set credentials.sm.existingSecret=synthkit-sm
kubectl -n synthkit logs job/synthkit-sm-provision-1
# 2. Apply, after reviewing that preview. Bump runId to make it a new Job.
helm upgrade synthkit ./charts/synthkit --namespace synthkit --reuse-values \
--set smProvision.apply=true --set smProvision.runId=2
kubectl -n synthkit logs job/synthkit-sm-provision-2
# 3. Restart the emitter so the matching registration activates the lane.
kubectl -n synthkit rollout restart deploy/synthkit
It is not a Helm hook. Hooks run automatically on install, which would destroy the preview-then-apply sequence the provisioner is built around. Because it writes to the emitter's ReadWriteOnce claim it is co-scheduled onto the emitter's node with a pod affinity.
See Synthetic Monitoring for what the two phases actually do, and treat collisions or a pending journal as a hard stop for operator review.
Upgrades¶
The chart's appVersion tracks a synthkit release, and image.tag follows it. For a standing deployment, prefer pinning the verified index digest instead:
helm upgrade recreates the pod, which resets counters and produces a clean rate() window. The identity-verification workflow in Deployment — verifying the signature, provenance, reported version and source revision of a candidate image before deploying it — applies to the digest you pin here just as it does to the Compose selector.
See also¶
- Deployment — the Compose deployment, queue-memory sizing, and image verification
- Credentials — what each credential is and where to get it
- Control Plane — the operator UI and HTTP API
- Configuration — every environment variable
- Kubernetes monitoring deployment permutations — choose the collector path your blueprint should represent
charts/synthkit/README.md— the full values reference and the resource measurement