Skip to content

Running in containers

A wireform-kafka-streams process keeps its working state on local disk. The RocksDB backend (Kafka.Streams.State.KeyValue.RocksDB, built with the +rocksdb Cabal flag) writes column-family files under <stateDir>/<applicationId>/<task>/; the WAL-backed Kafka.Streams.State.KeyValue.Persistent backend does the same. Either way, “the database lives in the container” is the model.

Containers break four assumptions that model relies on.

First, the filesystem is expected to outlive the process. Second, the hostname should be stable across restarts. Third, memory consumption should be predictable. Fourth, there should be time to flush state on shutdown.

In containers, none of these hold by default.

This page covers how to put each one back.

A streams instance owns RocksDB directories under stateDir. On unclean shutdown, RocksDB’s WAL replay on next open handles partial-write recovery (this is why Kafka.Streams.State.KeyValue.RocksDB.rocksDBKeyValueStore sets storeFlush = pure (): the WAL is the flush). The Kafka changelog topic behind every store is independent durability; the local copy is an optimisation.

Under processingGuarantee = ExactlyOnceV2, WAL replay alone isn’t enough: a store can hold writes from a transaction the broker aborted when the process died, and replaying the changelog tail on top of them applies those writes twice. So the runtime does what the JVM does. closeKafkaStreams writes a .wireform-clean-shutdown marker into <stateDir>/<applicationId> once the stream thread has stopped at a commit boundary and every task is closed. startKafkaStreams consumes that marker before it opens any store, and if the marker is missing it deletes everything under <stateDir>/<applicationId>. The stores then restore from their changelogs. A run that lost its stream thread to an uncaught exception, or was killed, never writes the marker. (An aborted transaction doesn’t need the wipe: the runtime recovers the tasks in place.) Two things follow:

  • Give each application instance its own stateDir. The wipe covers only <stateDir>/<applicationId>, and removes its entries, not the directory, so a volume mount point is safe.
  • Allow closeKafkaStreams its timeoutMs (default 30 s). If the loop doesn’t reach a commit boundary in time, the close cancels it, and the next start pays a full restore.

At-least-once never wipes; duplicates are within its contract.

The strategies, from cheapest restart to most expensive:

StrategyCold-boot costWhen to pick
Persistent volume at stateDir + persistent storesThe changelog tail after each task’s checkpoint (a full restore after an unclean EOS stop)Default for any non-trivial stateful topology
Ephemeral disk (or in-memory stores)O(state-size / restore-bandwidth) on every cold startSmall stores or recreatable workloads only

The Riffle snapshot store (Kafka.Streams.State.KeyValue.Snapshot) can restore itself from an object store, but the runtime’s restore doesn’t use it, so a task without a checkpoint still replays its whole changelog.

The runtime gives each task a directory <stateDir>/<applicationId>/<subtopology>_<partition>/, holding its stores and its changelog checkpoint. For a StatefulSet pod app-0 of application view-counter, the layout is roughly:

/var/lib/wireform/state/
view-counter/
.wireform-clean-shutdown -- after a clean close
0_0/ -- task 0_0: its stores + checkpoint
0_1/
...
1_0/

The volume must be writable by the process user, and the filesystem must support fsync semantics RocksDB expects (ext4, xfs are fine; NFS is not: RocksDB’s WAL atomicity assumptions don’t hold and compaction throughput collapses).

flowchart TD
  Start([Pod starts]) --> EOS{Exactly-once and<br/>no clean-shutdown<br/>marker?}
  EOS -- yes --> Wipe[Wipe stateDir/applicationId]
  EOS -- no --> Assign
  Wipe --> Assign[Partitions assigned;<br/>tasks built, sources paused]
  Assign --> CheckPt{Task has a<br/>checkpoint?}
  CheckPt -- yes --> TailReplay[Restore changelog tail<br/>from the checkpoint]
  CheckPt -- no --> FullReplay[Restore entire changelog<br/>from the log start]
  TailReplay --> Ready([Task processing])
  FullReplay --> Ready

The right branch is the one a container with no persistent volume always takes. Sizing rollouts around it is the cause of most “rolling deploys take three hours” incidents.

Three identities matter:

IdentitySet bySurvival requirement
Consumer-group memberapplicationId + member metadataStable across the group’s lifetime; one per process
Task assignmentThe streams assignor, from the state each member reportsA member gets back the tasks its local state covers, as long as the volume (with its checkpoints) comes back
RocksDB directory and process idstateDir mountStable iff the volume is reattached to the same pod

For a restarted pod to get its old tasks back it needs its volume back; a stable clientId makes it recognisable in broker logs and metrics:

import qualified Kafka.Streams.Config as C
import qualified System.Environment as Env
mkCfg :: IO C.StreamsConfig
mkCfg = do
hostname <- Env.getEnv "HOSTNAME"
pure C.defaultStreamsConfig
{ C.applicationId = "view-counter"
, C.bootstrapServers = ["broker:9092"]
, C.clientId = "view-counter-" <> T.pack hostname
, C.numStreamThreads = 4
, C.stateDir = "/var/lib/wireform/state"
}

A StatefulSet gives you hostname = view-counter-0, view-counter-1, … and rebinds the same PersistentVolumeClaim to the same ordinal on restart. The clientId derived from HOSTNAME is then automatically stable.

For Kubernetes specifically: a Deployment does not give you stable identity; pod names are random suffixes that change on every restart. Use a StatefulSet whenever the topology has state.

Every member tells the group’s leader how far its local state reaches into each task’s changelogs (from its checkpoints when it has just started). The leader keeps tasks where that state is caught up (within acceptableRecoveryLag), so a pod that comes back with its volume gets its own tasks back and restores only their tail. A pod that comes back without it (a Deployment, an ephemeral disk) has no state: its tasks stay on instances that are caught up while it warms up, and move to it at a probing rebalance.

The full rebalance walk is in Scaling and rebalancing → Processes across the group.

RocksDB allocates off-heap. Per store, a non-trivial slice of memory goes to:

  • The block cache (decompressed .sst block pages).
  • The memtable (per-column-family write buffer).
  • Index and filter blocks pinned in memory.
  • Iterator-held pinned blocks during range scans.

A streams pod with N stores and T tasks holds N × T RocksDB directories, each with its own buffers unless you’ve wired a shared Cache and WriteBufferManager through a RocksDBConfig customisation. Without that sharing, total RocksDB memory grows linearly with the number of tasks the pod owns: and the peak is hit during a rebalance, when tasks are being opened on the gaining instance before the losing instance has fully released them.

The container memory limit therefore needs:

container_limit = haskell_rts_resident
+ rocksdb_per_store * stores_per_task * tasks_per_pod
+ page_cache_headroom
+ per_record_inflight * commit_interval_records

The most common production failure mode is sizing the container to “RTS resident + a small constant” and getting OOM-killed during the first heavy compaction. Compaction can transiently double RocksDB memory.

Kafka.Streams.State.KeyValue.RocksDB.RocksDBConfig exposes the options the underlying binding accepts. The practical pattern is to build a single config in the boot path and reuse it for every store the topology constructs:

import qualified Kafka.Streams.State.KeyValue.RocksDB as RDB
bootRocksDBConfig :: FilePath -> RDB.RocksDBConfig
bootRocksDBConfig dir = (RDB.defaultRocksDBConfig dir)
{ RDB.rdbWriteSync = False
}

For deployments with many tasks per pod, a wrapper that shares a Cache across R.open calls is worth the bytes. Track the rocksdb-haskell-kadena API for the underlying Options you’d thread through.

RocksDB’s on-disk footprint is not the same shape as the changelog topic on the broker. Budget the volume for:

  • The steady-state size of every store on this task (sum across task assignments).
  • Compaction headroom: at least 1× the largest column family, on top of steady state, so L0→Ln compactions have room to rewrite .sst files.
  • The WAL, which is bounded but non-trivial in burst write workloads.
  • A buffer for the next deploy’s rebalance window, where the pod may transiently hold both its old and gaining-but-not-yet-released task directories.

A rule of thumb: provision 2× expected steady-state state size on fast local SSD. The Riffle tiered KV store (Kafka.Streams.State.KeyValue.Tiered) can move cold keys to object storage, which is the right escape hatch when state size grows past what’s affordable on local SSD - but it doesn’t change the hot-tier sizing on the pod.

Watch the runbook for unbounded state-dir growth.

closeKafkaStreams walks the shutdown path: final commit → close every task (flushing and checkpointing its stores) → write the clean-shutdown marker → leave the group. If the container is SIGKILL-ed before that completes:

  • The open transaction is aborted by the broker (under EOS the next owner reprocesses from the last committed offset, so no data loss: just extra work), and the next start on this volume wipes the local state and restores it all.
  • The pod leaves the group via session timeout instead of a clean Leave, so the rebalance starts later than it could.
  • RocksDB recovers from its WAL on next open: but it’s another full WAL replay cycle, which adds to startup time.

Kubernetes default is terminationGracePeriodSeconds: 30, the same as closeKafkaStreams’s default timeoutMs: not enough to also leave headroom for the process to exit. Set the grace period above the close timeout, plus whatever your slowest commit participant (two-phase sink) needs to prepare and commit.

Pair the grace period with a preStop hook that calls closeKafkaStreams (typically by sending the process a SIGTERM your runtime translates into the close call) and waits for the instance to reach StreamsClosed before exiting.

RocksDB opens many files. Per column family:

  • The current memtable file.
  • The WAL log file.
  • Every live .sst at every level: count is determined by your compaction settings (level0_file_num_compaction_trigger, max_bytes_for_level_base, etc.).
  • Iterators pin additional file descriptors for their lifetime.

A pod owning a few dozen tasks easily reaches several thousand open files. Most container runtimes inherit a generous default nofile soft limit, but explicit Pod-level overrides via securityContext or initContainers are common and have been known to lower it. Verify with ulimit -n inside the running container, not from the manifest.

Set nofile to at least 65536 for any stateful streams pod.

The Kubernetes YAML is outside this library’s scope, but the shape deployments converge to looks like:

ResourceWhy
StatefulSetStable pod hostnames (app-0, app-1, …) and stable PVC binding per ordinal
volumeClaimTemplates on a local-SSD StorageClassMount at stateDir; survives pod restarts
clientId = "<app>-$HOSTNAME"A recognisable member in broker logs and metrics (the volume is what brings a pod’s tasks back)
terminationGracePeriodSeconds above the 30 s close timeoutEnough wall-clock to commit, close, and leave cleanly
resources.limits.memory covers heap + RocksDB + headroomAvoids OOM during compaction spikes
readinessProbe distinguishes StreamsRunning from StreamsRebalancingDon’t take a pod out of rotation during a normal rebalance
livenessProbe triggers only on StreamsError / a hung commitDon’t restart a healthy pod that’s mid-restore

The probes should consult setStateListener output (or equivalent metric) rather than just “did the process start”; a pod can be process-alive but still restoring its stores for minutes after a rebalance, and you don’t want that to count as ready.

8. Which storage strategy for which workload

Section titled “8. Which storage strategy for which workload”
You have…Pick
Stateless topology (map / filter / branch only)Ephemeral disk; nothing to persist
Small state (< 10 GB per task) you can afford to replayEphemeral disk
Bounded state, want fast rolloutsPersistent volume + persistent stores + stable clientId
Large statePersistent volume, and more partitions so each task’s changelog restores in bounded time
You don’t run on Kubernetes; Nomad / bare ECS / Fly volumesSame principles: stable identity + persistent mount + grace period