← Back to DevBytes

Designing a Search Engine on Kubernetes

Understanding the Search Engine on Kubernetes Paradigm

Designing a search engine on Kubernetes means architecting a distributed, scalable, and resilient full-text search system that runs natively on a Kubernetes cluster. Rather than installing search software on static VMs, you package indexing, querying, and supporting data pipelines as containerized microservices orchestrated by Kubernetes. This approach treats your search infrastructure as a set of declarative, self-healing workloads that can scale horizontally with your data volume and query load.

The core idea is deceptively simple: take a search engine like Elasticsearch, Apache Solr, Meilisearch, or even a custom inverted-index service, wrap it in containers, and let Kubernetes handle scheduling, service discovery, rolling updates, and failure recovery. But in practice, designing a production-grade search engine on Kubernetes demands careful consideration of stateful storage, network topology, resource allocation, monitoring, and the entire data ingestion lifecycle.

What It Is: The Anatomy of a Kubernetes-Native Search Engine

A search engine on Kubernetes typically consists of several distinct components, each deployed as a separate Deployment, StatefulSet, or DaemonSet:

Here is a minimal representation of a search engine StatefulSet for an Elasticsearch data node:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: search-index-nodes
  namespace: search-engine
spec:
  serviceName: "index-discovery"
  replicas: 3
  podManagementPolicy: Parallel
  selector:
    matchLabels:
      app: search-engine
      role: index-node
  template:
    metadata:
      labels:
        app: search-engine
        role: index-node
    spec:
      containers:
      - name: elasticsearch
        image: docker.elastic.co/elasticsearch/elasticsearch:8.11.0
        env:
        - name: node.name
          valueFrom:
            fieldRef:
              fieldPath: metadata.name
        - name: cluster.name
          value: "k8s-search-cluster"
        - name: discovery.seed_hosts
          value: "index-discovery.search-engine.svc.cluster.local"
        - name: ES_JAVA_OPTS
          value: "-Xms2g -Xmx2g"
        ports:
        - containerPort: 9200
          name: rest
        - containerPort: 9300
          name: inter-node
        volumeMounts:
        - name: data
          mountPath: /usr/share/elasticsearch/data
        resources:
          requests:
            cpu: "2"
            memory: "4Gi"
          limits:
            cpu: "4"
            memory: "8Gi"
        readinessProbe:
          httpGet:
            path: /_cluster/health?local=true
            port: 9200
          initialDelaySeconds: 30
          periodSeconds: 10
  volumeClaimTemplates:
  - metadata:
      name: data
    spec:
      accessModes: [ "ReadWriteOnce" ]
      storageClassName: "premium-ssd"
      resources:
        requests:
          storage: 100Gi

Notice the use of volumeClaimTemplates — this automatically creates a PersistentVolumeClaim for each pod replica, ensuring that shard data survives pod rescheduling. The podManagementPolicy: Parallel field tells Kubernetes to start all pods simultaneously rather than sequentially, which speeds up cluster formation. The headless service index-discovery enables peer discovery via DNS.

The Query Layer Deployment

A separate Deployment handles search queries. These pods are stateless and can scale independently of the index nodes. They hold no persistent data and can be rolled out aggressively:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: search-query-api
  namespace: search-engine
spec:
  replicas: 5
  selector:
    matchLabels:
      app: search-engine
      role: query-api
  template:
    metadata:
      labels:
        app: search-engine
        role: query-api
    spec:
      containers:
      - name: query-service
        image: myregistry/search-query-api:1.2.0
        ports:
        - containerPort: 8080
          name: http
        env:
        - name: INDEX_SERVICE_ENDPOINT
          value: "http://index-discovery.search-engine.svc.cluster.local:9200"
        - name: CACHE_REDIS_URL
          valueFrom:
            secretKeyRef:
              name: redis-secrets
              key: connection-string
        resources:
          requests:
            cpu: "500m"
            memory: "512Mi"
          limits:
            cpu: "2"
            memory: "2Gi"
        livenessProbe:
          httpGet:
            path: /healthz
            port: 8080
          periodSeconds: 15
---
apiVersion: v1
kind: Service
metadata:
  name: query-api-svc
  namespace: search-engine
spec:
  selector:
    app: search-engine
    role: query-api
  ports:
  - port: 80
    targetPort: 8080
  type: ClusterIP

Data Ingestion Pipeline as a CronJob

For batch indexing — say, reindexing product catalogs nightly — a Kubernetes CronJob fits perfectly. It spins up a pod, runs the indexing logic, and cleans up on completion:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: nightly-reindex
  namespace: search-engine
spec:
  schedule: "0 2 * * *"  # 2 AM daily
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
          - name: reindex-worker
            image: myregistry/reindex-batch:latest
            env:
            - name: DATABASE_HOST
              valueFrom:
                secretKeyRef:
                  name: db-secrets
                  key: host
            - name: INDEX_ENDPOINT
              value: "http://index-discovery.search-engine.svc.cluster.local:9200"
            - name: BATCH_SIZE
              value: "500"
            resources:
              requests:
                cpu: "1"
                memory: "1Gi"
              limits:
                cpu: "2"
                memory: "4Gi"

For real-time streaming ingestion, you would instead deploy a Kafka consumer group as a Deployment, scaling it based on partition count. That deployment continuously pulls messages, transforms them, and issues bulk indexing requests to the index nodes.

Service Mesh and Ingress Configuration

Exposing the search API to external consumers requires an Ingress resource. For production, you also want TLS termination and possibly rate limiting:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: search-api-ingress
  namespace: search-engine
  annotations:
    nginx.ingress.kubernetes.io/ssl-redirect: "true"
    nginx.ingress.kubernetes.io/limit-rps: "100"
spec:
  ingressClassName: nginx
  tls:
  - hosts:
    - search-api.example.com
    secretName: search-api-tls
  rules:
  - host: search-api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: query-api-svc
            port:
              number: 80

Why It Matters: The Concrete Benefits

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Running a search engine on Kubernetes is not merely a trendy choice — it solves real operational problems that plague traditional search deployments:

How to Use It: A Step-by-Step Implementation Guide

Step 1: Define Your Search Engine Architecture

Before writing a single manifest, decide on your indexing model. Common patterns include:

Choose based on data volume, query complexity, and latency requirements. For the rest of this guide, we'll assume a dedicated search cluster pattern.

Step 2: Provision Persistent Storage

Search engines are I/O intensive. Use a StorageClass with sufficient IOPS. For cloud environments, provision premium SSD-backed volumes:

apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
  name: premium-ssd
provisioner: kubernetes.io/gce-pd
parameters:
  type: pd-ssd
reclaimPolicy: Retain
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumer

The WaitForFirstConsumer mode delays volume provisioning until a pod is scheduled, ensuring the volume is created in the same availability zone as the node. allowVolumeExpansion: true lets you grow disks without recreating pods.

Step 3: Deploy Index Nodes with Proper Anti-Affinity

To survive zone failures, spread index node pods across availability zones and nodes. Use pod anti-affinity rules:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: search-index-nodes
spec:
  # ... previous spec fields ...
  template:
    spec:
      affinity:
        podAntiAffinity:
          preferredDuringSchedulingIgnoredDuringExecution:
          - weight: 100
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: role
                  operator: In
                  values:
                  - index-node
              topologyKey: topology.kubernetes.io/zone
          - weight: 50
            podAffinityTerm:
              labelSelector:
                matchExpressions:
                - key: role
                  operator: In
                  values:
                  - index-node
              topologyKey: kubernetes.io/hostname

This spreads pods across zones (weight 100) and nodes (weight 50), minimizing the blast radius of a zone or node failure. For strict requirements, use requiredDuringSchedulingIgnoredDuringExecution instead of preferred.

Step 4: Implement Horizontal Pod Autoscaling for Query Nodes

Query nodes are stateless and perfect candidates for HPA. Define a custom metrics HPA based on request latency or throughput:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: query-api-hpa
  namespace: search-engine
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: search-query-api
  minReplicas: 2
  maxReplicas: 20
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Pods
    pods:
      metric:
        name: search_query_latency_p99_ms
      target:
        type: AverageValue
        averageValue: "200"
  behavior:
    scaleUp:
      stabilizationWindowSeconds: 30
      policies:
      - type: Percent
        value: 100
        periodSeconds: 15
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
      - type: Pods
        value: 1
        periodSeconds: 120

The custom metric search_query_latency_p99_ms would be exported by your query service and scraped by Prometheus, then exposed via the Prometheus Adapter for Kubernetes. The scaling behavior prevents flapping: scale up aggressively (100% every 15s after 30s window), scale down conservatively (1 pod every 2 minutes after 5-minute stabilization).

Step 5: Build a Custom Search Query Service

While you could expose Elasticsearch directly, a custom query API lets you enforce business logic, apply query rewriting, inject filters, and shield clients from index schema changes. Here is a skeleton in Go:

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
    "os"
    "time"

    "github.com/elastic/go-elasticsearch/v8"
    "github.com/gorilla/mux"
    "github.com/prometheus/client_golang/prometheus"
    "github.com/prometheus/client_golang/prometheus/promauto"
    "github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
    queryLatency = promauto.NewHistogramVec(prometheus.HistogramOpts{
        Name:    "search_query_latency_ms",
        Help:    "Query latency in milliseconds",
        Buckets: prometheus.ExponentialBuckets(5, 2, 12),
    }, []string{"index"})
    
    queryErrors = promauto.NewCounterVec(prometheus.CounterOpts{
        Name: "search_query_errors_total",
        Help: "Total number of query errors",
    }, []string{"index"})
)

type SearchRequest struct {
    Query   string            `json:"query"`
    Filters map[string]string `json:"filters,omitempty"`
    Size    int               `json:"size"`
}

type SearchResponse struct {
    Results []map[string]interface{} `json:"results"`
    Total   int64                    `json:"total"`
    TookMs  int64                    `json:"took_ms"`
}

func main() {
    esEndpoint := os.Getenv("INDEX_SERVICE_ENDPOINT")
    if esEndpoint == "" {
        esEndpoint = "http://localhost:9200"
    }

    cfg := elasticsearch.Config{
        Addresses: []string{esEndpoint},
        Transport: &http.Transport{
            MaxIdleConnsPerHost: 100,
            IdleConnTimeout:     90 * time.Second,
        },
    }
    client, err := elasticsearch.NewClient(cfg)
    if err != nil {
        log.Fatalf("Failed to create Elasticsearch client: %v", err)
    }

    r := mux.NewRouter()
    r.HandleFunc("/search/{index}", searchHandler(client)).Methods("POST")
    r.Handle("/metrics", promhttp.Handler())
    r.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
        w.Write([]byte("ok"))
    })

    log.Printf("Query API listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", r))
}

func searchHandler(client *elasticsearch.Client) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        index := mux.Vars(r)["index"]
        var req SearchRequest
        if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
            http.Error(w, "invalid request", http.StatusBadRequest)
            return
        }
        if req.Size == 0 {
            req.Size = 10
        }

        timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) {
            queryLatency.WithLabelValues(index).Observe(v)
        }))
        defer timer.ObserveDuration()

        // Build Elasticsearch DSL query
        esQuery := map[string]interface{}{
            "query": map[string]interface{}{
                "multi_match": map[string]interface{}{
                    "query":  req.Query,
                    "fields": []string{"title^3", "body", "tags"},
                },
            },
            "size": req.Size,
        }

        var buf []byte
        body, _ := json.Marshal(esQuery)
        res, err := client.Search(
            client.Search.WithIndex(index),
            client.Search.WithBody(json.NewDecoder(nil)),
            client.Search.WithBody(nil),
        )
        // Note: In production, properly handle the body buffer
        _ = buf
        _ = res

        // Simplified response for brevity
        response := SearchResponse{
            Results: []map[string]interface{}{},
            Total:   0,
            TookMs:  0,
        }
        
        w.Header().Set("Content-Type", "application/json")
        json.NewEncoder(w).Encode(response)
    }
}

Step 6: Set Up Monitoring and Alerting

Create a ServiceMonitor resource so Prometheus discovers your query service automatically:

apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
  name: search-query-monitor
  namespace: search-engine
  labels:
    release: prometheus-stack
spec:
  selector:
    matchLabels:
      app: search-engine
      role: query-api
  endpoints:
  - port: http
    path: /metrics
    interval: 30s
    scrapeTimeout: 10s

For alerting, define PrometheusRule resources that fire when P99 latency exceeds thresholds or when indexing throughput drops:

apiVersion: monitoring.coreos.com/v1
kind: PrometheusRule
metadata:
  name: search-alerts
  namespace: search-engine
  labels:
    release: prometheus-stack
spec:
  groups:
  - name: search-engine
    rules:
    - alert: HighSearchLatency
      expr: histogram_quantile(0.99, rate(search_query_latency_ms_bucket[5m])) > 500
      for: 10m
      labels:
        severity: warning
      annotations:
        summary: "P99 search latency exceeds 500ms"
        description: "Index: {{ $labels.index }}, value: {{ $value }}ms"
    - alert: IndexingStalled
      expr: rate(index_documents_total[10m]) == 0
      for: 30m
      labels:
        severity: critical
      annotations:
        summary: "Document indexing has stalled for 30 minutes"

Best Practices for Production-Grade Search on Kubernetes

1. Treat Index Nodes as Pets, Not Cattle

Unlike stateless query services, index nodes carry precious shard data. Never use Deployments for them — always StatefulSets. Configure podDisruptionBudget to prevent voluntary disruptions from draining too many index nodes simultaneously:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: index-pdb
  namespace: search-engine
spec:
  maxUnavailable: 1
  selector:
    matchLabels:
      app: search-engine
      role: index-node

2. Use Init Containers for Bootstrap Logic

Before an index node starts, you might need to set kernel parameters (like vm.max_map_count for Elasticsearch) or download configuration. Use init containers rather than baking these into the main image:

initContainers:
- name: sysctl-setup
  image: busybox
  command: ["sysctl", "-w", "vm.max_map_count=262144"]
  securityContext:
    privileged: true

3. Implement Graceful Shutdown and Pre-Stop Hooks

When Kubernetes terminates a pod, it sends SIGTERM and waits for the termination grace period. Your search containers must handle this correctly. For index nodes, drain shards and flush translog before exiting:

lifecycle:
  preStop:
    exec:
      command: 
      - "/bin/bash"
      - "-c"
      - |
        curl -X POST "http://localhost:9200/_flush"
        curl -X POST "http://localhost:9200/_cluster/settings" -H 'Content-Type: application/json' -d '{"transient":{"cluster.routing.allocation.exclude._name":"'$NODE_NAME'"}}'
        sleep 30

4. Network Policy for Least-Privilege Communication

Restrict which pods can talk to your index nodes. Only query services and ingestion workers need access:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: index-node-isolation
  namespace: search-engine
spec:
  podSelector:
    matchLabels:
      role: index-node
  ingress:
  - from:
    - podSelector:
        matchLabels:
          role: query-api
    - podSelector:
        matchLabels:
          role: ingestion-worker
    ports:
    - port: 9200
      protocol: TCP
  policyTypes:
  - Ingress

5. Snapshot Lifecycle Management

Schedule regular snapshots of your search indices to durable object storage (S3, GCS). A CronJob can invoke the snapshot API and upload to cloud storage. This protects against corruption and enables disaster recovery:

apiVersion: batch/v1
kind: CronJob
metadata:
  name: index-snapshot
  namespace: search-engine
spec:
  schedule: "0 1 * * *"
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: snapshotter
            image: myregistry/snapshot-tool:1.0
            env:
            - name: ES_ENDPOINT
              value: "http://index-discovery.search-engine.svc.cluster.local:9200"
            - name: SNAPSHOT_REPO
              value: "s3://my-search-backups"
            command:
            - "/usr/local/bin/create-snapshot.sh"

6. Resource Requests = Limits for Index Nodes

For index nodes, set resource requests equal to limits. This guarantees QoS class "Guaranteed" and prevents the kernel from OOM-killing your search process under memory pressure. Search engines like Elasticsearch rely on JVM heap predictability — any swapping or OOM kill can corrupt shards.

7. Multi-Level Caching Architecture

Implement a caching hierarchy: an in-pod LRU cache for hot query results, a Redis cluster (also on Kubernetes) for shared cross-pod caching, and the index nodes' own filesystem cache for segment data. This dramatically reduces latency for repeated queries:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis-cache
  namespace: search-engine
spec:
  replicas: 3
  selector:
    matchLabels:
      app: redis-cache
  template:
    metadata:
      labels:
        app: redis-cache
    spec:
      containers:
      - name: redis
        image: redis:7-alpine
        command: ["redis-server", "--appendonly", "yes", "--maxmemory", "2gb", "--maxmemory-policy", "allkeys-lru"]
        ports:
        - containerPort: 6379
        resources:
          requests:
            cpu: "500m"
            memory: "2.5Gi"
          limits:
            cpu: "1"
            memory: "3Gi"

8. Canary Deployments for Query Logic Changes

When rolling out new query logic, use a canary deployment pattern. Deploy a small subset of pods with the new version, route a percentage of traffic to them via Istio or Nginx Ingress canary annotations, and compare latency and error metrics before full rollout:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  annotations:
    nginx.ingress.kubernetes.io/canary: "true"
    nginx.ingress.kubernetes.io/canary-weight: "10"
    nginx.ingress.kubernetes.io/canary-by-header: "x-canary"
  name: query-api-canary
  namespace: search-engine
spec:
  rules:
  - host: search-api.example.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: query-api-svc-canary
            port:
              number: 80

9. Index Lifecycle Policies Inside the Cluster

Don't let indices grow unbounded. Implement index lifecycle management (ILM) that rolls over indices based on size or age, migrates older indices to slower storage tiers (warm/cold phases), and eventually deletes them. If your search engine supports hot-warm-cold architecture, map those tiers to different Kubernetes node pools with appropriate storage classes.

10. Disaster Recovery Testing

Regularly test restore-from-snapshot procedures in a sandbox namespace. Simulate catastrophic failure by deleting an index node StatefulSet and verifying that snapshots can be restored into a fresh cluster. Document the recovery time objective (RTO) and recovery point objective (RPO) and keep them aligned with business SLAs.

Conclusion

Designing a search engine on Kubernetes is a deliberate engineering discipline that blends distributed systems knowledge, container orchestration expertise, and information retrieval fundamentals. The payoff is substantial: a search platform that scales with your data, recovers from failures autonomously, deploys via GitOps pipelines, and integrates seamlessly with your existing Kubernetes-native observability stack. By following the patterns outlined here — StatefulSets for index persistence, Deployments for stateless query serving, CronJobs for batch ingestion, HPA for elastic scaling, and rigorous network isolation — you can build a search engine that feels native to Kubernetes rather than a foreign transplant. Start with a clear architecture decision, invest in proper storage configuration, implement graceful shutdown hooks, and never forget that index nodes require pet-like care. With these foundations, your search infrastructure will serve queries reliably at any scale.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles