Milvus on OpenEverest - A Vector On Your Platform

Milvus on OpenEverest - A Vector On Your Platform

• By Sergey Pronin Sergey Pronin

Sooner or later someone asks the platform team for a vector database. It starts with one RAG prototype, then a second team wants semantic search, and soon there are three different setups nobody wants to own.

Milvus is one of the most popular open-source answers. It is also a lot of moving parts: five kinds of Milvus processes, plus etcd for metadata, object storage for data, and a write-ahead log. That is great for scaling and not so great for the person on call.

As of provider-milvus 0.2.0, Milvus is available on OpenEverest. You get the same Instance API and UI you use for PostgreSQL, MongoDB or Valkey, and the provider takes care of the rest. I wanted to see if it holds up for real platform work, so I did what a team would do with it:

  1. Created a production-shaped Milvus cluster.
  2. Indexed the entire English Kubernetes documentation for semantic and full-text search.
  3. Upgraded Milvus while a client kept searching, and tried to downgrade it.
  4. Took scheduled backups to S3 and cloned production into a staging instance from one of them.

Everything below ran on a real cluster. The numbers are what I measured, including the parts that didn’t go well.

How Milvus fits into OpenEverest

OpenEverest v2 is built around providers. The core knows nothing about Milvus. It stores an Instance, and the provider turns it into whatever the database needs. provider-milvus doesn’t reimplement Milvus lifecycle management either. It drives the Milvus Operator, which the Milvus maintainers ship, and bundles it in the same Helm chart.

The provider watches Instance resources with spec.providerRef.name: milvus and reconciles them into a Milvus custom resource. The operator does the rest: it starts the Milvus processes, installs etcd and MinIO as bundled dependencies, and rolls upgrades. The provider adds what OpenEverest users expect from any database: generated credentials, a connection Secret, a downgrade guard, and backups.

Backups are the one place where the provider runs something itself. The Milvus Operator has no backup feature, so the provider runs milvus-backup, the Milvus project’s own tool, as Kubernetes Jobs. It doesn’t copy any data on its own.

Pick a topology

Milvus comes in two shapes, and the provider supports both. Click through to see what each one runs.

standalone etcd MinIO

All of Milvus in one process, plus etcd for metadata and MinIO for data. 3 pods and 2 volumes. My staging copy below ran this way. Good for development, staging and small datasets.

proxy × 2 mixCoord queryNode × 2 dataNode streamingNode etcd × 3 MinIO

Every Milvus role is its own Deployment and scales on its own: proxies take client traffic, query nodes serve searches, data nodes flush and compact, streaming nodes own the write-ahead log, and mixCoord coordinates. The WAL defaults to Woodpecker, which keeps it in object storage, so you don't need Pulsar or Kafka. Pulsar is still an option. This is the shape I used for "production": 11 pods and 4 volumes.

You can also point either topology at your own etcd and S3 bucket, including IAM-based access such as EKS IRSA. I used the bundled ones here.

The setup

  • Kubernetes 1.36 on Linode (LKE), 3 nodes with 8 vCPU and 16 GB each. The cluster also ran Valkey and PostgreSQL instances for other tests, so Milvus didn’t have it to itself.
  • OpenEverest v2.0.0-dev.4
  • provider-milvus 0.2.0, which bundles the Milvus Operator 1.3.9
  • Milvus 2.6.11 at first, upgraded to 2.6.15 later
  • An S3 bucket on a separate MinIO in the cluster, used as the backup target

Install the provider

The provider chart bundles the Milvus Operator, so one helm install is enough:

helm install provider-milvus \
  oci://ghcr.io/openeverest/charts/provider-milvus \
  --version 0.2.0 \
  --namespace everest-system
kubectl get providers milvus

Create a Milvus cluster

In the UI, Milvus shows up as one more engine. The form is generated from the provider’s own schema, so it only offers what the provider supports: topology and version first, then a section per Milvus role (replicas, CPU, RAM), then object storage, the metadata store and the message stream. Exposure, monitoring and pod scheduling sit under advanced configuration.

The UI creates an Instance. This is the one I used, with two proxies and two query nodes so a single pod restart doesn’t take searches down:

apiVersion: core.openeverest.io/v1alpha1
kind: Instance
metadata:
  name: docs-search
  namespace: milvus-demo
spec:
  providerRef:
    name: milvus
  version: "2.6.11"
  topology:
    type: cluster
  components:
    proxy:
      type: milvus
      replicas: 2
      resources:
        requests: {cpu: 250m, memory: 512Mi}
        limits: {cpu: "1", memory: 1Gi}
    mixCoord:
      type: milvus
      replicas: 1
    dataNode:
      type: milvus
      replicas: 1
    queryNode:
      type: milvus
      replicas: 2
      resources:
        requests: {cpu: 500m, memory: 1Gi}
        limits: {cpu: "2", memory: 2Gi}
    streamingNode:
      type: milvus
      replicas: 1
  backup:
    enabled: true
    classRef:
      name: milvus-backup
    storages:
      - storageRef:
          name: s3-backups

I trimmed the resources of the other roles; examples/instance-cluster.yaml in the provider repo has a complete cluster manifest.

It was Ready 2 minutes 42 seconds after kubectl apply, with 11 pods spread across the 3 nodes. Most of that time was etcd and MinIO starting and getting their volumes. Here is what one Instance turned into:

ResourceWhat docs-search got
Milvus pods7: 2 proxies, 2 query nodes, 1 each of mixCoord, dataNode, streamingNode
Dependencies3 etcd pods, 1 MinIO pod, 4 volumes of 10 GiB
Requests / limits2.65 CPU and 6 GiB requested, 9 CPU and 10 GiB limits
Servicedocs-search-milvus, gRPC on 19530
Secretsdocs-search-conn (connection details), docs-search-credentials (root password), docs-search-minio (generated MinIO keys)

Authentication is on from the start, with a random root password. Applications read everything from the connection Secret: host, port, username, password, a ready-made uri, and a token in the root:<password> form pymilvus expects. The password never appears in the Milvus custom resource or in the config Milvus renders. To change it, you edit the credentials Secret and the provider rotates it in Milvus.

OpenEverest overview page of the docs-search Milvus instance: Ready, cluster topology, Milvus 2.6.15, connection details, a card per Milvus role with replicas, CPU and RAM, and the latest backups and schedules

Put something real in it: semantic search over the Kubernetes docs

A vector database without vectors proves little. Platform engineers have a corpus everybody knows: the Kubernetes documentation. So I indexed all of it.

  • Data: every page under content/en/docs of kubernetes/website at commit 703e31c: 1,721 pages, split by ##/### headings into 15,582 chunks, 14 MiB of text.
  • Embeddings: BAAI/bge-small-en-v1.5 (384 dimensions) through fastembed, on CPU, in a Kubernetes Job. No GPU anywhere.
  • Search: an HNSW index on the embeddings, plus Milvus 2.6’s built-in BM25 full-text search on the same text. Milvus computes the BM25 sparse vectors itself, so the client only sends the text.

This is the collection schema:

schema = client.create_schema(auto_id=True)
schema.add_field("id", DataType.INT64, is_primary=True)
schema.add_field("title", DataType.VARCHAR, max_length=512)
schema.add_field("url", DataType.VARCHAR, max_length=512)
schema.add_field("text", DataType.VARCHAR, max_length=8192, enable_analyzer=True)
schema.add_field("dense", DataType.FLOAT_VECTOR, dim=384)
schema.add_field("sparse", DataType.SPARSE_FLOAT_VECTOR)
schema.add_function(Function(name="bm25", function_type=FunctionType.BM25,
                             input_field_names=["text"], output_field_names=["sparse"]))

index = client.prepare_index_params()
index.add_index(field_name="dense", index_type="HNSW", metric_type="COSINE",
                params={"M": 16, "efConstruction": 200})
index.add_index(field_name="sparse", index_type="SPARSE_INVERTED_INDEX", metric_type="BM25")

The client connects with the values from docs-search-conn, mounted as environment variables. Loading took 30 minutes, and Milvus was the fast part:

StepTime
Embedding 15,582 chunks on 4 CPU cores29 min 22 s
Inserting them into Milvus, 64 rows per batch35 s
Flush and load into the query nodes1.7 s

Then I asked it questions. Each one ran three ways: dense (vectors only), BM25 (keywords only) and hybrid (both, merged with reciprocal rank fusion in one hybrid_search call). Top 3 hits:

"Limit how many pods of a deployment can be down during a voluntary disruption"
  1. denseDisruptions / Pod disruption budgets · Disruptions / Dealing with disruptions · Deployments / Updating a Deployment
  2. bm25Specifying a Disruption Budget / Think about how your application reacts · Disruptions / Pod disruption budgets · Disruptions / Voluntary and involuntary disruptions
  3. hybridDisruptions / Pod disruption budgets · Specifying a Disruption Budget / Think about how your application reacts · Disruptions / Dealing with disruptions

The question never says "PodDisruptionBudget", and every mode still finds it. This is the case semantic search is for.

"How do I drain a node before maintenance without breaking my app?"
  1. denseUpgrading kubeadm clusters / Drain the node · Upgrading Linux nodes / Drain the node · Upgrading Windows nodes / Drain the node
  2. bm25Running ZooKeeper / Surviving maintenance · kubectl Quick Reference / Interacting with Nodes · Node lifecycle conditions
  3. hybridUpgrading kubeadm clusters / Drain the node · Running ZooKeeper / Surviving maintenance · kubectl Quick Reference / Interacting with Nodes

The honest miss. The page you want, "Safely Drain a Node", isn't in anyone's top 3. The dense model latches onto "drain the node" sections in upgrade guides, and BM25 onto the word "maintenance". Search for "PodDisruptionBudget" and dense search puts that page second. This is about chunking and the model, not the database, but it's the work your application teams will do on top of what you give them.

"Why is my pod stuck in CrashLoopBackOff?"
  1. densePod Lifecycle / How Pods handle problems with containers · Specifying a Disruption Budget / Unhealthy Pod Eviction Policy · Pod Lifecycle / Pod phase
  2. bm25Debug Pods / Debugging Pods · Jobs / Job tracking with finalizers · Windows debugging tips / Node-level troubleshooting
  3. hybridPod Lifecycle / How Pods handle problems with containers · Debug Pods / Debugging Pods · Jobs / Job tracking with finalizers

Dense gets the explanation, BM25 gets the debugging guide. Hybrid puts both in the top 2, which is why it's worth having both indexes in one collection.

"Run a pod only on nodes with a GPU"
  1. denseSchedule GPUs / Using device plugins · Schedule GPUs / Manage clusters with different types of GPUs · Running Pods on Only Some Nodes
  2. bm25Schedule GPUs / Using device plugins · Schedule GPUs / Automatic node labelling · Schedule GPUs / Automatic node labelling
  3. hybridSchedule GPUs / Using device plugins · Schedule GPUs / Manage clusters with different types of GPUs · Schedule GPUs / Automatic node labelling

Day 2, with traffic on

Creating a database is the easy part. I wanted to see what happens to the applications when the platform team does its job: upgrade, back up, clone.

So before touching anything, I started a probe: a Job with 4 threads running hybrid searches back to back, recording every result and its latency. On the untouched cluster it did about 900 searches per second, p50 4 ms, p99 10 ms. It ran for 20 minutes, through everything below.

Upgrading Milvus 2.6.11 → 2.6.15

In the UI you change the version on the instance. With kubectl it’s one field:

kubectl -n milvus-demo patch instance docs-search --type merge \
  -p '{"spec":{"version":"2.6.15"}}'

The provider sees that every component uses the same image and switches the operator to a rolling upgrade. The operator then upgrades one role at a time, in the order Milvus requires. Click through the timeline:

mixCoordstreamingNodequeryNode × 2dataNodeproxy × 2

Probe: ~900 searches/s, 0 errors

The version changes on the Instance, and the instance goes to Updating. The coordinator goes first: a new mixCoord pod starts on 2.6.15, takes over, and the old one goes away.

mixCoordstreamingNodequeryNode × 2dataNodeproxy × 2

Probe: ~880 searches/s, 0 errors

The streaming node, which owns the write-ahead log, is next. There is only one, so for a few seconds there were two, then one again.

mixCoordstreamingNodequeryNode × 2dataNodeproxy × 2

Probe: ~830 searches/s for 2 minutes, 0 errors

Query nodes are upgraded blue/green: the operator starts a second query node group on 2.6.15, moves the loaded segments over, then scales the old group to zero. This is the only part the probe noticed. Throughput dipped by about 8% while the new group loaded the collection, and the slowest second still had 768 searches.

mixCoordstreamingNodequeryNode × 2dataNodeproxy × 2

Probe: ~950 searches/s, 0 errors

The data node is upgraded. Searches don't touch it, so nothing to see.

mixCoordstreamingNodequeryNode × 2dataNodeproxy × 2

Probe: ~940 searches/s, 0 errors

Proxies go last, one at a time. The client was connected through the Service the whole time and didn't see a single failed call.

mixCoordstreamingNodequeryNode × 2dataNodeproxy × 2

Probe: ~930 searches/s, 0 errors

The last pod was running 2.6.15 about 6 minutes after the change. The Instance only said Ready at 8 minutes 38 seconds, when the operator reported the upgrade finished. Until then it stayed Updating and the connection details stayed published.

Hybrid searches per second and p99 latency over 20 minutes: about 900 searches per second throughout, a dip to about 830 during the query node handover, p99 latency stepping from about 10 ms to about 15 ms after the upgrade, and zero failed searches

Zero failed searches out of 468,420 during the upgrade, and 1,092,480 over the whole 20 minutes. The slowest single search took 46 ms.

One thing I can’t explain yet: about a minute after the proxies finished, p99 went from ~10 ms to ~15 ms and stayed there, while p50 stayed at 4 ms and throughput went up a bit. It could be a change in 2.6.15 or how segments landed on the new query nodes. I didn’t dig into it for this post. The two dips around 16 and 18 minutes are mine: two short Jobs loading the embedding model on a shared node.

To see what happened inside, you don’t need kubectl. The Inspector plugin shows every pod behind the instance, which node it runs on and how often it restarted, and streams its logs. Here, after the upgrade: the Milvus pods spread across the nodes with 0 restarts, and the coordinator’s log assigning the WAL channels to the streaming node.

OpenEverest Inspector plugin for docs-search: a diagram of the dataNode, mixCoord, two proxy and two queryNode pods, all running with 0 restarts, with their nodes, and the mixCoord container log below

Trying to go back

Downgrades are where vector databases bite you: the newer version may already have written metadata the older one doesn’t understand. So the provider refuses them:

kubectl -n milvus-demo patch instance docs-search --type merge \
  -p '{"spec":{"version":"2.6.11"}}'

Milvus didn’t change: every pod stayed on 2.6.15, and the probe didn’t notice. The provider log says exactly why:

component "dataNode" cannot be downgraded from 2.6.15 to 2.6.11; choose 2.6.15 or newer

The rough part is where you see it. The Instance went to Failed, but on OpenEverest v2.0.0-dev.4 the reason isn’t copied to the Instance status. “Failed” with no message is scary when Milvus itself is fine. Setting the version back to 2.6.15 brought the Instance back to Ready within seconds.

Backups to S3

OpenEverest describes backup targets with a BackupStorage. The provider reads it and hands its bucket and credentials to milvus-backup. Credentials are passed as Secret references, not written into any config:

apiVersion: backup.openeverest.io/v1alpha1
kind: BackupStorage
metadata:
  name: s3-backups
  namespace: milvus-demo
spec:
  type: s3
  s3:
    bucket: milvus-backups
    region: us-east-1
    endpointURL: http://backup-minio.backup-storage.svc:9000
    credentialsSecretRef:
      name: s3-backups
---
apiVersion: backup.openeverest.io/v1alpha1
kind: Backup
metadata:
  name: docs-search-before-reembed
  namespace: milvus-demo
spec:
  origin:
    type: Instance
    instanceRef:
      name: docs-search
  classRef:
    name: milvus-backup
  storageRef:
    name: s3-backups

The backup was Succeeded in 5 seconds and wrote 24 MB under everest/milvus/milvus-demo/docs-search/ in the bucket. The probe didn’t notice it.

Schedules live on the instance. Each one becomes a CronJob, and every run shows up as a regular Backup that you can restore from or delete:

spec:
  backup:
    storages:
      - storageRef:
          name: s3-backups
        schedules:
          - name: nightly
            enabled: true
            cron: "0 2 * * *"          # UTC
            retention: {type: count, count: 7}
          - name: every-15m
            enabled: true
            cron: "*/15 * * * *"
            retention: {type: time, duration: 1d}

The first every-15m run started at 12:15:00 and was done at 12:15:05. Retention deletes the old runs, and their data in the bucket with them.

Backups tab of docs-search: the on-demand backup docs-search-before-reembed and three every-15m runs, all Succeeded, on the s3-backups storage with the milvus-backup class, and 2 active schedules

The UI shows times in my local timezone (UTC+3), so 12:05 UTC is 15:05 here. The Size column is still empty; the provider doesn’t report backup sizes yet.

Cloning production into staging

This is the feature I’d show a team first. Staging that has the same data as production, created by the platform and not by a script someone wrote two years ago. A new Instance with a dataSource starts empty, restores the backup, and only then reports Ready. It doesn’t have to match production’s topology: staging here is a single standalone pod.

apiVersion: core.openeverest.io/v1alpha1
kind: Instance
metadata:
  name: docs-search-staging
  namespace: milvus-demo
spec:
  providerRef:
    name: milvus
  topology:
    type: standalone
  components:
    standalone:
      type: milvus
      replicas: 1
  backup:
    enabled: true
    classRef:
      name: milvus-backup
    storages:
      - storageRef:
          name: s3-backups
  dataSource:
    type: Backup
    backup:
      backupRef:
        name: docs-search-before-reembed
TimeStaging
12:06:16Instance created, phase Restoring
12:08:23Milvus is up, the restore Job starts
12:08:54Restore done: 15,582 rows, HNSW and BM25 indexes rebuilt
12:08:59Ready, connection Secret published

2 minutes 43 seconds from nothing to a searchable copy. The restore itself took 31 seconds; the rest was Milvus starting. While seeding, the instance reports Restoring and doesn’t publish its connection details, so nothing connects to a half-restored database.

Is it really the same? I ran the six questions against both instances and compared the top 5 results by primary key. They matched exactly, and so did the row count.

What’s not there yet

Core Errors don't reach the Instance status

On OpenEverest v2.0.0-dev.4, when the provider rejects a change, like the downgrade above, the Instance says Failed with an empty message. The reason is only in the provider log. Core is adding a Ready condition with the reason ValidationFailed (openeverest#3351); the part that fills it in is still in progress.

Limit Restores need Milvus 2.6.12 or newer

Older 2.6 releases corrupt data restored through bulk import (milvus-io/milvus#48114), so the provider refuses restores into them. Backups work on every version. 2.6.15 is the default in 0.2.0.

Limit No point-in-time recovery, no global snapshot

milvus-backup takes full backups, collection by collection. Writes during a backup aren't paused, so each collection is consistent with itself but not with the others. For most vector workloads that's fine. If your collections must line up exactly, pause writes around the backup.

Limit S3-compatible backup storage only

AWS S3, MinIO, and other S3-compatible stores. GCS through its S3 interoperability should work, but I haven't tested it. Native GCS and Azure Blob need new BackupStorage types in OpenEverest core.

Planned No UI for the data

OpenEverest manages the database, not its collections. There's no way to browse collections or run a query from the OpenEverest UI. Bundling Attu, Milvus's web UI, is on the list (#52).

Planned Bundled dependencies are for getting started

The bundled MinIO runs as a single pod by default, and pod scheduling rules apply to Milvus pods only, not to etcd or MinIO. For production data, point the instance at your own S3 bucket. The provider supports that, including IAM-based access, and external etcd.

In 0.2.0, but not in this post

  • GPU builds. A 2.6.15-gpu version bundle runs every role on the GPU image.
  • Node pools per role. Run one role, for example query nodes, as several groups with their own node selectors, such as one group on L40S nodes and one on H200.
  • Prometheus. One toggle creates a PodMonitor for every Milvus component.
  • External access. LoadBalancer or NodePort on the client-facing component, with the external address in the connection Secret.
  • Password rotation. Change the password in the credentials Secret and the provider applies it in Milvus.

Practical advice

If you’re about to offer Milvus to your teams:

  • Start teams on standalone, move to cluster when they need it. Standalone is 3 pods; the cluster above is 11.
  • Run at least two proxies and two query nodes in production. That’s the shape that kept the probe at zero errors through the upgrade. mixCoord and the streaming node ran one replica each here and were replaced without errors, but I didn’t kill them by force.
  • Plan for the embedding pipeline, not the database. Loading 15,582 chunks took 35 seconds in Milvus and 29 minutes in a CPU embedding model. Your teams’ bottleneck is GPUs for embeddings, not vector storage.
  • Turn on scheduled backups from day one, to real object storage, not the bundled MinIO.
  • Give teams staging copies from backups instead of letting them load production data by hand. It took under 3 minutes here.

Try It Yourself

If you try this and see something different, please open an issue in provider-milvus.

Try OpenEverest Join Slack Star the Provider

See Also