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.
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
Install the CRDs, then deploy the operator's controller manager:
# 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 are read directly from the VeltrixClusterSpec Go type (api/v1alpha1/veltrixcluster_types.go). 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. |
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. |
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 | UDP port for the gossip failure detector. |
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. |
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 | Index shard count. Must be a power of 2 and match the compiled C++ kNumShards constant. Changing on a live cluster requires a full data migration. |
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. |
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; checkpoint always runs on clean shutdown regardless. |
WAL / VLog flush
| Field | Default | Description |
|---|---|---|
walFlushWindowMs | 10 | WAL group-commit flush window (ms). Must equal vlogFlushWindowMs. |
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. The VeltrixDB binary is expected to watch these annotations via the downward API and recompute its consistent-hash partition map / trigger the actual shard rebalance — the operator's role is limited to triggering the reshard, not performing the data movement itself. - 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) 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 reshard-trigger flow described above.
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. |
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. |