Kubernetes Operator
The VeltrixDB Kubernetes Operator manages VeltrixCluster custom resources — provisioning the StatefulSet, NVMe storage, and networking, and reconciling scale, upgrade, and recovery events without manual intervention.
VeltrixDB-Kubernetes-Operator, not in the main veltrixdb repository, and is versioned separately. The CRD fields, defaults, status values and reconcile behaviour on this page were taken from that repository and have not been re-verified for server v1.12 — treat api/v1alpha1/veltrixcluster_types.go and controllers/veltrixcluster_controller.go in the version you deploy as the source of truth. Statements about what the server does with the flags the Operator passes are checked against the main repository and are called out below.Overview
The Operator is built with Kubebuilder and watches a single CRD, VeltrixCluster (group veltrix.io/v1alpha1, short name vc). On each reconcile pass it creates or updates the child resources needed to run a cluster: a StorageClass, an NVMe-provisioning DaemonSet, a ServiceAccount, headless and client Service objects, a ConfigMap, a ServiceMonitor (best-effort, if the Prometheus Operator CRDs are installed), and the StatefulSet itself.
Prerequisites
| Requirement | Detail |
|---|---|
| Kubernetes | 1.27+ |
| Go | 1.21+ (only needed if building the operator from source) |
| Nodes | Local NVMe SSDs — see the per-cloud notes below |
| GKE | Node pool created with --local-ssd-interface=NVME |
| AWS | Instance types with local NVMe storage: i3, i3en, i4i, im4gn, is4gen |
| Azure | Lsv2/Lsv3 series with local NVMe storage |
--local-ssd-interface=NVME. Without it, GKE merges all local SSDs into a single RAID-0 device and you lose the parallel I/O benefit that VeltrixDB's per-disk WAL/VLog layout depends on.Installation
From a checkout of the Operator repository, install the CRDs, then deploy the operator's controller manager:
git clone https://github.com/VeltrixDB/VeltrixDB-Kubernetes-Operator.git
cd VeltrixDB-Kubernetes-Operator
# 1. Install the CustomResourceDefinitions
kubectl apply -f config/crd/bases/
# 2. Deploy the operator (controller manager Deployment + RBAC)
kubectl apply -f config/manager/manager.yaml
Before creating any VeltrixCluster, deploy the NVMe mount-keeper DaemonSet so real XFS filesystems are bind-mounted at the PV source paths before kubelet bind-mounts them into the veltrixdb pod. Skipping this step causes ENOSPC on the very first WAL write.
kubectl apply -f config/manager/nvme-mount-keeper.yaml
kubectl rollout status daemonset/nvme-mount-keeper -n veltrixdb
See NVMe Disk Provisioning for why the keeper is required on GKE COS, AWS Bottlerocket, and any OS where kubelet runs inside its own mount namespace.
VeltrixCluster spec reference
The fields below were read from the VeltrixClusterSpec Go type (api/v1alpha1/veltrixcluster_types.go, Operator repo; not re-checked for this release). Defaults marked operator default are filled in by the reconcile loop's applyDefaults step when the field is left unset in the CR.
Core
| Field | Type | Default | Description |
|---|---|---|---|
size | int32 | 3 | Number of VeltrixDB nodes. Each node owns a contiguous shard range. Minimum 1. |
version | string | 1.0.0 | Container image tag. The default is an old release: set it explicitly to the server version you run. |
image | string | ghcr.io/veltrixdb/veltrixdb | Container image repository (combined with version to form the full image reference). |
replicationFactor | int32 | 3 | Replica copies per shard (min 1, max 5). The operator reports a ReplicationFactorMet condition when live replicas fall below this. The server itself has no replication-factor flag (replicated mode uses a fixed RF of 3), so this is a status threshold, not a server setting. |
cacheMaxSizeMB | int32 | 65536 | LIRS cache size in MiB, passed as -cache. Minimum 256. |
storageClassName | string | local-nvme | StorageClass used for NVMe PVCs — must match the class the provisioner creates. |
disksPerNode | int32 | 8 | NVMe disks (and PVCs) per pod. Min 1, max 16. |
diskSize | quantity | 375Gi | Capacity requested per PVC. |
resources | ResourceRequirements | — | Standard Kubernetes compute resource requests/limits for the veltrixdb container. |
Networking
| Field | Default | Description |
|---|---|---|
gossipPort | 7946 | Gossip port. Server fact: gossip is TCP, and peers always dial the derived address (client port + 3, i.e. 9003 with the default serverPort) — a different listen port does not change where peers connect. Leave the server on its derived ports and open client port + 1/2/3/5 between pods. |
serverPort | 9000 | TCP port for the binary/text protocol. |
metricsPort | 2112 | Prometheus metrics, /healthz, /readyz. |
tlsPort | 9443 | TLS listener; only active when tlsSecretName is set. The plain serverPort stays up for internal cluster traffic. |
| (not in the CRD) | No field for inter-node TLS (-cluster-tls-*, -cluster-mtls) or the cluster secret (-cluster-secret-file / VELTRIXDB_CLUSTER_SECRET, v1.11) is documented. Without one of them the server's transfer listener is unauthenticated — supply the secret through pod env if your Operator version cannot. | |
Scheduling
| Field | Default | Description |
|---|---|---|
podAntiAffinityTopologyKey | kubernetes.io/hostname | Topology key for required pod anti-affinity — one VeltrixDB pod per physical host. |
tolerations | — | Applied verbatim to the StatefulSet pod template. |
nodeSelector | — | Constrains pod scheduling to matching nodes. |
imagePullSecrets | — | For private registries. |
Storage engine tuning
| Field | Default | Description |
|---|---|---|
numShards | 1024 | Has no effect on the server: the index always uses its compile-time shard count (8192; StorageConfig.NumShards is vestigial) and the server has no shard flag. Leave it at the default. |
keyValueSeparation | true | Enables WiscKey-style KV separation — only key metadata lives in SSTables; values append to a per-disk VLog. |
vlogGCThreshold | "0.40" (operator default; CRD doc states 0.30) | Decimal string in [0.0, 1.0]. VLog garbage fraction that triggers defragmentation (server flag -gc-threshold, server default 0.30). |
lirRatio | "0.90" | Fraction of the LIRS cache reserved for the high-locality (LIR) resident set. |
autoTune | false | When true, the binary runs hardware detection at startup and derives cache size, shard count, compaction threads, and I/O mode itself; the operator omits -cache and passes -auto-tune instead. |
sysTune | true | Host-level tuning (sysctl, hugepages, CPU governor, IRQ balance, RLIMIT_NOFILE) via hardware.SysTune. Linux only; only meaningful when autoTune is true. Requires CAP_SYS_ADMIN or root. |
checkpointIntervalMs | 0 (disabled) | WAL checkpoint interval. When non-zero, passed as -wal-checkpoint-interval-ms. Keep it at 0: the current server defines no such flag, and Go's flag parser makes it exit at startup with "flag provided but not defined". A clean shutdown always writes a compacted checkpoint (wal.log.ckpt, renamed over wal.log). |
WAL / VLog flush
| Field | Default | Description |
|---|---|---|
walFlushWindowMs | 10 | WAL group-commit flush window (ms). Must equal vlogFlushWindowMs. Overrides the server's own default of 15 ms. Since v1.12 the server's adaptive group commit (-group-commit=adaptive, default) treats the window as an upper bound: a lone writer is synced at once. |
vlogFlushWindowMs | 10 | VLog fdatasync group-commit window (ms). WAL and VLog are submitted concurrently; effective P99 is the max of the two. |
walMaxBatchEntries | 4096 | Caps the WAL group-commit batch size before the flush timer fires early. Min 1, max 65536. |
Security
| Field | Description |
|---|---|
tlsSecretName | Name of a kubernetes.io/tls Secret with tls.crt/tls.key. When set, the binary is started with -tls-addr, -tls-cert, -tls-key. |
tlsCASecretName | Secret containing ca.crt for mTLS client verification; passed as -tls-ca alongside tlsSecretName. |
authConfigSecret | Secret with key auth-config.json containing RBAC user definitions. Passed as -auth-config. Permission bitmask: PermRead=1, PermWrite=2, PermDelete=4, PermAdmin=8. |
Raw NVMe block-device VLog
Set rawVLog to write the VLog directly to /dev/nvmeXnY block devices, skipping the filesystem for the value-bytes path. WAL and segment files still use the per-pod XFS PVCs.
| Field | Default | Description |
|---|---|---|
rawVLog.enabled | false | When true, the operator adds SYS_RAWIO to the container's capability set, mounts /dev as a HostPath volume, passes -raw-vlogs to the binary, sets seccompProfile: Unconfined and an unconfined AppArmor annotation, and runs the container privileged: true as root (required to pass the cgroup device allowlist on GKE COS). |
rawVLog.devices | [] | Explicit block-device list, paired index-by-index with the per-pod NVMe PVCs. When empty, the operator infers /dev/nvme0n1 … /dev/nvme(disksPerNode-1)n1 (or the p2 partition suffix when xfsPartitionPercent > 0) — the GKE local-NVMe convention. |
rawVLog.xfsPartitionPercent | 0 (whole-disk XFS) | Percentage of each NVMe disk reserved as an XFS partition for WAL/segments; the remainder stays a raw partition for VLog values. Min 5, max 50; typical production value 20. |
mkfs.xfs'd by the NVMe provisioner. WAL/segment XFS partitions belong on a separate slice of each disk (set via xfsPartitionPercent) or on the boot disk.Example CustomResource
Adapted from the operator's shipped production sample (config/samples/veltrix_v1alpha1_veltrixcluster.yaml) — 3 nodes, 480 GiB RAM, 8 NVMe disks each, replication factor 3, raw block-device VLog with a 20/80 XFS/raw partition split:
apiVersion: veltrix.io/v1alpha1
kind: VeltrixCluster
metadata:
name: veltrixdb-prod
namespace: veltrixdb
spec:
# Cluster topology
size: 3
version: "1.0.0"
image: "ghcr.io/veltrixdb/veltrixdb"
replicationFactor: 3
# Cache — 256 GiB LIRS cache per node (80% rule for 480 GiB nodes)
cacheMaxSizeMB: 262144
# Storage
storageClassName: local-nvme
disksPerNode: 8
diskSize: "375Gi"
# Resources (n2-highmem-96 class)
resources:
requests:
memory: "480Gi"
cpu: "60"
limits:
memory: "480Gi"
cpu: "60"
# Network ports
serverPort: 9000
metricsPort: 2112
gossipPort: 7946
# Raw NVMe block-device VLog: 20% XFS (keys+WAL), 80% raw (values)
rawVLog:
enabled: true
xfsPartitionPercent: 20
# Scheduling — one pod per physical host
podAntiAffinityTopologyKey: "kubernetes.io/hostname"
nodeSelector:
cloud.google.com/gke-local-nvme-ssd: "true"
tolerations:
- key: workload
value: veltrixdb
effect: NoSchedule
kubectl apply -f veltrixcluster.yaml
kubectl get veltrixcluster -n veltrixdb
Status fields
| Field | Values | Meaning |
|---|---|---|
phase | Provisioning / Running / Scaling / Degraded / Terminating | Overall cluster lifecycle state, computed from ready-pod count each reconcile. |
readyReplicas | integer | Pods currently passing the readiness probe. |
currentVersion | string | Image tag from spec.version at last reconcile. |
partitionMap | string | Summary of the current shard-to-node assignment; updated after a resharding event. |
conditions | Ready, ReplicationFactorMet | Standard Kubernetes conditions. ReplicationFactorMet=False carries the message "DATA LOSS RISK" when ready pods drop below replicationFactor. |
lastScaleTime | timestamp | Set when a resharding event is triggered. |
kubectl describe veltrixcluster my-cluster -n veltrixdb
What the reconcile loop actually does
This section is scoped to what is verifiably implemented in controllers/veltrixcluster_controller.go, not just described in prose elsewhere.
Child resource management
Each reconcile pass creates or updates, in order: the local-nvme StorageClass, an NVMe-provisioner DaemonSet (plus its ServiceAccount/ClusterRole/ClusterRoleBinding), the pod ServiceAccount, headless and client Service objects, a best-effort ServiceMonitor (a reconcile failure here only logs a warning — it does not fail the whole reconcile, since the Prometheus Operator CRDs may not be installed), the ConfigMap of engine env vars, and finally the StatefulSet.
Rolling upgrades — confirmed
The StatefulSet is built with updateStrategy.type: RollingUpdate and rollingUpdate.maxUnavailable: 1. Bumping spec.version (or spec.image) and re-applying the CR causes Kubernetes' native StatefulSet controller to replace one pod at a time, waiting for each to become ready before proceeding — the operator does not implement custom upgrade orchestration beyond setting this strategy.
kubectl patch veltrixcluster my-cluster -n veltrixdb \
--type merge --patch '{"spec":{"version":"1.1.0"}}'
Auto-reshard on replica count changes — confirmed, with caveats
The reconciler compares the StatefulSet's previous replica count to spec.size on every pass. When the cluster has grown (prevSize < spec.size), it calls triggerResharding, which:
- Blocks (up to 5 minutes) until all desired pods report
PodReady. - Annotates every pod with
veltrix.io/cluster-sizeandveltrix.io/prev-cluster-size. Server fact: nothing in the VeltrixDB server reads these annotations. A server builds its membership once at startup from--peers(or--seeds), gossip does not add members, and there is no runtime join command; on v1.14 and earlier--auto-rebalancemigrates keys only when that static membership changes state (a node fails or recovers). Fixed in v1.15 state changes never migrate and raft mode never migrates, so with static membership no keys move. In practice, on v1.16 and earlier a new node joins the ring only when every pod restarts with it listed in--peers. New on main after v1.16 — ships in the next release the server can add and remove members online (/admin/cluster/members,--join,--advertise-addr), but the operator does not use it yet: it still scales by restarting every pod with a new--peers, which stops working once a change was made online (persisted membership wins over--peers). The server repo’s intended pattern: keep--peersat the bootstrap members, add--join=<bootstrap pod addresses>and--advertise-addr=$(POD_FQDN):<port>to every pod, scale up by raising the replica count, and scale down by callingDELETE /admin/cluster/members/<pod>for the highest ordinal before lowering the count. See the membership runbook. - Records
status.lastScaleTimeand astatus.partitionMapsummary string.
Self-healing — partially confirmed
The operator itself does not run a custom pod-replacement loop; it relies on the standard StatefulSet controller to recreate any pod that is deleted or fails its liveness probe, which is the same "self-healing" every StatefulSet gets from Kubernetes. What the operator adds on top:
- It stamps every pod with
cluster-autoscaler.kubernetes.io/safe-to-evict: "false", specifically to stop the Cluster Autoscaler from deleting nodes that carry irreplaceable local NVMe PVs. - Its periodic requeue (every 30 seconds) re-evaluates
readyReplicasand updates theReady/ReplicationFactorMetconditions, surfacing drift for operators/alerting rather than performing autonomous data-rebuild actions itself.
Graceful WAL flush on termination — confirmed
Every pod gets a preStop lifecycle hook (kill -SIGTERM 1 && sleep 55 — note the official image is distroless with no shell, so an exec hook of this form cannot run there; the kubelet's own SIGTERM still triggers the server's clean shutdown) and a 60-second terminationGracePeriodSeconds, giving the engine time to flush its WAL before the container is killed. On VeltrixCluster deletion, the operator's finalizer (veltrix.io/finalizer) annotates pods as draining before removing itself — the actual flush is still performed by the pod's own SIGTERM handling and the grace period, not a separate operator-issued flush command.
Scaling
kubectl patch veltrixcluster my-cluster -n veltrixdb \
--type merge --patch '{"spec":{"size":5}}'
Increasing spec.size triggers the StatefulSet to add pods and, once they are ready, the annotation step described above. Whether the existing pods then learn about the new ones depends on the --peers list your Operator version renders and on a restart; the server does not discover peers at runtime.
Node, seccomp and image notes
- Server fact: a clean SIGTERM shutdown writes a compacted WAL checkpoint; budget the grace period for it plus, on the next start, the search-index rebuild (searches are refused until
INFOshowssearch_ready=1;/readyzdoes not wait for it). - The official image is built with
CGO_ENABLED=1(distrolesscc-debian12base), so the server uses the off-heap native index by default. New in v1.6 It needsvm.max_map_count ≥ 262144on the node at large key counts — a container cannot set it. Configure it in the node pool / node image or a privileged DaemonSet, or setVELTRIXDB_INDEX=mapin the pod env. - The
RuntimeDefaultseccomp profile blocksio_uring_setup. That only matters for the opt-in io_uring VLog write bridge (VELTRIXDB_URING_BRIDGE=on|sqpoll; default off), which then falls back topwrite. TherawVLog.enabledpath above already setsseccompProfile: Unconfined. - The experimental C++ network front-end (
-net=cpp|uring|poll) is refused in-mode raftand-mode replicatedand with-auth-config, so any multi-node or RBAC-enabled cluster must keep the default-net=go.
Troubleshooting
| Symptom | Cause / fix |
|---|---|
Pod crashes with write /mnt/nvmeN/wal.log: no space left on device | The pod started before the mount-keeper bound real XFS at the PV source path, so kubelet bind-mounted a 256 KB tmpfs placeholder. Confirm the keeper DaemonSet is Running, then delete the pod so kubelet re-creates the bind mount. |
Startup log [transfer] WARNING: listener … is unauthenticated | No cluster secret and no cluster mTLS: set VELTRIXDB_CLUSTER_SECRET (same value, ≥ 16 bytes, on every pod). |
Cluster stuck in Provisioning | Check kubectl get pv; on GKE confirm the node pool used --local-ssd-interface=NVME; check operator logs via kubectl logs -n veltrixdb-system deploy/veltrixdb-controller-manager. |
| Resharding not triggering after adding nodes | The operator only watches spec.size changes on reconcile. Force one with kubectl annotate veltrixcluster my-cluster force-reconcile=$(date +%s). |
| WAL flush timeout on pod termination | If pods terminate faster than the 30–60s grace window, raise terminationGracePeriodSeconds in the StatefulSet spec. |
| Cluster Autoscaler deletes database nodes (local NVMe data lost permanently) | The operator adds cluster-autoscaler.kubernetes.io/safe-to-evict: "false" automatically; if pods were created by an older operator version, annotate them manually. |