NATS JetStream
NATS JetStream is the messaging/streaming layer — a 3-node Raft cluster for pub/sub and persistent streams. Runs across a mixed-architecture cluster here (2× amd64 + 1× arm64), which is why a custom multi-platform image is required. Independent of the other services in this set — no dependency on Postgres, SeaweedFS, etc.
Overview
- 3 replicas — odd number required for Raft quorum (2 of 3 must agree)
- Custom image: base
nats:2.14.3-alpinedoesn't include thenatsCLI, so a custom image bakes it in — built multi-arch since the cluster mixes amd64 and arm64 nodes - Storage:
local-path, not Longhorn — JetStream already replicates stream data itself via Raft - Server clustering does not automatically replicate stream data —
each stream needs
replicasset explicitly at creation, or it lives on just 1 node despite the cluster being healthy (see "Creating streams" below — this is the step most likely to be missed)
Architecture
Client
↓
nats-cluster-client Service (ClusterIP, load-balances across all 3 pods)
↓
nats-cluster-0 / nats-cluster-1 / nats-cluster-2 (StatefulSet, 1 per node)
↑ peer discovery via headless Service (publishNotReadyAddresses: true)
↓
local-path-retain-expand StorageClass → 5Gi PVC per node (jetstream-data)
The headless Service (nats-cluster) is only for peer-to-peer cluster
routing between the 3 pods themselves — clients connect via the separate
nats-cluster-client Service instead.
Key decisions
Storage: local-path, not Longhorn
JetStream already replicates stream data across all 3 nodes at the application layer via Raft. Stacking Longhorn's volume-level replication on top would mean 3× (NATS) × 3× (Longhorn) = 9 copies of the same data for no real extra safety — same reasoning as why Postgres/CNPG in this doc set also skips Longhorn. If a node's disk is lost, JetStream just re-syncs that node's data from the other 2 healthy members.
local-path-retain-expand StorageClass: reclaimPolicy: Retain (a deleted
PVC orphans the volume instead of wiping it — cheap insurance against an
accidental kubectl delete) and allowVolumeExpansion: true (accepted by
the API, but rancher.io/local-path doesn't actually implement true online
resize under the hood — practically, growing storage later means delete PVC
→ let it recreate → let JetStream re-sync from peers, not a live resize).
Custom multi-arch image
The base image only ships the nats-server binary, not the natscli tool
needed for kubectl exec ... -- nats ... commands. The Dockerfile adds it
at build time using buildx's auto-populated TARGETARCH build arg, so the
same Dockerfile produces a correct binary for both amd64 and arm64:
docker buildx create --use # one-time
docker buildx build --platform linux/amd64,linux/arm64 \
-t registry.nexus.castis.io/nats-with-cli:2.14.3 \
--push .
If your cluster is single-architecture, you can drop
arm64from--platform— but if there's any chance of a mixed cluster later, building both now costs nothing extra and avoids re-doing this later.
Prerequisites
- 3+ node cluster (mixed-architecture is fine — see the custom image above)
nexus-registry-secretpull secret already provisioned in themessagingnamespacedocker buildxavailable wherever the custom image gets built
Files to apply
| File | Purpose |
|---|---|
00-storageclass.yaml |
local-path-retain-expand StorageClass |
01-configmap.yaml |
nats.conf — cluster + JetStream config |
02-services.yaml |
Headless Service (peer discovery) + client-facing Service (load-balanced) |
03-statefulset.yaml |
The 3-pod StatefulSet |
⚠️ Must configure before applying
| What | Where | Why it matters |
|---|---|---|
| Image tag | 03-statefulset.yaml → image: |
Pin the exact built version (e.g. 2.14.3) — not the floating alpine tag, which can silently upgrade on the next pod restart |
| Cluster routes list | 01-configmap.yaml → nats.conf → cluster.routes |
Must list exactly nats-cluster-0, -1, -2 (matching replicas: 3 and the messaging namespace) — a mismatch here means nodes can't find each other |
| Storage size | 03-statefulset.yaml → volumeClaimTemplates |
5Gi here is a starting point sized for staging-level traffic — revisit once real production retention/throughput numbers are known |
| Registry/image path | 03-statefulset.yaml → image: |
Must point at wherever the custom multi-arch image was actually pushed |
Do not change these — required exactly as documented
| Setting | Why it can't be adjusted casually |
|---|---|
publishNotReadyAddresses: true on the headless Service |
Without it: pods discover peers via DNS, but a headless Service only publishes DNS records for pods already Ready — and pods can't become Ready without first reaching their peers. This breaks that circular deadlock |
imagePullPolicy: Always |
IfNotPresent (the default) only checks by tag name, not digest — on a mixed-arch cluster this can silently keep reusing a stale single-arch image layer on a node after a multi-arch rebuild, causing exec format error |
replicas: 3 (odd number) |
Raft quorum math — 2 of 3 must agree. Even counts don't add fault tolerance, same logic as etcd elsewhere in this doc set |
podManagementPolicy: Parallel |
All 3 pods need to start simultaneously and see each other to form the Raft cluster — the StatefulSet default (sequential) would stall waiting on a pod that itself is waiting on peers |
topologySpreadConstraints with whenUnsatisfiable: DoNotSchedule |
Actually spreads pods across nodes — without this, a single node failure could take out enough replicas to lose quorum even with replicas: 3 |
pid_file: "/tmp/nats.pid" in nats.conf (and the matching preStop path) |
The default pidfile directory doesn't exist in this container; the preStop hook's ldm= path must point at the same file for graceful shutdown to work |
Deploy
# 1. Build and push the custom multi-arch image (see above) — one time per version
# 2. Apply manifests
kubectl apply -f 00-storageclass.yaml
kubectl apply -f 01-configmap.yaml
kubectl apply -f 02-services.yaml
kubectl apply -f 03-statefulset.yaml
# 3. Watch it come up
kubectl get pods -n messaging -w
Verify:
kubectl exec -it nats-cluster-0 -n messaging -- wget -qO- http://localhost:8222/healthz
# {"status":"ok"}
kubectl exec -it nats-cluster-0 -n messaging -- wget -qO- http://localhost:8222/jsz
# "meta_cluster": { "cluster_size": 3, "leader": "nats-cluster-1", "pending": 0 }
All 3 pods should show 1/1 Running, no restarts, and a leader elected.
⚠️ Critical next step — creating streams with explicit replication
Server clustering alone does not replicate stream data. The cluster
being healthy above only means the 3 servers can see each other — an
individual stream still needs replicas set explicitly when it's created,
or that stream's data lives on just 1 node.
This is a client-side action (any code connecting via a NATS SDK), done once per stream, typically in an app's bootstrap step — not a server config setting and not a per-message setting.
CLI example:
kubectl exec -it nats-cluster-0 -n messaging -- \
nats stream add ORDERS --subjects="orders.*" --replicas=3 --storage=file \
--server=localhost:4222
Verify:
kubectl exec -it nats-cluster-0 -n messaging -- nats stream info ORDERS --server=localhost:4222
# Look for "Cluster Information" showing a leader + 2 replicas
Whatever application code eventually calls AddStream() (Go/Node/Python
client) needs num_replicas: 3 set there too — this CLI step just proves
the pattern works.
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
CrashLoopBackOff: Could not write pidfile: /var/run/nats/nats.pid |
Config points at a directory that doesn't exist in the container | Set pid_file to /tmp/nats.pid in nats.conf, and match the preStop hook's ldm= path |
ImagePullBackOff: pull access denied / no basic auth credentials |
nexus-registry-secret not visible to the pod |
Confirm the secret exists in the messaging namespace specifically |
ImagePullBackOff: no match for platform in manifest |
Image was built single-platform (amd64-only) but the cluster has an arm64 node | Rebuild with docker buildx build --platform linux/amd64,linux/arm64 --push |
All 3 pods Running but 0/1 (never Ready), looping "JetStream is still recovering meta layer" |
Headless Service only publishes DNS for Ready pods, but pods can't become Ready without reaching peers first — circular deadlock |
Add publishNotReadyAddresses: true to the headless Service |
One pod: exec /usr/local/bin/nats: exec format error |
That node has a stale cached single-arch image from before a multi-arch rebuild — imagePullPolicy: IfNotPresent checks by tag, not digest |
Set imagePullPolicy: Always to force a real digest check on every pull |
Outstanding follow-ups worth tracking
- Wire
num_replicas: 3into actual application code once the app language/framework callingAddStream()is confirmed - Back-port these same fixes (pidfile path,
publishNotReadyAddresses,imagePullPolicy: Always, custom image) to staging, which may still be running the earlier unpatched manifests - Fix the duplicate
(default)StorageClass annotation if bothlocal-pathandlonghorn-retainare marked default at the same time — undefined behavior ifstorageClassNameis ever omitted somewhere - Revisit the 5Gi storage size and CPU/memory requests/limits once real production traffic patterns are known