Kubernetes¶
Run a Turbine cluster as a StatefulSet. It gives each pod what a Turbine node needs:
a stable name its peers can reach (turbine-0, turbine-1, …), a volume of its own that
follows it when it is rescheduled, and an ordinal that serves as its node id.
This page has everything a production deployment needs: the manifests and what each setting is for, how to size the shutdown and the memory, how to open the console and scrape the metrics, the day-to-day operations, and what the usual failures look like. How a cluster behaves — forming, a node going down, a node leaving — is described in Deployment; this page shows how it maps onto Kubernetes.
turbine-console only the ready ones — turbine-2, still catching up after a restart, gets no client traffic — and the headless turbine every one of them, since the pods must reach each other before any of them is ready. Click a box to jump to its manifest, anywhere else to enlarge.What you need first:
- an image of your app — see Packaging an app;
- a Kafka cluster the pods can reach — see what the broker connection supports;
- an S3-compatible bucket for checkpoints — see Checkpoint storage;
- a StorageClass that provides
ReadWriteOncevolumes (the cluster's default one is fine).
The app¶
The app reads its settings from the environment. Raft needs no code at all — the
TURBINE_RAFT_* variables of the StatefulSet below are enough:
# app.py
import os
from turbine import KafkaBroker, Turbine
kafka = KafkaBroker(bootstrap=os.environ["KAFKA_BOOTSTRAP"])
app = Turbine(
kafka,
app_id="my-pipeline",
state_dir="/var/lib/turbine", # the pod's volume
checkpoint_url=os.environ["CHECKPOINT_URL"], # shared storage, e.g. s3://bucket/turbine
)
The manifests¶
Four files, applied together with kubectl apply -f .. Two optional ones come further
down: a Service and an Ingress for the console
and a PodMonitor for the metrics.
The names are linked to each other: the StatefulSet is called turbine, its pods carry
the label app: turbine, the headless Service is called turbine too, and the peer
template spells out turbine-{ordinal}.turbine. To rename, change all of them together.
Secret¶
apiVersion: v1
kind: Secret
metadata:
name: turbine
type: Opaque
stringData:
TURBINE_API_TOKEN: "<output of: openssl rand -hex 32>" # the same token on every pod
AWS_ACCESS_KEY_ID: "<access key id>"
AWS_SECRET_ACCESS_KEY: "<secret access key>"
Every pod reads it whole. The token protects the API and is how the pods
authenticate to each other, so it must be the same on every pod; see Securing the
API for what it covers. Create the Secret from your secret
store rather than committing this file. On EKS, IAM roles for service accounts replace
the two AWS keys — see the AWS_WEB_IDENTITY_TOKEN_FILE row in S3 and S3-compatible
endpoints.
Headless Service¶
apiVersion: v1
kind: Service
metadata:
name: turbine
spec:
clusterIP: None # headless: one DNS name per pod
publishNotReadyAddresses: true # peers must resolve each other before they are ready
selector:
app: turbine
ports:
- name: api
port: 8400
This Service is how the pods find each other:
clusterIP: Nonegives each pod a stable DNS name,turbine-0.turbine,turbine-1.turbine, …publishNotReadyAddresses: trueis required. A pod is not ready until it is part of a cluster, and the cluster cannot form until the pods resolve each other: without it, DNS lists only ready pods and nothing ever becomes ready.
It is for the pods, not for clients: because it also lists pods that are not ready, do not put an Ingress or an application in front of it — see Opening the console.
StatefulSet¶
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: turbine
spec:
serviceName: turbine
replicas: 3
podManagementPolicy: Parallel # start every pod at once: the cluster forms when all answer
selector:
matchLabels:
app: turbine
template:
metadata:
labels:
app: turbine
spec:
enableServiceLinks: false # no Docker-link variables: see below
terminationGracePeriodSeconds: 60
affinity:
podAntiAffinity: # one pod per host, so a host failure costs one node
preferredDuringSchedulingIgnoredDuringExecution:
- weight: 100
podAffinityTerm:
topologyKey: kubernetes.io/hostname
labelSelector:
matchLabels:
app: turbine
containers:
- name: app
image: registry.example.com/my-pipeline:1.0.0
terminationMessagePolicy: FallbackToLogsOnError
ports:
- name: api
containerPort: 8400
env:
- name: TURBINE_RAFT_PEERS
value: "http://turbine-{ordinal}.turbine:8400"
- name: TURBINE_RAFT_INITIAL_SIZE
value: "3"
- name: TURBINE_RAFT_REBALANCE_DELAY_MS
value: "60000" # longer than a pod takes to come back, see below
- name: KAFKA_BOOTSTRAP
value: "kafka:9092"
- name: CHECKPOINT_URL
value: "s3://my-bucket/turbine"
- name: AWS_REGION
value: "eu-west-1"
envFrom:
- secretRef:
name: turbine # TURBINE_API_TOKEN, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
resources: # an example: size them as described below
requests:
cpu: "2"
memory: 4Gi
limits:
memory: 8Gi
volumeMounts:
- name: state
mountPath: /var/lib/turbine
startupProbe:
httpGet:
path: /health
port: api
periodSeconds: 2
failureThreshold: 60 # up to 2 minutes to start serving
livenessProbe:
httpGet:
path: /health
port: api
periodSeconds: 10
failureThreshold: 6
readinessProbe:
httpGet:
path: /ready
port: api
periodSeconds: 5
failureThreshold: 1 # fast withdrawal; also reacts to transient probe failures
volumeClaimTemplates:
- metadata:
name: state
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 20Gi
What each setting is for:
TURBINE_RAFT_PEERSas a template.{ordinal}stands for a pod's ordinal. Each pod takes the ordinal at the end of its host name as its node id (turbine-2is node2), listens at the template filled with it, and lists as peers every ordinal from0toTURBINE_RAFT_INITIAL_SIZE - 1— plus its own, for a pod added later. The template must name pods through the headless Service:turbine-2.turbineis the pod's stable DNS name.TURBINE_RAFT_INITIAL_SIZEis the number of pods of the first deployment: the cluster is formed once all of them answer. Set it toreplicasthe first time, then leave it alone — it plays no part once the cluster exists.podManagementPolicy: Parallelis required. The default (OrderedReady) createsturbine-1only onceturbine-0is ready, andturbine-0waits forturbine-1to answer before forming the cluster.enableServiceLinks: false. By default Kubernetes injects into every pod a set of variables for each Service of the namespace, named after the Service — among them<NAME>_PORT=tcp://<address>:<port>. A Service namedturbine-apiwould therefore setTURBINE_API_PORTto an address, and every pod would refuse to start (TURBINE_API_PORT must be an int, got 'tcp://…'). Turbine finds its peers through DNS and uses none of these variables.- The volume holds the node's partitions and its cluster membership (see Keep each node's state directory). A pod rescheduled elsewhere keeps its volume and resumes as the member it was. Size it for the state of the partitions a pod may hold, with room for the ones it takes over when another pod is down.
checkpoint_urlon shared storage is what lets a partition move to another pod with its state. A cluster with stateful subscriptions refuses to start without one; see Checkpoint storage.- The token comes from the Secret, the same value on every pod. The probes need none:
/healthand/readyanswer without it. terminationMessagePolicy: FallbackToLogsOnErrormakes the end of a failed container's logs available inkubectl describe podwhen its termination-message file is empty. It helps diagnose startup refusals even before the pod can contact the cluster. Kubernetes keeps only a short log tail there; usekubectl logs POD --previousfor the full previous attempt. See Kubernetes termination messages.- Probes:
/healthfor liveness and startup,/readyfor readiness — never/readyfor liveness, see Health probes. The HTTP server starts before the pod joins the cluster or restores any state, so the startup probe only covers the start of the process; a pod restoring a large partition keeps answering/healthand is not restarted. A pod is ready once it is a voter of a cluster with a leader and has caught up with it; until then it runs no partition. terminationGracePeriodSecondsmust cover the pod's shutdown — see Sizing the shutdown.TURBINE_RAFT_REBALANCE_DELAY_MSis how long a stopped pod keeps its partitions (the default, one minute, is written out so it sits next to the other numbers). It starts once the pod has finished stopping, after its final checkpoint, so the grace period does not count against it. What must fit in it is the rest of a restart: the leadership hand-over (up to ten seconds), the pod's re-creation, the image pull, the start and the catch-up with the cluster. Past it, the partitions move to the other pods, and the pod gets a share again when it returns.resources— see CPU and memory.- Anti-affinity is preferred, so a cluster with fewer hosts than pods still
schedules. Make it
required…if you would rather have a pod pending than two Turbine nodes sharing a host.
PodDisruptionBudget¶
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: turbine
spec:
maxUnavailable: 1 # drains take one pod at a time
selector:
matchLabels:
app: turbine
A node drain, a managed node upgrade or an eviction then takes one Turbine pod at a time, and only once the previous one is ready again — back in the cluster and caught up. Three voters tolerate one down; letting a drain take two would stop the cluster.
Withdrawing client traffic before shutdown¶
As soon as Turbine receives a stop request, /ready returns 503 with
status: "unavailable" and reason: "stopping"; /health stays 200 throughout
drainage. The FastAPI integration also withdraws
readiness when the host receives SIGTERM or SIGINT, before its lifespan exits.
That alone does not close the routing window. Kubernetes sets a terminating
endpoint's ready condition to false, but its serving condition continues to
follow the readiness probe. A load balancer that uses terminating endpoints for
drainage can therefore keep routing to the pod; sticky-session cookies can keep
reconnections on it too. Meanwhile, uvicorn closes its listening socket as soon
as SIGTERM arrives. See Kubernetes'
endpoint conditions.
For a client-facing service, add this lifecycle block to the app container.
It first withdraws readiness, then gives probes and the load balancer time to
notice, while both listeners remain open. Kubernetes sends SIGTERM after the
hook returns; workers and Raft stay running during the hook. This is a local
traffic withdrawal, not a cluster leave.
lifecycle:
preStop:
exec:
command:
- /app/.venv/bin/python
- -c
- |
import os
import time
import urllib.request
port = os.environ.get("TURBINE_API_PORT", "8400")
headers = {}
token = os.environ.get("TURBINE_API_TOKEN")
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
f"http://127.0.0.1:{port}/ready/stop", headers=headers, method="POST"
)
try:
with urllib.request.urlopen(request, timeout=2) as response:
response.read()
finally:
time.sleep(10)
The interpreter path is the one created by the packaging example; adapt it if your image installs Python elsewhere.
Ten seconds is an example: allow at least one readiness probe period plus the
load balancer's measured synchronization time. failureThreshold: 1 in the
StatefulSet above withdraws the pod at the first failed probe, including a
transient timeout during a CPU spike or an election. To tolerate those failures,
keep the default threshold of three and lengthen the hook to cover all three
probe periods plus load-balancer synchronization. The hook waits even if its
HTTP request fails, but that delay alone cannot guarantee readiness withdrawal:
fix authentication or connectivity errors reported by the hook. Check that the client-facing Service does not set
publishNotReadyAddresses: true, and verify that your ingress stops sending new
requests with an existing sticky cookie once serving is false. A single
remaining pod still means an interruption when it stops.
The termination grace period starts before preStop, so budget for the hook,
the web server's connection-drain timeout, Turbine's final checkpoints and
leadership hand-over, plus a margin. A sleep without the readiness withdrawal
can leave a terminating endpoint serving throughout the sleep. A direct
SIGTERM without this hook still withdraws readiness, but cannot wait for routing
updates before the web listener closes. See Kubernetes'
pod termination flow.
Sizing the shutdown¶
A restart, from the stop to the pod's return, crosses two settings in turn:
/ready turns 200 only once the pod has caught up — that is what the rolling update and the disruption budget wait for before stopping the next pod. Click to enlarge.On SIGTERM a pod:
- finishes the batch in flight on each of its partitions;
- writes a final checkpoint of every partition that changed since its last one, and uploads them — all at the same time;
- announces its stop to the cluster and, if it is the leader, hands the leadership over — usually well under a second; each of the two waits at most ten seconds when the cluster does not answer — and exits.
terminationGracePeriodSeconds must cover the three. Past it, Kubernetes kills the pod
and the stop becomes a crash, so its partitions take the crash-recovery path instead of
a clean hand-over: a pod back on its volume within the rebalance delay resumes from its
local state, while a partition that moves to another pod starts from its last periodic
checkpoint instead of the final one — see Crash Recovery.
Measure it: every stop logs its own duration. The last line a pod writes as it stops says how long the stop took, and where the time went:
node stopped in 20.4 s (workers and their final checkpoints 18.1 s, stop announcement and leadership hand-over 2.3 s)
The two parts follow each other, so they add up to the total; the same durations travel
as fields of the line (total_ms, workers_ms, cluster_ms). Read it after a rolling
update and keep the grace period comfortably above the longest one: the grace period
counts from the start of termination (including any preStop hook), the line
from when Turbine noticed the stop request — usually within a second,
later if it was busy at that moment, restarting a worker for instance — and the process
still has to exit after it.
Before the first stop, estimate it from the uploads. Checkpoint uploads are
incremental: a checkpoint sends only the storage files written since the previous one,
so the final checkpoint carries what changed since the last periodic one — not the whole
state, which goes up only when the bucket holds none of it yet (a partition's very first
checkpoint). The periodic checkpoints upload the same kind of increment, so
turbine_last_checkpoint_upload_seconds — how long each partition's last upload took —
tells you what its final one costs. A pod's partitions upload their final checkpoints at
the same time and share its bandwidth: add their last upload durations for a
conservative estimate of the upload step, then add one batch, up to twenty seconds for
the cluster step when the cluster does not answer, and a margin.
CPU and memory¶
Memory. Each partition a pod consumes reads ahead of processing: the Kafka client prefetches up to about half a million records, or 2 GB, per partition — whichever comes first. A partition that keeps up holds far less; one with a backlog to catch up (after a restart, a rebalance, a slow stretch) fills it. So size the memory limit from what a pod uses while catching up, with the most partitions it can be given, not from what it uses at steady state. A pod killed for memory is a crash, and it would be killed again at the same point of the next catch-up.
State. A stateful pod also keeps part of its state in memory: a block cache shared by its partitions, which takes a quarter of the memory limit by default (of the node's memory if you set no limit). State write buffers share that budget and trigger background flushes against that capacity. This is a soft target, so allow headroom for bursts and pending flushes. Caches of recent values remain separate per partition and thread: size for the most partitions the pod can be given. Memory describes these costs and settings.
CPU. Turbine sizes its thread pools to the CPUs the container may use: a CPU limit caps them, a request does not. Leave the limit out to let a pod use a whole node's spare CPU during a catch-up; set one to bound it, knowing that it also bounds the threads.
Opening the console¶
The pods' addresses are internal to the cluster: your browser cannot reach them, so listing a few nodes in the console's settings does not work from outside. Two ways in.
For one person: a port-forward¶
Any pod will do: each one shows the whole cluster. Nothing is exposed and no object is created; it lasts as long as the command, and does not follow the pod if it restarts.
For a team: a Service and an Ingress¶
apiVersion: v1
kind: Service
metadata:
name: turbine-console # not "turbine-api": see enableServiceLinks above
spec:
selector:
app: turbine # no publishNotReadyAddresses: ready pods only
ports:
- name: api
port: 8400
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: turbine-console
spec:
ingressClassName: nginx # your Ingress controller's class
tls:
- hosts:
- turbine.example.com
secretName: turbine-console-tls
rules:
- host: turbine.example.com # a host of its own, served from its root
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: turbine-console
port:
name: api
The console is then at https://turbine.example.com/console. Three things this layout
gets right that the obvious one does not:
- A second Service, not the headless one. The
turbineService lists pods that are not ready — it has to, for the cluster to form. A pod that has just restarted serves HTTP before it has caught up, and answers with its own, older view of the cluster: behind that Service the console would flip between the current picture and an outdated one from one refresh to the next, during every rolling update. This one keeps Kubernetes' default and routes to ready pods only. - A host of its own, at the root path. The console loads its files from
/console/…and calls the API at the root of the address it was served from (/cluster/nodes,/windows, …). Under a sub-path such ashttps://ops.example.com/turbine/, neither resolves. - Not named
turbine-api. That name setsTURBINE_API_PORTin every pod of the namespace unless the StatefulSet setsenableServiceLinks: false— the manifest above does both.
This exposes the whole API, not only the pages
Everything the API does is reachable at that address: reading your state, window
contents and dead-lettered records (often production payloads), and changing cluster
membership (the console's node actions, /cluster/leave, /cluster/force_release…).
The API token grants everything; the scrape token only allows /metrics.
Filtering HTTP methods at the Ingress is
no way out either: the console reads state with POST.
So: never without TURBINE_API_TOKEN; terminate TLS at the Ingress, or the
token travels in clear; and restrict who can reach the address with what your
Ingress controller offers (an IP allow-list, an authenticating proxy).
Metrics¶
Each pod serves its Prometheus metrics at /metrics on the api port. Scrape every
pod, not a Service: each one reports its own partitions. Configure a separate
TURBINE_METRICS_TOKEN on the Turbine pods so Prometheus can scrape without admin
rights (see Scraping the metrics). Add it to
the turbine Secret before applying the PodMonitor, keeping TURBINE_API_TOKEN
for administration and node-to-node calls:
Merge this key into the existing Secret, then restart the Turbine pods to load it.
It must differ from TURBINE_API_TOKEN. Prometheus needs only the scrape credential;
if you copy credentials to a separate monitoring Secret, copy only this key and
adapt the PodMonitor's Secret name.
With the Prometheus Operator, download
turbine-podmonitor.yaml and apply it in the
StatefulSet's namespace, alongside the turbine Secret:
curl -fsSLO https://docs.turbine.fast/grafana/turbine-podmonitor.yaml
kubectl apply -n default -f turbine-podmonitor.yaml
apiVersion: monitoring.coreos.com/v1
kind: PodMonitor
metadata:
name: turbine
labels:
app: turbine
spec:
jobLabel: app
selector:
matchLabels:
app: turbine
podMetricsEndpoints:
- port: api
path: /metrics
interval: 15s
authorization:
type: Bearer
credentials:
name: turbine
key: TURBINE_METRICS_TOKEN
relabelings:
- sourceLabels: [__meta_kubernetes_pod_name]
targetLabel: instance
The Secret is read from the PodMonitor's namespace, not Prometheus's namespace.
With no namespaceSelector, this monitor discovers pods in its own namespace only.
The pod label app: turbine selects the pods; their named container port api
selects port 8400. jobLabel: app takes the job value from the selected pod's
app label, giving job="turbine" for the dashboard and alert rules. The relabeling
uses the pod name as instance, so each node stays visible separately.
Prometheus must select both the namespace and the PodMonitor's metadata labels.
For example, merge these fields into your existing Prometheus resource, adapting
default to the app's namespace and preserving any selections you already need:
spec:
podMonitorNamespaceSelector:
matchLabels:
kubernetes.io/metadata.name: default
podMonitorSelector:
matchLabels:
app: turbine
Charts can add a release-label selector: add the matching label to the PodMonitor or adapt the chart's selector. Prometheus needs RBAC to discover pods in the app's namespace; the operator needs access to the monitor and its authorization Secret. An accepted PodMonitor alone does not prove that Prometheus selected it.
In Prometheus's Status → Target health, expect one target per Turbine pod,
job="turbine", the pod name as instance, and state UP. A missing target
usually means a namespace/label/port selection or RBAC problem; a target returning
401 means the Secret or its key differs from the app's scrape token. /metrics stays
protected. For a second deployment, use a distinct job value and adapt the alert
rules' job selector; the node names alone need not be globally unique.
Alert rules can be loaded through a PrometheusRule selected by the same operator.
Without the operator, a scrape job that finds the pods by itself. Prometheus needs
the scrape token in a file: mount the Secret's TURBINE_METRICS_TOKEN key into its
pod, or a copy of it if Prometheus runs in another namespace.
scrape_configs:
- job_name: turbine
scrape_interval: 15s
kubernetes_sd_configs:
- role: pod
namespaces:
names: [default] # the StatefulSet's namespace
authorization:
type: Bearer
credentials_file: /etc/prometheus/turbine/TURBINE_METRICS_TOKEN
relabel_configs:
# The Turbine pods' `api` port only.
- source_labels: [__meta_kubernetes_pod_label_app, __meta_kubernetes_pod_container_port_name]
regex: turbine;api
action: keep
# The pod's name as `instance`: the node's name in the dashboard.
- source_labels: [__meta_kubernetes_pod_name]
target_label: instance
Monitoring has the Grafana dashboard and the signals to alert on.
Day-to-day operations¶
- First deployment. Every pod starts at once;
turbine-0forms the cluster when all of them answer, and the others join it. Until then no pod is ready. If one pod can never start (a volume that will not bind, a node pool that is full), see How a node finds its cluster for forming it without that pod. - Rolling update (a new image, a changed setting). The StatefulSet restarts the pods
one at a time and waits for each to be ready — back in the cluster and caught up —
before stopping the next. A restarted pod keeps its partitions, provided it is back
within
TURBINE_RAFT_REBALANCE_DELAY_MS(what the delay must cover is listed above). Check runtime compatibility first; see Updating the application. - A pod or a host lost. The pod's partitions wait for the delay, then move to the others. When it comes back with its volume, it resumes or gets a share again.
- Draining a node. The disruption budget lets Kubernetes evict one Turbine pod at a time, and only once the previous one is ready again.
- Scaling up.
kubectl scale statefulset turbine --replicas=4. The new pod lists itself and the existing ones, finds the cluster and joins it; the existing pods are not restarted. -
Scaling down. Removing a pod stops it, and a stopped node is still a member. Have the pods being removed leave first — the highest ordinals, which the StatefulSet removes — then scale. The leave goes to the pod that leaves:
kubectl port-forward pod/turbine-3 8400:8400 & curl -fsS -X POST -H "Authorization: Bearer $TURBINE_API_TOKEN" \ http://localhost:8400/cluster/leave kill %1 kubectl scale statefulset turbine --replicas=3Skipping the leave keeps the pod in the quorum: the cluster then tolerates one failure fewer. The removed pod's volume stays behind (Kubernetes keeps it); a later scale-up brings the pod back on it, and it joins the cluster again. - A lost volume (deleted, or a pod rescheduled onto an empty one) needs nothing either: the pod sees that the cluster still counts it while it remembers nothing, has itself removed and joins again.
Updating the application¶
Separate an application change from an upgrade of Turbine itself. The first upgrade to the subscription registry, and the first upgrade to startup-refusal diagnostics, require every node to stop before the new runtime starts. Follow the runtime upgrade procedure before using rolling application updates. An older runtime may not be able to read the cluster data written by the newer one; reverting its image is then not a supported rollback.
For a coordinated runtime upgrade, first back up the state directories and check
the release's migration requirements in that procedure. The StatefulSet above
keeps its PVCs on scale-down (persistentVolumeClaimRetentionPolicy.whenScaled
defaults to Retain). If you changed that policy, verify it retains the PVCs
before stopping the cluster. Keep checkpoint storage too.
Stop all pods and wait for their deletion:
kubectl scale statefulset/turbine --replicas=0 &&
kubectl wait --for=delete pod -l app=turbine --timeout=5m
If either command fails, stop here: do not start the new runtime until every old pod has stopped. Perform any required migration while the cluster is stopped, then set the new image and restore the original replica count (three here):
kubectl set image statefulset/turbine app=registry.example.com/my-pipeline:2.0.0 &&
kubectl scale statefulset/turbine --replicas=3 &&
kubectl rollout status statefulset/turbine --timeout=5m
Replace the example image with the intended runtime upgrade and update the source
manifest to match. For the first subscription-registry upgrade, keep the original
subscription declaration for this restart. Preserve the existing app_id, PVCs
and checkpoints; a runtime upgrade is separate from an application declaration
change.
Once every node runs a compatible registry-aware runtime, keep the same app_id
and the existing subscription names when changing the application:
- Adding, removing or reordering subscriptions supports a rolling update. During the rollout, a subscription runs only on nodes whose application declares it.
- Removing a subscription keeps its state and checkpoints. Reintroducing the same name on the same source topic reuses the retained history, subject to the broker's input-topic and committed-offset retention.
- Renaming a subscription creates a new consumer group and fresh worker state; it does not migrate the old subscription's data. Changing an existing subscription's persisted state or input layout still needs its own compatibility check; the registry does not make every application change safe.
A stateless subscription's only resume position is its consumer group's committed offset. The broker can expire those offsets while the subscription is removed (7 days from the last commit by default, configurable). If they expire, reintroducing it uses the fresh-group starting policy: the topic head by default, skipping records produced during its absence, unless you explicitly choose another starting position. An exactly-once stateful subscription with committed timer history instead stops with a recovery error when that broker history is missing. No keepalive runs for a removed subscription; set broker retention longer than the planned absence, with margin. See Broker commit-history retention. The input topic must also retain the unconsumed records for any restart to read them.
Preserve the pods' volumes and checkpoint storage, including the subscription
registry, throughout the update and any rollback. Do not change app_id, delete
volumes or clear cluster data to get past a refusal. See
Subscription identities for the identity
rules.
Apply the new manifest, then wait for the rollout with a bound:
Choose the timeout to cover the whole rollout, including image pulls and recovery on each pod. A timeout ends the command; it does not stop the rollout or roll it back. If startup is refused, use the recovery procedure below.
Recovering a refused rollout¶
A pod whose startup configuration is refused exits and normally enters
CrashLoopBackOff. The rolling update waits for that pod to become ready before
replacing the next one. Pods not yet replaced keep the previous application;
pods already updated keep the new one. Turbine reports the refusal but does not
revert the StatefulSet's template.
-
Find the failed pod and read its reason. For example, if
turbine-2failed:A surviving pod's console may also retain the refused node's reason after cluster contact. Publication is best effort; use the pod's diagnostics if no reason appears. A failure before cluster contact, such as a refused legacy store or a port already in use, is available only through those diagnostics. Before the first container restart, omit
--previousto read the current attempt's logs. -
Correct the manifest, or select a known working application revision with a compatible Turbine runtime. Inspect the history first; the example restores revision
3, which you must replace with your chosen revision:kubectl rollout history statefulset/turbine kubectl rollout history statefulset/turbine --revision=3 kubectl rollout undo statefulset/turbine --to-revision=3If a runtime upgrade changed the cluster's persisted format, keep the compatible runtime and correct the application/configuration instead of downgrading it. A rollout revision restores only the pod template: restore any independently changed Secrets or ConfigMaps too. Their referenced contents are not part of that revision. Keep the corrected source manifest in sync with the deployed template so the next
kubectl applydoes not restore the refused configuration. -
After restoring the template, check the pod. If it still runs the refused revision, delete only that failed pod, then wait again:
This preserves its volume; the StatefulSet recreates it from the restored template. Do not delete the healthy pods or their PVCs. The StatefulSet forced-rollback caveat can also affect this page's
Parallelpolicy: parallel initial creation does not make a failed rolling update repair itself when the template is reverted.
Finally, verify that all pods are ready and the surviving console shows the expected subscriptions and their processing health. Readiness confirms cluster admission, not that every partition worker is healthy. An admitted restart clears that node's retained startup refusal.
When something goes wrong¶
| Symptom | Likely cause | What to do |
|---|---|---|
| After the first deployment, no pod ever becomes ready | podManagementPolicy left at OrderedReady, publishNotReadyAddresses missing, or one pod cannot start |
Fix the two settings. For a pod that will never start, form the cluster without it: How a node finds its cluster |
Every pod crashes at start: TURBINE_API_PORT must be an int, got 'tcp://…' |
A Service named turbine-api in the namespace, with service links on |
enableServiceLinks: false on the StatefulSet, or rename the Service |
A pod stays not ready, /ready says no_raft_leader |
No quorum: more than one pod down, or the pods cannot reach each other on the API port (a NetworkPolicy, a different token — listed as unauthorized in the console's Nodes view) |
Bring pods back; check that each pod reaches turbine-N.turbine:8400 and that every pod has the same token |
| A pod exits because startup was refused | Incompatible startup configuration or an admission error | Read the reason in kubectl describe pod, kubectl logs POD --previous or the surviving console, then follow Recovering a refused rollout |
A pod stays not ready, /ready says catching_up |
It cannot get the cluster's current state from the leader | Same checks as above, from that pod |
| Partitions stop being consumed after a pod went away | Expected for TURBINE_RAFT_REBALANCE_DELAY_MS; they move afterwards |
Nothing, or bring the pod back; turbine_partitions_awaiting_rebalance counts them |
| With exactly-once, a partition that moved never starts | Its previous owner died hard and could not hand it over | Recovering a stalled partition handoff |
A pod is OOMKilled while catching up, again and again |
Memory limit below what a catch-up prefetches | Raise the limit: CPU and memory |
A stopping pod is killed before it finishes (no node stopped line at the end of its logs) |
terminationGracePeriodSeconds shorter than the stop — the stop becomes a crash |
Raise it: Sizing the shutdown |
| The console flips between two pictures of the cluster | The Ingress points at the headless Service | A second Service: For a team |