Most attempts to run OpenSearch on Kubernetes fail in the same few places. Pods crash-loop with max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]. Data nodes get OOMKilled because the JVM heap was sized against the node instead of the container. Or a plain StatefulSet restarts the next pod before the cluster has recovered its replicas. The OpenSearch Kubernetes Operator handles most of this for you. Version 3.0.0 shipped on 25 September 2026, moved its CRDs to the opensearch.org/v1 API group, and supports OpenSearch 2.19.2 through the latest 3.x release (3.9.0 as of November 2026). This guide covers the OpenSearchCluster manifest, storage, heap and memory limits, TLS, rolling upgrades, scaling, S3 snapshots, monitoring, and when the plain Helm chart is the better choice.
- Install cert-manager first, then operator chart 3.0.14 (operator 3.0.0). Write new manifests against
opensearch.org/v1;opensearch.opster.iois deprecated. - Give each role its own node pool. The operator creates one StatefulSet and one Service per pool, so three dedicated
cluster_managernodes stay separate from data and ingest nodes. - Set the heap to half of the container's memory request and set requests equal to limits. The rest of the memory is page cache for Lucene, not waste.
- Version upgrades are a one-field change to
spec.general.version. The operator restarts one node at a time, refuses downgrades, and rejects jumps of more than one major version. - Upgrading the operator itself from 2.x to 3.0 restarts every node once. Schedule it like any rolling restart.
Scope and review date: November 2026. This post covers the OpenSearch Kubernetes Operator 3.0.0 (Helm chart 3.0.14) running OpenSearch 2.19.x and 3.x, plus the standalone opensearch Helm chart 3.9.0. Field names, defaults, and versions were checked on 9 November 2026 against the operator's v3.0.0 user guide and CRD reference and the OpenSearch documentation. The manifests and commands were not run in a lab, so apply them to a staging cluster first.
Which OpenSearch operator and OpenSearch versions should you run?
As of November 2026, the current operator release is 3.0.0, published on 25 September 2026 together with operator Helm chart 3.0.14. The operator's compatibility table lists OpenSearch 2.19.2 as the minimum and "latest 3.x" as the maximum. On the OpenSearch side, 3.9.0 came out on 29 September 2026, and the 2.19 line is still patched (2.19.7 shipped on 20 October 2026).
The biggest change in 3.0 is the API group. Resources move from opensearch.opster.io/v1 to opensearch.org/v1. The legacy group still works, but its webhooks deny new resources and spec changes in it. A migration controller copies each running legacy cluster to a new opensearch.org twin.
# cert-manager issues the operator's webhook certificate, so install it first
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager \
--namespace cert-manager --create-namespace --set crds.enabled=true
helm repo add opensearch-operator https://opensearch-project.github.io/opensearch-k8s-operator/
helm install opensearch-operator opensearch-operator/opensearch-operator \
--namespace opensearch-operator --create-namespace --version 3.0.14Interpretation: without cert-manager, helm install fails with no matches for kind "Certificate" in version "cert-manager.io/v1". If you cannot run cert-manager, the operator's webhook guide describes supplying the webhook certificate yourself or disabling the webhook.
The StatefulSet selector is immutable, and 3.0 selects pods by new opensearch.org/... labels. The operator recreates each StatefulSet with orphaned pods and then rolls every pod once. The migration guide also lists stored values to recheck afterwards: rotateDaysBeforeExpiry: -1, smartScaler: false, and a lost setVMMaxMapCount: false.
From one OpenSearchCluster resource to StatefulSets
Follow the numbers: one custom resource, one operator, one StatefulSet per node pool
Per-pool objects: because each pool is its own StatefulSet, you can size, schedule, and protect cluster managers, data nodes, and ingest nodes separately.
What does a production OpenSearchCluster manifest look like?
The operator's quickstart runs three nodes that hold both cluster_manager and data. That is fine for a demo. In production, give each role its own pool. Valid roles include cluster_manager, data, ingest, ml, remote_cluster_client, search and warm. In 3.0.0, the admission webhook rejects unknown roles and any cluster without a cluster-manager pool of at least one replica.
apiVersion: opensearch.org/v1
kind: OpenSearchCluster
metadata:
name: logs
namespace: search
spec:
general:
serviceName: logs
version: "3.9.0"
httpPort: 9200
pluginsList: ["repository-s3"]
dashboards:
enable: true
version: "3.9.0"
replicas: 2
tls:
enable: true
generate: true
security:
tls:
transport:
generate: true
perNode: true
http:
generate: true
nodePools:
- component: masters
replicas: 3
diskSize: "20Gi"
roles: ["cluster_manager"]
jvm: "-Xms2g -Xmx2g"
resources:
requests: { memory: "4Gi", cpu: "1" }
limits: { memory: "4Gi", cpu: "1" }
pdb:
enable: true
maxUnavailable: 1
- component: data
replicas: 3
diskSize: "500Gi"
roles: ["data"]
jvm: "-Xms8g -Xmx8g"
resources:
requests: { memory: "16Gi", cpu: "4" }
limits: { memory: "16Gi", cpu: "4" }
persistence:
pvc:
storageClass: ssd-expandable
accessModes: ["ReadWriteOnce"]
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: DoNotSchedule
labelSelector:
matchLabels:
opensearch.org/opensearch-nodepool: data
- component: ingest
replicas: 2
diskSize: "10Gi"
roles: ["ingest"]
resources:
requests: { memory: "4Gi", cpu: "2" }
limits: { memory: "4Gi", cpu: "2" }Interpretation: this produces StatefulSets named after the cluster and component, for example pods logs-masters-0 to logs-masters-2. A temporary logs-bootstrap-0 pod helps the first cluster-manager election and is removed after initialization. Three cluster-manager pods keep a quorum when one is down. The ingest pool has no jvm field, so the operator sets the heap to half of its memory request.
kubectl apply -f logs-cluster.yaml
kubectl -n search get opensearchclusters.opensearch.org logs
kubectl -n search get statefulsets
kubectl -n search get pods -l opensearch.org/opensearch-cluster=logsInterpretation: the custom resource prints HEALTH, NODES, VERSION, PHASE, and AGE columns. PHASE is PENDING, RUNNING, or UPGRADING, and RUNNING only means the operator reconciled the cluster. Health comes from OpenSearch. Use the fully qualified opensearchclusters.opensearch.org while both API groups are installed, so kubectl does not resolve the legacy kind.
How do you size storage, JVM heap, and memory limits?
Storage
By default each pool gets PVCs from the default StorageClass. Name a StorageClass explicitly; when you set persistence.pvc, accessModes is required. StatefulSet volume claim templates are immutable, so pick the class before the first apply. To grow disks later, raise diskSize. The operator expands the PVCs online if the StorageClass has allowVolumeExpansion: true, and keeps the same unit (Gi or G) you started with.
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: ssd-expandable
provisioner: ebs.csi.aws.com
parameters:
type: gp3
allowVolumeExpansion: true
volumeBindingMode: WaitForFirstConsumerInterpretation: WaitForFirstConsumer creates each volume in the zone where its pod is scheduled, which matters with zone spread constraints. Leave headroom for OpenSearch's disk watermarks. The cluster settings reference lists defaults of 85% (no new replicas), 90% (shards move away), and 95% (indexes become read-only).
PVCs survive a scale-down or a deleted cluster unless general.persistentVolumeClaimRetentionPolicy says otherwise; keep Retain in production.
Heap versus memory limits
The OpenSearch install guide recommends a heap of half the system RAM. In a pod, "system RAM" is the container's memory limit, not the Kubernetes node. The operator follows the same rule: if jvm omits -Xms and -Xmx, it uses half of resources.requests.memory, or 512 MB when no request is set. The other half is page cache for Lucene's memory-mapped files and off-heap memory. The k-NN plugin's native-memory circuit breaker, for example, defaults to 50% of the RAM left over after the heap.
# Heap in use per node, against its maximum
curl -sk -u "admin:$OS_PASS" \
"https://localhost:9200/_cat/nodes?v&h=name,node.role,heap.percent,heap.max,ram.max"Interpretation: heap.max should be about half of each pod's memory limit. If it is close to the full limit, the JVM and the page cache compete, and the kernel OOM-kills the container instead of the JVM failing cleanly.
The operator derives the default heap from the request. If the limit is higher than the request, the pod can be scheduled on a node that cannot give it the limit, and page-cache pressure shows up as slow searches long before any error.
How do you fix vm.max_map_count on Kubernetes?
OpenSearch refuses to start on Linux when the host's vm.max_map_count is below 262144, and logs max virtual memory areas vm.max_map_count [65530] is too low, increase to at least [262144]. The setting belongs to the host kernel, not to the container. By default, the operator sets it to 262144 with a privileged init container in every OpenSearch pod (setVMMaxMapCount, default true).
# Read the value the pod actually sees (it is the host's value)
kubectl -n search exec logs-data-0 -- cat /proc/sys/vm/max_map_countInterpretation: anything below 262144 means the init container did not run or the node was reset. On clusters whose Pod Security admission blocks privileged containers, set the value on the nodes instead, for example in the EKS launch template or node bootstrap, and turn the init container off:
spec:
general:
setVMMaxMapCount: false # nodes already run sysctl -w vm.max_map_count=262144Interpretation: with this set to false, the operator stops adding the privileged sysctl init container. A separate init container still fixes volume ownership as root, and the user guide explains how to disable that too.
How does the operator set up the security plugin, TLS, and Dashboards?
The security plugin needs TLS on the transport layer (port 9300) and, for the operator's admin tasks, on HTTP (port 9200). With generate: true, the operator creates its own CA and signs the certificates. With perNode: true, each node gets its own transport certificate. Generated certificates last one year by default (duration: "8760h"), and the operator reissues them 30 days before expiry (rotateDaysBeforeExpiry: 30). On OpenSearch 3.x, TLS hot reload is on by default, so nodes pick up renewed certificates without a restart.
# The operator generates a random admin password and stores it in a Secret
kubectl -n search get secret logs-admin-password \
-o jsonpath='{.data.password}' | base64 -dInterpretation: the operator uses this admin account for health checks and node draining, and Dashboards gets its own kibanaserver password in logs-dashboards-password. Give people named users instead.
For anything you do not supply in security.config.securityConfigSecret, operator 3.0.0 applies the security plugin's demo configuration files. Replace roles and role mappings before production. The operator applies your files with a logs-securityconfig-update Job and reruns it when the secret changes. If you supply your own HTTP certificates, you must also provide an admin client certificate whose DN is listed under security.tls.http.adminDn.
spec:
security:
config:
securityConfigSecret:
name: logs-securityconfig # config.yml, roles.yml, roles_mapping.yml, ...
adminCredentialsSecret:
name: logs-operator-admin # keys: username, passwordInterpretation: if your secret includes roles_mapping.yml, it replaces the default file entirely. Keep the Dashboards user mapped to a role with cluster monitoring permissions (kibana_server by default), or Dashboards cannot log in.
Deleting and regenerating the CA Secret is not a supported rotation procedure. The operator's user guide warns that it can leave nodes trusting different CAs while they reload.
How does the operator run a rolling OpenSearch upgrade?
Change spec.general.version and apply the manifest. The operator restarts nodes one at a time. After each node, it waits for green health. If some replicas can never be assigned, a stable yellow is enough: every data node has joined and no shards are initializing or relocating. It never restarts a node that holds the only active copy of a shard that should have replicas. The user guide marks downgrades and jumps across more than one major version as unsupported.
OpenSearch adds its own rules. The rolling upgrade guide sets 2.19.0 as the minimum cluster version for any upgrade to 3.x. The 3.x line reads only indexes created by 2.x, so reindex anything created on 1.x first.
Operator-driven upgrade runbook, for example 3.8.0 to 3.9.0
Seven steps; no step takes the cluster down, but health goes yellow while each node restarts
-
Check the upgrade path
Same major, or the next major from 2.19.0 or later. Indexes created on 1.x must be reindexed before 3.x.
kubectl -n search get \ opensearchclusters.opensearch.org logs -
Confirm every plugin exists for the target
Plugin zips must match the OpenSearch version exactly. The Prometheus exporter for 3.9.0 shipped six days after the server.
curl -sIL -o /dev/null -w "%{http_code}\n" \ $EXPORTER_ZIP_URL_FOR_3_9_0 -
Take a snapshot
This snapshot is the rollback path, because the operator will not downgrade. Wait for state SUCCESS.
PUT _snapshot/s3-snapshots/pre-upgrade -
Start from green
Fix unassigned shards first; a red cluster blocks the upgrade.
GET _cluster/health?wait_for_status=green&timeout=60s -
Change the version
The patch file sets spec.general.version and spec.dashboards.version to 3.9.0. In GitOps, edit the manifest instead.
kubectl -n search patch \ opensearchclusters.opensearch.org logs \ --type merge --patch-file v3.9.0.yaml -
Watch the phase
PHASE shows UPGRADING until the last pod restarts, then RUNNING. Expect yellow between nodes.
kubectl -n search get \ opensearchclusters.opensearch.org logs -w -
Verify every node's version
All nodes should report 3.9.0 before you change anything else.
GET _cat/nodes?v&h=name,version,node.role
Rollback point: once the first node runs the new version, there is no in-place way back. Rolling back means a new cluster at the old version, restored from the step 3 snapshot.
Leave drainDataNodes off for PVC-backed pools. It moves every shard off a node before each restart, which the user guide recommends only for emptyDir data.
How do scaling and shard rebalancing work?
To add data nodes, raise replicas on the pool. New pods join, and OpenSearch rebalances shards onto them on its own. To remove data nodes, lower replicas. With SmartScaler (spec.confMgmt.smartScaler, default true for new clusters), the operator first adds the node to cluster.routing.allocation.exclude._name, waits until it holds no shards, and only then deletes the pod. Cluster-manager nodes leave one at a time through voting configuration exclusions, whatever the SmartScaler setting.
# Where are shards moving, and why is one unassigned?
curl -sk -u "admin:$OS_PASS" "https://localhost:9200/_cat/recovery?v&active_only=true"
curl -sk -u "admin:$OS_PASS" "https://localhost:9200/_cluster/allocation/explain?pretty"Interpretation: active recoveries show each relocating shard with its source and target node. The allocation explain output names the decider that blocks an unassigned shard, such as a disk watermark or allocation awareness.
Rebalancing speed is limited on purpose. cluster.routing.allocation.cluster_concurrent_rebalance defaults to 2, and indices.recovery.max_bytes_per_sec defaults to 40 MB per node. Raise them temporarily if a scale-out has to finish inside a window, and put them back afterwards. Replicas of a restarted node wait out index.unassigned.node_left.delayed_timeout (default 1m) before OpenSearch rebuilds them elsewhere.
spec:
general:
nodeAttributes:
- name: zone
nodeLabel: topology.kubernetes.io/zone
additionalConfig:
cluster.routing.allocation.awareness.attributes: zoneInterpretation: nodeAttributes copies each Kubernetes node's zone label into node.attr.zone, so OpenSearch keeps a primary and its replica in different zones. Pair it with the pool's topologySpreadConstraints. Changing additionalConfig triggers a rolling restart, so use the cluster settings API for runtime changes.
Cluster health during a data pool scale-out
Illustrative output: the shape of the operator status and the _cluster/health response, not a measured cluster
Read relocating_shards: the cluster is green while shards move to the new fourth data node, and two at a time is the default cluster_concurrent_rebalance limit. Field names follow the cluster health API reference.
How do you back up OpenSearch on Kubernetes to S3?
PVCs are not backups. Use OpenSearch snapshots to an object store. The operator installs the repository-s3 plugin from pluginsList, loads credentials into the OpenSearch keystore from a Kubernetes Secret, and registers the repositories in snapshotRepositories once the cluster is running.
spec:
general:
pluginsList: ["repository-s3"]
keystore:
- secret:
name: s3-snapshot-credentials
keyMappings:
accessKey: s3.client.default.access_key
secretKey: s3.client.default.secret_key
snapshotRepositories:
- name: s3-snapshots
type: s3
settings:
bucket: acme-opensearch-snapshots
region: us-east-1
base_path: logsInterpretation: only the keys listed under keyMappings are loaded, renamed to the keystore names the S3 plugin expects. The IAM principal needs s3:ListBucket on the bucket plus s3:GetObject, s3:PutObject, and s3:DeleteObject on its objects. Changing pluginsList on a running cluster triggers a rolling restart.
Schedule snapshots with Snapshot Management. The operator has an OpensearchSnapshotPolicy resource, but its development docs mark it and the other auxiliary CRDs deprecated for removal in operator v4. The SM API or the Terraform provider will outlast it.
POST _plugins/_sm/policies/daily-logs
{
"description": "Daily snapshot of all indexes, keep 14",
"creation": {
"schedule": { "cron": { "expression": "0 2 * * *", "timezone": "UTC" } },
"time_limit": "1h"
},
"deletion": {
"schedule": { "cron": { "expression": "0 4 * * *", "timezone": "UTC" } },
"condition": { "max_age": "14d", "max_count": 14, "min_count": 7 },
"time_limit": "1h"
},
"snapshot_config": {
"repository": "s3-snapshots",
"indices": "*",
"include_global_state": "false"
}
}Interpretation: GET _plugins/_sm/policies/daily-logs/_explain shows the last creation and deletion results. A restore drill matters more than the policy: restore one index under a new name with rename_pattern every month, and time it.
How do you monitor OpenSearch on Kubernetes?
With monitoring.enable: true, the operator installs the OpenSearch Prometheus exporter plugin and creates a ServiceMonitor for the Prometheus Operator. The user guide's example scrapes every 30 seconds. In 3.0.0, a missing ServiceMonitor CRD no longer blocks reconciliation, but you get no scrape target either.
spec:
general:
monitoring:
enable: true
scrapeInterval: 30s
monitoringUserSecret: logs-monitoring-user # read-only user, not admin
tlsConfig:
insecureSkipVerify: true # the ServiceMonitor cannot take a CA from the operator yetInterpretation: by default the exporter is scraped as the admin user; a dedicated read-only user limits the damage if Prometheus credentials leak. The exporter zip is downloaded from GitHub at pod start, so air-gapped clusters set pluginUrl to an internal mirror.
Alert on cluster status, unassigned shards, heap use, and disk use against the watermarks. On the Kubernetes side, watch the operator's events and pod restarts.
kubectl -n search describe opensearchclusters.opensearch.org logs | sed -n '/Events/,$p'
kubectl -n search get pods -l opensearch.org/opensearch-cluster=logs \
-o custom-columns=POD:.metadata.name,RESTARTS:.status.containerStatuses[0].restartCount,LAST:.status.containerStatuses[0].lastState.terminated.reasonInterpretation: OOMKilled in the last column means the container hit its memory limit; lower the heap or raise the limit. Events such as RollingRestart or Upgrade explain restarts the operator started itself.
Should you use the operator or the OpenSearch Helm chart?
There are two Helm paths, and they are easy to confuse. The operator project ships an opensearch-cluster chart that only renders an OpenSearchCluster resource, so the operator still does the work. The opensearch-project/helm-charts repository ships a standalone opensearch chart (3.9.0, matching OpenSearch 3.9.0) that creates StatefulSets directly, with no operator.
helm repo add opensearch https://opensearch-project.github.io/helm-charts/
helm install logs-masters opensearch/opensearch --version 3.9.0 -f masters-values.yaml
helm install logs-data opensearch/opensearch --version 3.9.0 -f data-values.yamlInterpretation: the standalone chart installs one node group per release, set by nodeGroup and roles. Data releases join the managers through masterService. From OpenSearch 2.12, the chart's demo security setup also needs OPENSEARCH_INITIAL_ADMIN_PASSWORD.
Operator 3.0.0 or the standalone opensearch Helm chart
Switch tabs to see who does each day-2 job
OpenSearch operator
| Job | OpenSearch operator |
|---|---|
| What you install | Operator chart once, then one OpenSearchCluster per cluster |
| Version upgrade | Change general.version; one node at a time, gated on cluster health |
| Removing data nodes | SmartScaler drains shards before deleting the pod |
vm.max_map_count | Privileged init container, on by default |
| TLS certificates | Generated per node, reissued 30 days before expiry |
| Snapshot repositories | Declared in general.snapshotRepositories |
Standalone Helm chart
| Job | Standalone Helm chart |
|---|---|
| What you install | One opensearch release per node group, plus Dashboards |
| Version upgrade | helm upgrade; StatefulSet rolling update gated by the readiness probe (TCP on 9200 by default) |
| Removing data nodes | StatefulSet removes the highest ordinal; you drain it first by hand |
vm.max_map_count | Off by default (sysctlInit.enabled: false) |
| TLS certificates | Demo configuration by default; you mount and rotate your own |
| Snapshot repositories | Registered through the REST API |
The readiness gap: the chart's README says Kubernetes waits for green after each pod, but the default readiness probe in its values.yaml only opens a TCP socket on port 9200. Add a health-based probe or use updateStrategy: OnDelete before you trust helm upgrade in production.
Choose the standalone chart if you already have tooling for upgrades, certificates, and draining. Choose the operator if you want those jobs automated, at the cost of one more component to upgrade.
Frequently asked questions
Which OpenSearch versions does the OpenSearch Kubernetes Operator support?
Operator 3.0.0, released on 25 September 2026, is tested with OpenSearch 2.19.2 up to the latest 3.x release, which is 3.9.0 as of November 2026. Operators 2.5.0 to 2.7.0 cover OpenSearch 1.3.x to 2.19.2. The operator does not block untested versions, but the project recommends testing them outside production first.
Do I need cert-manager to install the OpenSearch operator?
With the default Helm values, yes. Operator chart 3.0.14 installs a validating webhook and asks cert-manager 1.0 or later to issue its serving certificate. Without cert-manager, the install fails on the Certificate kind. You can instead supply the webhook certificate yourself through webhook.secretName, or set webhook.enabled to false.
How do I fix "vm.max_map_count [65530] is too low" on Kubernetes?
Raise vm.max_map_count to at least 262144 on every Kubernetes node that runs OpenSearch. The OpenSearch operator does this by default with a privileged init container. If your cluster blocks privileged containers, set it in the node bootstrap instead and set spec.general.setVMMaxMapCount to false. The standalone Helm chart has sysctlInit, which is off by default.
How much JVM heap should an OpenSearch pod get?
Half of the container's memory, with memory requests equal to limits. OpenSearch recommends a heap of half the system RAM, and in a pod that means the memory limit. If you omit -Xms and -Xmx, the operator uses half of the memory request. The other half serves Lucene's page cache and off-heap memory such as k-NN.
Can the OpenSearch operator upgrade from OpenSearch 2.x to 3.x?
Yes, as a rolling upgrade, if the cluster runs 2.19.0 or later, as OpenSearch requires for any 3.x upgrade. Change spec.general.version and the operator restarts one node at a time. Reindex any index created on OpenSearch 1.x first, because 3.x reads only indexes created by 2.x. Take a snapshot before you start.
Key takeaways
- Run operator 3.0.0 with cert-manager, and write new resources against
opensearch.org/v1. Moving from operator 2.x restarts every node once. - Use separate node pools for cluster managers, data, and ingest, with a PodDisruptionBudget and zone spread on each.
- Size the heap at half the container memory, with requests equal to limits, and leave the rest to the page cache.
- Treat every version upgrade as one-way: snapshot first, start from green, and check that plugin zips exist for the target version.
- Schedule S3 snapshots with Snapshot Management rather than the deprecated policy CRD, and test restores on a schedule.
Run OpenSearch on Kubernetes with JusDB
JusDB's OpenSearch on Kubernetes team can review your OpenSearchCluster manifests or Helm values, plan the move from operator 2.x to 3.0 or from OpenSearch 2.19 to 3.x, and write the upgrade and restore runbooks for your clusters.
Bring your current manifests, operator and OpenSearch versions, node pool sizes, and snapshot setup. Your team approves every production change.